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@ordelayed@simulator.groundmail.dev(plus addressing such asbounce+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>"
}'frommust be onmail.groundmail.dev(usenoreply@mail.groundmail.dev).- Every recipient in
to,ccandbccmust be your account email. Anything else returns403with codeforbidden, 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:
- a verified domain for
from, and - 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 a504whose outcome is unknown. - Failures a retry can fix release the key, so retrying with the same key runs the request again: validation errors,
403sending rules,502/503from the mail server, and500. Rate limit (429) responses are returned before the key is checked, so retry them with the same key.
Retry recipe
- Send with an
Idempotency-Key. - On
429,500,502or503: wait (honorRetry-After), then resend the identical body with the same key. Back off exponentially, up to about 5 tries. - On
504: the email may still be delivered. Resending with the same key returns the same504and never sends twice; checkGET /emailsbefore deciding to send a new message with a new key. - On any other
4xx: do not retry unchanged. Follow the error'shint.
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.