Dead Ringer
Docs

Agent and MCP setup

Paste this to an agent working on a client integration.


We have a local mock of the WhatsApp Cloud API, Dead Ringer, running at http://127.0.0.1:3010. It speaks Meta's Graph API: same paths, same payloads, same error shapes. Your job is to point this project at it instead of graph.facebook.com, then exercise the integration end to end and report what breaks.

Nothing leaves the machine, with one exception: a media link in a send is downloaded, as Meta downloads it (see Media below). There is no real WhatsApp connection and no Meta account involved. Every number and token is fake.

1. Create a project on the mock

A "project" is an isolated tenant — one per client integration.

curl -s -X POST http://127.0.0.1:3010/_api/projects \
  -H 'Content-Type: application/json' \
  -d '{"name":"Romi"}'

The response carries everything you need:

{
  "id": "romi",
  "waba_id": "…",
  "phone_number_id": "…",
  "access_token": "EAA…",
  "app_secret": "…",
  "webhook_verify_token": "…",
  "base_url": "http://127.0.0.1:3010"
}

GET /_api/projects/romi/env returns ready-to-paste .env blocks (whatsapp and email) in whatever variable names this instance was told the app uses (PUT /_api/settings/env, or "Edit variable names" under Settings). MCP's create_project returns the same under env.

A new project has no customers. Add them as shown under "Several customers", or just send to a number — the recipient is added automatically.

2. Configure this project

Point the Graph base URL at the mock's base_url — the host only, never with the phone number ID appended, since the app adds /{version}/{phone-number-id} itself and a doubled ID makes every send 404. If this project runs in Docker, use http://host.docker.internal:3010: inside a container, localhost is the container. Use the credentials above. Do not remove the Authorization header: the token is fake and never validated, but the mock returns Meta's real 401 (code 104) if the header is missing, deliberately, so that auth handling is still exercised.

The API version prefix (/v25.0/) is optional and accepted whatever you set it to.

3. Register your webhook

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

Give the URL as the host machine sees it — localhost and the published port, even if the app runs in Docker — because the mock runs on the host, not in a container. Never host.docker.internal here; it only resolves inside containers. https:// works with a certificate the machine trusts (mkcert, Herd). Saving runs the handshake immediately and names the cause of a failure. Over MCP this is update_project_settings, and verify_webhook runs the handshake again once the app is up.

Webhooks carry X-Hub-Signature-256 as sha256=<hex>, an HMAC-SHA256 of the raw request body bytes using app_secret. Verify against the raw body, not a re-encoded version of it.

4. Play the customer

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

# tapping a reply button — different payload shape from text; context_message_id
# is the wamid of the message tapped
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/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/romi/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"12025550142","list_reply":{"id":"size_large","title":"Large"},"context_message_id":"wamid.…"}'

# tapping a template's quick-reply button — type "button", not interactive
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/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 (.ogg, voice=true)
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/inbound \
  -F from=12025550142 -F [email protected] -F caption="Signed lease"

Every tap names the message tapped. Pass the wamid from your send as context_message_id, and the webhook carries "context": {"from", "id"} as WhatsApp's does; apps that match a reply to what they sent depend on it.

Template quick-reply buttons are their own shape: "type": "button" with button.payload and button.text, not interactive.button_reply. Handlers that only switch on interactive miss them in production, so test both. payload is what the app set on that button (sub_type: quick_reply, matched by index); omit it if the app set none and the button text is sent, as Meta does. context_message_id is required with button. Carousel card buttons tap the same way. URL and phone buttons send nothing. Over MCP, simulate_inbound_message takes template_button_text, template_button_payload and context_message_id.

A file arrives as Meta's image, video, audio or document message, chosen by the file (or -F type=…), with a media id the app resolves with GET /{media-id} and downloads with its access token, exactly as from Meta. Over JSON, base64 it: {"from":"…","document":{"content":"<base64>","filename":"lease.pdf"}}. MCP's simulate_inbound_message takes file_path (a local file) or file_base64, with caption and voice. Meta's limits apply: photos JPEG or PNG ≤ 5 MB, video and audio ≤ 16 MB, documents any type ≤ 100 MB.

