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 withInsufficientCredits. - A token expiring.
expire_sms_tokens, then call again: the next one getsAuthenticationToken.Expired, notInvalid. 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
optOutset, 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:
- Sending every message type this project uses.
- 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 bycontext.id. For a file, check the app actually downloads it. - Signature verification against the raw body.
- The delivery status lifecycle, including out of order, the same status
twice (Meta does this; handlers must be idempotent) and
readnever arriving (the customer did not open it). - Each failure code, and how the app surfaces it to a user.
- 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 awa_idto get a fictional US 555-01xx number. Those pass libphonenumber validation (which Romi'sclean_cellruns) and are reserved for fiction, so they are never a real person. - Clean up customers too.
DELETE /_api/projects/romi/customers/{wa_id}ordelete_customerremoves a customer and their whole conversation. For one message,DELETE /_api/projects/romi/messages/{wamid}ordelete_messageremoves it and its statuses and leaves the chat and its window as they were.delete_emailanddelete_smsdo 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}/numbersoradd_phone_number. Each number has its own conversations and windows; passphone_number_idto/inbound(and?phone_number_id=to the customer routes) to act on a number other than the first, and check the app routes bymetadata.phone_number_idin the webhook. - A fresh project is outside the 24-hour window, matching Meta. The first
free-form send is accepted and then fails with
131047as 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
PENDINGtemplate is rejected. - A media
linktolocalhost, a.testsite or a private address fails with131053after a 200, because Meta cannot reach it. Serve the file from a public URL or upload it and send theid.
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.
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 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.
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.