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 }); }
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, and Primitive creates the inbound endpoint for you. Use Functions when you do not want to run a public webhook server.
See 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.
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 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.idoremail.idas 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.
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>Webhook delivery attempts can also be replayed from the webhook delivery API.
Replay does not create a new inbound email. It reuses the stored event and delivery data, so the same idempotency rules apply.
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.
| Field | Type | Effect |
|---|---|---|
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. 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 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.composts to a helpdesk webhook;bugs@yourdomain.cominvokes a triage Function;receipts@yourdomain.comis 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. Two endpoints with the same scope both fire on every matching inbound, so keep one enabled endpoint for a domain when you want a single Function or webhook to 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 signed download URLs. Attachment downloads use signed URLs rather than API keys.
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: event schema and field semantics.
- Endpoints: endpoint scoping, rules, and event-type subscriptions.
- Signature Verification: HMAC verification details.
- Functions: hosted inbound handlers.