Several customers. A project can hold any number, each with their own conversation and 24-hour window — needed for any flow involving two people. Add one with POST /_api/projects/romi/customers {"wa_id":"27823895956","name":"Roger Staples"}, using a number the app already knows to link to an existing user, then pass "from" (and optionally "name") on inbound. GET /_api/projects/romi/conversations lists them all.

Each response includes the webhook attempt: the exact signed body, the HTTP status your handler returned, and the duration. A 200 from your handler is not evidence it worked — assert on the side effect, not the status.

5. Drive delivery statuses

Auto delivery sends sent and delivered only. read arrives when the customer opens the chat with read receipts on — POST /_api/projects/romi/customers/{wa_id}/read or the mark_conversation_read tool — or when you send it explicitly. Do not wait for a read that nobody has triggered.

Switch to manual so nothing fires behind your back:

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

Then, with the wamid a send returned:

# happy path in one call
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/messages/$WAMID/status/sequence -d '{}'

# individually, in any order
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/messages/$WAMID/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/romi/messages/$WAMID/status \
  -H 'Content-Type: application/json' -d '{"status":"failed","error_code":131049}'

6. Templates

Your existing provisioning code should run unchanged against POST /{waba-id}/message_templates. Templates land PENDING. Approve or reject them to fire a real message_template_status_update webhook:

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

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

Note: creating a template uses uppercase component types (BODY, HEADER, BUTTONS, CAROUSEL); sending one uses lowercase. The mock is strict about this because Meta is.

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.

7. Make a send fail

There are no override headers. Nothing of ours is ever added to a request or a response on a Meta endpoint, so what you send here is what you would send in production. What a message does is decided by who it is addressed to.

Built-in test numbers, in every project, with nothing to set up:

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 with a wamid, then a failed status
27000131047 re-engagement required 200 with a wamid, then a failed status
27000131049 marketing frequency cap 200 with a wamid, then a failed status
27000131053 media upload error 200 with a wamid, then a failed status
27000130429 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

The pattern is 27000 + a six-digit Meta error code; codes below 100 are the behaviour presets above. Any other code works and fails asynchronously. The list_test_numbers MCP tool and GET /_api/coverage return the same table.

The 27 is not a locale setting and is not configurable. A 0 straight after the country code is never a real subscriber number, so these can never reach a person, while the whole thing is still the right length to pass a length check. If the app under test validates strictly rather than by length it will reject them; create a customer with no wa_id and a behaviour instead, which returns a NANPA 555-01xx number that passes libphonenumber.

Which side of the line matters. Meta rejects some codes at the API call and reports others as a status webhook after answering 200. An integration written for the wrong one has an error handler that never runs. Assert on the failed status, not only on the response, for the asynchronous codes.

A behaviour on one of your own customers, when the built-ins do not fit — ordered steps, the last one repeating:

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

outcome is sent, delivered, read, failed or sync_error. Also on create_customer and rename_customer over MCP, and {"behaviour":null} clears it.

One-shot, whoever it is addressed to: POST /_api/projects/{id}/failures with {"error_code":131026}, or the simulate_failure MCP tool. The code decides how the failure arrives, by the same table.

SMS, via SMSPortal

The same project also stands in for rest.smsportal.com. Credentials come back from create_project and from GET /_api/projects/{id}.

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"}]}'

Assert on the body, not the status. A bulk send returns 200 with dropped destinations listed in sendResponse.errorReport.faults — malformed, duplicated, opted out, empty. messages counts what was accepted, not what was asked for. A test that only checks the HTTP status passes while messages silently vanish, which is the failure this endpoint exists to reproduce.

SUBMITD and STAGED are not failures. They mean in flight. Of the eight statuses only DELIVRD is success, and five distinct ones mean "did not arrive": UNDELIV, EXPIRED, BLIST, NOROUTE, CANCELLED. Check status_final rather than comparing against DELIVRD.

Tools: list_sms, simulate_sms_status, simulate_sms_reply, expire_sms_tokens, delete_sms. Over HTTP the same things are GET /_api/projects/{id}/sms, POST …/sms/{id}/status, POST …/sms/reply, POST …/sms/tokens/expire and DELETE …/sms/{id}.

