# Email routing

Decide what happens to mail for each address on a [receiving domain](https://docs.groundmail.dev/inbound): 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.

| Action | What happens |
|--------|--------------|
| `url` | The raw message is POSTed to your URL, the shape a Workers `email()` handler takes. See [Send to a URL](#send-to-a-url). |
| `forward` | A copy goes to an email address, once that address [confirms](#forward-to-an-address). |
| `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`

```bash
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"
  }'
```

`address` is the local part (`support`), the full address on the domain, or `*` for the catch-all. Each address has one route.

```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"
}
```

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:

```ts
await groundmail.routes.create('inbound.example.com', { address: 'billing', action: 'forward', forwardTo: 'finance@example.com' })
```

```bash
groundmail routes add inbound.example.com --address noreply --action drop
```

---

## Send to a URL {#send-to-a-url}

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

```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
```

- `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](https://docs.groundmail.dev/integrations/openorange) Workers receive mail.

---

## Forward to an address {#forward-to-an-address}

```bash
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" }'
```

- 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.

---

## On each received email

Received emails show what routing did:

```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" }
    ]
  }
}
```

`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`.
