eSMS AfricaeSMS Africa
API ReferenceSMS APITwo-Way SMS

Sandbox

The Two-Way SMS sandbox API - simulated phones, simulated inbound with the live matching rules and a debug record, sends to simulated phones, the sandbox clock, a sandbox webhook URL, reset and request bins.

The sandbox runs your keyword flow on simulated phones. Inbound messages are matched by the same function as live traffic, but every effect stays in your private sandbox: no SMS is sent, nothing is charged, and your live inbox, opt-outs, conversations and webhook log are untouched. Read the sandbox guide for the concepts; this page lists the endpoints.

  • Who: any account with a two-way keyword that is pending, active, past_due or suspended (otherwise 403 no_two_way_service).
  • Auth: dashboard session or API key. Reads need two_way or query; everything that changes the sandbox needs two_way. A test key (esms_test_) works the same as a live key here.
  • Also sandbox: POST /v1/two-way/messages with an esms_test_ key, or with "environment": "sandbox".

Get the sandbox context

GET https://sms.esmsafrica.io/v1/two-way/sandbox

Everything the simulator needs: your testable keywords, the selected keyword and its code, other keywords sharing the code, your phones, the sandbox clock, where webhooks will go and your request bins. Pass service_id to select a keyword (default: your first). The first call creates a simulated phone for you.

{
  "eligible": true,
  "is_admin": false,
  "services": [
    { "id": 42, "keyword": "CLINIC", "type": "shared", "status": "pending", "awaiting_payment": false,
      "country_code": "NG", "code": "3450", "company_name": "Example Health Ltd", "mine": true }
  ],
  "selected": {
    "service": { "id": 42, "keyword": "CLINIC", "type": "shared", "status": "pending", "owner": "you",
                 "company_name": "Example Health Ltd", "country_code": "NG", "help_text": null,
                 "auto_reply_enabled": false, "auto_reply_text": null, "strip_keyword": false,
                 "webhook_url": "https://example.com/hooks/esms" },
    "code": "3450",
    "virtual_code": false,
    "mode": "shared",
    "code_status": "active",
    "window_hours": 72,
    "dial_code": "234",
    "sharing": [],
    "other_keywords_on_code": 3
  },
  "phones": [{ "id": 9, "phone": "+2348000000001", "label": "Patient" }],
  "state": { "clock_offset_hours": 0.0, "sim_now": "2026-10-12T09:30:00+00:00", "webhook_override_url": null },
  "webhook": { "account_mo_url": null, "signing_secret_set": true, "active": true },
  "bins": []
}

virtual_code: true means your keyword has no short code yet and the sandbox is using a stand-in such as TESTNG. other_keywords_on_code counts other customers' keywords on the code (never named); window_hours is the conversation-affinity window.

curl "https://sms.esmsafrica.io/v1/two-way/sandbox?service_id=42" \
  -H "Authorization: Bearer $ESMS_API_KEY"

List testable keywords

GET https://sms.esmsafrica.io/v1/two-way/sandbox/services

Your keywords that can be tested (pending or live), with their code. Optional search (keyword, company or country). Returns { "services": [ ... ] } with the same rows as services above.

curl "https://sms.esmsafrica.io/v1/two-way/sandbox/services" \
  -H "Authorization: Bearer $ESMS_API_KEY"

Simulated phones

MethodPathBodyReturns
POST/v1/two-way/sandbox/phoneslabel (1-40 chars, default "Phone"), optional phone (7-15 digits) and country_code (default NG)201 { "id", "phone", "label" }
PATCH/v1/two-way/sandbox/phones/{phone_id}labelThe phone
DELETE/v1/two-way/sandbox/phones/{phone_id}-{ "ok": true }

Without phone, the next free number like +2348000000002 is picked. Up to 12 phones (409 too_many_phones); a number you already simulate gives 409 duplicate_phone.

curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/phones" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "Mother", "country_code": "NG"}'

Simulate an inbound message

POST https://sms.esmsafrica.io/v1/two-way/sandbox/inbound

A simulated phone texts the keyword's short code. The message runs through the live matching rules (STOP / START / HELP, keyword, conversation affinity, unmatched) on sandbox data. The response is the debug record, any platform replies (STOP and HELP confirmations, the unmatched hint, your auto-reply) and the webhook payload. The webhook itself is posted in the background; poll List sandbox messages for delivery results. Rate limit: 120 per minute.

