SMS Webhook Debugging: Twilio, Vonage, MessageBird & Plivo

Last updated April 10, 202614 min read
TL;DR

SMS webhook debugging is harder than typical REST webhooks because:

  • Delivery receipts (DLRs) arrive asynchronously, out of order, and sometimes hours late
  • Every provider ships a different payload shape, signature scheme, and error taxonomy
  • Twilio and Plivo send form-encoded bodies; Vonage and MessageBird send JSON
  • Signature validation silently breaks whenever your tunnel URL rotates

Point your provider at a Hooklistener endpoint, inspect the raw headers and body, then replay failed events to your local handler.

If you've ever sent a perfectly valid SMS and then stared at an empty webhook log wondering whether the carrier ate the delivery receipt, you already know SMS webhooks are a different beast. This guide covers the tools and workflow developers use to debug SMS API webhook payloads from the four most common providers — Twilio, Vonage (Nexmo), MessageBird, and Plivo — plus a unified debugging workflow that works regardless of provider.

Why SMS webhooks are harder to debug

HTTP webhooks for REST APIs (Stripe, GitHub, Shopify) are predictable: you send a request, an event fires, your endpoint receives a payload that roughly matches the docs. SMS is different because there's a carrier network between you and the handset, and that network introduces several categories of surprise.

  • Asynchronous, out-of-order DLRs. Delivery receipts travel back from the mobile carrier through your SMS provider to your webhook. A delivered event can arrive before the sent event, or minutes — sometimes hours — after the message was originally queued. Don't write state machines that assume queued → sent → delivered ordering.
  • Carrier routing delays. International routes, MNP lookups, and congested SMSC queues can hold a message for long enough that your test run finishes before the DLR arrives. This is the single biggest reason developers conclude "the webhook isn't firing" when really it hasn't fired yet.
  • Cryptic error codes. Twilio uses 30003/30007, Vonage uses numeric err-code values, MessageBird uses named statuses, and Plivo uses its own codes. There is no industry standard.
  • MO vs MT routing. Mobile-Originated (inbound, user → you) and Mobile-Terminated (outbound, you → user) events are often sent to differentURLs, configured in different places. Debug the wrong one and you'll swear the webhook is broken.
  • Per-provider signature quirks.Twilio still uses HMAC-SHA1 over URL + sorted params. Vonage uses JWTs. MessageBird uses a JWT-like HS256 signature. Plivo is on its third signature version. You can't copy-paste validation code between providers.
  • Payload shapes differ wildly. Even the field name for "the message ID" varies: MessageSid, messageId, id, MessageUUID. Your normalization layer needs per-provider branches.

Common SMS webhook event types

Regardless of provider, SMS webhooks fall into a small number of logical categories. The names differ, but the semantics are consistent.

EventMeaning
MessageQueuedProvider accepted the outbound message and queued it for the carrier.
MessageSentMessage handed to the carrier SMSC. Not yet on the handset.
MessageDeliveredCarrier confirmed delivery to the destination handset (final success state).
MessageFailedProvider-side failure — invalid number, suspended account, unroutable.
MessageUndeliveredCarrier-side failure — handset off, filtered, blocked, or unreachable.
InboundMessage (MO)User replied to or messaged your long code / short code / sender ID.
OptOut (STOP)Carrier- or provider-handled STOP keyword. You must stop messaging that number.

Debugging Twilio SMS webhooks

Twilio is the most widely deployed SMS provider, and it has the largest surface area of footguns. Configure your webhook on a Messaging Service (or per-number) in the Twilio Console: Messaging → Services → [your service] → Integration, or set statusCallback per-message when calling the REST API.

Twilio posts application/x-www-form-urlencoded bodies, not JSON. This is the single most common source of "my handler sees an empty body" bug reports. A typical MessageStatus callback looks like this when parsed:

twilio-status-callback.json
{
  "MessageSid": "SM1a2b3c4d5e6f7890abcdef1234567890",
  "MessageStatus": "delivered",
  "To": "+15551234567",
  "From": "+15559876543",
  "AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "ApiVersion": "2010-04-01",
  "ErrorCode": "",
  "ErrorMessage": ""
}

Every Twilio request includes an X-Twilio-Signature header. The signature is an HMAC-SHA1 (yes, SHA-1 — a legacy choice Twilio still uses) computed over the full request URL concatenated with the POST parameters sorted alphabetically by key. Validate it with the official SDK rather than reimplementing:

