Test mode
What a test key reaches, and the four places the sandbox behaves differently from a handset.
Every project has two environments, and a key names one of them: mp_live_…
reaches real lines and real handsets, mp_test_… reaches the project's
sandbox and nothing else. Both take exactly the same routes — the whole of
/v1 — and only the transport behind them differs, which is the point of
having a test mode at all. The two see none of each other's data: a
conversation, a contact, an event or a webhook endpoint belongs to one
environment, and a test key asking about a live id gets 404.
The sandbox
A sandbox is a fake iMessage world: one line of its own (+199…), three
contacts — Riley Chen, Sam Okafor and Jordan Vale, at handles in a number range
no carrier issues — and a phone you hold on the project's Sandbox page,
which is the contact's device rather than yours. Compose there and you are
composing as Riley; what your agent sends lands on Riley's screen.
The device is real in the one way that matters: a send is carried out as a
live line carries it out, against a simulated gateway, so a refusal in
test is the refusal live gives. Ask to send to a handle the world does not know
and the command settles failed / send_returned_not_ok, exactly as an address
no device answers to would settle live.
Each contact carries settings that decide what happens to a send:
| Setting | What it does |
|---|---|
| Delivery mode — Delivers · Delivers, then reads · Sends, then not delivered · Never confirms · Unreachable | The outcome a send to this contact gets. Never confirms is the one a real handset produces only by accident: the send leaves, its command frame settles ambiguous with gateway_response_lost and its message is needs_reconcile — the state your handler most needs to have seen once. Sends, then not delivered is Apple's "Not Delivered", below. |
| Auto-reply — Stays quiet · Echoes what it is sent | Whether the contact answers. Echo is enough to drive an integration test end to end with nobody at the other handset. |
| Delivery delay (world-wide) | How long a send waits before the world settles it, 0–60 s. |
Every stamp the sandbox produces is labelled Simulated, and every
message.delivered, message.read and message.delivery_failed event it
emits carries "simulated": true — the sandbox never lets a rehearsal
masquerade as a receipt.
Sends, then not delivered
This mode rehearses Apple's "Not Delivered": the message left, and the
provider said afterwards that it did not arrive. The send settles succeeded
and the message sent, as under Delivers; then, once the world's delivery
delay has passed, the world reports the message not delivered, and three
things follow.
- The message stays
sent. The verdict is about delivery, not about the send, so it moves no status. It is notmessage.failedeither — that event means the send itself failed. message.delivery_failedis reported, with"simulated": true: to every webhook endpoint subscribed to it, and onGET /v1/response_streamas anevent: delivery_failedframe — but only on a stream opened with?events=delivery_failures. A stream opened without that option never carries it. The send's owncommandframe still arrives either way — live, possibly after this one, below.- The thread is flagged. The stamp under the bubble reads Simulated · Not delivered, and while that message is the thread's newest, the thread is listed under the Conversations page's Needs attention filter with a Not delivered badge.
An echoing contact does not answer it: a message its device never received is not one it can echo.
Live, the verdict can come first
A live iMessage line reports Apple's own verdict as the same event and the
same frame, with the same fields, differing only in simulated. It does not
report it in the same order. Here the world reports a verdict only on a
message that is already sent, so the verdict is always emitted after the
send's message.sent, and on the stream it always arrives after the send's
command frame. Live, Apple's verdict can reach the platform before the
send's settlement does: message.delivery_failed can be emitted, and its
frame arrive, while the message is still sending — ahead of message.sent
and of the command frame, and either of those may never arrive at all.
So correlate a verdict by its commandId, the handle the 202 gave you, and
do not assume the send was recorded first. A handler that applies a verdict to
a send it recorded from the command frame, or that assumes the message is
sent, passes every rehearsal here and misses Apple's verdict on the day.
The frame's fields, and the ?events= option that asks for it, are in the
stream reference.
Where it differs from a handset
Four divergences, and each is worth knowing before a rehearsal that passed surprises you on the day. The Sandbox page prints the same four in its world panel.
- Settlement is immediate. A test send settles in milliseconds, where a live send can take seconds. A client that only works because a live send is slow will find out here. Order runs the other way: a test send has always settled before any not-delivered verdict about it is reported, where live the verdict can come first, so a client that relies on the settlement arriving first finds out only on the day.
- Delivery timing is a knob, not a measurement. The delay is a number the world waits before settling. It is not modelled on any handset and it is the same for every contact.
- Nothing here asks Apple. Every handle in the world exists because the
world made it.
POST /v1/handle-checknever leaves the database, so it can neither find a real iMessage account nor miss one — it answersavailable: truefor the world's own contacts andavailable: falsewithreason: "not_in_sandbox"for everyone else. - The device behaves as you tell it to. Every delivery mode above is a setting on a contact. That is the point — those are the outcomes a real handset only produces by accident — but a green stamp here is your own choice reflected back, not a fact about a phone.
Four more that are about surface rather than timing:
- Commands the sandbox can execute:
message.send,reaction.tapback,name_photo.share,typing.start,message.read,message.edit,message.unsend,name_photo.get,name_photo.set,poll.vote,chat_background.setandchat_background.remove. An edit or an unsend settles as live's does. The line's Name and Photo card starts as the line's name, a set changes it and the Sandbox page shows it; a photo link is fetched and checked as live checks it, and is neither kept nor drawn. Alocation_requestis sent and delivered, and nobody shares back. A chat background is set or removed in a conversation with one person — there is no group chat to set one in — and settles as live's does: nothing draws the background, and no message with no text is filed, where a live line files one your handler has to skip. Areaction.tapbacknames its message as on a live line: by theexternalIditsmessageframe carried, which in test is the simulated device's own id for the message, or by the message's ownmessageIdwhere the contact sent it.group.createis accepted and settlesfailed / unsupported_capability— which is deliberately not the404 unsupported_commanda type the channel never carries gets, because "this environment cannot do it" is worth retrying against a live key and "no line of this channel can" is not. A test line has no group chat — the sandbox files none — so a well-formedgroup.updateorgroup.leaveis422 validation_failedattarget.conversationIdthere, never a202. No attachments, no inbound media. - Validation is where it runs. A
message.sendpayload is checked at the POST in both environments, and so is how an iMessagereaction.tapback,name_photo.share,group.create,group.update,group.leave,message.edit,message.unsend,name_photo.get,name_photo.set,chat_background.setorchat_background.removeis addressed — its conversation, its message, its participants or its line — and an edit's, a set's, a group update's, a leave's and a background change's payload. Other commands' payloads are checked by nothing in test, while a live line's transport checks some before answering and the rest when it runs them, reporting them as afailedsettlement on the stream (see commands). Handle both. - Idempotency differs. The sandbox holds the key of every command,
group.create's included, and compares what a reused key arrives with: anything but a repeatmessage.sendthat differs from what the key first named is409 conflict(idempotency_key_reused). On a live linegroup.create,typing.startandmessage.readignore the key, so agroup.createretry is a second group, and a key reused for a different command is compared or not depending on the type and the transport — the same409, or adopted as the first without a word. One key reused across achat_background.setand achat_background.removeis that409here, where a live line answers with the first command and changes nothing more. A rehearsal cannot show you either; see Idempotency. - A contact's location, edits and unsends come from the sandbox phone. What
the sandbox phone shares, edits or unsends arrives as
locationandmoderationevents withsimulated: true.
Contacts in test
A test contact is only ever created by the sandbox — on the Sandbox page, or
by the world when a project is born. A /v1 send to an address
the world does not know fails the way it fails live. A test that quietly
provisioned a contact on first send would make test mode stop resembling the
thing it is a test of.
Resetting the world
Reset world on the Sandbox page starts a new generation. Nothing is
deleted: every message, command and event stays on the record, the phone
empties, the roster's counters reset, and — the part a test harness cares
about — every Idempotency-Key used in the previous generation works again,
so a fixture can send first-hello on every run. The ids the world handed out
are never reused.
Budget
Test traffic is budgeted rather than metered: nothing in test is billed, and
each world admits a bounded number of actions per UTC day (the Sandbox page's
Budget meter, N of M actions today). Past it, a command answers 429 sandbox_budget_exhausted until the day rolls over.