Skip to main content
Robase fires webhook events at every SMS lifecycle transition. Subscribe to the ones you care about when you register a webhook.

Event types

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.
{ "type": "sms.queued", "data": { "sms_id": "...", "status": "queued", ... } }
The upstream carrier accepted the message. This is not delivery — just that the carrier acknowledged the handoff.
{ "type": "sms.sent", "data": { "sms_id": "...", "status": "sent", "provider": "beem", "provider_message_id": "beem-a1b2" } }
The handset confirmed receipt (via the carrier’s delivery report). This is the event you care about for OTP flows.
{ "type": "sms.delivered", "data": { "sms_id": "...", "status": "delivered", "carrier": "mtn_ng", ... } }
A permanent failure after send — the carrier tried but couldn’t deliver. Typically: handset off too long, SIM no longer exists, blacklist.
{ "type": "sms.failed", "data": { "sms_id": "...", "status": "failed", "error_code": "17", "error_message": "handset unreachable" } }
Wallet overage is automatically refunded for permanent failures.
Pre-send rejection — e.g. phone_invalid, dnd_blocked, or carrier-level sender-ID block. No attempt was made to deliver.
{ "type": "sms.rejected", "data": { "sms_id": "...", "status": "rejected", "error_code": "dnd_blocked" } }
Also refunds overage automatically.

Payload envelope

Every event has the same top-level shape:
{
  "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 for the verification code per language.

Common patterns

OTP flow

// 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

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.