twilio_validate.py
from flask import Flask, request, abort
from twilio.request_validator import RequestValidator
import os

app = Flask(__name__)
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])

@app.post("/twilio/status")
def twilio_status():
    # Use the FULL public URL Twilio POSTed to — not localhost
    url = "https://k7x2m9.hook.events/twilio/status"
    signature = request.headers.get("X-Twilio-Signature", "")
    params = request.form.to_dict()

    if not validator.validate(url, params, signature):
        abort(403)

    print(params["MessageSid"], params["MessageStatus"], params.get("ErrorCode"))
    return ("", 200)

Common Twilio gotchas:

  • ngrok-style tunnel URL rotates on restart and your signature validation starts failing silently because Twilio signed against the old URL.
  • Your handler uses express.json() and gets an empty body. Use express.urlencoded() for status callbacks.
  • Error 30003 (unreachable handset — phone off, out of coverage) vs 30007 (carrier spam filter) look similar but require completely different remediation.
  • Twilio's debugger at console.twilio.com/develop/debugger surfaces non-2xx responses from your handler — check it before assuming nothing fired.

Debugging Vonage (Nexmo) SMS webhooks

Vonage — still branded "Nexmo" in many corners of its docs — configures inbound messages and delivery receipts as two separate URLs in the dashboard under Your numbers → Manage → Inbound Webhook URL and Account settings → SMS settings → Delivery receipts. If only one fires, you probably only configured one URL.

Vonage sends JSON for the Messages API v1 and newer. A delivery receipt looks like:

vonage-dlr.json
{
  "message_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "to": { "type": "sms", "number": "15551234567" },
  "from": { "type": "sms", "number": "15559876543" },
  "timestamp": "2026-04-10T12:34:56.789Z",
  "status": "delivered",
  "usage": { "currency": "EUR", "price": "0.0067" },
  "client_ref": "order-12345"
}

Vonage ships two different signing schemesdepending on which API you're on, and conflating them is the #1 source of broken verification code copied off the internet:

  • Vonage Messages API (current). Inbound and status webhooks are signed as a JWT in the Authorization: Bearer header using RS256. The signing key is your Vonage application's private key (the same key you use for application auth) — verification is done with the corresponding public key, not a shared secret. Anyone showing you HS256 + a "signature secret" for Messages API is wrong.
  • Legacy Vonage SMS API (deprecated, still widely deployed). Used an HMAC (MD5/SHA-1/SHA-256 depending on account settings) over the request parameters, delivered as a sig query parameter and keyed by your account's signature secret. If you're still on this, migrate — but in the meantime validate via the sig param, not a JWT.

Because both schemes drift across SDK versions, the safest approach is to use the official Vonage SDK's built-in verifier rather than hand-rolling JWT decoding. Conceptually, for the Messages API:

vonage_validate.py
# Vonage Messages API webhook verification (conceptual)
# Docs: https://developer.vonage.com/en/getting-started/concepts/webhooks#signed-webhooks
#
# 1. Read the JWT from the Authorization: Bearer <token> header.
# 2. Decode and verify the JWT using RS256 and your Vonage application's
#    PUBLIC key (the pair of the private key uploaded to the application).
# 3. Check the 'iat' / 'exp' claims and reject stale tokens.
# 4. Optionally verify the payload hash claim matches a hash of the raw body
#    to detect tampering.
#
# Prefer the official SDK helper — the exact function name varies by SDK
# version (e.g. vonage.Client / vonage-jwt). Consult the docs for your
# language instead of inventing an import path.
#
# Example shape (pyjwt, RS256, public key):
#
#   import jwt
#   with open("vonage_public.key", "rb") as f:
#       public_key = f.read()
#   claims = jwt.decode(token, public_key, algorithms=["RS256"])
#
# For the LEGACY SMS API, do NOT use JWT at all — verify the 'sig' query
# parameter with HMAC keyed by your signature secret instead.

Common Vonage gotchas:

  • Copy-pasting HS256 + "signature secret" code for the Messages API. Messages API is RS256 with your application's private/public key pair — it will never verify against a shared secret.
  • Uploading a new private key to the Vonage Application dashboard and forgetting to redeploy the matching public key to your verifier.
  • Clock skew on your webhook host rejecting valid JWTs via the iat/exp claims.
  • Mixing legacy SMS API (sig query param, HMAC) and Messages API (JWT in Authorization header) validation code in the same handler — check which API your Vonage account is actually using.

