Test sends and idempotency

How to test Groundmail without emailing real people, and how to retry without sending twice. Written for AI agents and the people who supervise them, and true for anyone calling the API.

Related: Errors · Quickstart · MCP server · Agent skill notes


Test sends

The safest test delivers nothing at all:

  • Simulator addresses. Send to delivered@, bounce@, block@, complaint@, suppressed@ or delayed@simulator.groundmail.dev (plus addressing such as bounce+run-1@… works). With any key, from the sandbox address or your domain, on any plan. Nothing leaves Groundmail; the email gets that outcome and its webhooks, marked "test": true.
  • A test key (gm_test_…). Every send with it runs the same checks as a live send and fires webhooks, but is never delivered and never counts toward quota.

Details: Testing. The rest of this section is about real sends that can only reach you, from least to most real.

1. Sandbox sender: before your domain is verified

Send from the shared sandbox address to the email on your Groundmail account:

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sandbox-check-1" \
  -d '{
    "from": "noreply@mail.groundmail.dev",
    "to": "YOUR_ACCOUNT_EMAIL",
    "subject": "Groundmail sandbox check",
    "html": "<p>It works.</p>"
  }'
  • from must be on mail.groundmail.dev (use noreply@mail.groundmail.dev).
  • Every recipient in to, cc and bcc must be your account email. Anything else returns 403 with code forbidden, and nothing is sent.
  • No DNS and no payment method needed.
  • The email is delivered to your inbox, shows up in GET /emails, fires your webhooks, and counts toward your plan's daily and monthly quota.

This is the safe default for agents: the worst a buggy loop can do is fill your own inbox, and it stops at the Free plan's 100 a day.

2. Your verified domain, to yourself

After you verify a domain, you can send from it (for example hello@yourdomain.com) to your account email on the Free plan without a payment method. Use this to check DNS, branding and rendering from the real domain.

3. Real recipients

Sending to anyone else needs both:

  1. a verified domain for from, and
  2. a payment method on file at Billing. The Free plan is not charged; the card unlocks sending (3,000 a month, 100 a day). Pro and Scale raise the limits.

If either is missing the API returns 403 forbidden with a hint that names the missing step. An agent should stop and ask the person before it adds a domain, asks for a card, or sends to anyone other than them.

Webhook tests without sending

POST /emails/test builds a webhook payload for an event such as email.delivered or email.bounced and delivers it to your webhook endpoints. No email is sent.

curl -X POST https://api.groundmail.dev/emails/test \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "email.bounced", "sync": true}'

Pass "deliver": false to get the payload back without delivering it, or webhook_id to target one endpoint. The MCP tool send_test_webhook_event does the same thing.

Template previews

POST /templates/{id}/test-send sends the current draft of a template once, with sample variables. The same sender rules apply: from the sandbox address it only reaches your account email.


Idempotency keys

Add an Idempotency-Key header to POST /emails and POST /events so a retry never sends the same email (or fires the same event) twice. Generate one key per logical message, for example password-reset-<user id>-<token id>, store it with the job, and reuse it on every retry of that job.

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-user-8812" \
  -d '{"from":"hello@yourdomain.com","to":"user@example.com","subject":"Welcome","html":"<p>Hi</p>"}'
import { randomUUID } from 'node:crypto'

const idempotencyKey = job.idempotencyKey ?? randomUUID() // save it with the job

const res = await fetch('https://api.groundmail.dev/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.GROUNDMAIL_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify(email),
})

How it behaves

Situation Response
First request with a key Processed normally.
Same key, same body, after the first finished The original status and body are returned again. Nothing is sent.
Same key, same body, first still running 409 idempotency_conflict. Wait a second and retry.
Same key, different body 409 idempotency_conflict. Use a new key for a new message.
Key longer than 256 characters 400 validation_error.

Details:

  • Keys are scoped to your team and kept for 24 hours. After that the same key starts a new request.
  • "Same body" compares the JSON content, so key order and whitespace do not matter.
  • Successful sends are stored and replayed. So are refusals that would fail the same way again: suppressed or opted-out recipients (422), a refusal from the mail server, and a 504 whose outcome is unknown.
  • Failures a retry can fix release the key, so retrying with the same key runs the request again: validation errors, 403 sending rules, 502/503 from the mail server, and 500. Rate limit (429) responses are returned before the key is checked, so retry them with the same key.

Retry recipe

  1. Send with an Idempotency-Key.
  2. On 429, 500, 502 or 503: wait (honor Retry-After), then resend the identical body with the same key. Back off exponentially, up to about 5 tries.
  3. On 504: the email may still be delivered. Resending with the same key returns the same 504 and never sends twice; check GET /emails before deciding to send a new message with a new key.
  4. On any other 4xx: do not retry unchanged. Follow the error's hint.

With the Node SDK

import { Groundmail } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

await groundmail.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Welcome',
  html: '<p>Hi</p>',
  idempotencyKey: `welcome-${user.id}`,
})

groundmail.events.trigger takes idempotencyKey the same way. Neither the SDK nor the MCP send_email tool adds a key for you.