Dead Ringer
Docs

Quickstart

No UI is needed. The mock is an HTTP server; your app talks to it exactly as it would talk to the real service, and you play the customer with curl.

Three channels, one project. Dead Ringer stands in for all three at once, so an app that sends a WhatsApp template, the email that follows it and an SMS fallback can be pointed at one place and watched in one window.

Channel Stands in for What your app needs Where
WhatsApp Meta's Cloud API, graph.facebook.com Graph base URL, access token §3
Email Postmark's API, and SMTP on port 3025 five lines of mail config §14
SMS SMSPortal client ID and API secret §15

Sections 1 to 13 are WhatsApp, because it has by far the most surface. Email and SMS need nothing beyond their own credentials, and the project you create in §2 already carries all three sets.

1. Start it

Open Dead Ringer. Installing on Mac covers the download and first launch.

Leave it running: it serves on http://127.0.0.1:3010 and takes SMTP on port 3025 for as long as the window is open, and every curl below talks to that. Closing the window does not quit it — the mock keeps serving webhooks, and Quit is Cmd-Q. Its data lives in your user config directory.

2. Create a project

One project is one client integration, and it carries the credentials for all three channels.

curl -s -X POST http://127.0.0.1:3010/_api/projects \
  -H 'Content-Type: application/json' \
  -d '{"name":"Acme Corp"}'
{
  "id": "acme-corp",
  "waba_id": "100000000000001",
  "phone_number_id": "100000000000002",
  "access_token": "EAA…",
  "app_secret": "f409…",
  "webhook_verify_token": "4da4…",
  "email_token": "88bb…",
  "sms_client_id": "7bbe223aad710329",
  "sms_api_secret": "9c97…",
  "base_url": "http://127.0.0.1:3010",
  "graph_api_url": "http://127.0.0.1:3010/100000000000002",
  "postmark_api_url": "http://127.0.0.1:3010",
  "smtp": { "host": "127.0.0.1", "port": 3025, "username": "acme-corp", "password": "88bb…" }
}

Trimmed — every project setting comes back as well. The three credential sets are the point: access_token for WhatsApp, email_token for Postmark and SMTP (it is the SMTP password), sms_client_id and sms_api_secret for SMSPortal. Use graph_api_url as it is given, phone number ID and all; postmark_api_url is the host on its own.

A new project has no customers. Add one with + New chat in the UI, or over the API (below). Use a number your app already knows to link to an existing user, or leave the number blank for a fictional one — US 555-01xx, reserved for fiction so it is never a real person, but valid to phone-number validation. Sending to any number from your app also adds that customer.

3. Point your app at it

Replace the Graph base URL and credentials in your app's config. For a typical Laravel setup:

META_GRAPH_URL=http://127.0.0.1:3010   # or http://host.docker.internal:3010 if the app runs in Docker
META_GRAPH_VERSION=v25.0
META_WHATSAPP_PHONE_NUMBER_ID=100000000000002
META_WHATSAPP_BUSINESS_ACCOUNT_ID=100000000000001
META_APP_ID=100000000000006
META_WHATSAPP_ACCESS_TOKEN=EAA…
META_APP_SECRET=f409…
META_WHATSAPP_VERIFY_TOKEN=4da4…

These are Meta's own variable names, so an app already talking to the real thing changes one URL rather than renaming seven variables. If your app uses different names, set yours under Settings › Edit variable names and the copyable block comes back in them.

The version prefix is optional and is accepted whatever you set it to.

The base URL is the host only — the drop-in for https://graph.facebook.com. Do not include the phone number ID: your app appends /{version}/{phone-number-id} itself, and a base that already ends in the ID doubles it, so every send 404s.

If your app runs in Docker, use http://host.docker.internal:3010. Inside a container localhost is the container itself, not your machine.

4. Tell the mock where your webhook is

curl -s -X PATCH http://127.0.0.1:3010/_api/projects/acme-corp \
  -H 'Content-Type: application/json' \
  -d '{"webhook_url":"http://acme.test/api/whatsapp/webhook"}'

