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

# SMS webhooks

> Events fired during an SMS's lifecycle.

Robase fires webhook events at every SMS lifecycle transition. Subscribe to the ones you care about when you [register a webhook](/concepts/webhooks).

## Event types

<AccordionGroup>
  <Accordion title="sms.queued">
    Fires the moment we accept a `POST /v1/sms`. Useful for reconciling "we intended to send X" vs "we actually sent X" in your audit log.

    ```json theme={null}
    { "type": "sms.queued", "data": { "sms_id": "...", "status": "queued", ... } }
    ```
  </Accordion>

  <Accordion title="sms.sent">
    The upstream carrier accepted the message. This is not delivery — just that the carrier acknowledged the handoff.

    ```json theme={null}
    { "type": "sms.sent", "data": { "sms_id": "...", "status": "sent", "provider": "beem", "provider_message_id": "beem-a1b2" } }
    ```
  </Accordion>

  <Accordion title="sms.delivered">
    The **handset confirmed receipt** (via the carrier's delivery report). This is the event you care about for OTP flows.

    ```json theme={null}
    { "type": "sms.delivered", "data": { "sms_id": "...", "status": "delivered", "carrier": "mtn_ng", ... } }
    ```
  </Accordion>

  <Accordion title="sms.failed">
    A permanent failure after send — the carrier tried but couldn't deliver. Typically: handset off too long, SIM no longer exists, blacklist.

    ```json theme={null}
    { "type": "sms.failed", "data": { "sms_id": "...", "status": "failed", "error_code": "17", "error_message": "handset unreachable" } }
    ```

    Wallet overage is **automatically refunded** for permanent failures.
  </Accordion>

  <Accordion title="sms.rejected">
    Pre-send rejection — e.g. `phone_invalid`, `dnd_blocked`, or carrier-level sender-ID block. No attempt was made to deliver.

    ```json theme={null}
    { "type": "sms.rejected", "data": { "sms_id": "...", "status": "rejected", "error_code": "dnd_blocked" } }
    ```

    Also refunds overage automatically.
  </Accordion>
</AccordionGroup>

## Payload envelope

Every event has the same top-level shape:

```json theme={null}
{
  "id": "evt_01H8XKQJ3Z...",
  "type": "sms.delivered",
  "created_at": "2026-04-17T10:30:05Z",
  "data": {
    "sms_id": "01H8XKQJ3Z...",
    "to": "+2348012345678",
    "from": "SHUTTLERS",
    "status": "delivered",
    "segments": 1,
    "provider": "beem",
    "provider_message_id": "beem-a1b2c3",
    "carrier": "mtn_ng",
    "error_code": "",
    "error_message": "",
    "cost": { "amount_kobo": 400, "currency": "NGN" }
  }
}
```

`data` matches the shape of `GET /v1/sms/:id` — if you've typed your fetch code, the webhook shape is the same.

## Signature verification

Same scheme as email: `Robase-Signature: t=<unix>,v1=<hex-hmac>`, where the HMAC is over `{t}.{body}` with your webhook's secret. See [Webhooks](/concepts/webhooks) for the verification code per language.

## Common patterns

### OTP flow

```typescript theme={null}
// 1. User enters phone number.
const { id } = await pm.sms.send({
  to: phone,
  from: 'MYAPP',
  body: `Your OTP is ${code}. Valid for 5 minutes.`,
});
await db.otps.insert({ user_id, code, sms_id: id, status: 'queued' });

// 2. Webhook handler:
app.post('/webhooks/robase', async (req, res) => {
  const event = await verifyAndParse(req);
  if (event.type === 'sms.delivered') {
    await db.otps.update({ sms_id: event.data.sms_id }, { status: 'delivered' });
  }
  if (event.type === 'sms.failed' || event.type === 'sms.rejected') {
    await db.otps.update({ sms_id: event.data.sms_id }, { status: 'failed' });
    await notifyUserToRetry(event.data.sms_id);
  }
  res.status(200).end();
});
```

### Campaign monitoring

```typescript theme={null}
app.post('/webhooks/robase', async (req, res) => {
  const event = await verifyAndParse(req);
  if (event.type.startsWith('sms.')) {
    await metrics.increment(`sms.${event.data.status}.count`, {
      carrier: event.data.carrier,
      country: event.data.country,
    });
  }
  res.status(200).end();
});
```

## Delayed DLRs

African carriers sometimes take minutes or even hours to confirm delivery. Don't tie UI behavior to `sms.delivered` — use `sms.sent` as the "we handed it off" signal, and treat `sms.delivered` as asynchronous confirmation that may arrive later.

For OTP-style flows where you need fast feedback, show the user "Code sent — check your messages" as soon as you get `sms.sent`, and only escalate to "Didn't get it?" after a 60-second client-side timeout.

## Test-mode webhooks

Test-mode sends fire the same events, with `test_mode: true` in `data`. Use this to write idempotent webhook handlers — your real production traffic will go through the same code path with the flag off.
