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.
Worked examples: the wire, the markers, the 503
Prose defines the contract; these snippets pin it. Each one is complete enough to paste into a test. The Sandbox and test mode guide walks the full flow (magic numbers, test numbers, go-live gate) — this page shows the response markers you assert against at each step.Send in test mode
Authenticate with adv_test_sk_* key (or send X-Test-Mode: true from
an authenticated dashboard session) and POST a message exactly as you
would live:
test_sent status, and the response
envelope asserts meta.test_mode: true:
dv_live_sk_*) sends whether you pass X-Test-Mode: true
or not — the two markers above are the only reliable signal that test
mode actually engaged.
Read the test row back
Every test send persists, so your integration’s retrieval path has a real row to fetch — no mocks needed:metadata.test_mode on the stored row is the stamp the API prints when
it persists the message; it is what your metrics queries exclude, and —
with the honest test_sent status — what keeps test rows from reading
as real deliveries.
Branch on meta.test_mode
A webhook handler that gates on the marker is safe even when it ignores
the sandbox-URL split. For a Node handler that successfully verified the
payload, drop test events before any side effect:
message.sent: simulated
delivery-receipt sequences and inbox events carry the same stamp and are
the ones that slip through a single-event filter.
Assert in code
In CI, assert the marker rather than trusting the key prefix you used:The provisioning 503
While the org’s tenant schema is still being created, most endpoints return503 with the TENANT_PROVISIONING code. It is onboarding
state, not an error in your integration — meta.request_id there is
what support will ask for if it ever appears stuck:
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. - The channel test-account model — the shared-asset → BYO ladder on interactive channels (Telegram, WhatsApp): a real delivery path behind gates, orthogonal to this page’s simulated test mode.
- Sandbox and test mode guide — walks the end-to-end flow (magic numbers, test numbers, simulated inbound, deterministic verify codes, go-live gate); the send curls live there — this page pins the response markers each curl produces.