Receiving email
Point an MX record at us and the email addressed to your domain arrives as JSON on your webhook, with SPF, DKIM and DMARC already evaluated.
Routes
A route is a verified domain plus a local part. POST /email/inbound/routes creates one; the domain has to be verified for sending first, because that DNS proof is what shows the domain is yours. Accepting mail for a domain nobody proved would mean receiving somebody else’s correspondence, and the real owner would never know.
| Field | Notes |
|---|---|
| domain_id | A domain of yours that already passes verification. Anything else is a 400 telling you to publish the DNS records first. |
| local_part | The part before the @, or * for every address on the domain. A specific route wins over the wildcard, so support@ and a catch-all can coexist. |
| webhook_endpoint_id | Where email.received for this route goes. Omit it and every endpoint of the project that subscribes to the event receives it. |
| retain_days | How long the raw copy is kept, 1 to 30. The bucket expires at 30 either way, so a longer promise would be a promise we cannot keep. |
| enabled | Turn receiving off without deleting the route, and without losing what already arrived. |
Then publish the MX record for the domain or subdomain, pointing at the host support gives you. Until we reload the mail server with your domain on it, senders get a bounce: tell us when the route is live.
The email.received event
Every message that matches a route becomes one email.received webhook, signed and retried like the rest. The payload carries the envelope, the parsed headers, text and html, the attachment catalogue and the authentication verdict. Subscribe to it on the webhooks page of the panel.
| Field | What it holds |
|---|---|
| inbound_message_id | Use it on GET /email/inbound/{id}, on /raw, and as in_reply_to_inbound when you answer. |
| envelope_from / envelope_to | What the sending server declared. Not the same as the From: and To: headers, and the difference is the point. |
| from / to / cc / subject | The parsed headers, as objects with email and optional name. |
| text / html | The body, truncated at 256 KB with a truncated flag. The full message is always at /raw. |
| attachments | A catalogue: index, filename, content_type, size_bytes, content_id, inline. The bytes stay in the raw message and come from the attachment endpoint. |
| authentication | spf, dkim and dmarc, plus details. Read the next section before trusting a From: address. |
Anyone can write any address in a From: header, so the header alone proves nothing. dkim is verified against the sender’s own DNS key; spf comes from what our mail server stamped on arrival; dmarc is the alignment of the From: domain with whichever of the two passed. A dmarc of pass is the one that means the sender is who the message says.
Reading what arrived
GET /email/inbound lists messages in arrival order, oldest first, paged by cursor. That direction is deliberate: polling means walking forward in time, so you store the cursor, sleep, and come back asking what happened after it. Filter with route_id and since.
| Endpoint | What it returns |
|---|---|
| GET /email/inbound | The page of messages, ascending by received_at, with next_cursor. |
| GET /email/inbound/{id} | One message, with the same fields the webhook carried. |
| GET /email/inbound/{id}/raw | The original .eml, byte for byte: full headers, intact DKIM signature, embedded attachments. |
| GET /email/inbound/{id}/attachments/{index} | One attachment, extracted from the raw message on the spot. |
| DELETE /email/inbound/{id} | Deletes the stored copy and the record. This is how you honour an erasure request. |
To answer a message and have the reply land in the same conversation on the sender’s side, send with in_reply_to_inbound set to the inbound_message_id. We fill In-Reply-To and References from the received message; headers you set yourself always win.
How long it is kept
The raw copy lives for the route’s retain_days and is then deleted, file and record together. The parsed fields you see on the message are not affected; only /raw stops working, and it answers 410 rather than pretending the message is gone.