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 |
|---|---|---|---|
Meta's Cloud API, graph.facebook.com |
Graph base URL, access token | §3 | |
| 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-Typeis not one Meta takes for that message type, which is what a login page orapplication/octet-streamgets you - the file is over the size limit for its type, or empty
- the link points somewhere Meta cannot reach:
localhost, a.testor.localname, 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 theid.
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 optionalphone_number_idto 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/expireis 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.