More sandboxes
On this page
WhatsApp sandbox
Test WhatsApp Business flows without sending a real WhatsApp message. The sandbox speaks the WhatsApp Cloud API: point your app's Graph API base URL at Testlie and every message is captured, rendered in a chat view and reported back to your webhook with Meta's status updates. Templates and the 24-hour customer-service window are enforced, so mistakes show up before production.
Create a sandbox
Open WhatsApp in the workspace and create a sandbox. It gets a fake business number and the values a real WhatsApp Business app has:
| Setting | Value |
|---|---|
| Base URL | https://testlie.com/wa, used in place of https://graph.facebook.com |
| Phone number ID | 15 digits, the {phone-number-id} in API paths |
| WhatsApp Business Account ID | 15 digits, for message_templates and the webhook's entry[].id |
| Access token | EAAT…, sent as Authorization: Bearer <token> |
| App secret | 32 hex characters, the key of X-Hub-Signature-256 |
| Verify token | Sent in the webhook verification request |
Keep these separate from your production Meta credentials. Admins can rotate the access token and app secret; the old values stop working at once.
Base URL. Paths keep their Graph API shape after the /wa prefix, with any version: https://testlie.com/wa/v21.0/<phone-number-id>/messages. Most apps keep the Graph host in one setting (for example GRAPH_API_BASE or WHATSAPP_API_URL); set it to https://testlie.com/wa.
Send a message
curl -X POST "https://testlie.com/wa/v21.0/$PHONE_NUMBER_ID/messages" \
-H "Authorization: Bearer $WHATSAPP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "template",
"template": {
"name": "order_update",
"language": { "code": "en_US" },
"components": [
{ "type": "body", "parameters": [ { "type": "text", "text": "Ada" }, { "type": "text", "text": "#42" } ] }
]
}
}'The response is the Cloud API's:
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "15551234567", "wa_id": "15551234567" }],
"messages": [{ "id": "wamid.HBgL…", "message_status": "accepted" }]
}Supported endpoints
Method and path (after https://testlie.com/wa) |
Description |
|---|---|
POST /{version}/{phone-number-id}/messages |
Send any message type below, or mark an incoming message as read with { "messaging_product": "whatsapp", "status": "read", "message_id": "wamid…" } (answers { "success": true }) |
POST /{version}/{phone-number-id}/media |
Upload media (multipart: file, type, messaging_product=whatsapp); answers { "id" } |
GET /{version}/{media-id} |
{ messaging_product, url, mime_type, sha256, file_size, id } |
GET /media/{media-id} |
The url above: the file's bytes (bearer token required) |
DELETE /{version}/{media-id} |
Delete uploaded media |
GET /{version}/{phone-number-id} |
The phone number (display_phone_number, verified_name, quality_rating) |
GET /{version}/{waba-id}/phone_numbers |
The account's phone number |
GET, POST, DELETE /{version}/{waba-id}/message_templates |
List, create (Meta's components format) or delete (?name=) templates |
Message types
type |
Notes |
|---|---|
text |
body up to 4096 characters, preview_url |
template |
name, language.code, components with header, body and button parameters (text, currency, date_time, image, video, document, location, payload, coupon_code) |
image, video, audio, document, sticker |
By link (http/https) or uploaded media id; caption (not audio/sticker); filename for documents |
interactive |
button (1-3 reply buttons, titles up to 20 characters), list (up to 10 sections and 10 rows), cta_url |
reaction |
message_id and emoji (an empty emoji removes the reaction) |
location |
latitude, longitude, optional name and address |
contacts |
At least name.formatted_name per contact |
A context.message_id makes the message a reply. Media ids must belong to the sandbox (131053 otherwise). JSON bodies are expected; form-encoded bodies with JSON values are accepted too, like the Graph API.
Templates
Each sandbox has a template registry (Templates tab, the app API, or POST /{waba-id}/message_templates). A template has a name, language, category (MARKETING, UTILITY, AUTHENTICATION), approval status, optional header (TEXT with up to one placeholder, IMAGE, VIDEO, DOCUMENT, LOCATION), a body with {{1}}, {{2}}… placeholders (or named ones such as {{first_name}}), an optional footer and buttons (QUICK_REPLY, URL with an optional {{1}} suffix, PHONE_NUMBER, COPY_CODE).
Template sends are checked like Meta checks them:
- Unknown name, or no translation for
language.code: 132001. - Status
PENDINGorREJECTED: 132001;PAUSED: 132015;DISABLED: 132016. Change the status on the Templates tab to test how your app handles it. - Body, header or button parameter count differs from the placeholders: 132000, with the component and both counts in
error_data.details. - A parameter of the wrong type (a video for an image header, a body image, a quick reply without
payload): 132012. Empty values: 131008; values with more than four consecutive spaces: 131009.
Templates created through the Graph API are approved immediately.
The 24-hour window
Free-form messages (everything except templates) are only allowed within 24 hours of the test user's last message. Outside the window the message is accepted and then reported as failed with 131047 (re-engagement message) on your webhook, as the Cloud API does. Simulate a user message in the chat view (or with the app API) to open the window. The chat header shows whether the window is open and until when.
Outcomes per number
By default every message goes sent → delivered → read, one step per second. Outcome rules (on the Outcomes tab) match the recipient with a glob (* any run of digits, ? one) and change that:
| Outcome | Statuses |
|---|---|
| Deliver and read | sent, delivered, read (the default) |
| Deliver, never read | sent, delivered |
| No WhatsApp account | failed with 131026 (message undeliverable) |
| Fail | failed with the code you pick: 131000, 131026, 131049, 131021 or 131053 |
{ "outcomeRules": [
{ "pattern": "1555000*", "outcome": "no_whatsapp" },
{ "pattern": "1555111*", "outcome": "failed", "errorCode": 131049 }
] }Set error mode to sync to get window and outcome failures as an HTTP 400 error on the send itself instead of a failed status.
Your webhook
Set the callback URL and verify token on the Credentials & webhook tab. Saving sends Meta's verification request:
GET <callback>?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=<verify token>Your endpoint must answer 200 with the hub.challenge value as the body. (Tick skip the verification request to save a URL that cannot answer yet.)
Then every status change and every simulated user message is POSTed as JSON in Meta's format, signed with X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the raw body, keyed with the app secret>:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<waba-id>",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15550101234", "phone_number_id": "<phone-number-id>" },
"statuses": [{
"id": "wamid.HBgL…",
"status": "failed",
"timestamp": "1792238400",
"recipient_id": "15551234567",
"errors": [{ "code": 131047, "title": "Re-engagement message", "message": "Re-engagement message",
"error_data": { "details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number." } }]
}]
}
}]
}]
}Successful statuses carry conversation and pricing (pricing_model: "PMP", category from the template, service for free-form replies). User messages arrive as value.contacts and value.messages: text, button (template quick reply: payload and text), interactive (button_reply or list_reply with id and title), location and reaction, with context when they reply to one of your messages.
Deliveries are SSRF-guarded like Testlie's own webhooks and retried on network errors, timeouts, 5xx, 408 and 429. The tab shows the last delivery's result.
// Express: verification handshake and signature check
app.get("/webhooks/whatsapp", (req, res) =>
req.query["hub.verify_token"] === process.env.WHATSAPP_VERIFY_TOKEN ? res.send(req.query["hub.challenge"]) : res.sendStatus(403));
app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", process.env.WHATSAPP_APP_SECRET).update(req.body).digest("hex");
if (req.get("X-Hub-Signature-256") !== expected) return res.sendStatus(401);
// handle JSON.parse(req.body).entry[0].changes[0].value.statuses / .messages
res.sendStatus(200);
});Simulate the user
The Chats tab shows one conversation per test user, as their phone would: templates rendered with header, footer and buttons, media, interactive messages and delivery ticks. From there you can write as the user, tap quick-reply, reply and list buttons, share a location, react, reply to a message, and mark a message read. Each action reaches your webhook like a real user's would.
From tests, use the app API with a team API key:
curl -X POST -H "Authorization: Bearer $TESTLIE_API_KEY" -H "Content-Type: application/json" \
"https://testlie.com/api/whatsapp-sandboxes/$SANDBOX_ID/simulate" \
-d '{ "type": "text", "from": "15551234567", "profileName": "Ada", "text": "Hi, where is my order?" }'type is text, button (contextWamid, index or text of the quick reply), button_reply or list_reply (contextWamid, id), location (latitude, longitude), reaction (messageId, emoji) or read (wamid).
Assert on the capture
curl -f -H "Authorization: Bearer $TESTLIE_API_KEY" \
"https://testlie.com/api/whatsapp-sandboxes/$SANDBOX_ID/messages/wait?to=15551234567&template=otp_code&timeout=30"A match returns 200 with { message }, including the rendered template and extracted.codes (a copy-code button's value first) and extracted.links; a timeout returns 408. Filters: to, contains, template, type, direction (outbound by default, or inbound), since (ISO time) and timeout (1-120 s).
| Method and path | Description |
|---|---|
GET, POST /api/whatsapp-sandboxes |
List or create sandboxes ({ "name", "projectId"?, "verifiedName"?, "outcomeRules"?, "errorMode"? }); responses include the credentials |
GET, PATCH, DELETE /api/whatsapp-sandboxes/:id |
Read, update (also webhookUrl, verifyToken, skipVerification) or delete |
POST /api/whatsapp-sandboxes/:id/rotate |
{ "secret": "accessToken" | "appSecret" | "verifyToken" } |
POST /api/whatsapp-sandboxes/:id/verify-webhook |
Run the verification request again: { ok, status, error } |
GET, POST /api/whatsapp-sandboxes/:id/templates |
List or add templates |
PATCH, DELETE /api/whatsapp-sandboxes/:id/templates/:templateId |
Change (for example the status) or delete a template |
GET, DELETE /api/whatsapp-sandboxes/:id/messages |
List (waId, direction, q, page, limit, plus each user's last message time) or clear |
GET /api/whatsapp-sandboxes/:id/messages/wait |
Long-poll, see above |
POST /api/whatsapp-sandboxes/:id/simulate |
Act as a test user, see above |
GET /api/whatsapp-sandboxes/:id/media/:mediaId |
Uploaded media bytes |
GET, DELETE /api/whatsapp/messages/:wamid |
One message with the request as sent; delete |
GET /api/whatsapp/messages/:wamid/extract |
{ wamid, codes, links } |
Team-wide webhooks subscribed to whatsapp.received get a signed JSON POST (same signing as message.received) for every captured message: { "event": "whatsapp.received", "whatsappSandbox": { id, name }, "message": { wamid, to, type, templateName, text, status, receivedAt, url } }.
Errors
Errors use the Graph API envelope:
{ "error": { "message": "(#132000) Number of parameters does not match the expected number of params", "type": "OAuthException", "code": 132000,
"error_data": { "messaging_product": "whatsapp", "details": "body: number of localizable_params (1) does not match the expected number of params (2)" },
"fbtrace_id": "…" } }| Code | HTTP | When |
|---|---|---|
| 190 | 401 | Missing, wrong or rotated access token |
| 100 | 400 | Invalid parameter (details name it); subcode 33 when the phone number, account or media id is not the token's |
| 130429 | 429 | More than 50 requests per second for one number, or the plan's monthly message quota is used up |
| 131021 | 400 | Sending to the business's own number |
| 131026 | 400 / webhook | No WhatsApp account (outcome rule) |
| 131047 | 400 / webhook | Free-form message outside the 24-hour window |
| 131053 | 400 | Unknown media id, or an upload that is empty or too large |
| 132000, 132001, 132012, 132015, 132016 | 400 / 404 | Template errors, see above |
Limits
WhatsApp shares the SMS limits: sandboxes count toward the plan's SMS inboxes, and each captured message toward its monthly SMS messages (see plans and limits). Simulated user messages do not count. A sandbox keeps the plan's newest messages per inbox and its newest 200 uploads of up to 16 MB each; uploads expire after 30 days, like Meta's. Messages are deleted after the team's retention period.
Simulated statuses arrive about a second apart. Webhook retries follow the same schedule as Testlie's own webhooks, and callback URLs must be reachable from the internet (no private addresses).
Not supported: real delivery, calling, flows, catalogs and product messages, typing indicators beyond accepting them, business profile and phone registration endpoints, template review (templates are approved at once unless you set another status), inbound media from simulated users, and webhook fields other than messages.