---
title: "Receiving Mail"
description: "Inbound MX flow, webhook delivery, signature verification, retries, replays, endpoint rules, and stored email access."
canonical: "https://docs.primitive.dev/docs/receiving"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Receiving Mail

Primitive receives inbound mail through MX, parses it, stores it, and delivers it to your code as a signed JSON event.

You can receive through a webhook endpoint you host, a Primitive Function hosted by Primitive, or the REST API after the message is stored.

## Inbound Flow


```
external sender
      |
      v
Primitive MX
      |
      v
parse + authenticate + store
      |
      +--> signed webhook endpoint
      +--> hosted Primitive Function
      +--> REST API resource
```


Every inbound event has a stable event id. Retries reuse that id, so your handler can be idempotent.

## Webhook Endpoint

Set a webhook URL in the dashboard or via the API. The endpoint must be HTTPS and publicly reachable.

Primitive sends `application/json` with a `Primitive-Signature` header. Verify the signature before trusting the payload.


```
import primitive from '@primitivedotdev/sdk';


export async function POST(req: Request) {
  const email = await primitive.receive(req, {
    secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
  });


  console.log(email.sender.address, email.subject, email.text);
  return Response.json({ ok: true });
}
```

```
import os
import primitive
from fastapi import FastAPI, Request


app = FastAPI()


@app.post('/webhooks/email')
async def handle(request: Request):
    body = await request.body()
    email = primitive.receive(
        body=body,
        headers=dict(request.headers),
        secret=os.environ['PRIMITIVE_WEBHOOK_SECRET'],
    )


    print(email.sender.address, email.subject, email.text)
    return {'ok': True}
```

```
func handle(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    email, err := primitive.Receive(primitive.HandleWebhookOptions{
        Body:    body,
        Headers: r.Header,
        Secret:  os.Getenv("PRIMITIVE_WEBHOOK_SECRET"),
    })
    if err != nil {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }


    log.Println(email.Sender.Address, email.Subject)
    w.WriteHeader(http.StatusOK)
}
```


Return any 2xx response to acknowledge delivery. Non-2xx responses and network failures are retried.

## Primitive Functions

Functions are hosted receive handlers. Deploy a JavaScript handler, then bind a route to it with `primitive functions:route-set` (or `PUT /v1/functions/{id}/route`) to create the inbound endpoint. Use Functions when you do not want to run a public webhook server.

See [Functions](/docs/functions) for the handler shape, deploy flow, logs, and secrets.

## Signature Verification

Every webhook is signed. You must verify the exact raw request body, not a re-serialized JSON object.

The high-level `primitive.receive(...)` helper verifies signatures automatically. Lower-level helpers are documented in [Signature Verification](/docs/signature-verification).

## Retries

Primitive retries webhook delivery when your endpoint does not return a 2xx response. Don't rely on `delivery.attempt` to tell a first delivery from a retry — dedupe on the top-level event `id` instead. See [Webhook Payload](/docs/webhook-payload) for the field's semantics.

For idempotency, dedupe on the top-level event `id` (or the email id), not on `delivery.attempt`. If you already processed the event, return 2xx again instead of failing the duplicate.

Retry-safe handlers should:

- use `event.id` or `email.id` as a dedupe key;

- perform external side effects idempotently;

- return 2xx for duplicate events that were already processed;

- avoid returning 4xx for unknown future event types you intentionally ignore.

Primitive retries with backoff for delivery failures. A handler that keeps failing will surface in webhook delivery logs and can be replayed after you deploy a fix.

