---
title: "Endpoints, Filters & Deliveries"
description: "Manage webhook endpoints and their delivery rules, set allow/block sender filters, and inspect or replay past webhook deliveries."
canonical: "https://docs.primitive.dev/docs/endpoints"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Endpoints, Filters & Deliveries

This page covers the three controls that decide **where** inbound mail is delivered and **what** reaches each destination:

- Endpoints — the HTTP URLs and Primitive Functions that receive your mail, plus the per-endpoint `rules` that filter what they get.

- Filters — org-wide or per-domain allow/block lists keyed on the sender address.

- Webhook deliveries — the delivery history you can list, inspect, and replay.

This is distinct from [Custom Routes](/docs/routing): a route binds a *recipient address pattern* to a single destination, while this page manages the destinations themselves and the rules that govern delivery to them. For the end-to-end inbound flow, see [Receiving Mail](/docs/receiving).

All endpoints below live under `https://api.primitive.dev/v1` and authenticate with a bearer API key.

## Endpoints

An **endpoint** is a delivery destination for inbound mail. It comes in two kinds:

- `kind: "http"` — a webhook `url` you host. It must be HTTPS and publicly reachable; private and loopback hosts are rejected.

- `kind: "function"` — a hosted [Primitive Function](/docs/functions), referenced by `function_id`. No URL is sent over the wire; Primitive dispatches to the function internally.

### Create an endpoint

Create an HTTP endpoint:


```
curl -X POST https://api.primitive.dev/v1/endpoints \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "http",
    "url": "https://api.acme.dev/hooks/primitive",
    "enabled": true
  }'
```


Create a function-backed endpoint instead by passing `function_id` and omitting `url`:


```
curl -X POST https://api.primitive.dev/v1/endpoints \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "function",
    "function_id": "<function-uuid>"
  }'
```


`kind` defaults to `"http"`. For an HTTP endpoint, `url` is required and `function_id` is not allowed; for a function endpoint, `function_id` is required and `url` is not allowed. `enabled` defaults to `true`. Pass `domain_id` to scope the endpoint to one of your organization's domains (see the active-endpoint rule below). A function has at most one endpoint — recreating it returns the existing row.

A URL can have one endpoint per domain, and endpoints with no `domain_id` count as one domain. This covers route targets too, unlike the default-destination rule below, and a disabled endpoint still holds its URL. Creating an endpoint at a URL another endpoint has on the same domain, or changing one's URL to it, is refused with `400`; moving one onto a domain where another endpoint has its URL is refused with `409`. Give each its own path (`https://api.acme.dev/hooks/primitive/billing`), or change or delete the other. URLs are compared whole, with only the scheme and host case-insensitive, so the same path with a different query string is accepted too, but then one handler receives each matching event once per endpoint. Deleting an endpoint frees its URL, and creating one there again makes a new endpoint with a new id. Function endpoints have no URL, so this does not apply to them.

### The one-active-endpoint-per-domain rule

Each domain has a single **default destination** slot. An organization also has one org-wide fallback slot (the endpoint with `domain_id: null`), used for domains that have no scoped endpoint of their own.

- Only one active, default-destination endpoint may occupy a given domain's slot (or the org-wide fallback slot). Creating or re-enabling a second one returns `409 conflict`; delete the existing endpoint first.

- Endpoints created with `is_route_target: true` are exempt from this slot. They are reachable only through an explicit [route](/docs/routing) and never serve as a domain default, so many of them can share a domain. Routes mint these for you when you point a route at a `function_id`.

Plan limits apply to the default slots: the free Developer plan allows a single global (org-wide) webhook, and per-domain endpoints require a paid plan. Your effective `webhooks_max_global` and `webhooks_per_domain` limits are reported by [Limits](/docs/limits).

### List, update, and delete