Give the URL as your machine sees it: localhost and the port your app publishes, or a Herd .test domain. That holds even when the app runs in Docker, because the mock runs on your machine, not in a container. Never use host.docker.internal here — that name only resolves inside containers.

https:// works with a certificate your machine trusts, such as mkcert or Herd. Saving the URL runs the handshake straight away, and a failure names the cause: the Docker hostname, DNS, nothing listening, an untrusted certificate, or a timeout.

5. Send from your app

Whatever your app normally does. Free-form sends will fail first time, because a fresh project is outside the 24-hour window — exactly as production behaves. That is the point.

Note how it fails, because this is the detail most integrations get wrong: Meta accepts the call, returns a wamid, and then sends a failed status webhook carrying 131047. It does not reject the send. Code that only checks the response of the send never notices.

Open the window by having the customer speak (step 6), or turn enforcement off:

curl -s -X PATCH http://127.0.0.1:3010/_api/projects/acme-corp \
  -H 'Content-Type: application/json' -d '{"window_enforcement":"off"}'

6. Be the customer

# a text message
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"12025550142","text":"hello"}'

# tapping a reply button — a different payload shape, and the one that most
# often breaks handlers. context_message_id is the wamid of the message tapped.
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"12025550142","button_reply":{"id":"confirm_order","title":"Confirm"},"context_message_id":"wamid.…"}'

# choosing a list item
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"12025550142","list_reply":{"id":"size_large","title":"Large"},"context_message_id":"wamid.…"}'

# tapping a quick-reply button on a template — a different shape again
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"12025550142","button":{"text":"Reschedule","payload":"RESCHEDULE_4815"},"context_message_id":"wamid.…"}'

# sending a file: a document, photo, video, audio file or voice note
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -F from=12025550142 -F [email protected] -F caption="Signed lease"
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -F from=12025550142 -F [email protected] -F voice=true

Each response includes the webhook attempt — the signed body, the status your handler returned, and how long it took. A webhook that never arrived says so, rather than failing silently.

A file arrives as Meta sends it: type is image, video, audio or document, decided by the file unless you pass -F type=…, and the object carries a media id, mime_type, sha256, and a caption or filename where Meta has them. Voice notes add "voice": true and must be .ogg files, because WhatsApp records them as Ogg Opus. Your app fetches the file the way it would from Meta, through GET /{media-id} and the download URL with its access token (§10). The limits are Meta's: photos are JPEG or PNG up to 5 MB, video MP4 or 3GPP up to 16 MB, audio AAC, AMR, MP3, MP4 or OGG up to 16 MB, and documents any type up to 100 MB. From JSON, send the file base64-encoded: {"from":"…","document":{"content":"<base64>","filename":"lease.pdf"}}, and likewise image, video and audio. In the chat, the paperclip beside the message box does the same.

Taps carry context. WhatsApp names the message a tap was on, as "context": {"from": "<business number>", "id": "<wamid>"}, and an app that matches a reply back to the message it sent reads context.id. Pass the wamid your app got back from the send as context_message_id. The chat does this for every tap.

A template's quick-reply button is not an interactive reply. Tapping one sends "type": "button" with button.payload and button.text, not interactive.button_reply, and an app that only handles interactive misses it in production. payload is what your app set on that button when it sent the template (a button component with "sub_type": "quick_reply", matched by index); leave it out when your app set none, and it is the button text, as Meta sends. context_message_id is required with button, because Meta always sends it. Buttons on carousel cards tap the same way, naming the carousel message. URL and phone buttons send nothing, in the chat as on WhatsApp.

from is required: the number of the customer who is speaking. A number the project has not seen before is added as a customer, with their window closed until they message.

Several customers

A project holds any number of customers, each with their own conversation and their own 24-hour window. That is what makes flows involving two people — an applicant and a guarantor — testable.

In the UI, + New chat adds one. Over the API:

# add a customer; use a number your app already knows to link to an existing user
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/customers \
  -H 'Content-Type: application/json' \
  -d '{"wa_id":"27823895956","name":"Roger Staples"}'

# speak as them; "name" also sets their WhatsApp profile name
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' -d '{"from":"27823895956","text":"Hi"}'

# every conversation, with its last message and window state
curl -s http://127.0.0.1:3010/_api/projects/acme-corp/conversations

