Custom Routes
A route binds a recipient address pattern to one destination endpoint. Inbound mail resolves to a single destination at delivery time — no fan-out — and the decision is fully explainable: you can ask where any address would land, and why, before a single email arrives.
This is the product stance that an email address is a dispatch pattern, not a mailbox. A route maps where mail goes; your handler still owns what happens next.
All endpoints below live under https://api.primitive.dev/v1. Every command shown also exists as a primitive routes CLI verb.
Requesting access
Custom routing is rolling out gradually and is gated by the recipient_routing organization entitlement, off by default. If the /v1/routes endpoints return feature_disabled, the feature is not enabled for your organization yet. To request access, contact support or email the Primitive dev_help agent at dev_help@agent.primitive.dev.
Concepts
- Pattern — the recipient address a route matches on, in one of three tiers:
exact— one full address, e.g.billing@acme.dev.wildcard— a glob over the local part, e.g.*@acme.dev(the rest of a domain).regex— a safe, linear-time pattern. Regex matching is not generally available yet; on current plans, useexactorwildcard.
- Destination — exactly one endpoint. Provide an existing
endpoint_id, or pass afunction_id: Primitive binds the function's existing route-target endpoint, or mints one in the same transaction if the function has none (per-address function routing). - Priority — evaluation order within a scope. Lower is checked first.
- Scope — a route is scoped to a domain, or org-wide when
domain_idis null. Domain-scoped routes are evaluated before org-wide ones. - Fallback — if no route matches, mail falls through to the domain's default endpoint, then the org default. If neither exists, the outcome is
none: the mail is stored, and nothing is delivered.
How a route is chosen
For a given normalized recipient, enabled matching routes are ranked in a deterministic order and the first match wins:
- Scope — domain-scoped routes outrank org-wide routes. This is the primary key, so a domain-scoped route at
priority: 999still beats an org-wide route atpriority: 1. - Priority ascending — within the same scope, lower priority is checked first.
- Tier specificity —
exact>wildcard>regex. - Wildcard literal length — a wildcard with more fixed (non-
*) characters is more specific and wins. - Creation time, then route id, as stable final tiebreaks.
Mail is delivered to that one endpoint, and the full evaluation trace is recorded, so every delivery can answer "what went where, and why."
Create a route
Bind an exact address to a function. With function_id, the route-target endpoint is minted and bound for you:
curl -X POST https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "match_type": "exact", "pattern": "billing@acme.dev", "function_id": "<function-uuid>" }'
Catch the rest of the domain with a wildcard pointed at an existing endpoint, at a lower priority so the exact rule always wins:
curl -X POST https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "match_type": "wildcard", "pattern": "*@acme.dev", "endpoint_id": "<endpoint-uuid>", "priority": 200 }'
Provide exactly one of endpoint_id or function_id. priority defaults to 100 and enabled defaults to true.
Simulate before you ship
Ask where an address would land — and why, rule by rule — without sending anything:
curl -X POST https://api.primitive.dev/v1/routes/simulate \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "recipient": "sales@acme.dev" }'
{ "outcome": "matched", "recipient": "sales@acme.dev", "endpoint_id": "a0d6f29c-4e13-4b87-9c52-1f8a3e7b6d09", "matched_route_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "matched_tier": "wildcard", "matched_pattern": "*@acme.dev", "default_scope": null, "evaluated": [ { "route_id": "b7e2d4a1-9c3f-4e08-8a17-2d6c5f0b9e44", "tier": "exact", "pattern": "billing@acme.dev", "result": "miss", "reason": "recipient 'sales@acme.dev' is not 'billing@acme.dev'" }, { "route_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "tier": "wildcard", "pattern": "*@acme.dev", "result": "hit" } ], "truncated": false }
outcome is matched (a route hit), defaulted (no route matched, a fallback endpoint applied — see default_scope), or none (nothing matched and no fallback exists). event_type defaults to email.received; pass it to model how a different event subscribes.
List, reorder, update, delete
# routes in evaluation order curl https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" # repriority in bulk curl -X POST https://api.primitive.dev/v1/routes/reorder \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "updates": [{ "id": "<route-uuid>", "priority": 50 }] }' # change or disable a route curl -X PATCH https://api.primitive.dev/v1/routes/<route-uuid> \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' # remove a route curl -X DELETE https://api.primitive.dev/v1/routes/<route-uuid> \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN"
From the CLI
Every operation above is a primitive routes verb, generated from the same API spec:
primitive routes create-route --match-type exact \ --pattern billing@acme.dev --function-id <function-uuid> primitive routes list-routes primitive routes simulate-route --recipient sales@acme.dev
The CLI prints the response data as formatted JSON. Add --envelope to see the full { success, data, meta } wrapper.
Limits
Routes are plan-limited by count. Regex matching is not generally available yet on any plan. Disabled or deleted routes are skipped at delivery; a route pointing at a deactivated endpoint falls through to the next match rather than dropping mail.