FieldTypeRequiredDescription
service_idintegerYesAny of your keywords; picks the short code the phone texts.
phonestringYesThe simulated phone (any 7-15 digit number).
textstringYes1 to 1,600 characters.
{
  "message": {
    "id": 311,
    "direction": "inbound",
    "kind": null,
    "code": "3450",
    "phone": "+2348000000001",
    "text": "2",
    "service_id": 42,
    "keyword": "CLINIC",
    "matched_by": "affinity",
    "action": null,
    "encoding": "GSM7",
    "segments": 1,
    "sim_at": "2026-10-12T09:32:10+00:00",
    "created_at": "2026-10-12T09:32:10+00:00",
    "debug": {
      "label": "affinity",
      "matched_by": "affinity",
      "route": "deliver",
      "action": null,
      "keyword_parsed": "2",
      "body_without_keyword": "2",
      "service": { "id": 42, "keyword": "CLINIC", "type": "shared", "status": "pending", "owner": "you", "company_name": "Example Health Ltd" },
      "reason": null,
      "segments": 1,
      "would_cost": { "amount": 0.0068, "currency": "USD", "unit_price": 0.0068, "segments": 1, "charged": false },
      "billed_in_live": true,
      "affinity": { "service_id": 42, "keyword": "CLINIC", "last_sent_at": "2026-10-12T09:30:40+00:00",
                    "window_hours": 72, "expires_at": "2026-10-15T09:30:40+00:00" },
      "conversation_id": 57,
      "notes": [
        "This keyword is not live yet. The sandbox treats it as active so you can build now.",
        "No keyword, so it went to the service that last texted this phone from 3450 within 72h."
      ],
      "webhook_payload": { "event": "message.inbound", "sandbox": true, "environment": "sandbox", "...": "..." }
    },
    "webhook": {
      "deliveries": [
        { "event": "message.inbound", "url": "https://example.com/hooks/esms", "source": "service_webhook",
          "status": "pending", "payload": { "...": "..." }, "attempts": [] }
      ]
    }
  },
  "replies": []
}

Key debug fields:

FieldDescription
label / matched_bykeyword, affinity, dedicated, unmatched, or stop / start / help for compliance words.
routedeliver, service_control (STOP/START/HELP for one keyword), bare_control (bare STOP/START, or HELP with no conversation) or unmatched.
body_without_keywordExactly what the live webhook would carry.
service.owneryou, or another_customer when the message matched someone else's keyword on the code (nothing else about them is shown and no webhook is sent).
reasonWhy it was unmatched: no_keyword_match, no_active_service or number_disabled.
would_cost / billed_in_liveWhat live traffic would charge for this message. Never charged here.
affinity / affinity_stateThe conversation that decided an affinity match, and all of this phone's conversations on the code with expiry times.
notesPlain-language explanation of the decision.
webhook_payloadThe exact signed body your webhook receives.

webhook.deliveries[].source is sandbox_override, service_webhook or account_mo_url; status moves from pending to delivered, failed or blocked (URL not public), with each attempt's status_code, latency_ms and response.

curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/inbound" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"service_id": 42, "phone": "+2348000000001", "text": "CLINIC"}'

Send to a simulated phone

POST https://sms.esmsafrica.io/v1/two-way/sandbox/send

Sends FROM your keyword to a simulated phone, with the live checks (recipient country, length, opt-out) and sandbox conversation affinity, so the phone's plain replies route back to you. Never sent or charged. Rate limit: 120 per minute. This is the same as POST /v1/two-way/messages with a test key, by service_id.

FieldTypeRequired
service_idintegerYes
tostringYes
textstringYes, 1 to 1,600 characters
{
  "id": "sbx_312",
  "status": "delivered",
  "sandbox": true,
  "environment": "sandbox",
  "segments": 1,
  "encoding": "GSM7",
  "cost": 0.0,
  "would_cost": 0.0068,
  "currency": "USD",
  "service_id": 42,
  "keyword": "CLINIC",
  "code": "3450",
  "to": "+2348000000001",
  "conversation_id": 57,
  "affinity_expires_at": "2026-10-15T09:33:00+00:00",
  "message": { "id": 312, "direction": "outbound", "kind": "reply", "...": "..." }
}
curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/send" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"service_id": 42, "to": "+2348000000001", "text": "Maternity: Reply 1 to find the nearest clinic, 2 to book an antenatal visit."}'

Send a demo menu

POST https://sms.esmsafrica.io/v1/two-way/sandbox/demo

Sends a numbered menu to a simulated phone ("<KEYWORD>: Welcome! Reply 1 for Emergency, 2 for Maternity, 3 for Pharmacy, 4 to talk to a nurse. Reply STOP to opt out."), or your own text, so you can try numbered replies straight away. Body: service_id, to, optional text. Same response as Send to a simulated phone.

curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/demo" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"service_id": 42, "to": "+2348000000001"}'

List sandbox messages

GET https://sms.esmsafrica.io/v1/two-way/sandbox/messages

Every simulated message on the keyword's code, oldest first, each with its debug record and webhook attempts.

