> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ugatext.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time SMS delivery receipts on your server.

Webhooks send delivery receipts to your server as messages move through their lifecycle. Webhook requests are signed with HMAC SHA-256 so you can verify they came from UgaText.

## Events

| Event | Description |
| - | - |
| `sms.sent` | The message was handed to the carrier. |
| `sms.delivered` | The handset confirmed receipt. |
| `sms.failed` | The message could not be delivered. |
| `wallet.topup` | A Mobile Money top-up completed and credits were added to your wallet. |

## Payload envelope

Every event uses the same envelope. Event-specific fields are in `data`.

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique event ID (`evt_...`). |
| `event` | string | Event type, for example `wallet.topup`. |
| `delivery_id` | string | Unique ID for this delivery attempt. |
| `created_at` | string | ISO-8601 timestamp when the event was created. |
| `data` | object | Event-specific payload. |

The `delivery_id` field matches the `X-UgaText-Delivery-ID` header.

### SMS events

| Event | Fired when |
| - | - |
| `sms.sent` | UgaText dispatches the message to the mobile network operator (MTN, Airtel, Safaricom, and others). |
| `sms.delivered` | The operator returns a handset delivery receipt (DLR) confirming the message reached the phone. |
| `sms.failed` | Carrier delivery fails, for example a switched-off handset after the retry window, an unallocated number, or a DND or carrier rejection. |

```json sms.sent theme={null}
{
  "id": "evt_1760425200100_a1b2c3d4",
  "event": "sms.sent",
  "delivery_id": "del_1760425200100_1f2e3d4c",
  "created_at": "2026-10-28T16:10:00.100Z",
  "data": {
    "message_id": "msg_1760425200000_a1b2c3d4",
    "to": "+256750625684",
    "recipient": "+256750625684",
    "sender_id": "UGATEXT",
    "status": "sent",
    "network": "MTN Uganda",
    "mode": "live",
    "units": 1,
    "cost_ugx": 25,
    "currency": "UGX",
    "sent_at": "2026-10-28T16:10:00.100Z"
  }
}
```

```json sms.delivered theme={null}
{
  "id": "evt_1760425202720_b2c3d4e5",
  "event": "sms.delivered",
  "delivery_id": "del_1760425202720_5a6b7c8d",
  "created_at": "2026-10-28T16:10:02.720Z",
  "data": {
    "message_id": "msg_1760425200000_a1b2c3d4",
    "to": "+256750625684",
    "recipient": "+256750625684",
    "sender_id": "UGATEXT",
    "status": "delivered",
    "network": "MTN Uganda",
    "mode": "live",
    "units": 1,
    "cost_ugx": 25,
    "currency": "UGX",
    "provider_reference": "pdr_1760425200120_a1b2c3d4",
    "delivered_at": "2026-10-28T16:10:02.720Z"
  }
}
```

```json sms.failed theme={null}
{
  "id": "evt_1760425205400_c3d4e5f6",
  "event": "sms.failed",
  "delivery_id": "del_1760425205400_9e8f7a6b",
  "created_at": "2026-10-28T16:10:05.400Z",
  "data": {
    "message_id": "msg_1760425200000_a1b2c3d4",
    "to": "+256750625684",
    "recipient": "+256750625684",
    "sender_id": "UGATEXT",
    "status": "failed",
    "network": "Airtel Uganda",
    "mode": "live",
    "error_code": "ABSENT_SUBSCRIBER",
    "error_reason": "Handset unreachable or switched off",
    "failed_at": "2026-10-28T16:10:05.400Z"
  }
}
```

| Field | Type | Events | Description |
| - | - | - | - |
| `message_id` | string | All | Message ID returned when the SMS was sent (`msg_...`). |
| `to`, `recipient` | string | All | Recipient number in E.164 format (`+256...`, `+254...`, `+243...`). |
| `sender_id` | string | All | Sender ID used for the message. |
| `status` | string | All | `sent`, `delivered`, or `failed`. |
| `network` | string | All | Destination carrier, for example `MTN Uganda`, `Airtel Uganda`, or `Safaricom`. |
| `mode` | string | All | Environment: `live` or `sandbox`. |
| `units` | integer | `sms.sent`, `sms.delivered` | Number of SMS segments billed. |
| `cost_ugx` | number | `sms.sent`, `sms.delivered` | Total cost in UGX. |
| `currency` | string | `sms.sent`, `sms.delivered` | Billing currency: `UGX`, `KES`, or `USD`. |
| `provider_reference` | string | `sms.delivered` | Upstream gateway delivery receipt reference (`pdr_...`). |
| `sent_at` | string | `sms.sent` | ISO-8601 time the message was handed to the carrier. |
| `delivered_at` | string | `sms.delivered` | ISO-8601 time handset delivery was confirmed. |
| `error_code` | string | `sms.failed` | Carrier failure code, for example `ABSENT_SUBSCRIBER`, `INVALID_DESTINATION`, or `DND_BLOCKED`. |
| `error_reason` | string | `sms.failed` | Human-readable reason for the failure. |
| `failed_at` | string | `sms.failed` | ISO-8601 time the failure was recorded. |

> [!NOTE]
>
> `sms.failed` webhooks report the reason in `error_reason` (alongside `error_code`). `GET /sms/status/{id}` and cross-border DLR callbacks use `failure_reason` instead. To handle both, read `data.error_reason || data.failure_reason`.

### wallet.topup

Fired as soon as a Mobile Money top-up (MTN Mobile Money, Airtel Money, or M-Pesa) completes and SMS credits are added to your wallet.

