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
rulesthat 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: 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.
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 webhookurlyou host. It must be HTTPS and publicly reachable; private and loopback hosts are rejected.kind: "function"— a hosted Primitive Function, referenced byfunction_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 verified domain (see the active-endpoint rule below). A function has at most one endpoint — recreating it returns the existing row.
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: trueare exempt from this slot. They are reachable only through an explicit route 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 afunction_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.
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. Deleting an endpoint deactivates it; deactivated endpoints stop receiving mail and free up their domain slot.
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.
| Field | Type | Meaning |
|---|---|---|
max_size_bytes | integer | Maximum total email size in bytes. Larger messages are not delivered to this endpoint. |
exclude_attachments | boolean | When true, deliver only messages that have no attachments. |
attachment_limit_mb | number | Maximum total attachment size in MB. Larger messages are skipped. |
sender_whitelist | array of emails | When set, deliver only from these sender addresses. |
sender_blacklist | array of emails | When set, never deliver from these sender addresses. |
event_types | array of strings | Event types this endpoint subscribes to (1–50 entries). Omitted = receive all event types. An empty array is rejected — omit the field to receive everything. |
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 all event types — 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 for the full list of event types and their field semantics.
Subscriptions are matched by exact string, and values are not validated against the known event names. A typo like email.bouncd saves without error and silently matches nothing, so the endpoint quietly receives no events of that kind. Copy event names verbatim from Webhook Payload. A test send only validates email.received; verify specialized subscriptions such as email.bounced with a real matching event or by checking 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. The test payload is signed exactly like a real delivery, so use it to validate your signature verification. 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).
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"— allow senders matching the pattern.type: "blocklist"— block senders matching the pattern.
# 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 verified domain 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 inbound email to an endpoint is recorded as a delivery. The delivery history is your audit trail for what fired, when, and whether it succeeded.
List deliveries
curl "https://api.primitive.dev/v1/webhooks/deliveries?limit=50&status=failed" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
Supported query parameters:
| Parameter | Meaning |
|---|---|
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. |
status | One of pending, delivered, header_confirmed, failed. |
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).
Inspect a delivery
curl https://api.primitive.dev/v1/webhooks/deliveries/<delivery-id> \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
{ "id": "184293", "email_id": "a0d6f29c-4e13-4b87-9c52-1f8a3e7b6d09", "endpoint_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "endpoint_url": "https://api.acme.dev/hooks/primitive", "status": "failed", "attempt_count": 3, "duration_ms": 812, "last_error": "HTTP 500: internal error", "last_error_code": "http_500", "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" } }
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. For a function-backed endpoint, endpoint_url is an opaque function://<id> identifier rather than a callable URL.
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 reuses the original stored payload and event id, so your handler's idempotency rules still apply — the same email is not re-ingested. Replay re-sends to the original endpoint; if it has been deleted or deactivated (delete is a soft-deactivate), the replay is rejected. Note that merely disabling an endpoint (enabled: false) does not block replay — the original still receives it. Replay currently supports email deliveries only and is rate limited per organization.
To re-deliver a whole inbound email (rather than one delivery row), see the replay flow in Receiving Mail.
Related pages
- Custom Routes: bind recipient address patterns to destinations.
- Receiving Mail: the end-to-end inbound flow.
- Webhook Payload: event types and field semantics.
- Signature Verification: verify the signed payloads endpoints and tests send.
- Functions: hosted inbound handlers.
- Connectors: managed integrations.
- Errors and Limits: error codes and plan limits.
- API Reference: the full v1 surface.