# open or close one customer's window without a message
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/customers/27823895956/window \
  -H 'Content-Type: application/json' -d '{"open":true}'

Customers your app messages first are added automatically. Until you name one, webhooks carry a placeholder built from their number, such as Customer 5956. A customer's number cannot change once they have messages; add a new customer for a different number.

Several business numbers

A project starts with one business number and can have up to 20, as a verified business can on Meta — for an app that runs a sales line and a support line, or one number per branch. The numbers share the WABA, its templates, the access token and the webhook. Each has its own chats: the same customer writing to two numbers is two conversations with two 24-hour windows, and every webhook names the number in metadata.phone_number_id.

In the UI, Settings › Phone numbers adds them, and the chat tab gets a switcher once there is more than one. Over the API:

# add a number; the name labels it, and defaults to the project's
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/numbers \
  -H 'Content-Type: application/json' -d '{"name":"Support"}'
# → {"id":"…","display_phone_number":"+12025550163","verified_name":"Support","graph_api_url":"…"}

# your app sends through it as it would on Meta
curl -s -X POST http://127.0.0.1:3010/{that id}/messages …

# be a customer writing to that number
curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"27823895956","text":"Hi","phone_number_id":"{that id}"}'

Every customer route takes ?phone_number_id= and means the first number without it; listing conversations without it lists every number's. The project's own phone_number_id stays its first number, so a single-number .env keeps working. GET /{waba-id}/phone_numbers and GET /{phone-number-id} answer as Meta's do. Deleting a number deletes its chats; the last one cannot be deleted.

7. Drive delivery statuses

In auto mode, the default, your app gets sent and then delivered. It does not get read by itself: a real customer reads a message by opening the chat. Turn on read receipts in Settings and opening a chat in the UI sends read for the app's messages in it; the green badge in the chat list counts what is unread. In the UI, the ⋯ button on any of the app's messages sends any status on demand, in any order.

Switch to manual so nothing fires behind your back:

curl -s -X PATCH http://127.0.0.1:3010/_api/projects/acme-corp \
  -H 'Content-Type: application/json' -d '{"delivery_status":"manual"}'

Then, using the wamid your send returned:

M=wamid.xxxx

# the happy path in one call
curl -s -X POST "http://127.0.0.1:3010/_api/projects/acme-corp/messages/$M/status/sequence" -d '{}'

# or one at a time, in any order you like
curl -s -X POST "http://127.0.0.1:3010/_api/projects/acme-corp/messages/$M/status" \
  -H 'Content-Type: application/json' -d '{"status":"read"}'

# a failure, with a real Meta error code
curl -s -X POST "http://127.0.0.1:3010/_api/projects/acme-corp/messages/$M/status" \
  -H 'Content-Type: application/json' -d '{"status":"failed","error_code":131049}'

Things worth trying, because you cannot produce any of them against Meta's test number: statuses out of order, the same status twice, and never firing read.

8. Make a send fail

The outcome is carried by the number you send to, not by a header. Every project has the same built-in test numbers, with nothing to set up:

# accepted, then sent, delivered and read
curl -X POST "http://127.0.0.1:3010/100000000000002/messages" \
  -H 'Authorization: Bearer EAA…' -H 'Content-Type: application/json' \
  -d '{"messaging_product":"whatsapp","to":"27000000000","type":"text","text":{"body":"hi"}}'

# accepted, then a failed status carrying 131026
curl -X POST "http://127.0.0.1:3010/100000000000002/messages" \
  -H 'Authorization: Bearer EAA…' -H 'Content-Type: application/json' \
  -d '{"messaging_product":"whatsapp","to":"27000131026","type":"text","text":{"body":"hi"}}'

The number is 27000 followed by six digits. Below 100 they are behaviour presets; at 100 and above they are Meta error codes, and the send fails with that code — including codes not listed here.

