Sandbox
Inject synthetic inbound messages so your integration can exercise the receive path without a real Meta webhook.
Quickstart diagnostics
Response
| Field | Type | Notes |
|---|---|---|
objectrequired | "sandbox_quickstart" | |
environmentrequired | object | |
api_keyrequired | object | |
senderrequired | object | |
webhooksrequired | object | |
templatesrequired | object | |
capabilitiesrequired | object | |
missing_scopesrequired | string[] |
Simulate inbound
This is the canonical sandbox inbound endpoint. Use it with a tx_sandbox_... key that has sandbox:write. For WhatsApp service-window tests, set from to the customer phone you will later send to, and set to to the sandbox sender id used by the outbound request.
curl "$TYXTER_API_BASE_URL/v1/sandbox/inbound-messages" \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-H "tyxter-trace-id: diag_open_window" \
-d '{
"channel": "whatsapp",
"from": "+5511999999999",
"to": "sandbox_sender",
"type": "text",
"text": { "body": "Opening the sandbox service window" }
}'The WhatsApp inbound from must be a possible E.164 subscriber number. An impossible number is rejected with 400 invalid_phone_e164 (param: from) before rate-limiting, idempotency, or any persistence — a real provider can never deliver a message from an impossible number, so the sandbox simulator does not accept one either. This check is WhatsApp-only; Instagram from values are opaque provider ids and are not phone-validated.
Request body
| Field | Type | Notes |
|---|---|---|
channel | "whatsapp" | "instagram" | "whatsapp_channel" | |
fromrequired | string | len 1..∞ |
torequired | string | len 1..∞ |
typerequired | "text" | "media" | "interactive" | "flow" | "unsupported" | "unknown" | What the simulated provider delivers. "text", "media", "interactive" and "flow" carry the matching content field. "unsupported" simulates content the provider refused to deliver (a WhatsApp video note), and "unknown" simulates a delivered type this platform does not project yet (a shared location) — both produce the same message row, message read and message.received content as the real provider path, so an integrator can rehearse those branches. |
text | object | |
media | object | |
interactive | object | |
flow | object | |
unsupported | object | Only for type "unsupported". Optional. provider_type is the provider's own name for the refused format and defaults to "video_note". It must be a provider type token — 1-64 characters of lowercase letters, digits and underscores, like "video_note" — not free text, because it is stored on the message as a provider-authored label and is deliberately kept free of personal data. The refusal reason is always synthesized as the real WhatsApp one — code 131051, "Message type is currently not supported." — so the simulated message carries the same unsupported block a production inbound would. |
unknown | object | Only for type "unknown". Optional. provider_type is the provider's own type name and defaults to "location". It must be a provider type token — 1-64 characters of lowercase letters, digits and underscores, like "location" — not free text, because it is stored on the message as a provider-authored label and is deliberately kept free of personal data. No reason is attached, matching production: the content was delivered, only the typed projection is missing. |
metadata | object |
Response
| Field | Type | Notes |
|---|---|---|
idrequired | string | |
objectrequired | "message" | |
directionrequired | "inbound" | |
channelrequired | "whatsapp" | "instagram" | "whatsapp_channel" | |
typerequired | string | |
statusrequired | string | |
environmentrequired | "sandbox" | "production" | |
senderrequired | object | |
recipientrequired | object | |
providerrequired | string | null | |
provider_message_idrequired | string | null | |
payloadrequired | unknown | |
trace_idrequired | string | |
created_atrequired | string<ISO-8601> | pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ |
Simulate a template status change
Sandbox keys can move a local template into rejected, paused, or disabled without calling Meta. This deterministic fixture lets an integration verify its template lifecycle and webhook handling. Production keys are rejected.
Request body
| Field | Type | Notes |
|---|---|---|
statusrequired | "rejected" | "paused" | "disabled" | |
rejection_reason | string | len 1..500 |
Response
| Field | Type | Notes |
|---|---|---|
idrequired | string | |
objectrequired | "template" | |
namerequired | string | |
languagerequired | string | |
categoryrequired | "marketing" | "utility" | "authentication" | |
parameter_formatrequired | "POSITIONAL" | "NAMED" | How Meta binds template variables. POSITIONAL uses {{1}}, {{2}}, ... and is the compatible default for create and generation. NAMED accepts unique {{[a-z][a-z0-9_]*}} BODY, TEXT HEADER, and dynamic URL-button tokens; BODY/TEXT HEADER variables require exact named examples. Update may retain the live format and duplicate may inherit the source format when omitted. |
statusrequired | "draft" | "submitted" | "approved" | "rejected" | "paused" | "disabled" | "orphaned" | |
environmentrequired | "sandbox" | "production" | |
componentsrequired | object[] | |
provider_template_idrequired | string | null | |
rejection_reasonrequired | string | null | |
provider_qualityrequired | "green" | "yellow" | "red" | "unknown" | |
authoring_signalsrequired | object[] | |
submitted_atrequired | string<ISO-8601> | null | |
approved_atrequired | string<ISO-8601> | null | |
created_atrequired | string<ISO-8601> | pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ |
updated_atrequired | string<ISO-8601> | pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ |
Arm an LLM failure
Arms the next sandbox completion with a normalized provider failure. Omit ttl_seconds for a one-shot fixture, or bound how long the fixture remains active. Re-arming replaces the current fixture. This write supports Idempotency-Key and never calls a live provider.
Request body
| Field | Type | Notes |
|---|---|---|
failurerequired | "provider_down" | "auth_failed" | "timeout" | "cost_cap_reached" | |
ttl_seconds | integer | range -∞..3600 |
Response
| Field | Type | Notes |
|---|---|---|
armedrequired | "true" | |
failurerequired | "provider_down" | "auth_failed" | "timeout" | "cost_cap_reached" | |
expires_atrequired | string | null |
Simulate Flow completion
To prove the flow.completed webhook without a real Meta submission, drive an outbound flow send through the flow_completed scenario. POST /v1/sandbox/inbound-messages with type: "flow" only emits message.received; it does not emit flow.completed. The supported path is a normal POST /v1/messages flow send tagged with metadata.sandbox_scenario = "flow_completed". This outbound fixture does not simulate Meta's ordinary inbound interactive.nfm_reply callback.
provider_flow_id — the value from GET /v1/flows/{id} after publish — for the send, because that is the value Meta requires in production. The sandbox completion scenario also accepts the Tyxter flw_... id when it correlates the completion.Full five-step sequence with a sandbox key:
POST /v1/flows— create a flow.POST /v1/flows/{id}/publish— publish it (sandbox auto-publishes locally and assigns aprovider_flow_id).GET /v1/flows/{id}— readprovider_flow_idfrom the response.POST /v1/messageswith the flow message below.GET /v1/webhook-events/listen?event_type=flow.completed— read the completion proof.
curl "$TYXTER_API_BASE_URL/v1/messages" \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{
"channel": "whatsapp",
"sender": { "type": "whatsapp_phone_number", "id": "sandbox_sender" },
"recipient": { "type": "phone_e164", "id": "+15551234567" },
"message": {
"type": "flow",
"flow": {
"body": { "text": "Tap below to complete your booking." },
"action": {
"name": "flow",
"parameters": {
"flow_id": "<provider_flow_id from GET /v1/flows/{id}>",
"flow_token": "booking-7f3a",
"flow_cta": "Book now"
}
}
}
},
"metadata": {
"sandbox_scenario": "flow_completed",
"sandbox_flow_response": { "email": "[email protected]", "size": "M" }
}
}'metadata.sandbox_flow_response is optional and controls the submitted response data. The sandbox returns it verbatim as submission.response; it defaults to { "sandbox": true }. The listen response returns the same flow.completed data shape a real Meta submission produces:
{
"object": "webhook_event_listen",
"data": [
{
"type": "flow.completed",
"payload": {
"type": "flow.completed",
"data": {
"flow_id": "flw_...",
"name": "lead_capture",
"status": "published",
"provider_flow_id": "1234567890",
"rejection_reason": null,
"submission": {
"id": "sub_...",
"provider_token": "booking-7f3a",
"response": { "email": "[email protected]", "size": "M" },
"message_id": "msg_...",
"received_at": "2026-04-27T12:05:00.000Z"
}
}
}
}
]
}submission.provider_token echoes the flow_token you sent, and submission.message_id is the source outbound message. Do not duplicate flow_token in metadata.sandbox_flow_response for correlation; use those two fields instead. Production Meta interactive.nfm_reply parsing strips a top-level token, but the sandbox fixture returns the configured response as supplied.