Skip to content
Docs menu

Test mode

A test-mode key sends through the real pipeline (DKIM, tracking, header injection) but the message is captured and never delivered. This is exactly what that guarantee rests on, what it skips to get there, and what it refuses to pretend to do.

Get a test key

A test key looks like zst_<8 hex>_<48 hex>: the same three-segment shape as a live zs_<prefix>_<secret> key, with a different scheme so you can tell them apart by eye. The scheme is only a hint; the database column that actually decides what a key can do is separate from the string itself, so don't write code that parses the prefix to make an authorization decision. Let the API enforce it.

Create one from the dashboard: API Keys > Create API key > Mode > Test key. It's an OWNER/ADMIN-only action, the same endpoint that creates live keys, and, like any key, it's shown once. It is not a paid feature: FREE and PRO both get it, because it's meant to be used while you're still finishing an integration, not after you've upgraded.

The same key authenticates identically over HTTP and over SMTP. See SMTP relay for the SMTP side. There is no separate "test mode" concept per transport; it's one mode on one key, read off the credential and passed straight through to the send pipeline either way.

What is identical

Sending with a test key is not a simulation bolted on beside the real one: it is the real send pipeline, pointed at a second, non-delivering mail server instead of the one that reaches real inboxes. Concretely, all of these still happen exactly as they would for a live send:

  • DKIM signing, with your domain's real key when you already have one: the sandbox reuses the DKIM keypair your domain verified with, so a test send signs with the same selector a live send would use.
  • The tracking-link rewrite (open pixel and click-tracking redirects), under the same condition a live send needs: it only happens if the From domain has open_tracking/click_tracking enabled. Test mode doesn't turn tracking on for a domain that has it off; it mirrors whatever that domain is already configured to do. See Domains for the tracking switches.
  • Message-ID injection when a raw SMTP message has none, and List-Unsubscribe header injection: both run unconditionally, regardless of mode. See Sending email for what gets injected and why.

The raw MIME and the headers the sandbox shows you are what a live send would have produced, byte for byte. That fidelity is the entire value of the feature; if any of the above were faked or skipped, a message that looked clean could still fail in production. The rendered preview is the one thing that is not byte-identical: it is displayed under a content-security policy that blocks network images, stylesheets and fonts, and <base> and meta-refresh tags are removed before it renders. That is a property of the viewer, not of the message. Read the Raw tab for the message itself.

What is skipped

Everything skipped in test mode is skipped for the same reason: nothing a test key sends is ever delivered, so the checks that exist to protect a real recipient or a real quota have nothing left to protect.

  • From-domain DNS verification. You can send from a domain that has never had a DNS record touched, useful for testing before propagation, or before you own the domain you'll eventually send from.
  • Account email verification. The gate that otherwise blocks an unverified account from sending live mail doesn't apply to a test send either.
  • The monthly quota. Test sends don't reserve or consume it, and don't show up in GET /v1/usage/current.
  • Suppression-list filtering. A recipient who unsubscribed or bounced on your live traffic is not excluded from a test send: there's no real inbox for the suppression list to protect here.

Because the domain gate is skipped, the mail server would otherwise reject the send outright for an unregistered From address, so the sandbox registers your sending domain on the sandbox server automatically, on first use. That registration exists purely to satisfy the mail server's own requirement that a From domain be known to the server sending on its behalf; it never makes the domain sendable anywhere else.

curl -X POST https://api.zsender.io/v1/emails/send \
  -H "Authorization: Bearer YOUR_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["recipient@example.com"],
    "from": "hello@any-domain.test",
    "subject": "Testing zSender",
    "text": "This never reaches a real inbox."
  }'

Why nothing ever gets delivered

A test send is not routed to the live server with a "don't actually send" flag: it is routed to a genuinely separate mail server, configured in Development mode, which holds every message it receives instead of handing it to an MTA. That separation only protects you if the server it routes to is verified, on every send, to actually be that server.

