API reference

Sandbox

Inject synthetic inbound messages so your integration can exercise the receive path without a real Meta webhook.

Quickstart diagnostics

GET/v1/sandbox/quickstart

Response

FieldTypeNotes
objectrequired"sandbox_quickstart"
environmentrequiredobject
api_keyrequiredobject
senderrequiredobject
webhooksrequiredobject
templatesrequiredobject
capabilitiesrequiredobject
missing_scopesrequiredstring[]

Simulate inbound

POST/v1/sandbox/inbound-messages202 Accepted

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

FieldTypeNotes
channel"whatsapp" | "instagram" | "whatsapp_channel"
fromrequiredstring
len 1..∞
torequiredstring
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.
textobject
mediaobject
interactiveobject
flowobject
unsupportedobject
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.
unknownobject
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.
metadataobject

Response

FieldTypeNotes
idrequiredstring
objectrequired"message"
directionrequired"inbound"
channelrequired"whatsapp" | "instagram" | "whatsapp_channel"
typerequiredstring
statusrequiredstring
environmentrequired"sandbox" | "production"
senderrequiredobject
recipientrequiredobject
providerrequiredstring | null
provider_message_idrequiredstring | null
payloadrequiredunknown
trace_idrequiredstring
created_atrequiredstring<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

POST/v1/sandbox/templates/{template_id}/status

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

FieldTypeNotes
statusrequired"rejected" | "paused" | "disabled"
rejection_reasonstring
len 1..500

Response

FieldTypeNotes
idrequiredstring
objectrequired"template"
namerequiredstring
languagerequiredstring
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"
componentsrequiredobject[]
provider_template_idrequiredstring | null
rejection_reasonrequiredstring | null
provider_qualityrequired"green" | "yellow" | "red" | "unknown"
authoring_signalsrequiredobject[]
submitted_atrequiredstring<ISO-8601> | null
approved_atrequiredstring<ISO-8601> | null
created_atrequiredstring<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_atrequiredstring<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

POST/v1/sandbox/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

FieldTypeNotes
failurerequired"provider_down" | "auth_failed" | "timeout" | "cost_cap_reached"
ttl_secondsinteger
range -∞..3600

Response

FieldTypeNotes
armedrequired"true"
failurerequired"provider_down" | "auth_failed" | "timeout" | "cost_cap_reached"
expires_atrequiredstring | 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.

Use the published flow's 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:

  1. POST /v1/flows — create a flow.
  2. POST /v1/flows/{id}/publish — publish it (sandbox auto-publishes locally and assigns a provider_flow_id).
  3. GET /v1/flows/{id} — read provider_flow_id from the response.
  4. POST /v1/messages with the flow message below.
  5. 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.