Messaging-provider webhooks
Why these are different
Twilio and Meta sign their webhooks (HMAC), so the platform verifies a
signature. MSG91 and Gupshup do not HMAC-sign their callbacks. The accepted
mechanism for those is a secret token appended to the callback URL (plus an
optional edge IP allow-list). Every handler fails closed — a missing or
incorrect token, or an unset secret, returns 403 before any state mutation —
so a spoofed callback can never forge an opt-out or a delivery status.
Endpoints
| Provider | Channel | Endpoint | Auth |
|---|---|---|---|
| MSG91 | SMS (India / +91) | /api/msg91-webhook | ?token= (or x-msg91-token header) |
| Gupshup | /api/gupshup-webhook | ?token= | |
| Twilio | SMS | /api/sms-webhook | Twilio signature |
| Meta | /webhooks/whatsapp | X-Hub-Signature-256 |
MSG91 — /api/msg91-webhook
Register this URL in the MSG91 dashboard as the delivery-report (DLR) webhook and, if two-way SMS is enabled, the incoming-message webhook — with the shared secret appended:
https://<app>/api/msg91-webhook?token=<MSG91_WEBHOOK_SECRET>
Two event families, both normalized by the MSG91 adapter and fed into the same provider-neutral handlers Twilio uses:
- Delivery report (DLR) →
applyDeliveryStatus(monotonic status write). - Inbound message →
handleInboundKeyword(STOP/START opt-out, parity with the rest of SMS compliance).
Gupshup — /api/gupshup-webhook
Register this URL in the Gupshup app's callback settings, with the secret token:
https://<app>/api/gupshup-webhook?token=<GUPSHUP_WEBHOOK_SECRET>
Two event families, normalized by the Gupshup adapter into the same provider-neutral handlers Meta uses:
- message-event (DLR) →
applyWhatsAppDeliveryStatus(monotonic status write). - message (inbound) → STOP-keyword opt-out (parity with SMS).
This handler covers delivery reconciliation and the compliance-critical opt-out path. Full inbound conversation ingestion for Gupshup (session window, click-to-WhatsApp, agent inbox) is a follow-up; today, two-way conversations run on Meta's Cloud API.
The monotonic status pipeline
Providers emit status callbacks that can arrive duplicated and out of
order — a sent DLR landing after delivered, a retried callback
re-delivering queued. Writing whatever arrives last would corrupt the message
log (a delivered message flipping back to "sent", a read receipt clobbered).
Every DLR — from any provider — passes through one shared guard that makes status writes monotonic: it only advances along the lifecycle and makes terminal states sticky, so late or duplicate callbacks are safe no-ops.
queued → sent → delivered → read (positive lifecycle; higher = further)
failed / undelivered (terminal — sticky)
The status vocabulary is provider-neutral and matches the strings already stored on the message log, so nothing downstream changes when a message was sent via MSG91 vs. Twilio, or Gupshup vs. Meta.
Configuration checklist
- Set the secret env var(s):
MSG91_WEBHOOK_SECRET,GUPSHUP_WEBHOOK_SECRET. - Register the callback URL(s) with the token appended, in each provider's dashboard.
- (Optional) Restrict the endpoint to the provider's published egress IPs at the edge.
- Verify with a test send — the message log should progress
queued → sent → deliveredand a STOP reply should record an opt-out.
See also
- WhatsApp Cloud API webhooks — the Meta-signed path
- Webhooks overview
- SMS channel — the merchant-facing view
- Authentication & scopes