```json theme={null}
{
  "id": "evt_1760425200890_c4d5e6f7",
  "event": "wallet.topup",
  "delivery_id": "del_1760425200890_9a8b7c6d",
  "created_at": "2026-10-28T16:05:00.890Z",
  "data": {
    "transaction_id": "TXN_1760425200890",
    "amount_ugx": 50000,
    "currency": "UGX",
    "units_credited": 2000,
    "new_balance_ugx": 175000,
    "sms_units_remaining": 7000,
    "phone": "256750625684",
    "provider": "MTN Mobile Money",
    "status": "completed",
    "timestamp": "2026-10-28T16:05:00.890Z"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `transaction_id` | string | Unique payment reference for the top-up (`TXN_...`). |
| `amount_ugx` | number | Top-up amount credited to the wallet, in UGX. |
| `currency` | string | Billing currency: `UGX`, `KES`, or `USD`. |
| `units_credited` | integer | SMS units added by this top-up, for example 50,000 / 25 = 2,000. |
| `new_balance_ugx` | number | Total wallet balance in UGX after the credit. |
| `sms_units_remaining` | integer | Total SMS units now available on the account. |
| `phone` | string | Mobile Money number that authorized the payment. |
| `provider` | string | Payment channel: `MTN Mobile Money`, `Airtel Money`, or `M-Pesa`. |
| `status` | string | Payment status: `completed`. |
| `timestamp` | string | ISO-8601 timestamp when the wallet credit was confirmed. |

## Configure in the dashboard

**1. Open webhook settings**

In the dashboard, go to **API & Webhooks** and open the **Webhooks** tab.

**2. Set your endpoint**

Paste your HTTPS destination URL, set a webhook secret, and choose which events to receive.

**3. Adjust advanced options**

Optionally configure retry policies and payload formats, then save.

**4. Test**

Use the built-in webhook simulator to send test events to your endpoint.

## Manage webhooks with the API

Register a webhook:

```bash theme={null}
curl -X POST https://ugatext.com/api/v1/webhooks \
  -H "Client-ID: YOUR_CLIENT_ID" \
  -H "Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourdomain.com/sms/dlr",
    "events": ["sms.sent", "sms.delivered", "sms.failed"]
  }'
```

| Action | Endpoint |
| - | - |
| Register a webhook | `POST /webhooks` |
| List webhooks | `GET /webhooks` |
| Delete a webhook | `DELETE /webhooks/{id}` |

An invalid URL returns `400 INVALID_URL`.

## Request headers

Every webhook `POST` from UgaText includes these headers:

| Header | Example | Description |
| - | - | - |
| `X-UgaText-Signature` | `t=1760425200,v1=5d41402abc4b2a76...` | Timestamped HMAC SHA-256 signature in the form `t=<timestamp>,v1=<hex_digest>`. |
| `X-UgaText-Event` | `sms.delivered` | Event type. |
| `X-UgaText-Delivery-ID` | `del_1760425200890_9a8b7c6d` | Unique ID for this delivery attempt. |
| `X-UgaText-Timestamp` | `1760425200` | Unix timestamp when the webhook was dispatched. |
| `User-Agent` | `UgaText-Webhooks/2.0 (+https://ugatext.com)` | UgaText webhook dispatcher. |

> [!NOTE]
>
> All webhook URLs on your account share one account-level signing secret (`whsec_...`).

## Verify signatures

Always verify the signature before trusting the payload.

**1. Parse the header**

Split `X-UgaText-Signature` on `,` to get the timestamp `t` and the signature `v1`.

**2. Build the signed payload**

Join the timestamp, a literal period, and the raw, unparsed request body:

```text theme={null}
<timestamp>.<raw_json_body>
```

**3. Compute the digest**

Compute an HMAC SHA-256 of that string with your signing secret (`whsec_...`), encoded as lowercase hex.

**4. Compare**

Compare your digest with `v1` using a constant-time function such as `crypto.timingSafeEqual` or `hmac.compare_digest`. To prevent replay attacks, also reject requests where `t` is more than 5 minutes (300 seconds) old.

> [!WARNING]
>
> Use the raw request body. Parsing and re-serializing the JSON changes the bytes and breaks verification.

```javascript Node.js (Express) theme={null}
import crypto from 'crypto';
import express from 'express';

const app = express();

// Capture the raw body for exact HMAC verification
app.post('/webhooks/ugatext', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.headers['x-ugatext-signature'];
  const secret = process.env.UGATEXT_WEBHOOK_SECRET; // "whsec_..."

  if (!sigHeader || !secret) {
    return res.status(401).send('Missing signature or secret');
  }

  const parts = Object.fromEntries(
    sigHeader.split(',').map((part) => part.split('='))
  );
  const timestamp = parts.t;
  const receivedSig = parts.v1;

  const rawBody = req.body.toString('utf8');
  const signedPayload = `${timestamp}.${rawBody}`;

  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  const isValid = crypto.timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expectedSig, 'hex')
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);
  console.log('Verified UgaText event:', event.event, event.data);
  res.status(200).json({ received: true });
});
```

```python Python theme={null}
import hmac
import hashlib

def verify_ugatext_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
    parts = dict(item.split("=", 1) for item in sig_header.split(",") if "=" in item)
    timestamp = parts.get("t", "")
    received_sig = parts.get("v1", "")

    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected_sig = hmac.new(
        secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(received_sig, expected_sig)
```

> [!TIP]
>
> If you prefer polling, call `GET /sms/status/{id}` to read a message's current status.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.