Debugging MessageBird SMS webhooks

MessageBird configures webhooks per Flowin the dashboard — a Flow is essentially a routing rule that fires HTTP callbacks when SMS events occur. If you're debugging and not seeing events, double-check which Flow is bound to which number.

A typical SMS event payload is JSON:

messagebird-sms.json
{
  "id": "e8077d803532449d8b5a5c8e0e3b1234",
  "status": "delivered",
  "recipient": 15551234567,
  "originator": "+15559876543",
  "direction": "mt",
  "createdDatetime": "2026-04-10T12:34:56+00:00",
  "statusDatetime": "2026-04-10T12:34:58+00:00"
}

MessageBird's current "Signed Webhooks" scheme ships the signature as a JWT in the MessageBird-Signature-JWT header, signed with HS256 using your webhook signing key. The JWT payload carries standard iat/nbf/exp claims plus a hash of the request body, so replay protection and tamper detection come from verifying the token itself — don't invent timestamp headers that aren't in the spec. (An older integration used a plain MessageBird-Signatureheader with a request-timestamp header; if you see that in legacy code, you're on the deprecated scheme.)

messagebird_validate.py
# MessageBird Signed Webhooks — verify the JWT in MessageBird-Signature-JWT
# Docs: https://developers.messagebird.com/docs/verify-http-requests
#
# 1. Read the token from the MessageBird-Signature-JWT header.
# 2. Verify HS256 signature using your webhook signing key.
# 3. Enforce 'iat' / 'nbf' / 'exp' claims — pyjwt does this for you.
# 4. Verify the payload_hash claim matches SHA-256 of the raw request body
#    so an attacker can't swap the body under a valid token.
# 5. Constant-time compare the computed hash to the claim.
#
# Prefer the official messagebird SDK helper when it's available — the
# exact claim names have moved across SDK versions, so consult the current
# docs above before hand-rolling verification.

Common MessageBird gotchas:

  • Conflating Voice Flows and SMS Flows — both use the same Flow Builder but emit different payload shapes.
  • Forgetting to enable signed webhooks in the dashboard; unsigned requests will arrive with no signature header at all.
  • Assuming the recipient field is a string — it's frequently sent as an integer, which breaks strict JSON schemas.

Debugging Plivo SMS webhooks

Plivo attaches webhook URLs to Applications. The SMS endpoint is set as message_url on the Application, and the Application is then assigned to a number. Edit the Application — not the number — when your callback URL changes.

Like Twilio, Plivo sends application/x-www-form-urlencoded bodies:

plivo-status.json
{
  "MessageUUID": "b7f2e8a0-1234-5678-9abc-def012345678",
  "Status": "delivered",
  "From": "15559876543",
  "To": "15551234567",
  "MessageState": "delivered",
  "ErrorCode": "",
  "TotalRate": "0.0065",
  "Units": "1"
}

Plivo has shipped three generations of webhook signatures — V1, V2, and the current V3 — and most "my Plivo signature won't verify" bugs come from validating against the wrong version. V3 is sent in the X-Plivo-Signature-V3 header alongside an X-Plivo-Signature-V3-Nonce, and is an HMAC-SHA256 over a canonical string built from the request, keyed by your Auth Token.

The SDK helper's import path and function signature have changed between Plivo SDK releases, so rather than ship copy-paste code that ImportErrors on half of readers, here are the conceptual steps — then defer to the docs for the exact current helper in your language:

plivo_validate.py
# Plivo V3 signature validation (conceptual)
# Docs: https://www.plivo.com/docs/sms/concepts/signature-validation/v3/
#
# 1. Read the X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers.
# 2. Build the canonical string Plivo specifies:
#      <method> | <full-request-uri> | <nonce> | <sorted-form-params>
# 3. Compute HMAC-SHA256(canonical_string, key=PLIVO_AUTH_TOKEN) and
#    base64-encode it.
# 4. Constant-time compare against the X-Plivo-Signature-V3 header value.
#
# Use Plivo's current SDK helper rather than rolling this by hand — the
# exact function name and module path vary by SDK version (the Python SDK
# exposes it under plivo.utils; Node/PHP/Go differ). Consult the docs for
# your language and SDK version at the URL above.

Common Plivo gotchas:

  • Validating against the wrong signature version. Plivo has shipped V1, V2, and V3 — V1/V2 used different headers and algorithms, so code written for an older version silently fails on V3 traffic. Check which version Plivo is sending before debugging your verifier.
  • Editing the phone number's direct webhook instead of the Application's message_url, then wondering why changes don't take effect.
  • Form-encoded body confusion — just like Twilio, a JSON parser will give you an empty object.