```
# list active endpoints
curl https://api.primitive.dev/v1/endpoints \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


# change the URL, scope, rules, or enabled flag
curl -X PATCH https://api.primitive.dev/v1/endpoints/<endpoint-uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'


# remove an endpoint
curl -X DELETE https://api.primitive.dev/v1/endpoints/<endpoint-uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


`PATCH` accepts `url`, `enabled`, `domain_id`, and `rules`. You cannot set `url` on a function endpoint. Changing `url` keeps the endpoint: its id, routes, stats, signing secret and owed deliveries stay, and its consecutive failure count starts again. A webhook turned off because its owner changed is turned back on (the change is refused when it could not be turned on, for example at the plan's webhook limit), unless the same request sets `enabled` to `false`. A URL another endpoint on the same domain already uses, enabled or not, is refused (endpoints with no domain count as one domain). A caller limited to its own endpoints (an API key or a person limited to some mailboxes) cannot change the URL of an API key's webhook that signs with the organization's secret; give it a signing secret of its own first. Owners, admins and other callers with the whole organization's mail can change it. Deleting an endpoint deactivates it; deactivated endpoints stop receiving mail and free up their domain slot.

`enabled` is the endpoint's own switch, and an enabled endpoint can still be sent nothing. Each webhook in a list or get carries `delivery`. `delivery.delivering` is `false` when it is off, held pending a decision, or its owner cannot receive mail (for example, the API key that owns it is suspended, expired or revoked), and `delivery.reasons` says which, with the same codes an [endpoint test](#test-an-endpoint) gives. Anyone who may read the endpoint sees `delivery`, and it never names the key. For a caller with access to every mailbox, `delivery.held_for_owner` counts rule-granted emails waiting while the owner cannot act, and `expires_at` says when the oldest is given up on. `delivery` is `null` when it could not be checked just now, and create and update answers do not carry it.

### The rules object

Every endpoint carries a `rules` object that filters which messages it receives. **All fields are optional and ANDed together** — a message must pass every rule present to be delivered. An empty object (`{}`, the default) applies no filtering.

FieldTypeMeaning`max_size_bytes`integerMaximum total email size in bytes. Larger messages are not delivered to this endpoint.`exclude_attachments`booleanWhen `true`, deliver only messages that have no attachments.`attachment_limit_mb`numberMaximum total attachment size in MB. Larger messages are skipped.`sender_whitelist`array of emailsWhen set, deliver **only** from these sender addresses.`sender_blacklist`array of emailsWhen set, never deliver from these sender addresses.`event_types`array of stringsEvent types this endpoint subscribes to (1 to 50 entries). **Omitted = receive every event type except opt-in events, such as `sent_email.*`, [email_rule_request.*](/docs/webhook-payload#rule-request-events) and [email_rule_objection.*](/docs/webhook-payload#rule-objection-events)**, which an endpoint receives only if it lists them. An empty array is rejected; omit the field instead. Names match exactly, and a name that is not an event type is rejected with a `400` naming it (a pull destination takes only the inbound `email.*` types).

The size and attachment rules (`max_size_bytes`, `attachment_limit_mb`, `exclude_attachments`) are evaluated against a fast approximation of the message size, not an exact byte count, so treat their thresholds as approximate.

Set rules at create time or with `PATCH`:


```
curl -X PATCH https://api.primitive.dev/v1/endpoints/<endpoint-uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": {
      "max_size_bytes": 5242880,
      "exclude_attachments": true,
      "sender_whitelist": ["alerts@acme.dev"],
      "event_types": ["email.received"]
    }
  }'
```


#### How event_types controls subscription

`event_types` decides which event types reach the endpoint. If you **omit** it, the endpoint receives every event type except opt-in events, such as `sent_email.*`, `email_rule_request.*` and `email_rule_objection.*`. This is the default and the grandfathered behavior for endpoints that predate event subscriptions. If you set it, the endpoint receives only the listed types (for example `email.received`, `email.bounced`) and nothing else. See [Webhook Payload](/docs/webhook-payload) for the full list of event types and their field semantics.

`sent_email.accepted`, `sent_email.delivered`, `sent_email.failed` and `sent_email.completed` (events about mail you send, see [Sent Email Events](/docs/webhook-payload#sent-email-events)) are delivered only to an endpoint that lists them, and only for sends made after the endpoint first listed one of them. They carry a different payload from inbound mail, so we recommend a dedicated endpoint; an endpoint that also handles inbound mail must keep its inbound event types in the list. Route-target endpoints, pull endpoints and Function endpoints do not receive them, and a domain-scoped endpoint receives them only for sends from its domain.

Subscriptions are matched by **exact string**, and a name that is not an event type is refused: a typo like `email.bouncd` gets a `400` naming it, instead of saving and silently matching nothing. Copy event names verbatim from [Webhook Payload](/docs/webhook-payload). A [test send](#test-an-endpoint) only validates `email.received`; verify specialized subscriptions such as `email.bounced` with a real matching event or by checking [webhook deliveries](#webhook-deliveries).

The other `rules` fields filter on message content (size, attachments, sender); `event_types` filters on the event itself. They compose: an endpoint can subscribe to only `email.received` *and* require the message be under 5 MB *and* come from a whitelisted sender.

### Test an endpoint

Send a synthetic `email.received` payload to an HTTP endpoint to confirm it is reachable and that your signature verification works:


```
curl -X POST https://api.primitive.dev/v1/endpoints/<endpoint-uuid>/test \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


