Testing

Build and test your email code, and your webhook handling, without sending real mail. Groundmail has two tools for this:

  • Test keys (gm_test_…): the API works exactly as it does with a live key, but nothing is delivered.
  • Simulator addresses (…@simulator.groundmail.dev): each one produces a specific outcome, such as a hard bounce or a spam complaint, with real webhooks. They work with test keys and live keys.

Neither touches your sending reputation, your stats or your bill.

Test keys

Create a test key on the API keys page (tick Test key), or through the API:

curl -X POST https://api.groundmail.dev/api-keys \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI", "scopes": ["emails:send", "emails:read"], "test": true}'
groundmail keys create --name CI --scopes emails:send,emails:read --test
const key = await groundmail.apiKeys.create({ name: 'CI', scopes: ['emails:send', 'emails:read'], test: true })

The token in the response starts with gm_test_. A test key has scopes like any other key, and is listed with a Test badge on the API keys page.

What a test send does

A send with a test key runs the whole pipeline:

  • Request validation, templates and variables, Idempotency-Key, scheduled sends (scheduled_at) and batch sends.
  • The checks a live send gets: your from domain must be verified (or the sandbox address), suppressed and opted-out recipients are refused, and Groundmail validation holds back addresses that cannot receive mail.

Then, instead of handing the message to the mail server, Groundmail marks it delivered and fires the same webhooks a real delivery would. The response says so:

{ "id": "msg_…", "status": "sent", "test": true }

The email is stored like any other, with "test": true. On the Emails page it has a Test badge, and the Test only filter shows just test emails (GET /emails?test=true in the API). Its webhook events carry "test": true in data.

Test sends do not count toward your plan's monthly or daily volume, the new account ramp-up, deliverability stats, or billing. They are not stopped by those limits either.

A test key never delivers, even to a real address: a test send to you@yourcompany.com reports delivered without sending anything. To see a simulated bounce or complaint, send to a simulator address.

Good to know:

  • An Idempotency-Key used with a test key never matches one used with a live key.
  • Resending a test email (POST /emails/{id}/resend, or Resend in the dashboard) sends a test email again.
  • Events sent with a test key (POST /events) start test runs of your automations: their emails are test sends and their SMS steps are skipped. A test event never resumes or cancels a live run.
  • SMTP credentials are always live. Simulator addresses still work over SMTP.

Simulator addresses

Send to these addresses to get a specific outcome. They work with a live key or a test key, from your verified domain or the sandbox address, and on any plan.

Address Outcome Status Webhooks
delivered@simulator.groundmail.dev Delivered delivered email.delivered
bounce@simulator.groundmail.dev Hard bounce: the mailbox does not exist bounced, bounce_type: "hard" email.bounced
block@simulator.groundmail.dev Block bounce: the receiving server refused the sender bounced, bounce_type: "block" email.bounced
complaint@simulator.groundmail.dev Delivered, then marked as spam complained email.delivered, then email.complained a few seconds later
suppressed@simulator.groundmail.dev Treated as a suppressed recipient suppressed (the send returns 422) email.suppressed
delayed@simulator.groundmail.dev The receiving server deferred delivery delayed email.delivery_delayed

Plus addressing works, so you can tell your tests apart: bounce+signup-42@simulator.groundmail.dev hard-bounces like bounce@. Any other address at simulator.groundmail.dev is refused with a 400 validation_error.

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "bounce+signup-42@simulator.groundmail.dev",
    "subject": "Bounce handling test",
    "html": "<p>Simulated.</p>"
  }'
import { simulatorAddress } from 'groundmail'

await groundmail.emails.send({
  from: 'you@yourdomain.com',
  to: simulatorAddress('bounce', 'signup-42'),
  subject: 'Bounce handling test',
  html: '<p>Simulated.</p>',
})

Mail to the simulator never leaves Groundmail. Sends to it are test sends ("test": true), with everything that means above. They also never touch:

  • your suppression list (bounce@ and complaint@ are not added to it, and suppressed@ is refused without being on it),
  • the shared list of addresses that hard-bounced, which Groundmail validation uses,
  • your bounce and complaint rates.

With a live key, a send must go either to simulator addresses only or to real recipients only; mixing them returns a 400 validation_error. With a test key you can mix them, since nothing is delivered either way. When one email has several simulator recipients, it takes the most severe outcome: bounce, then block, then complaint, then delayed, then delivered.

Testing webhooks

Point a webhook at your handler, then send to simulator addresses to get each event with a real payload and a real signature. For example, a simulated hard bounce:

{
  "type": "email.bounced",
  "created_at": "2026-10-18T12:00:00.000Z",
  "data": {
    "email_id": "msg_…",
    "to": ["bounce+signup-42@simulator.groundmail.dev"],
    "from": "you@yourdomain.com",
    "subject": "Bounce handling test",
    "status": "bounced",
    "bounce_reason": "550 5.1.1 <bounce+signup-42@simulator.groundmail.dev>: Recipient address rejected: User unknown (simulated hard bounce)",
    "bounce_type": "hard",
    "test": true
  }
}

data.test is true on every simulated event and absent on events from real mail, so your handler can skip side effects for tests. To send one event to one endpoint without an email, use POST /emails/test (its events are marked test too).