Markdown

Email routing

Decide what happens to mail for each address on a receiving domain: send it to your app, forward it to a teammate, keep it in Inbound, or drop it. Exact addresses are matched first, then the catch-all (*). An address with no route, on a domain with no catch-all, is kept in Inbound.

Updated Nov 14, 20263 min read

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

POST/domains/{id}/routes

address 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 /domains/{id}/routes Lists routes, exact addresses first, with last_7_days counts
PATCH /domains/{id}/routes/{route_id} Changes action, url, secret, forward_to or skip_spam
DELETE /domains/{id}/routes/{route_id} Removes it: the address falls back to the catch-all
POST /domains/{id}/routes/{route_id}/resend-confirmation Emails the forwarding address its link again
POST /domains/{id}/routes/test Sends a real test email to address (default test) on the domain, through its route
POST /received-emails/{id}/forward/retry Sends one routed email to its URL or address again

From the SDK and CLI:

Terminal
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"
  }'
JSON
{
  "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"
}
TypeScript
await groundmail.routes.create('inbound.example.com', { address: 'billing', action: 'forward', forwardTo: 'finance@example.com' })
Terminal
groundmail routes add inbound.example.com --address noreply --action drop

Send to a URL

Each email is POSTed as the raw RFC 5322 message, headers and attachments included:

  • from and to are 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 show secret_set.
  • A 2xx answer delivers it. A 4xx means your endpoint refused that message (a Worker's setReject() answers 400), so it isn't retried. A 5xx, 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.

HTTP
POST /cdn-cgi/handler/email?from=sender%40example.org&to=support%40inbound.example.com
Content-Type: message/rfc822
Authorization: Bearer a-long-random-secret

Forward to an address

  • Groundmail emails forward_to a 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_to can't be on the same domain, so mail can't loop.
Terminal
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.

JSON
{
  "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" }
    ]
  }
}

Was this page helpful?