Number What it does How it is reported
27000000000 happy path: sent, delivered, read accepted
27000000001 delivered, never read accepted
27000000002 stuck at sent accepted
27000131026 undeliverable 200, then a failed status
27000131047 re-engagement required 200, then a failed status
27000131049 marketing frequency cap 200, then a failed status
27000131053 media upload error 200, then a failed status
27000130429 throughput / rate limit 429 at the call
27000132000 wrong number of template params 400 at the call
27000132001 template does not exist 400 at the call
27000100 … 100, 131008, 131009 400 at the call

Which side of that line a code falls on is Meta's, not ours, and it is the thing worth rehearsing: an integration written for a synchronous rejection has an error branch that never runs in production. GET /_api/coverage returns the same table as JSON, and it is on the project's Settings tab.

The 27 is not about where you are. It is not a South African setting and it is not configurable, any more than 192.168.x.x or example.com are. It is chosen for one property: a 0 straight after the country code is never a real subscriber number, so these can never reach a person, while 27 plus nine digits is the right length to pass a length check. The part you have to recognise — the Meta error code — is the same everywhere.

If your app validates strictly rather than by length, these will be rejected before they reach the mock. Use a customer of your own instead; one call gets a number that passes libphonenumber, with the behaviour already on it:

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/customers \
  -H 'Content-Type: application/json' \
  -d '{"name":"Flaky","behaviour":{"steps":[{"outcome":"failed","error_code":131026}]}}'
# → {"wa_id":"12125550171", …} — a NANPA 555-01xx number, reserved for fiction

A behaviour on your own customer. Ordered steps; the last one repeats.

curl -s -X PATCH http://127.0.0.1:3010/_api/projects/acme-corp/customers/27823895956 \
  -H 'Content-Type: application/json' \
  -d '{"behaviour":{"steps":[{"outcome":"read"},{"outcome":"failed","error_code":131026}]}}'

That one works once and fails from then on. outcome is sent, delivered, read, failed (accepted, then a failed status) or sync_error (rejected at the call). Send {"behaviour":null} to put the customer back to the project default. The same field works on POST /_api/projects/{id}/customers.

One-shot, whoever it is to. Arms the project's next send:

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/failures \
  -H 'Content-Type: application/json' -d '{"error_code":131026}'

The code decides how it arrives, by the same table as above.

9. Templates

Your provisioning code should run unchanged against POST /{waba-id}/message_templates. Templates land PENDING; approve or reject them yourself, which fires a real message_template_status_update webhook:

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/templates/order_update/status \
  -H 'Content-Type: application/json' -d '{"event":"APPROVED"}'

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/templates/order_update/status \
  -H 'Content-Type: application/json' -d '{"event":"REJECTED","reason":"INCORRECT_CATEGORY"}'

Set {"template_approval":"auto"} on the project to approve on creation. The webhook still fires. In the UI they are under the WhatsApp tab, beside Chat.

A template with an image, video or document header needs an example file, which Meta takes through its resumable upload API, and so does the mock: POST /{app-id}/uploads with file_name, file_length and file_type, then POST the file's bytes to the upload:… ID that returns, with a file_offset: 0 header. The answer's h is the handle for the header's example.header_handle. Every send then supplies the header itself, as an id or a link; leaving it out, or sending the wrong kind, fails with 132012 as it does on Meta.

No override headers

There are none, deliberately. Nothing of ours is ever added to a request or a response on a Meta endpoint — no header, no field, no query parameter — so the send your app makes here is byte for byte the send it makes in production.

Behaviour that used to be pinned per request with X-Mock-Force-Failure, X-Mock-Window and X-Mock-Delivery is now decided by the recipient (§8), so two tests can still share a project without interfering: they address different customers.

10. Media

# upload
curl -s -X POST http://127.0.0.1:3010/v25.0/{phone_number_id}/media \
  -H 'Authorization: Bearer EAA…' \
  -F messaging_product=whatsapp -F type=image/png -F [email protected]
# → {"id":"100000000000005"}

# resolve the ID to a URL
curl -s http://127.0.0.1:3010/v25.0/100000000000005 -H 'Authorization: Bearer EAA…'
# → {"url":"…","mime_type":"image/png","sha256":"…","file_size":165,…}

# fetch the bytes — the auth header is required, as it is with Meta
curl -s "$URL" -H 'Authorization: Bearer EAA…' -o photo.png

