Catch test emails from any email provider and wait for them in your tests. Your team has a test domain, <team>.test.groundmail.dev. Mail sent to any address there is kept in your test inbox for 14 days and never delivered to anyone. Emails you send with a gm_test_ key land here too.
For the walkthrough with Playwright, Vitest, Jest, Cypress and pytest, see Email testing.
Base URL: https://api.groundmail.dev
Auth (any key with the emails:read scope, test keys included):
| Endpoint | What it does |
|---|---|
GET /test-inbox |
Your test domain, an example address and this month's usage |
GET /test-inbox/emails |
Caught emails, newest first, filtered by to and since |
GET /test-inbox/emails/{id} |
One email with bodies, headers and source |
GET /test-inbox/wait |
Long-poll until an email to to arrives (up to 60 seconds) |
Every email carries code and links, extracted for you:
code: the one-time code in the email, 4 to 8 digits (123456, also written123 456or123-456), preferring one next to words like "code" or "verification". Years, prices, times and order numbers don't count.nullwhen there is none.links: everyhttp(s)link in the email, in order, without duplicates (the HTML's links, or the URLs in the text when there is no HTML).
Authorization: Bearer gm_...Get your test domain
/test-inboxThe first call creates your team's test domain. address is a fresh example address on every call.
usage.included is 100 a month on every account, or 5,000 with the email testing add-on. Without the add-on, test emails past the limit are refused (usage.rejected) until resets_at. With it, they are accepted and billed per 1,000 (usage.overage: true). Emails sent with a gm_test_ key are free and don't count.
curl https://api.groundmail.dev/test-inbox \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY"{
"test_inbox": {
"object": "test_inbox",
"domain": "acme.test.groundmail.dev",
"address": "run-k3m9x2pq@acme.test.groundmail.dev",
"addon": { "active": false, "status": null },
"usage": {
"month": "2026-10",
"received": 42,
"rejected": 0,
"included": 100,
"overage": false,
"resets_at": "2026-11-01T00:00:00.000Z"
},
"retention_days": 14
}
}List test emails
/test-inbox/emails| Query | Description |
|---|---|
to |
Only emails to this exact address |
since |
Only emails received at or after this time (ISO 8601) |
limit |
1–100, default 25 |
cursor |
next_cursor from the previous page |
source is inbound for mail received at your test domain, and test_key for an email you sent through Groundmail with a gm_test_ key (email_id is then that email's id).
curl "https://api.groundmail.dev/test-inbox/emails?to=signup-k3m9x2pq@acme.test.groundmail.dev" \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY"{
"emails": [
{
"object": "test_inbox_email",
"id": "tie_3f9c2a1b7d8e4f6a0b1c2d3e",
"source": "inbound",
"email_id": null,
"from": "Acme <no-reply@acme.com>",
"to": ["signup-k3m9x2pq@acme.test.groundmail.dev"],
"cc": [],
"subject": "Your Acme verification code",
"code": "482913",
"links": ["https://app.acme.com/verify?token=abc"],
"received_at": "2026-10-22T10:00:00.000Z",
"expires_at": "2026-11-05T10:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null
}Retrieve a test email
/test-inbox/emails/{id}Returns { "email": … }: the list fields plus text_body, html_body, headers ([{ "name", "value" }] in message order) and raw_source (the raw message, when available).
404 not_found when there is no such email for your team (or it expired).
curl https://api.groundmail.dev/test-inbox/emails/tie_3f9c2a1b7d8e4f6a0b1c2d3e \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY"Wait for a test email
/test-inbox/waitHolds the request open until an email to to arrives, then returns it like GET /test-inbox/emails/{id}. An email that already arrived is returned at once, so it doesn't matter whether your test calls this before or after your app sends.
| Query | Description |
|---|---|
to |
Required. The exact address your app sent to |
since |
Only emails received at or after this time (ISO 8601). Useful when one address receives several emails |
timeout |
Seconds to wait, default 30, at most 60 |
When several emails match, the newest is returned. If nothing arrives in time, the response is 408 with code timeout:
Calling again is safe. The SDK's testInbox.waitFor repeats the call for timeouts longer than 60 seconds.
curl "https://api.groundmail.dev/test-inbox/wait?to=signup-k3m9x2pq@acme.test.groundmail.dev&timeout=30" \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY"{
"error": {
"message": "No email to signup-k3m9x2pq@acme.test.groundmail.dev arrived within 30 seconds",
"code": "timeout",
"hint": "Nothing arrived in time. Check that your app sent the email to the exact address you are waiting on, then call again: the wait is safe to repeat and returns an email that arrived in the meantime. Waits last at most 60 seconds per call.",
"docs_url": "https://docs.groundmail.dev/errors#timeout",
"details": { "to": "signup-k3m9x2pq@acme.test.groundmail.dev", "timeout": 30 }
}
}SDK, CLI and MCP
MCP tools: get_test_inbox_address, wait_for_test_email, list_test_emails, get_test_email.
import { Groundmail } from 'groundmail'
const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
const to = await groundmail.testInbox.address({ prefix: 'signup' }) // signup-k3m9x2pq@acme.test.groundmail.dev
const email = await groundmail.testInbox.waitFor({ to, timeout: 30_000 })
console.log(email.code, email.links)
await groundmail.testInbox.list({ to })
await groundmail.testInbox.get(email.id)
await groundmail.testInbox.info() // domain and usageTO=$(groundmail test-inbox address --prefix signup)
groundmail test-inbox wait --to "$TO" --timeout 30
groundmail test-inbox list --to "$TO"