Deliveries to [Function](/docs/functions) endpoints follow the same retry schedule. A function invocation can run for up to 10 minutes while awaiting I/O, so a delivery to a slow handler stays pending until the invocation finishes. An invocation that exceeds the 10-minute window is canceled, the delivery is marked failed with the `function_timeout` error code, and it is retried like any other failure. CPU time is a separate, shorter limit; see [Functions](/docs/functions#limits) for how the two relate.

## Replays

Replay an inbound email when you need to re-deliver the stored event after fixing your endpoint.


```
primitive emails:replay-email-webhooks --id <email-id>
```

```
curl -X POST https://api.primitive.dev/v1/emails/<email-id>/replay \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Webhook delivery attempts can also be replayed from the webhook delivery API.

Replay does not create a new inbound email. It resends the stored event's content unchanged and refreshes only the delivery fields: the event `id` and `delivery.endpoint_id` for the endpoint it is sent to, `delivery.attempt`, `delivery.attempted_at` and the download links. A replay to the same endpoint keeps the original `id`, so the same idempotency rules apply. See [replays in the webhook payload reference](/docs/webhook-payload#replays).

Replay is rate-limited by a 10-second cooldown: replaying the same email within 10 seconds of its last delivery attempt returns `429 Too Many Requests`. Wait for the cooldown to pass before retrying.

## Endpoint Rules

Endpoints can be scoped to domains and rules. Use this when one organization has multiple inbound workflows and only some addresses should reach a given endpoint.

Rules are evaluated before delivery. If no active endpoint matches an inbound email, the message is still stored and can be read from the REST API.

An endpoint's `rules` object accepts the following optional fields. All rules are ANDed together, and an empty `rules` (`{}`) means no filtering — the endpoint receives everything for its scope.

FieldTypeEffect`max_size_bytes``integer`Skip messages larger than this size.`exclude_attachments``boolean`When `true`, only deliver messages that have no attachments.`attachment_limit_mb``number`Skip messages whose total attachment size exceeds this many MB.`sender_whitelist``string[]`When set, deliver only from these sender addresses.`sender_blacklist``string[]`Skip messages from these sender addresses.`event_types``string[]`Event types this endpoint receives, for example `["email.received", "email.bounced"]`. Omitted or unset means the endpoint receives all event types (the grandfathered default for endpoints that predate subscriptions).

`event_types` is the subscription model: a newer endpoint receives only the `email.*` events it lists, so a pre-existing endpoint will not get `email.bounced` (or other specialized types) until you add them to `event_types`. The other rules filter individual messages within the types the endpoint is subscribed to.

Subscription values are matched by **exact string** and are not validated against the known event names, so a typo like `email.bouncd` saves without error and silently matches nothing. Copy event names verbatim from [Webhook Payload](/docs/webhook-payload). The endpoint test only sends `email.received`, so it cannot confirm a specialized subscription — verify `email.bounced` and the other report types with a real matching event or by checking webhook deliveries.

See [Endpoints](/docs/endpoints) for the full endpoint configuration reference, including domain scoping and the subscription model.

Domain-scoped endpoints override fallback endpoints. For an accepted inbound on a verified domain, Primitive first looks for enabled endpoints pinned to that domain. If any exist, only those endpoints are considered. Endpoints with `domain_id = null` receive mail only for domains without their own enabled scoped endpoint.

Use endpoint rules when one organization has several mail workflows, for example:

- `support@yourdomain.com` posts to a helpdesk webhook;

- `bugs@yourdomain.com` invokes a triage Function;

- `receipts@yourdomain.com` is stored for polling only.

Rules should be narrow enough that a message reaches the intended handler, but not so narrow that important mail is silently skipped. Only one endpoint fires per scope: Primitive enforces one active endpoint per domain (and one org-wide fallback), and delivery resolves to a single endpoint for the winning scope, so give a domain to the one Function or webhook that should own it. Even skipped mail remains available through the REST API.

## Stored Email Access

List and fetch inbound mail with the API:


```
curl https://api.primitive.dev/v1/emails \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


curl https://api.primitive.dev/v1/emails/<id> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


The full email resource includes parsed headers, body fields, attachments metadata, authentication results, delivery data, and download URLs where applicable.

Use search for operational workflows that need to filter stored mail by sender, recipient, subject, body text, date, or processing state.

## Raw Content and Attachments

Small raw emails may be included inline in the webhook payload. Larger raw content is stored and exposed through download URLs (`/v1/emails/{id}/raw` and `/v1/emails/{id}/attachments.tar.gz`).

Both endpoints accept two forms of authentication:

- Signed URLs delivered in the webhook payload carry a `token` query parameter and need no API key. They expire 24 hours after the webhook is delivered (the payload's `downloadExpiresAt` field states the exact time).

- API-key requests (`Authorization: Bearer YOUR_API_KEY`) against the same URLs, without the `token` parameter, do not expire. Stored content remains downloadable this way indefinitely, until you discard it.

An expired signed URL returns `401` with code `download_token_expired`; retry the same URL with API-key authentication instead.

## Discarding Content

When you no longer need a stored message body, you can discard its content to free storage:


```
curl -X POST https://api.primitive.dev/v1/emails/<id>/discard-content \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Content discard is irreversible: it permanently removes the stored raw email, parsed body, and attachments. Because of that, it is opt-in — you must enable it in your webhook settings first. Calling the endpoint before opting in returns `403` with the code `discard_not_enabled`.

Threading metadata is preserved on the email row after a discard, so the message still participates in conversation threading. The content itself is gone, and a discarded email can no longer be replayed or replied to.

## Related Pages

- [Webhook Payload](/docs/webhook-payload): event schema and field semantics.

- [Endpoints](/docs/endpoints): endpoint scoping, rules, and event-type subscriptions.

- [Signature Verification](/docs/signature-verification): HMAC verification details.

- [Functions](/docs/functions): hosted inbound handlers.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