A unified debugging workflow

The tool-level details above differ per provider, but the debugging loop is the same. The workflow below works for all four providers and is the fastest way to isolate whether a bug lives in the carrier, the provider, the transport, or your handler.

  1. Create a Hooklistener endpoint.This gives you a public HTTPS URL that captures every request, headers and body, with zero server-side interpretation. No tunnel is required — it's a real endpoint.
  2. Paste the URL into your provider's webhook config. Twilio Messaging Service status callback, Vonage inbound + DLR URLs, MessageBird Flow step, or Plivo Application message_url.
  3. Trigger a test SMS.Use each provider's CLI or API so you control exactly what's sent:
trigger-test-sms.sh
# Twilio CLI
twilio api:core:messages:create \
  --from "+15559876543" \
  --to "+15551234567" \
  --body "test" \
  --status-callback "https://k7x2m9.hook.events/twilio/status"

# Vonage CLI
vonage sms "+15551234567" "test" --from "15559876543"

# MessageBird (curl)
curl -X POST https://rest.messagebird.com/messages \
  -H "Authorization: AccessKey $MESSAGEBIRD_API_KEY" \
  -d "originator=HL" -d "recipients=15551234567" -d "body=test"

# Plivo (curl)
curl -X POST https://api.plivo.com/v1/Account/$PLIVO_AUTH_ID/Message/ \
  -u "$PLIVO_AUTH_ID:$PLIVO_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"src":"15559876543","dst":"15551234567","text":"test"}'
  1. Inspect headers, body, and raw bytes.Open the request in the Hooklistener dashboard. Confirm the Content-Type (form-urlencoded or JSON), find the provider's signature header, and read the exact field names the provider used.
  2. Replay the event to your local handler.Once you can see the real payload, replay it against your handler as many times as needed to reproduce the bug. No more "send another test SMS and wait" loops.
  3. Forward to staging or production while keeping full history, so you can diff the payloads that broke against the ones that worked.

Info:Because Hooklistener stores the raw request bytes, you can retroactively verify signatures on yesterday's DLRs — something that's impossible if the provider's retry window has already closed.

SMS webhook signature verification — summary table

Quick reference for the four providers covered above:

ProviderHeaderAlgorithmSecret source
TwilioX-Twilio-SignatureHMAC-SHA1 over URL + sorted paramsAccount Auth Token
VonageAuthorization: Bearer <jwt>Signed JWT (HS256)API Signature Secret
MessageBirdMessageBird-Signature-JWTJWT (HS256) + timestamp headerWebhook Signing Key
PlivoX-Plivo-Signature-V3HMAC-SHA256 (V3) + nonceAccount Auth Token

Docs: Twilio · Vonage · MessageBird · Plivo.

Common pitfalls across all providers

  • Rotating tunnel URLs. ngrok-style tunnels assign a new hostname on every restart. Every provider signs against the URL it was told to POST to, so signature validation starts failing the moment the URL changes — without any obvious error.
  • Content-Type mismatches. Twilio and Plivo send application/x-www-form-urlencoded; Vonage and MessageBird send application/json. Parse the raw body and switch on Content-Type — don't assume.
  • Clock skew on timestamp validation. MessageBird enforces a timestamp replay window, Vonage JWTs carry iat/exp. Run NTP on your webhook host or you'll reject valid traffic intermittently.
  • Assuming DLR ordering. Don't write a state machine that requires sent before delivered. Treat each DLR as an independent observation and merge by message ID.
  • Non-portable error codes. Twilio's 30007 (carrier filtered) has no direct equivalent in Vonage or Plivo. Build a per-provider mapping table rather than a global enum.
  • STOP / opt-out compliance.Inbound "STOP" messages are usually handled by the provider automatically, but you still need to receive and persist the resulting webhook. Test the inbound MO flow end-to-end, not just MT.
  • Rate limiting during high-volume debugging. Firing hundreds of test messages through a trial account will trip rate limits. Use replay instead of re-sending whenever possible.

Debug any SMS provider from one dashboard

Hooklistener captures the raw headers, body, and bytes of every SMS webhook — Twilio, Vonage, MessageBird, Plivo, or anything else that speaks HTTP. Inspect payloads, replay failed DLRs against your local handler, and forward events to staging without losing history.

Related guides