ParameterDescription
service_idRequired.
phoneOnly this simulated phone (also returns its affinity state).
after_idOnly messages after this ID - use it to poll.
limit1 to 500, default 200.
{
  "messages": [ { "id": 311, "direction": "inbound", "text": "2", "matched_by": "affinity", "...": "..." } ],
  "code": "3450",
  "sim_now": "2026-10-12T09:32:30+00:00",
  "affinity": [
    { "conversation_id": 57, "service_id": 42, "keyword": "CLINIC", "last_sent_at": "2026-10-12T09:30:40+00:00",
      "expires_at": "2026-10-15T09:30:40+00:00", "active": true }
  ]
}
curl "https://sms.esmsafrica.io/v1/two-way/sandbox/messages?service_id=42&phone=%2B2348000000001&after_id=310" \
  -H "Authorization: Bearer $ESMS_API_KEY"

Fast-forward the clock

POST https://sms.esmsafrica.io/v1/two-way/sandbox/clock

Moves your sandbox time forward so conversation affinity can expire without waiting. Send {"advance_hours": 73} (more than 0, up to 60 days in total) or {"reset": true}. Only your sandbox is affected. Returns the state:

{ "clock_offset_hours": 73.0, "sim_now": "2026-10-15T10:30:00+00:00", "webhook_override_url": null }
curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/clock" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"advance_hours": 73}'

Set a sandbox webhook URL

PUT https://sms.esmsafrica.io/v1/two-way/sandbox/settings

Sandbox webhooks go to webhook_override_url instead of your keyword or account webhook. It must be a public http(s) URL (private, loopback and cloud-metadata addresses return 422 url_not_public) or one of your request bins. Send null or "" to go back to your normal webhook. Returns the state.

curl -X PUT "https://sms.esmsafrica.io/v1/two-way/sandbox/settings" \
  -H "Authorization: Bearer $ESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhook_override_url": "https://example.com/hooks/esms-sandbox"}'

Reset the sandbox

POST https://sms.esmsafrica.io/v1/two-way/sandbox/reset

Clears your sandbox messages, conversations, opt-outs and clock. Phones, bins and the sandbox webhook URL are kept. Returns the state.

curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/reset" \
  -H "Authorization: Bearer $ESMS_API_KEY"

Request bins

A request bin is a temporary URL we host that records the requests sent to it, so you can see your webhooks without running a server. Each bin lives 24 hours and keeps the last 50 requests (bodies up to 16 KB). You can hold 3 bins at a time and create up to 10 per hour.

Create a bin

POST https://sms.esmsafrica.io/v1/two-way/sandbox/bins
{
  "token": "pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd",
  "url": "https://sms.esmsafrica.io/api/two-way/sandbox/bins/pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd",
  "expires_at": "2026-10-13T09:30:00+00:00",
  "created_at": "2026-10-12T09:30:00+00:00",
  "requests": 0
}

Use url as your sandbox webhook URL. It also accepts a POST (or PUT) from anything, without authentication: the unguessable token is the secret.

curl -X POST "https://sms.esmsafrica.io/v1/two-way/sandbox/bins" \
  -H "Authorization: Bearer $ESMS_API_KEY"

List captured requests

GET https://sms.esmsafrica.io/v1/two-way/sandbox/bins/{token}/requests

Newest first. Headers are recorded without cookies or credentials. 410 expired once the bin has expired.

{
  "bin": { "token": "pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd", "url": "https://sms.esmsafrica.io/api/two-way/sandbox/bins/pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd",
           "expires_at": "2026-10-13T09:30:00+00:00", "created_at": "2026-10-12T09:30:00+00:00", "requests": 1 },
  "requests": [
    {
      "id": 77,
      "method": "POST",
      "headers": {
        "content-type": "application/json",
        "x-webhook-id": "evt_9c2b7e4f1a3d5c60",
        "x-webhook-signature": "sha256=4f1c...",
        "x-esms-environment": "sandbox",
        "user-agent": "eSMS-Webhooks/1.0 (sandbox)"
      },
      "body": "{\"event\": \"message.inbound\", ...}",
      "json": { "event": "message.inbound", "sandbox": true, "...": "..." },
      "source": "sandbox",
      "received_at": "2026-10-12T09:32:10+00:00"
    }
  ]
}
curl "https://sms.esmsafrica.io/v1/two-way/sandbox/bins/pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd/requests" \
  -H "Authorization: Bearer $ESMS_API_KEY"

Delete a bin

DELETE https://sms.esmsafrica.io/v1/two-way/sandbox/bins/{token}

Deletes the bin and its requests (and clears it as your sandbox webhook URL if it was set). Returns { "ok": true }.

curl -X DELETE "https://sms.esmsafrica.io/v1/two-way/sandbox/bins/pT4m9x2QbV7cK1sN8eW3rY6uZ0aH5jLd" \
  -H "Authorization: Bearer $ESMS_API_KEY"

On this page