Worth exercising, none of which can be produced on demand against the real SMSPortal:

  • Credits running out mid-campaign. Set {"sms_credits":5} on the project and send six. The balance falls as you send and a send that will not fit is refused whole with InsufficientCredits.
  • A token expiring. expire_sms_tokens, then call again: the next one gets AuthenticationToken.Expired, not Invalid. The difference matters — one says fetch another, the other says your credentials are wrong.
  • A reply of STOP. It arrives as an ordinary message with optOut set, and the gateway refuses that number afterwards whether or not the app noticed.
  • Parts. 160 GSM characters, 153 once split, 70 the moment one non-GSM character appears. A 140-character message with one em dash costs three.

The built-in test numbers work here too: 27000000000 comes to rest at DELIVRD, 27000131026 at UNDELIV, 27000131049 at BLIST, and 27000000002 stays at SUBMITD for ever.

Clean up after yourself

If you create projects for tests, delete them when you are done:

curl -s -X DELETE http://127.0.0.1:3010/_api/projects/{id}

or the delete_project MCP tool. It removes every row and uploaded file for that project and reports what went. Projects are cheap and isolated, so one per test is the right pattern — but leftovers accumulate in the UI.

What to actually test

Please exercise and report on:

  1. Sending every message type this project uses.
  2. Your webhook handler on inbound text, button replies, list replies, template button taps (type: "button") and files (a document, a photo, a voice note) — the interactive, button and media shapes are different from text and are where handlers usually break. For a tap, check the app matches it to its message by context.id. For a file, check the app actually downloads it.
  3. Signature verification against the raw body.
  4. The delivery status lifecycle, including out of order, the same status twice (Meta does this; handlers must be idempotent) and read never arriving (the customer did not open it).
  5. Each failure code, and how the app surfaces it to a user.
  6. Template provisioning, approval and rejection handling.

Gotchas

  • Every inbound message needs from. There is no default customer; a call without one gets a 400 saying so. Use a number the app already knows, or create a customer without a wa_id to get a fictional US 555-01xx number. Those pass libphonenumber validation (which Romi's clean_cell runs) and are reserved for fiction, so they are never a real person.
  • Clean up customers too. DELETE /_api/projects/romi/customers/{wa_id} or delete_customer removes a customer and their whole conversation. For one message, DELETE /_api/projects/romi/messages/{wamid} or delete_message removes it and its statuses and leaves the chat and its window as they were. delete_email and delete_sms do the same for one email or one SMS. None of them sends a webhook.
  • A project can have several business numbers. If the app runs more than one, add them with POST /_api/projects/{id}/numbers or add_phone_number. Each number has its own conversations and windows; pass phone_number_id to /inbound (and ?phone_number_id= to the customer routes) to act on a number other than the first, and check the app routes by metadata.phone_number_id in the webhook.
  • A fresh project is outside the 24-hour window, matching Meta. The first free-form send is accepted and then fails with 131047 as a status webhook, which is what Meta does; do not expect a rejection from the send itself. Open the window by simulating an inbound, or set {"window_enforcement":"off"} on the project.
  • Only approved templates can be sent. A PENDING template is rejected.
  • A media link to localhost, a .test site or a private address fails with 131053 after a 200, because Meta cannot reach it. Serve the file from a public URL or upload it and send the id.

Media

The full chain works: upload returns an ID, the ID resolves to a URL, and the URL returns the bytes with an auth header. The URL is on whichever host you called, so an app in Docker that resolves the ID through host.docker.internal gets a host.docker.internal URL back and can download from it.

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]

Then send it by id. Sending an ID that was never uploaded is rejected, as Meta does. Mime types and per-type size limits are enforced at upload.

A send by link is fetched and stored when it is sent, as Meta does. A link that cannot be used — an error status, a Content-Type Meta does not take for that type, a file over the limit, or a host Meta cannot reach such as localhost or a .test site — is accepted with a wamid and then fails with 131053, "Media upload error", with the cause in error_data.details. Assert on that status, not on the send's response. A project with {"media_links":"off"} fetches nothing and fails nothing.

Email

