Sandbox, test mode, and provisioning
Two transient states look similar from the client side and mean completely different things: an org whose database schema is still provisioning returns 503TENANT_PROVISIONING, and an org operating in
test mode returns 2xx with meta.test_mode: true. The first is a
lifecycle fact about the org itself; the second is a per-credential,
per-request decision you control. This page defines both, catalogues
what test mode does and does not touch, and lists the integration
mistakes that recur most.
The test-mode switch: three ways in
Test mode is resolved on every request, before any handler runs. Three inputs turn it on, and any one of them is sufficient:- A test-prefixed API key. A key beginning with
dv_test_sk_*unconditionally enables test mode on every request it authenticates. This is the canonical switch for server-to-server integrations — rotate to a test key and every call through it is sandboxed. - The
X-Test-Mode: trueheader on a session-authenticated request. The dashboard’s in-topbar Test mode toggle sets this header on every call it makes. It is honored only for browser-session auth — a live API key (dv_live_sk_*) that sendsX-Test-Mode: truegets live mode anyway; the header is ignored there so a partner or reseller key cannot flip into the shared sandbox sender pool from the wire. Server-to-server clients always use adv_test_sk_*key instead. - An org flagged as a sandbox org. Every key minted on a sandbox org resolves to test mode regardless of key prefix or header; there is no way to send live from a sandbox org.
metadata.test_mode: true on a message send is the output marker,
not an input. Passing metadata: { test_mode: true } in a send body
does not sandbox the call — switch the credential or the header.
What a test send does and does not touch
A test-mode send is written to your tenant database like any other send, then short-circuits before any provider dispatch. Concretely:- It is persisted. The row exists, with
metadata.test_mode: true, so your integration canGET /messages/:idand exercise its retrieval code paths against it. - No provider sees it. No SMPP submission, no WhatsApp/Meta handoff, no SMTP session. The carrier side is never reached.
- No balance moves. No wallet debit, no credit reservation, no usage charge. Ever.
- It resolves to
test_sent. Delivery is simulated: the message terminates at thetest_sentstatus, and the sandbox simulator can additionally synthesize a delivery-receipt sequence (delivered, failed, expired, undelivered — keyed by the recipient’s trailing digit, see Sandbox magic numbers) so your webhook handlers run against realistic transitions. Those simulated receipts appear only in webhook payloads; the stored message keeps its honesttest_sentstatus. - Every response carries
meta.test_mode: true, so your client can assert sandbox dynamically rather than trusting environment naming.
Which objects participate
Test mode is per-org (scoped to your tenant), not a separate environment, and one class of objects plays no part in it:- Messages. All channels (SMS, MMS, WhatsApp, RCS, email, and the
rest) short-circuit the same way: persisted, never dispatched,
metadata.test_mode: true, no charge. - Verify sessions. Verification flows are the one object with an
explicit trust boundary: a live credential cannot run a
verification with a caller-supplied
custom_code— that path requiresdv_test_sk_*credentials (orX-Test-Mode: trueon session auth), and the API tells you so. In live mode the platform always generates the OTP. - Webhook receivers. Simulated lifecycle events for test sends (the
magic-number DLR sequences, the
message.sentevent for thetest_sentterminal state) are delivered to your org’s sandbox webhook URL — a receiver you configure separately from the live one, under Settings → API Keys → Sandbox or via thesandbox_webhook_urlorganization setting. Properly configured, your production handler never sees test traffic at all. Every test-mode webhook payload carriesmetadata.test_mode: trueas well, so a handler that ignores the two-URL split can still detect and drop test events programmatically.
Keeping sandbox out of production
Two disciplines keep the two worlds separate on your side:- Branch on
metadata.test_modefor every event type, not justmessage.sent. A test send emits the same event families a live send does —message.sentwithstatus: "test_sent", plus simulated delivery-receipt events if magic numbers are in play. A webhook handler that filters only one event type will let the others through. The right gate is an earlyif (data.metadata?.test_mode === true)return on all message events — or, better, separate delivery: the sandbox webhook URL means the event never reaches your production handler. - Exclude
metadata.test_mode = truerows from every metric you report. Delivered-rate, failure-rate, cost, and throughput aggregations that count test rows overstate volume and distort outcome rates. The condition is cheap (metadata->>'test_mode'is null or absent on live rows); the mistake is expensive because it is silent and compounds over time.
The provisioning context
Test mode decides whether a send is simulated; provisioning decides whether the org can serve API traffic at all. Every organization starts as a catalog row. Provisioning then creates the org’s tenant schema — the isolated per-org data store described in Tenant isolation — runs the tenant table set against it, and finally flips the org’stenant_schema_ready flag.
While that flag is false, most endpoints return
503 TENANT_PROVISIONING.
That 503 is transient onboarding state, not a client error. The
integration lives in your code before the org’s schema exists in the
database; retry with backoff and the same call succeeds once
provisioning completes. Endpoints that legitimately run pre-provisioning
(an onboarding status poll is the canonical case) opt out of the gate
explicitly.
Test mode and provisioning are orthogonal. They combine without special
rules: a key on a not-yet-provisioned org gets TENANT_PROVISIONING
whether it is live or test — provisioning finishes when you finish
onboarding, not when you pick a credential. A provisioned org serves
live and test traffic side by side, decided per request by the
credential or header.
Common pitfalls
- Counting
test_sentin delivered metrics.test_sentis a terminal status that appears in list endpoints and metrics rows just likedelivered. Excludemetadata.test_mode = truefrom every aggregation, or your delivered rate includes messages no carrier ever saw. - Treating
TENANT_PROVISIONING503 as a real onboarding error. It fires when an org row exists but its schema is not ready yet. It resolves itself; the only bug is surfacing it to the end user as a failure instead of retrying or showing an onboarding-in-progress state. - Sending
X-Test-Mode: truewith a live API key and expecting sandbox. The header is ignored on server-to-server auth. Requests remain live — real provider dispatch, real charges. Rotate to adv_test_sk_*key. - Setting
metadata.test_mode: truein the request body and expecting sandbox. It is an output marker the platform stamps, not an input. The request stays live. - Pointing sandbox events at a production handler. Either
configure the separate sandbox webhook URL so test events never
reach it, or branch on
metadata.test_modeat the top of the handler — on every event type, not onlymessage.sent. - Provisioning a Verify integration with
custom_codebefore switching to test credentials. Live credentials cannot supply an OTP; generate the code server-side or move the integration to adv_test_sk_*key first.
Cross-references
- Delivery lifecycle — where
test_sentsits in the message state machine and which webhook events it emits. - Tenant isolation — the per-tenant schema
that provisioning creates, and the request-resolution chain that
returns
TENANT_PROVISIONINGwhile it is absent. - Sandbox magic numbers — simulate specific delivery-receipt sequences by recipient trailing digit.
- Error codes reference —
TENANT_PROVISIONINGandTENANT_SCHEMA_INCOMPLETEsemantics.