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:

SettingWhat it does
Delivery mode — Delivers · Delivers, then reads · Sends, then not delivered · Never confirms · UnreachableThe 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 sentWhether 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 not message.failed either — that event means the send itself failed.
  • message.delivery_failed is reported, with "simulated": true: to every webhook endpoint subscribed to it, and on GET /v1/response_stream as an event: delivery_failed frame — but only on a stream opened with ?events=delivery_failures. A stream opened without that option never carries it. The send's own command frame 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.

  1. 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.
  2. 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.
  3. Nothing here asks Apple. Every handle in the world exists because the world made it. POST /v1/handle-check never leaves the database, so it can neither find a real iMessage account nor miss one — it answers available: true for the world's own contacts and available: false with reason: "not_in_sandbox" for everyone else.
  4. 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.set and chat_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. A location_request is 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. A reaction.tapback names its message as on a live line: by the externalId its message frame carried, which in test is the simulated device's own id for the message, or by the message's own messageId where the contact sent it. group.create is accepted and settles failed / unsupported_capability — which is deliberately not the 404 unsupported_command a 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-formed group.update or group.leave is 422 validation_failed at target.conversationId there, never a 202. No attachments, no inbound media.
  • Validation is where it runs. A message.send payload is checked at the POST in both environments, and so is how an iMessage reaction.tapback, name_photo.share, group.create, group.update, group.leave, message.edit, message.unsend, name_photo.get, name_photo.set, chat_background.set or chat_background.remove is 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 a failed settlement 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 repeat message.send that differs from what the key first named is 409 conflict (idempotency_key_reused). On a live line group.create, typing.start and message.read ignore the key, so a group.create retry is a second group, and a key reused for a different command is compared or not depending on the type and the transport — the same 409, or adopted as the first without a word. One key reused across a chat_background.set and a chat_background.remove is that 409 here, 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 location and moderation events with simulated: 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.

On this page