The same project also stands in for Postmark and takes SMTP. Mail the app sends lands in the project's Email tab; replies and delivery events come back to the app as Postmark-shaped webhooks.

Outbound, from a Laravel app: use SMTP. Laravel's built-in postmark mailer cannot be pointed at a plain-HTTP host (Symfony hard-codes https), so:

MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1        # the mock runs on the host; from Docker use host.docker.internal
MAIL_PORT=3025
MAIL_USERNAME=romi         # the project ID — this is what routes mail to the project
MAIL_PASSWORD=anything     # required, never checked
MAIL_ENCRYPTION=null

Outbound over the Postmark API works for SDKs with a host override: postmark-php (PostmarkClientBase::$BASE_URL = 'http://127.0.0.1:3010'), postmark.js ({ requestHost: '127.0.0.1:3010', useHttps: false }). The server token is email_token on the project. POST /email, /email/batch and /email/withTemplate answer in Postmark's shapes, including its 422 {"ErrorCode":300,...} errors and 401 ErrorCode 10 for a missing token.

Webhooks in. Set where they go:

curl -s -X PATCH http://127.0.0.1:3010/_api/projects/romi \
  -H 'Content-Type: application/json' \
  -d '{"email_inbound_url":"http://romi.test/webhooks/postmark/inbound","email_events_url":"http://romi.test/webhooks/postmark"}'

Postmark signs nothing; its security is basic auth in the URL, which is honoured (http://user:[email protected]/...).

# the customer emails in, replying to something the app sent
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/emails/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"Jane Doe <[email protected]>","text":"Thanks, when do I start?","in_reply_to":"<MessageID>"}'

# HTML as well as text, and attachments; content_id makes one an inline cid: image
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/emails/inbound \
  -H 'Content-Type: application/json' \
  -d '{"from":"[email protected]","text":"CV attached","html":"<p>CV attached</p>",
       "attachments":[{"name":"cv.pdf","content_type":"application/pdf","content":"<base64>"}]}'

# delivery events for something the app sent, in any order, as often as you like
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/emails/<MessageID>/event \
  -H 'Content-Type: application/json' -d '{"record_type":"Delivery"}'
curl -s -X POST http://127.0.0.1:3010/_api/projects/romi/emails/<MessageID>/event \
  -H 'Content-Type: application/json' -d '{"record_type":"Bounce","bounce_type":"HardBounce"}'

Record types: Delivery, Bounce, SpamComplaint, Open, Click. A hard bounce or spam complaint makes the address inactive, and every later send to it fails with Postmark's 406 (SMTP: 550) until DELETE /_api/projects/romi/emails/suppressions/{address}. That is real Postmark behaviour and worth a test. email_delivery on the project is auto (a Delivery event follows each send) or manual.

Read what was sent with GET /_api/projects/romi/emails and GET /_api/projects/romi/emails/{id} (bodies, headers, attachments, events, raw). DELETE /_api/projects/romi/emails/{id} removes one, with its attachments and events; no webhook is sent, and an address it made inactive stays inactive.

With html alone, TextBody and StrippedTextReply are derived from it, as a real client always sends a text part. Postmark's 35 MB limit per inbound message, attachments included, is enforced.

MCP: list_emails, get_email, simulate_inbound_email (attachments by local path or content_base64), simulate_email_event, reactivate_email_address.

MCP

If you would rather drive the mock as tools than as curl, it serves MCP over HTTP:

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

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.

Every simulated event is labelled SIMULATED … in the tool output. Leave that in anything you paste into a test report: it is what stops a passing suite hiding a conversation nobody actually had.

What is and is not implemented

Do not report unbuilt surface as a bug. Ask the mock instead:

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

Every endpoint and all 19 of Meta's webhook fields, each marked supported, partial (with the specific gap), unsupported or not-planned, plus the date the list was last checked against Meta's docs. The email key does the same for the Postmark surface.

Setting the webhook URL runs Meta's hub.challenge handshake against your endpoint and returns the result, including a hint naming the specific mistake if it fails. Re-run it with POST /_api/projects/{id}/webhook/verify.

The same call reports which Graph API versions Meta still supports, so you can check the one you are calling has not been retired. The mock accepts any version and records it on the message; nothing is added to the response.