> ## 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.

# Webhooks

> Subscribe to delivery events and sender-ID reviews in real time.

Webhooks push event notifications to your server the moment something happens — you don't have to poll.

## Registering an endpoint

From the dashboard (**Webhooks → Add webhook**) or via the API:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.robase.dev/v1/webhooks \
    -H "Authorization: Bearer rb_live_xxx" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/webhooks/robase",
      "events": ["sms.delivered", "sms.failed", "email.bounced"],
      "description": "production"
    }'
  ```

  ```typescript Node.js theme={null}
  const hook = await pm.webhooks.create({
    url: 'https://your-app.com/webhooks/robase',
    events: ['sms.delivered', 'sms.failed', 'email.bounced'],
    description: 'production',
  });
  console.log(hook.secret); // store this — used for signature verification
  ```
</CodeGroup>

<Warning>
  The `secret` is returned only once on creation. Store it immediately — lose it and you'll have to rotate.
</Warning>

## Event types

| Event              | When it fires                                  |
| ------------------ | ---------------------------------------------- |
| `sms.queued`       | SMS accepted into our queue                    |
| `sms.sent`         | Handed off to the upstream carrier             |
| `sms.delivered`    | Carrier confirmed delivery to handset          |
| `sms.failed`       | Carrier reported permanent failure             |
| `sms.rejected`     | Pre-carrier reject (invalid number, DND, etc.) |
| `email.sent`       | Handed off to SES                              |
| `email.delivered`  | SES confirmed delivery                         |
| `email.bounced`    | Permanent bounce                               |
| `email.complained` | Recipient marked as spam                       |
| `email.opened`     | Open-tracking pixel fired                      |
| `email.clicked`    | Link was clicked                               |

## Payload shape

Every event uses the same envelope:

```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-7af3c1",
    "carrier": "mtn_ng",
    "cost": { "amount_kobo": 400, "currency": "NGN" }
  }
}
```

The `data` object shape matches the resource GET response — same shape as `GET /v1/sms/:id`.

## Signature verification

Every request includes two headers:

```http theme={null}
Robase-Signature: t=1718637005,v1=3c2a9f...
Robase-Event: sms.delivered
```

`v1` is the hex HMAC-SHA256 of `{t}.{body}`, signed with your webhook's secret. Verify it before trusting the payload:

<CodeGroup>
  ```typescript Node.js theme={null}
  import { verifyWebhook } from '@robase/node';

  app.post('/webhooks/robase', async (req, res) => {
    const raw = await rawBody(req);
    const sig = req.headers['robase-signature'] as string;

    const ok = await verifyWebhook(raw.toString('utf8'), sig, process.env.WEBHOOK_SECRET!);
    if (!ok) return res.status(401).end();

    const event = JSON.parse(raw.toString('utf8'));
    await processEvent(event);
    res.status(200).end();
  });
  ```

  ```python Python theme={null}
  from robase import verify_webhook

  @app.route("/webhooks/robase", methods=["POST"])
  def handler():
      raw = request.get_data()
      sig = request.headers["Robase-Signature"]

      if not verify_webhook(raw, sig, os.environ["WEBHOOK_SECRET"]):
          return "", 401

      event = json.loads(raw)
      process_event(event)
      return "", 200
  ```

  ```go Go theme={null}
  http.HandleFunc("/webhooks/robase", func(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      sig := r.Header.Get("Robase-Signature")

      if !robase.VerifyWebhook(body, sig, os.Getenv("WEBHOOK_SECRET"), 300) {
          http.Error(w, "", 401)
          return
      }

      var event struct { Type string; Data map[string]any }
      _ = json.Unmarshal(body, &event)
      processEvent(event)
      w.WriteHeader(200)
  })
  ```
</CodeGroup>

<Note>
  Reject timestamps older than 5 minutes — stale signatures are replay attempts. Our SDK helpers enforce this by default.
</Note>

## Retry behavior

If your endpoint returns a non-2xx status, we retry with exponential backoff:

```
1s → 5s → 30s → 2m → 15m → 1h → 6h
```

Seven attempts total, across roughly 7.5 hours. After that the delivery is marked failed in the dashboard and we stop.

## Testing from the dashboard

**Webhooks → your webhook → Send test** fires a synthetic event at your URL. Use this to prove your signature verification works before shipping. See each delivery attempt (status code, latency, response body) in the **Deliveries** tab.