The response reports the HTTP `status` your endpoint returned, a truncated `body`, and the `signature` that was sent. A webhook's `signed_with` says which secret signs its deliveries, the organization's or its own (see [Which secret](/docs/signature-verification#which-secret)). The `signature` comes back for the organization's secret to a caller with permission to read the webhook secret. For a webhook's own secret it comes back only when a person owns the webhook, and only to that person; for one a key, an app or the organization owns, to nobody. The request it sends is signed either way. The test payload is signed exactly like a real delivery, so use it to validate your [signature verification](/docs/signature-verification). It is a complete `email.received` event, the same shape a real delivery has, with example addresses and an `email.id` that starts with `test-`, so a handler can tell a test from real mail. Testing applies to HTTP endpoints only: there is nothing to call for a function-backed endpoint.

Because the test always sends an `email.received` payload, it confirms reachability and signing but cannot validate a specialized `event_types` subscription (such as `email.bounced`).

A `200` from your endpoint means it is reachable, not that mail is sent to it. The test is sent whether or not real mail would be, and the response says which apart from that: `reachable` is `true` when your endpoint answered with a 2xx, and `delivery.would_deliver` says whether real `email.received` events would be sent to it. When they would not, `delivery.reasons` lists every reason, each with a `code` and a sentence: the endpoint is turned off (`disabled`; the endpoint's `disabled_reason` says why), it is held pending an owner or admin decision after its creator's access was reduced (`held`), its owner cannot act now (`acting_owner_suspended`, which covers an expired key, `acting_owner_revoked`, `acting_owner_left`), its owner does not read mail, reads no received mail at all now, or sees headers only (`acting_owner_permission`, `acting_owner_scope`, `acting_owner_depth`; a caller who may not read API keys sees `acting_owner_access` instead, since these describe the owner's access), or it does not subscribe to `email.received` (`event_type_not_subscribed`). Which endpoint routing sends a given message to is decided per message: check a recipient with `POST /v1/routes/simulate`, and a received email against an endpoint with `POST /v1/endpoints/<endpoint-uuid>/rules/test`, which also judges the owner on that email's mailbox and the hold of the route it came through (and answers `503` if those cannot be read just now). A rule grant can still deliver an email outside the owner's mailboxes. `delivery.events_paused_until` is set while repeated failures have paused webhook events other than mail at this endpoint; received mail is still attempted.

Test sends are rate limited to **4 per minute** and **30 per hour** per organization. Successful tests, and tests against a URL on one of your verified domains, do not count against the per-minute limit.

## Filters

Filters are allow/block lists evaluated on the **sender address**, applied in addition to any per-endpoint `sender_whitelist` / `sender_blacklist` rules. A filter has a `type` and a `pattern`:

- `type: "whitelist"`: matches the From address. A matching sender skips the blocklist and the spam threshold only when that From authenticates: DMARC-aligned SPF or DKIM, or, for a domain with no DMARC evaluation, a signature the sending provider attests for that domain. A message that got past a blocklist or the spam threshold only because of the whitelist, and then fails authentication, is not delivered and shows as rejected (`sender_blocked`). Mail sent through an email service provider matches on its From address, not its bounce address.

- `type: "blocklist"`: matches the envelope sender (SMTP `MAIL FROM`) or the From address, and rejects the message.

When a blocklist rule and a whitelist rule both match, the more specific rule wins. An exact address (`user@example.com`) is more specific than a whole domain (`*@example.com` or `example.com`), which is more specific than a wildcard domain (`*.example.com`), which is more specific than `*`. So a blocklist entry for `user@example.com` still rejects that address even when `example.com` is whitelisted. When both rules are equally specific, the whitelist rule applies.

For allowlist-only mode, add whitelist rules for your trusted senders and a `*` blocklist rule to reject everyone else.


```
# block a specific sender org-wide
curl -X POST https://api.primitive.dev/v1/filters \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "blocklist", "pattern": "spam@bad.example" }'
```


Patterns are normalized to lowercase and trimmed before storage, and may be up to 500 characters. New filters are created enabled.

### Per-domain scoping

Pass `domain_id` to scope a filter to a single one of your organization's domains instead of the whole organization:


```
curl -X POST https://api.primitive.dev/v1/filters \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "whitelist",
    "pattern": "partner@acme.dev",
    "domain_id": "<domain-uuid>"
  }'
```


Per-domain filters require a paid plan; org-wide filters (`domain_id` omitted or `null`) are available on all plans.

### List, toggle, and delete


```
# list filters, newest first
curl https://api.primitive.dev/v1/filters \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


# enable or disable a filter (the only patchable field)
curl -X PATCH https://api.primitive.dev/v1/filters/<filter-uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'


# remove a filter
curl -X DELETE https://api.primitive.dev/v1/filters/<filter-uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


`PATCH` only toggles `enabled`. To change a pattern or type, delete the filter and create a new one.

## Webhook deliveries

Every attempt to deliver an event to an endpoint (an inbound email, or an opt-in `sent_email.*` event) is recorded as a **delivery**. The delivery history is your audit trail for what fired, when, and whether it succeeded.

A retry is recorded as a new delivery with its own `id` and `attempt_count`; an earlier attempt never changes. To follow a failing delivery, read `latest_attempt` in the list, or read any attempt by id: its `attempts` lists every attempt of that delivery. `retry_scheduled` says whether an automatic retry will try it again.

### List deliveries


```
curl "https://api.primitive.dev/v1/webhooks/deliveries?limit=50&status=failed" \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Supported query parameters:

ParameterMeaning`limit`Page size, 1–100 (default 50).`cursor`Opaque cursor from the previous page's `meta.cursor`, for pagination.`email_id`Only deliveries for this inbound email.`event_type`Only deliveries of this event type, for example `sent_email.failed`.`sent_email_id`Only deliveries of `sent_email.*` events about this sent email.`endpoint_id`Only deliveries to this endpoint. A caller limited to some mailboxes still sees only deliveries of mail it reads.`status`One of `pending`, `delivered`, `header_confirmed`, `failed`, `skipped_by_rules`.`date_from` / `date_to`ISO 8601 timestamps bounding `created_at`.

Results are newest-first. The response `meta` includes `total` and a `cursor` for the next page (null when there are no more).

A delivery of an `email_rule_request.*` or `email_rule_objection.*` event (an opt-in event about a rule one member asked another for, or an objection one member made to another's rule) is listed, counted and read only by the person whose webhook it went to and by owners and admins who can list members, and only when they read every mailbox: a caller held to some mailboxes sees deliveries of mail it reads, and these carry none. Anyone else, an API key for the whole organization included, is answered as if it did not exist. These deliveries are never replayed.

A delivery of a `credential.*` event (the credential audit trail) is listed, counted and read only from a dashboard session, as the trail itself is: by people who can read the credential audit trail (`credential_events:read`), and by the person whose credential stream it went to while the stream is still theirs (not handed to someone else, and under the same membership). Either way only when they read every mailbox. Anyone else, an API key or an app acting for a person included, is answered as if it did not exist. These deliveries are never replayed.

### Inspect a delivery


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


```
{
  "id": "184293",
  "org_id": "7f2c1d84-0a5b-4c96-8e37-2b6d9f01c4a8",
  "email_id": "a0d6f29c-4e13-4b87-9c52-1f8a3e7b6d09",
  "event_type": "email.received",
  "sent_email_id": null,
  "endpoint_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31",
  "endpoint_url": "https://api.acme.dev/... (path and query hidden)",
  "status": "failed",
  "attempt_count": 3,
  "duration_ms": 812,
  "last_error": "HTTP 500: internal error",
  "last_error_code": "handler_5xx",
  "response_status": 500,
  "response_body": "internal error",
  "created_at": "2026-06-26T14:03:11.000Z",
  "updated_at": "2026-06-26T14:08:42.000Z",
  "email": {
    "sender": "alerts@acme.dev",
    "recipient": "ops@yourdomain.com",
    "subject": "Disk usage warning"
  },
  "latest_attempt": true,
  "retry_scheduled": true,
  "next_attempt_at": null,
  "attempts": [
    { "id": "184101", "attempt_count": 1, "status": "failed", "last_error_code": "handler_5xx", "duration_ms": 640, "created_at": "2026-06-26T13:28:02.000Z", "response_status": 500, "response_body": "internal error", "retry_scheduled": false },
    { "id": "184187", "attempt_count": 2, "status": "failed", "last_error_code": "handler_5xx", "duration_ms": 702, "created_at": "2026-06-26T13:33:05.000Z", "response_status": 500, "response_body": "internal error", "retry_scheduled": false },
    { "id": "184293", "attempt_count": 3, "status": "failed", "last_error_code": "handler_5xx", "duration_ms": 812, "created_at": "2026-06-26T14:03:11.000Z", "response_status": 500, "response_body": "internal error", "retry_scheduled": true }
  ]
}
```


**Following retries.** The attempts of one delivery share its endpoint and its event (or, for attempts recorded without one, its email). `latest_attempt` is true only on the one recorded last; a record of no attempt (`attempt_count` 0: a rule skip, a held send) is latest only when no attempt was made. Attempt numbers are not always increasing: a replay numbers past every attempt of the email, and automatic retries count their own passes. Failed deliveries may be retried automatically; `retry_scheduled` says whether one is coming. A retry decides the endpoint afresh (its rules, its owner's access, whether it is still on). A retry shows up as a new attempt once it is made. `attempts` (on the single read only) lists every record of the delivery in the order recorded, oldest first, each with what the endpoint answered that attempt (`response_status`, and `response_body` for a caller who reads message content, until the message's content is discarded); with more than 100, the newest 100.

**Is a retry coming?** A retry is a delivery pass for the email: it sends again to the endpoint the email's routing resolves then (its recorded route, or the current endpoint for its domain), unless the email already reached it, deciding it afresh. A [rule-granted delivery](/docs/email-rules#webhooks-that-receive-rule-granted-mail) is retried on a schedule of its own, to the same endpoint, deciding it afresh. On its latest attempt `retry_scheduled` is `true` while another attempt is to come, with `next_attempt_at` saying when it is due (it runs at the first retry pass at or after that time, and one runs every few minutes, so a time already past means it is due now). It is `null` while an attempt is under way (read it again shortly), while it waits on an owner who cannot receive mail or on a held endpoint (checked again hourly, and sent once that clears), when it is past its 7-day limit, or when that could not be read just now. It is `false` otherwise: once nothing will try it again, and once the endpoint is off or already has the email. `retry_scheduled` is `null` on every attempt of a delivery retried or settled on a schedule of its own that it does not report: a `held` one (its release settles it, including the attempts it makes itself), a pull attempt, and a `sent_email.*` or other event delivery. On the latest attempt that failed, is `pending` or was skipped in a way still owed, of an email that was not rejected and still has its content, to an endpoint that is still on, takes the event, has an owner the pass would deliver to (a revoked or suspended key, or an owner that may not receive the message, reads `false`) and has not received the email by another delivery, it is `null` while a delivery pass for the email is running (whether another follows is decided when it ends; read it again shortly), `true` when the email has another automatic pass to come (a scheduled retry, the reclaim of a pass that stopped, or the recovery of a first pass that did not finish), and `false` otherwise. Because a pass decides afresh, `true` promises a pass, not a delivery to this endpoint, and `next_attempt_at` stays `null` on every delivery that is not rule-granted. Every other attempt, including every attempt a later one followed, is `false`: no automatic retry of the email's delivery will try it again, which does not mean nothing else ever will.

`status` is `delivered` (2xx), `header_confirmed` (2xx plus a confirmation header from your handler), `failed`, or `pending`. `last_error` and `last_error_code` explain failures. `event_type` is the delivered event type (null for deliveries recorded before event types existed). A `sent_email.*` delivery has `email_id` and `email` set to `null` and `sent_email_id` set to the send it is about. `endpoint_url` names a webhook by its scheme and host only, followed by `/... (path and query hidden)` when its URL has more, since a path or query string can be the receiver's own credential; `GET /v1/endpoints/{id}` has the whole URL. For a function-backed endpoint, `endpoint_url` is an opaque `function://<id>` identifier rather than a callable URL. A redirect's location in `last_error` is named the same way. Where a webhook's answer repeats its URL's query string, a query parameter, a query value long enough to be a credential, or its userinfo, that part is stored as `(hidden)`; the rest of the answer is kept as your handler wrote it.

For a webhook URL, an HTTP failure records the `error.code` from a Primitive API error envelope when your handler returns one, and otherwise `handler_4xx` or `handler_5xx`. The exact status is in `last_error` (for example `HTTP 500: internal error`).

What a webhook answered (the text after the status in `last_error`, `response_body`, and a code it chose) is shown only to a caller who can read that webhook with `GET /v1/endpoints/{id}`, since an answer can restate the URL's credential. Anyone else who can see the delivery, such as a member limited to some mailboxes reading a delivery to someone else's webhook, gets the status with a note (`HTTP 404 (the receiver's answer is shown only to callers who can read this webhook)`), `response_body` as `null`, and `handler_4xx` or `handler_5xx` in place of the webhook's own code. A deleted webhook's answers stay readable only to callers who read every webhook. An email's `webhook_last_error` is whichever endpoint failed last, so its answer is quoted only for a caller who can read every webhook it may have come from, a delivery still under way included; a Function's text keeps the Function's own rule when no webhook can be the source. Text the platform writes, such as a redirect that was not followed, a network error or a rule skip, is shown to everyone.

Deliveries to a function-backed endpoint can carry these `last_error_code` values in addition to the ordinary HTTP failure codes:

- `function_timeout`: the invocation exceeded the 10-minute wall-clock window and was canceled. The delivery is retried on the standard schedule. See [Functions](/docs/functions#limits) for how the wall-clock and CPU limits relate.

- `content_discarded`: the stored email content was discarded before the delivery could complete, so there was nothing left to deliver.

- `superseded`: another delivery to the same endpoint already succeeded for this email, so this one was skipped rather than delivered twice.

- `queue_dead_letter`: the dispatch could not be completed after repeated attempts. Replay the delivery to try again.

### Replay a delivery

After fixing a handler, re-send the stored payload to the endpoint:


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


```
{ "delivered": 1, "failed": 0 }
```


Replay of an inbound email delivery resends the original event's content unchanged, so the same email is not re-ingested, 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 (see [replays](/docs/webhook-payload#replays)). A replay to the same endpoint keeps the original event id, so your handler's idempotency rules still apply. Replay re-sends to the original endpoint at its current URL. If its URL was changed since the delivery, the replay is checked as a new send there would be: you must be allowed to manage the endpoint, and it must still take the event type. An active original endpoint with `enabled: false` is rejected. A deactivated original with a recorded active successor follows that successor; a soft-deleted endpoint without one is rejected. If the original row no longer exists, replay may choose an eligible fallback endpoint. Replay supports inbound email deliveries and `sent_email.*` deliveries, and is rate limited per organization. A `sent_email.*` delivery is replayed with the same event id but a payload rebuilt from the send as it is now; it is rejected if the send was deleted or the endpoint is no longer an active http endpoint.

To re-deliver a whole inbound email (rather than one delivery row), see the replay flow in [Receiving Mail](/docs/receiving).

## Related pages

- [Custom Routes](/docs/routing): bind recipient address patterns to destinations.

- [Receiving Mail](/docs/receiving): the end-to-end inbound flow.

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

- [Signature Verification](/docs/signature-verification): verify the signed payloads endpoints and tests send.

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

- [Connectors](/docs/connectors): managed integrations.

- [Errors](/docs/errors) and [Limits](/docs/limits): error codes and plan limits.

- [API Reference](/docs/api): the full v1 surface.

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