| Action | What happens |
|---|---|
url |
The raw message is POSTed to your URL, the shape a Workers email() handler takes. See Send to a URL. |
forward |
A copy goes to an email address, once that address confirms. |
keep |
Stored in Inbound and sent to your email.received webhooks. Nothing else. |
drop |
Discarded: not stored, no webhook. Only counted. |
Every action except drop also stores the email in Inbound and fires email.received, so Inbound stays the log of everything you received.
In the dashboard, open the domain and choose the Routing tab.
Add a route
/domains/{id}/routesaddress is the local part (support), the full address on the domain, or * for the catch-all. Each address has one route.
Other calls:
| Call | Does |
|---|---|
GET / |
Lists routes, exact addresses first, with last_7_days counts |
PATCH / |
Changes action, url, secret, forward_to or skip_spam |
DELETE / |
Removes it: the address falls back to the catch-all |
POST / |
Emails the forwarding address its link again |
POST / |
Sends a real test email to address (default test) on the domain, through its route |
POST / |
Sends one routed email to its URL or address again |
From the SDK and CLI:
curl -X POST https://api.groundmail.dev/domains/inbound.example.com/routes \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"address": "support",
"action": "url",
"url": "https://your-worker.example.com/cdn-cgi/handler/email",
"secret": "a-long-random-secret"
}'{
"id": "4f0c…",
"address": "support",
"email": "support@inbound.example.com",
"catch_all": false,
"action": "url",
"url": "https://your-worker.example.com/cdn-cgi/handler/email",
"secret_set": true,
"forward_to": null,
"forward_status": null,
"skip_spam": true,
"last_7_days": { "delivered": 0, "failing": 0, "pending": 0, "kept": 0, "dropped": 0, "last_at": null, "last_error": null },
"created_at": "2026-11-12T20:00:00.000Z",
"updated_at": "2026-11-12T20:00:00.000Z"
}await groundmail.routes.create('inbound.example.com', { address: 'billing', action: 'forward', forwardTo: 'finance@example.com' })groundmail routes add inbound.example.com --address noreply --action dropSend to a URL
Each email is POSTed as the raw RFC 5322 message, headers and attachments included:
fromandtoare the envelope sender and recipient.secret(16 to 256 characters) is sent as a Bearer token so your endpoint can check the request came from Groundmail. It's write-only: routes showsecret_set.- A
2xxanswer delivers it. A4xxmeans your endpoint refused that message (a Worker'ssetReject()answers400), so it isn't retried. A5xx,408,429, timeout (30 seconds) or connection error is retried with backoff, 8 tries over about 2 hours. - Spam the domain flags is still sent, flagged in its headers. Spam the domain drops never arrives.
This is how OpenOrange Workers receive mail.
POST /cdn-cgi/handler/email?from=sender%40example.org&to=support%40inbound.example.com
Content-Type: message/rfc822
Authorization: Bearer a-long-random-secretForward to an address
- Groundmail emails
forward_toa link to confirm. Nothing is forwarded until it's clicked (forward_status: "pending"), and the link expires in 7 days. Until then, mail for the address is kept in Inbound. - Forwarded mail goes out from the address it was sent to, with the original sender as Reply-To, the original subject, body and attachments. It counts as a sent email on your plan.
- Every forwarded email has a link at the bottom to stop forwarding. Using it sets
forward_status: "stopped"; mail is then kept in Inbound until you change the route. - With
skip_spam: true(the default), mail at or above the domain's spam limit stays in Inbound instead of being forwarded. forward_tocan't be on the same domain, so mail can't loop.
curl -X POST https://api.groundmail.dev/domains/inbound.example.com/routes \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "address": "billing", "action": "forward", "forward_to": "finance@example.com" }'On each received email
Received emails show what routing did:
forward.status is pending, delivered, rejected (your URL answered 4xx) or failed (retries ran out, or the send was refused). attempt_log is on GET /received-emails/{id} only. In the dashboard, a received email shows the same as a Routing card, with Retry now.
The older domain fields inbound_forward_url and inbound_forward_secret still work: they set the catch-all route to url.
{
"route": { "id": "4f0c…", "address": "support", "action": "url" },
"forward": {
"status": "pending",
"attempts": 2,
"forwarded_at": null,
"error": "HTTP 503: Service Unavailable",
"attempt_log": [
{ "at": "2026-11-12T11:42:08.000Z", "outcome": "retry", "http_status": 503, "error": "HTTP 503: Service Unavailable" }
]
}
}