Sandbox & test numbers
Test keys (ssms_sk_test_) run against a simulated environment: no charges,
and no text is sent to any phone, with one exception, your own verified phone
(below). Every endpoint works, so you can build your whole integration,
including webhooks, before going live.
Simulated or real
A simulated message looks exactly like a real one. Its status goes to
sent and then delivered, it has a destination_carrier
(Sandbox Wireless), and the same events and webhooks fire. None of that
means a handset received anything. Three things tell you which it was:
| Where | Simulated | Real |
|---|---|---|
simulated on the message, and on message.sent, message.delivered, message.failed, message.received, message.opted_out, message.opted_in and message.help_requested event payloads | true | false |
X-TextKit-Simulated header on the response to POST /v1/messages (test keys) | true | false |
notice on the response to POST /v1/messages | Present when to is not a +1 500 555 test number, e.g. "Simulated: nothing was sent to +14155550132. Test keys only deliver to your verified phone. Verify it at https://jointextkit.com/console/settings or use a live key." | Absent |
Every live message has "simulated": false. With a test key, only a send to
your own verified phone can be real. A test-key send to any other real number,
including one of your verified test recipients, is simulated: nothing arrives.
The console shows the same thing. It lists live and test messages together; a
test message is tagged Test, a simulated one Test · simulated, and it says
"Nothing was sent to a phone" when you open it. API Keys → Test credentials
lists your sandbox number and every magic number below.
Your sandbox number
Signup provisions a sandbox number in the reserved +1 500-555-XXXX range.
It's the from for outbound tests and the to for simulated inbound. You can
"purchase" more from GET /v1/numbers/available + POST /v1/numbers; the
whole numbers API works in the sandbox.
Your own phone
Sign-up offers to verify your cell as its last step (optional, and you can do
it later under Settings): we text you a code. Once it's
verified, a test key delivers to that one number for real, so your first
API call makes your phone buzz. Every other destination stays simulated. Real
sandbox texts are capped at 10 a day and end with - via TextKit sandbox.
The response is a normal message with "test": true, "simulated": false
and an X-Sandbox-Real-Delivery: true header. Your phone also counts as your
first verified recipient when you go live on the free tier.
Real delivery to your phone needs a verification sender on our side. If none
is available the send is simulated like any other, and says so:
"simulated": true with a notice that real delivery is unavailable right
now. Nothing is ever reported as real that was not handed to a carrier.
Magic destination numbers
Send to these numbers to trigger fixed behaviors:
| Number | Behavior |
|---|---|
+15005550006 | Delivered: message.sent then message.delivered; the message is delivered by the time the send returns. Any other number behaves the same. |
+15005550013 | Delayed delivery: status sent, then really delivered at least 5 seconds later. Poll GET /v1/messages/{id} (it settles as soon as the delay has passed) or wait for the message.delivered webhook (within about a minute). Use it to test code that waits on delivery. |
+15005550001 | Stuck: status stays queued forever, no delivery event. |
+15005550002 | Failed, no reason given: failure.code unknown. |
+15005550007 | Failed: filtered as spam, failure.code carrier_filtered. |
+15005550008 | Failed: refused by the carrier, failure.code carrier_rejected. |
+15005550009 | Failed: number not in service, failure.code invalid_number. |
+15005550010 | Failed: a landline, failure.code landline (and GET /v1/lookup says landline). |
+15005550014 | Failed: phone unreachable, failure.code unreachable. |
+15005550011 | Opted out: 403 recipient_opted_out, exactly as for a recipient who replied STOP. Nothing is written to your opt-out list; the refusal is stored as a blocked message. |
+15005550012 | Rate limited: 429 rate_limited with a Retry-After header. The refusal is stored as a blocked message. |
+15005550016 | No opt-in on file: a marketing message ("category": "marketing", or any broadcast or batch) answers 403 consent_required. The other 500-555 numbers count as opted in, so test broadcasts to them deliver. See opt-in on file. |
+15005550021 to +15005550027 | Verify only: a code to one of these answers 403 verification_blocked with one Verify Shield reason each (destination_cooldown, destination_velocity, tenant_velocity, ip_velocity, voip_not_supported, unsupported_region, toll_free_destination), like a live key. See Verify. |
+15005550015 | Quiet hours: a recipient for whom it is always night. A marketing message answers 403 quiet_hours, or 202 with a held scheduled message when you add "quiet_hours": "defer". Any other message answers 202 with a held scheduled message. Only a message that reads as a one-time code delivers. See quiet hours. |
Failed numbers return 201 with status failed and emit message.sent
then message.failed; the failure object and the event payload are the
same ones a real carrier failure produces (see Errors).
Every sandbox message has a full timeline, segments, encoding,
a price of 0, and destination_carrier from the sandbox lookup.
Simulated inbound
POST /v1/test/inbound delivers a fake inbound SMS to one of your sandbox
numbers through the real pipeline: it appears in GET /v1/messages and emits
a message.received event with "simulated": true. (Test keys only; live
keys get a 403 test_mode_only.) to must be a sandbox number.
STOP, START, YES and HELP behave as they would for real, so you can rehearse
opt-out. Once your account has live access its opt-out list is real, so a
simulated message changes it only when from is a +1 500 555 number or
one of your verified numbers. From any other number the message is handled as
an ordinary one, consent unchanged, and the response carries a notice
saying so.
Test and live data
Once your account has live access, each key sees only its own mode's data, as
in Stripe. A test key lists and reads sandbox messages, events, batches,
schedules, scheduled messages, automations, numbers, exports and webhook
endpoints; a live key the live ones. Asking for an item of the other mode
answers 404 not_found, with a message saying which key to use. Every event
says which it is ("mode": "test" or "live"), and a
webhook endpoint receives only its
own mode's events. Before live access there is no live key, so your test keys
see everything on the account, as they always have.
A test key never changes anything live: it can't send or schedule from a live
number, or change a live schedule, automation, batch, scheduled message or
number (403 forbidden), and a bulk number action reports
mode_mismatch for each number of the other mode.
Some data belongs to the whole account: contacts, segments and topics, the
consent list, templates, customers, registrations, number pools, the spend
limit and the audit log. On a sandbox account test keys use it as before.
Once your account has live access, it needs a live key (403
live_access_required). Balance and usage stay readable with either key.
The key textkit login makes is a test key restricted to what the CLI does:
send and read messages, Verify, numbers, lookups and events.
Sandbox limits
A ceiling of 1,000 messages/day per account keeps the sandbox healthy. It's not a product quota. If you legitimately hit it, tell us.