Then send by id. Sending an ID that was never uploaded is rejected. Mime types and per-type size limits (5MB images, 16MB video and audio, 500KB stickers, 100MB documents) are enforced at upload rather than at send.

Or send by link, and the mock fetches it, as Meta does. Meta downloads the file when the message is sent, checks it and keeps its own copy. So does the mock: the chat shows the stored copy, not your URL. A link that cannot be used fails the message the way it will in production — a 200 with a wamid, then a failed status carrying 131053, "Media upload error", with the cause in error_data.details:

  • the link answers with an error: Downloading media from weblink failed with http code 404, status message Not Found
  • the Content-Type is not one Meta takes for that message type, which is what a login page or application/octet-stream gets you
  • the file is over the size limit for its type, or empty
  • the link points somewhere Meta cannot reach: localhost, a .test or .local name, or a private address. The mock could fetch it from your machine; Meta never can, so it fails here rather than in production. Serve the file from a public URL, or upload it and send the id.

Meta reuses a file fetched from a link for ten minutes, so a changed file at the same URL is not picked up; add a query string to force a fresh fetch. The mock does the same.

This is the one request the mock makes to the internet on your app's behalf, and only because your app sent a link. On a machine that must not reach out, set {"media_links":"off"} on the project (or Media links › Off under the WhatsApp gear): links are accepted without being fetched, and the chat shows the link instead of the file.

11. MCP

One instance serves every workspace on the machine, so agents do not each need their own process:

claude mcp add --transport http --scope user deadringer http://127.0.0.1:3010/mcp

Or open Connect an agent at the top right of the UI. It shows the same command with the live URL, plus setup for Cursor, VS Code and Codex.

Tools:

  • Projects: create_project, list_projects, get_project, delete_project, update_project_settings, verify_webhook.
  • Customers: list_conversations, create_customer, rename_customer, delete_customer, set_window, mark_conversation_read, list_test_numbers.
  • Phone numbers: add_phone_number, rename_phone_number, delete_phone_number. The customer and WhatsApp tools take an optional phone_number_id to say which number the conversation is with.
  • WhatsApp: simulate_inbound_message, simulate_delivery_status, simulate_template_status_update, simulate_failure, list_messages, get_message, delete_message, list_templates.
  • Email: list_emails, get_email, delete_email, simulate_inbound_email, simulate_email_event, list_email_suppressions, reactivate_email_address.
  • SMS: list_sms, delete_sms, simulate_sms_status, simulate_sms_reply, list_sms_optouts, remove_sms_optout, expire_sms_tokens.

update_project_settings takes every field the PATCH above does, so an agent can take a project from nothing to connected without a human: create_project, then update_project_settings with the webhook_url, which runs the handshake and returns the result. get_project and create_project return the .env blocks under env.

Simulated events are labelled SIMULATED … in the tool output, so a green suite cannot quietly hide a conversation an agent fabricated on both sides.

Deleting a project

curl -s -X DELETE http://127.0.0.1:3010/_api/projects/acme-corp

Removes everything in the project — messages, customers, templates, the webhook log and every uploaded file — and returns a report of exactly what went. It is also on the Settings tab. Recreating a project with the same name gives back the same phone number and WABA IDs, with new tokens and secrets.

12. What is implemented

curl -s http://127.0.0.1:3010/_api/coverage

Every endpoint and all 19 webhook fields, each marked supported, partial (with the gap named), unsupported or not-planned, plus the Graph API version the list was last checked against. A test fails the build if the registry and the routes disagree, so it cannot quietly drift.

It also carries the Graph API versions Meta still supports, so a run can check that the version it is calling has not been retired. The mock accepts any version and records it on the message, but adds nothing to the response.

13. Mark as read

curl -X PUT "http://127.0.0.1:3010/v25.0/{phone_number_id}/messages" \
  -H 'Authorization: Bearer EAA…' -H 'Content-Type: application/json' \
  -d '{"messaging_product":"whatsapp","status":"read","message_id":"wamid.…"}'

Optionally with "typing_indicator":{"type":"text"}. No webhook fires — the customer sees blue ticks, but nothing comes back to the business.

14. Email