Before a test send reaches the mail server, the API checks two things in one query: that the configured sandbox API credential really belongs to the configured sandbox server, and that that server is really in Development mode. Checking the server's mode alone would not be enough: mail goes out from whichever server the credential belongs to, not whichever server id happens to be configured next to it, so a credential that quietly pointed at a different server would defeat a check that only looked at the server. Both halves have to hold.

If either check fails (sandbox credentials not configured, misconfigured to match the live ones, or the sandbox server no longer in Development mode) the send is refused outright. It does not fall back to a live send. What you observe is a 503 sandbox_unavailable response (see Errors) rather than a delivered test message: refusing loudly is the failure mode this mechanism is built around, not a silent fallback to the address you were trying to avoid hitting.

The check runs before every test send, and a failed or errored result is never cached, so a misconfiguration is caught on the very next send, not just the first one after it appears. A confirmed-safe result is cached briefly (60 seconds) purely to avoid a database round trip on every send; the guarantee is re-established continuously rather than assumed once and forgotten.

The sandbox inbox

Every captured test send lands in Sandbox in the dashboard, and the same data is reachable over the API: GET /v1/sandbox/messages lists your captured sends, and GET /v1/sandbox/messages/:id returns one in full: rendered/text/raw/header views plus a link and alt-text audit computed at send time, over what you actually submitted rather than the mail server's rewritten copy. See the sandbox reference for the exact response shape.

A message the mail server is still holding but hasn't yet processed has no stored body: the detail response reflects that honestly rather than erroring, so treat a missing body as "still processing," not as a failure.

Retention: 7 days

Captured test sends are pruned 7 days after creation. That number isn't one zSender picked: it's the mail server's own default hold-expiry, the point at which it has already discarded the message body on its own side regardless of what zSender keeps. Don't build an integration test that expects a captured message to still be readable past a week.

Simulating a webhook

POST /v1/sandbox/messages/:id/simulate fires one synthetic event, event plus either endpoint_id (one of your real stored webhook endpoints) or an ad-hoc url, at a destination you choose, using the exact payload builder the real fan-out uses. See Webhooks for the event names and the payload shape.

The two destinations aren't equivalent for testing your signature verification. A stored endpoint is signed with that endpoint's real whsec_<secret> secret, so a simulated event genuinely exercises your verification code. An ad-hoc URL has no stored secret to sign with, so it's signed with a one-off secret generated just for that call: it proves your handler returns the right status code and can parse the payload shape, but a 2xx from it proves nothing about whether your signature check actually works. The response tells you which case you're in.

Every simulated payload carries "mode": "test" and an X-Zsender-Mode: test header, so your handler can tell a simulation apart from a real event if it needs to. The call is synchronous: your endpoint's actual status code and body come back in the response, it is never retried, and it draws from its own rate bucket (20/min per tenant) separate from the send limit below.

What a test key cannot do

Test sends draw from their own fixed rate bucket (60/min, the same for every plan) instead of your plan's live send limit, so exercising the sandbox heavily can't eat into your paid send-rate ceiling and vice versa.

A test key is refused with 403 test_mode_restricted (see Errors) on surfaces that only describe real traffic, because returning real-looking numbers to a test key would make the sandbox indistinguishable from production:

  • GET /v1/analytics: delivery/open/click rates are meaningless for mail that was never delivered.
  • GET /v1/usage/current: test sends never consume quota, so there is nothing to report.
  • POST /v1/suppressions and DELETE /v1/suppressions/:email: suppression protects real recipients, and a test send never reaches one.
  • GET /v1/emails/received, GET /v1/emails/received/:id, GET /v1/emails/received/:id/attachments/:attachmentId, and GET /v1/emails/received/:id/raw: inbound mail has no test mode; a received message is always real.

Test sends also never appear in the live message list, live analytics, or usage (they are excluded at the query, not just hidden in the UI) so a captured test send and a live send with the same subject line will never collide in a report you're looking at.