Email API integration sandbox testing email

Test Email Flows Without Sending Real Mail

Testing email against production leaks messages to real users and dents your reputation. How to use sandbox keys, seed addresses and CI to test transactional flows safely.

A test email being recorded in a sandbox and stopped before delivery, versus a live email reaching an inbox
A test email being recorded in a sandbox and stopped before delivery, versus a live email reaching an inbox

Every team has a story about the test email that wasn't a test. A loop that accidentally sent to the real user table. A "quick check" that mailed 4,000 customers a lorem-ipsum draft. A CI run that fired live password resets at a seed list of ex-colleagues. Testing email is uniquely dangerous because the side effect — a message in someone's inbox — can't be undone, and at volume it can quietly damage the sender reputation your real mail depends on.

The good news: you almost never need to send a real email to know your code works. The trick is separating "did my request succeed and get accepted?" from "did a message actually land?" — and testing the first far more often than the second.

Most email bugs are request bugs. You can catch nearly all of them without delivering a single message.

The sandbox key: your default for logic and CI#

A sandbox key (nv_test_…) is the workhorse. A send made with it is fully processed and recorded so you can inspect it — but it is never delivered and never counted against your quota. That combination is what makes it safe to run in automated tests as often as you like.

What the sandbox lets you assert without any real mail:

  • The request is well-formed and returns 202 with the expected envelope ({ success, code, message, data, errors }) and a data.messageId.
  • Your error handling is correct: send a bad address and confirm you get 422; send with a read-scoped key to a send endpoint and confirm 403; send malformed JSON and confirm 400.
  • Idempotency works: send twice with the same Idempotency-Key and confirm you don't get two distinct messages.
  • Template rendering resolves: POST /v1/send/template with a data object and confirm the placeholders are accepted.

Because none of this delivers, it belongs in CI, running on every pull request.

Layer your testing — don't rely on one level#

Sandbox catches request-level bugs, but it doesn't tell you whether the HTML renders correctly in Gmail or whether your webhook handler works end to end. Use a layered strategy, spending your "real send" budget only where it buys something sandbox can't.

LevelKeyWhat it provesHow often
Unit / integrationsandbox nv_test_…Request shape, error handling, idempotency, retriesEvery commit / PR (CI)
Rendering QAproduction, to seed inboxes you ownHTML in real clients, subject/preheader, linksPer template change
Webhook end-to-endsandbox or seed + a tunnelYour handler verifies signatures and updates statePer handler change
Pre-launch smokeproduction, to one or two real addressesThe whole path actually deliversRight before ship

The mistake is skipping levels — either testing everything against production (dangerous) or trusting sandbox alone and shipping a template that renders broken in the one client your users actually use.

Guardrails so a test never becomes a broadcast#

Even with sandbox as the default, put fences around real sends so a mistake can't scale:

  • Environment-scoped keys. Non-production environments hold only nv_test_… keys. The production key exists in exactly one place. If test code physically can't reach a live key, it physically can't send live mail.
  • Recipient allow-list in non-prod. In staging, refuse to send to any address not on an owned allow-list. A stray loop over the user table then sends nothing.
  • A kill switch. A single flag that short-circuits all sends, so you can stop everything instantly if something goes wrong.
  • No production data in test runs. Seed with synthetic recipients, not a dump of real customers.

Hands-on: the same test, sandbox then seed#

Sandbox assertion (runs in CI, delivers nothing):

JavaScript
// Node test — assert acceptance and idempotency, no real email sent
const KEY = process.env.NOTIFIVA_TEST_KEY; // nv_test_…

async function send(idempotencyKey) {
  const res = await fetch("https://api.notifiva.com/v1/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Idempotency-Key": idempotencyKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "[email protected]",
      to: "[email protected]",
      subject: "CI smoke",
      text: "Sandbox send — never delivered.",
      tag: "ci",
    }),
  });
  return { status: res.status, body: await res.json() };
}

test("accepts a well-formed send", async () => {
  const { status, body } = await send("ci-run-42");
  expect(status).toBe(202);
  expect(body.data.messageId).toBeTruthy();   // read the id here, not body.data.id
});

test("idempotent retry does not duplicate", async () => {
  const a = await send("ci-run-43");
  const b = await send("ci-run-43");           // same key
  expect(b.status).toBeLessThan(300);          // deduped, not a new distinct send
});

Bad-input assertion (still sandbox, still no delivery):

Bash
# Expect 422 for an invalid recipient
curl -s -o /dev/null -w "%{http_code}\n" https://api.notifiva.com/v1/send \
  -H "Authorization: Bearer nv_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"from":"[email protected]","to":"not-an-email","subject":"x","text":"x"}'
# -> 422

Only when these pass do you swap in a production key and send one real message to an inbox you own, to eyeball rendering. That's the entire real-send footprint of a well-tested flow.

Example scenario: the CI job that mailed real users#

(Illustrative scenario, not a customer case.) A team wires an end-to-end email test into CI, pointing it at the production key "just to be sure it really sends." The test seeds recipients from a fixture that, after a refactor, accidentally pulls from the real users table. Every CI run — dozens a day — sends live mail to real customers. Complaints climb, and the sending domain's reputation dips right as a marketing push needs it. Nothing about the code under test was wrong; the test harness had a live key and real data. Sandbox keys in CI plus a non-prod allow-list would have made the blast radius zero.

Editorial disclosure: Drafted with AI assistance; reviewed, fact-checked and edited by Alkım Kaplaner. Next scheduled review: .

Frequently asked questions

What is a sandbox (test) API key?

A key prefixed nv_test_… that fully processes and records a send but never delivers it and never counts against your quota. It lets you assert request shape, response envelopes, error codes and idempotency in automated tests without any real email going out.

Can I run email tests in CI safely?

Yes — with a sandbox key. Because sandbox sends don't deliver or consume quota, you can run them on every commit. Keep production keys out of CI entirely, so a test can't physically send live mail even if it tries.

How do I test that my HTML renders correctly?

Sandbox can't show you rendering, so send with a production key to a small set of seed inboxes you own and check the major clients per template change. Reserve real sends for rendering and one pre-launch smoke test; use sandbox for everything logic-related.

How do I test webhook handlers without spamming users?

Trigger sandbox sends or send to owned seed addresses, and point the webhook at your handler via a local tunnel. Assert that your handler verifies the signature and updates state correctly. You're testing your code, not the recipient's inbox.

How do I stop a test from accidentally emailing real customers?

Three fences: keep only sandbox keys in non-production environments, enforce a recipient allow-list in staging, and seed tests with synthetic data rather than a copy of real users. Add a global kill switch so any runaway send can be stopped instantly.

Send transactional email you can rely on

Notifiva gives you a REST API and an SMTP relay, SPF/DKIM signing, delivery webhooks and per-message logs — so the mail your users are waiting on actually arrives.