The same project stands in for Postmark and takes SMTP. For a Laravel app the whole setup is env:

MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=3025
MAIL_USERNAME=acme-corp     # the project ID routes the mail
MAIL_PASSWORD=anything
MAIL_ENCRYPTION=null

Sent mail appears in the project's Email tab, where you can fire Postmark's Delivery, Bounce, SpamComplaint, Open and Click webhooks at your app, and reply as the customer to post Postmark's inbound webhook. Set the two URLs under Settings. The Postmark HTTP API (POST /email, token under Settings) is there for SDKs that can be pointed at a host; Laravel's built-in postmark mailer cannot, hence SMTP.

A reply can carry an HTML body as well as plain text, written with a formatting toolbar or pasted as source. Both parts are sent, the way a mail client sends multipart/alternative; with HTML alone the text part is derived from it. It can carry attachments too, up to Postmark's 35 MB per message. Tick Inline on an attached image to give it a Content-ID and show it with <img src="cid:photo.jpg">. Inline images in mail your app sends render in place in the HTML view as well.

15. SMS

The same project also stands in for SMSPortal. Credentials come back from the project and are on the Settings tab.

# Basic, on every call
curl -X POST http://127.0.0.1:3010/v3/BulkMessages \
  -u '{sms_client_id}:{sms_api_secret}' \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"destination":"27820000001","content":"hi","customerId":"order-7"}]}'

# or exchange for a token first — it lasts 24 hours
curl -X POST http://127.0.0.1:3010/Authentication -u '{client_id}:{secret}'
# → {"token":"…","schema":"Bearer","expiresInMinutes":1440}

Read the body, not just the status. This is the thing SMS integrations get wrong. A bulk send answers 200 even when destinations were dropped:

{"statusCode":200,"sendResponse":{
  "messages":1,"parts":1,"cost":1,"remainingBalance":999,
  "errorReport":{"noNetwork":1,"duplicates":1,"faults":[…]}}}

Three numbers went in, one went out. A malformed number, a duplicate, an opted-out number and an empty body are all dropped into errorReport.faults with the call still succeeding. Code that checks only the HTTP status loses messages silently.

Delivery receipts. Eight statuses, and five of them are different kinds of "did not arrive":

Status Means Final
DELIVRD reached the handset yes
UNDELIV inactive or unreachable number yes
EXPIRED no update inside the validity period yes
BLIST restricted by the owner or WASPA yes
NOROUTE no route to that number yes
CANCELLED scheduled send, cancelled yes
SUBMITD handed to the network, awaiting an update no
STAGED waiting to be submitted no

SUBMITD and STAGED are not failures. Code that treats "not DELIVRD" as failure raises an alert on every message in flight.

Set the two webhook URLs under Settings › SMS, then fire receipts and replies from the SMS tab or:

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/sms/{id}/status \
  -H 'Content-Type: application/json' -d '{"status":"UNDELIV"}'

curl -s -X POST http://127.0.0.1:3010/_api/projects/acme-corp/sms/reply \
  -H 'Content-Type: application/json' -d '{"from":"27820000001","text":"STOP"}'

Webhooks are signed HMAC-SHA256 over v1:{timestamp}|POST|{url}|{body} and sent as SmsWebhookEngine-Signature: v1,hmac_sha256=<uppercase hex>. Verify against the raw body — re-encoding a decoded payload shifts key order and the signature stops matching.

Three things worth provoking.

  • Running out of credits. The balance falls as you send and a send that will not fit is refused whole. Set it low from Settings › SMS or with {"sms_credits":5} on the project.
  • The 24-hour token. An integration that fetches one at boot and caches it forever passes every test and starts returning 401 a day after deployment. POST /_api/projects/{id}/sms/tokens/expire is that day, on demand.
  • Parts, not messages. 160 GSM characters per part, 153 once a message is split — and 70 the moment one non-GSM character appears. A 140-character message with a single em dash in it costs three credits, not one.

The built-in test numbers work here too, mapped onto SMSPortal's statuses by intent: 27000000000 is DELIVRD, 27000131026 is UNDELIV, 27000131049 is BLIST, and 27000000002 stays at SUBMITD for ever.