REST API
The machine-readable contract is openapi.yaml (OpenAPI 3.1). A running deployment also
serves it at /openapi.json, generated from the Rust types. This page is the readable version. If the
two ever disagree, the OpenAPI file is the contract and this page has a bug.
Basics
| Base URL | https://<your-api-host>/v1, for example https://mail.example.com/v1 |
| Auth | Authorization: Bearer pmk_live_… (or pmk_test_…) |
| Format | JSON (application/json; charset=utf-8). Times are RFC 3339 UTC. Sizes are bytes |
| Request ID | Every response carries a Request-Id header (req_…), also echoed in errors |
| Versioning | Breaking changes get a new prefix (/v2). Additive changes (new fields, new event types, new enum values) can happen within /v1, so clients must ignore unknown fields and handle unknown enum values |
Pagination
List endpoints take limit (default 25, max 100) and cursor. They return:
{ "data": [ ... ], "next_cursor": "c_01J9..." }
next_cursor is null on the last page. Cursors are opaque and expire after 24 hours.
Idempotency
- Required on
POST …/messages,…/reply,…/reply-alland…/forward. A missing key returns400 idempotency_key_required. The one exception is a dry run (?dry_run=true), where the key is optional and never recorded (Sending). - Optional on every other
POST, except the two signing endpoints (…/assertionsand…/http-signatures), which ignore the header and never record it: each call signs anew, and a replay record would have to store what was signed. - The header is
Idempotency-Key: <1–255 printable ASCII characters>. Keys are kept for 30 days, scoped per identity for mail and per tenant for everything else. - The same key with the same request returns the original response, with
"deduplicated": truein mail responses and the headerIdempotent-Replayed: true. - The same key with a different request returns
409 idempotency_conflict. - The same key while the first request is still running returns
409 request_in_progresswithretryable: true.
Rate limits
| Bucket | Default | Scope |
|---|---|---|
| All requests | 600 per minute | per API key |
Search (keyword, semantic, hybrid, related messages, contacts) | 120 per minute | per API key |
| Agentic search | 20 per minute | per API key, plus a daily tenant cap |
| Send (accepted into queue) | 120 per minute | per identity, plus daily caps from policy |
Signing (agent assertions and HTTP signatures together, binding RL_SIGN) | 600 per minute | per identity |
Responses include RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 includes
Retry-After (seconds) and error.code = rate_limited.
Permissions
A key holds a list of permissions. Every endpoint below names the one it needs.
| Permission | Allows |
|---|---|
tenants:manage | Create, update and suspend tenants, and their billing accounts (platform keys only) |
identities:read, identities:write | Read, and create, update, pause or delete identities and addresses, and test forwarding; read, and create, rotate or revoke identity signing keys. Deleting an identity also needs erasure:manage, because it starts an identity-scope erasure |
identities:sign | Mint agent assertions and Web Bot Auth HTTP signatures as an identity. Tenant and identity keys (an identity key only for its own identity); platform keys cannot hold it |
domains:read, domains:write | Read, and add, update, verify, probe or remove domains |
messages:read | Threads, messages, raw MIME, deliveries |
messages:send | Send, reply, reply-all, forward, cancel |
messages:write | Labels, read state, re-run triage, resolve uncertain sends |
attachments:read | Attachment bytes and extracted text |
search:read | Keyword, semantic, hybrid search, contacts, related, wait |
search:agentic | Agentic search |
quarantine:review | See and release quarantined mail |
webhooks:read | Read webhook endpoints and their deliveries |
webhooks:manage | Create, change, test, rotate and delete webhook endpoints, and replay. Includes webhooks:read |
keys:manage | API keys within the caller’s scope |
erasure:manage | Erasure requests, legal holds, exports |
suppressions:manage | Suppressions and allow or block lists |
usage:read | Plan, allowances and usage figures. Every tenant and identity key holds it implicitly for its own workspace, without listing it. Platform keys must hold it explicitly and pass tenant_id |
audit:read | Audit log |
members:read | List console members and pending invitations (tenant and platform keys; every console role holds it) |
members:manage | Invite, revoke, change roles and remove console members (tenant and platform keys). Includes members:read |
platform:ops | Platform operations: signing-key rotation, the dead-letter queue, maintenance jobs, waitlist invitations (platform keys only) |
Key levels limit which resources a key can reach, whatever its permissions:
- A platform key reaches every tenant.
- A tenant key reaches its own tenant.
- An identity key reaches its own identity. It also reaches the tenant’s domains read-only with
domains:read, and the tenant’s webhook endpoints and deliveries read-only withwebhooks:read.
A route or field that needs a higher key level than the caller’s returns 403 scope_denied.
Some permissions can be held only at some levels. POST /v1/keys refuses a key that
lists one its level cannot hold with 400 invalid_request and
details.reason = "permission_not_allowed_for_level":
| Permissions | Key levels that can hold them |
|---|---|
tenants:manage, platform:ops | platform |
members:read, members:manage, suppressions:manage, audit:read, usage:read | platform, tenant (an identity key holds usage:read implicitly for its own workspace, but cannot list it) |
identities:sign | tenant, identity |
| Every other permission | platform, tenant, identity |
There are no wildcard permissions and no implicit full set: every key, a platform key included, holds the
permissions listed when it was created, plus the implicit usage:read of tenant and identity keys. A
POST /v1/keys without permissions, or with an empty list, returns 400 invalid_request.
The console and billing routes
These routes are served by the same Worker but are not part of the developer API. None takes an API key: they use session cookies, OAuth state, unsubscribe tokens, or Stripe, SNS and link signatures instead.
| Route | What it is | In openapi.yaml | Design |
|---|---|---|---|
/console/*: the server-rendered console, including /console/sign-in… (link and code), /console/sign-up, /console/waitlist, /console/workspaces/new, /console/oauth/{provider}/start, /console/oauth/{provider}/callback, /console/settings/security, /console/settings/notifications, /console/plan/return and /console/connect | Console pages, sign-up and sign-in (session cookies) | No | Console design, Cloud sign-up and sign-in |
GET /console/notifications/unsubscribe?t={token}, POST /console/notifications/unsubscribe?t={token} | Unsubscribe from a kind of notification email. GET shows a confirmation page with a one-click form; POST is the RFC 8058 one-click unsubscribe and turns that kind off for that person and workspace. The token t is the only authority: no session, no CSRF token or Origin check, served even with PM_CONSOLE=off. An expired or foreign token changes nothing | No | Notifications |
/billing/stripe/webhook | Stripe events (Stripe signature) | No | Billing design |
POST /hooks/ses, POST /hooks/ses/inbound | Amazon SES delivery events and inbound mail, through SNS (SNS signature) | Yes | Signed links and provider hooks |
GET /v1/links/{token} | Signed downloads (link signature) | Yes | Signed links and provider hooks |
Two hosts. PM_CONSOLE_HOST names the console’s host and defaults to PM_API_HOST, so a deployment
can keep one hostname. When the two differ, console paths (/console/*, the unsubscribe pair included)
answer only on PM_CONSOLE_HOST, and the API host PM_API_HOST serves exactly:
- the REST API,
/v1/*; - MCP,
/mcp; /openapi.jsonand/health;/.well-known/*(the security contact, identity JWK Sets and the Web Bot Auth key directory);- signed links,
/v1/links/*; - the provider hooks,
/hooks/*, and/billing/stripe/webhook.
Anything else returns 404. No cookie is set or read on the API host
(Cloud sign-up › Hostnames).
Errors
Every error looks like this:
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was used with a different request body.",
"retryable": false,
"fix": "Use a new Idempotency-Key for a different message, or resend the original body.",
"request_id": "req_01J9Z4…",
"details": { "original_message_id": "msg_01J9Z3…" }
}
}
The code catalogue is in Errors.
Meta
GET /health
No auth. Returns { "status": "ok", "version": "1.0.0", "commit": "abc1234", "env": "production" }.
env is PM_ENV. With an invalid configuration it returns 503 unavailable.
GET /openapi.json
No auth. The OpenAPI 3.1 document for this deployment.
GET /v1/me
Any key. Describes the calling key.
{
"key_id": "key_01J9…", "name": "pylota-api", "level": "tenant", "mode": "live",
"tenant_id": "ten_01J9…", "identity_id": null,
"permissions": ["identities:read", "messages:send", "search:read"],
"expires_at": null
}
Tenants
Platform keys with tenants:manage. A tenant key can GET /v1/tenants/{tenant_id} for its own tenant;
it cannot list tenants.
POST /v1/tenants
{
"slug": "acme",
"name": "Acme Car Hire",
"mode": "live",
"timezone": "Europe/London",
"address_suffix": ".acme",
"policy": { "identity_daily_send_cap": 500 },
"owner": { "email": "sam@acmecarhire.example", "name": "Sam Patel" },
"billing": { "mode": "exempt" }
}
address_suffixdefaults to"." + slug. Only one tenant (the default tenant made bypmail setup) can have an empty suffix.policyis merged over the defaults. See Configuration › Tenant policy.owner(optional) creates the workspace’s console owner and emails them a sign-in link. Without it, a platform key can add an owner later with an invitation and an ownership transfer in the console.billing.modedefaults tometeredon a deployment with billing on (planfree) and todisabledotherwise.
Returns 201 with a Tenant.
GET /v1/tenants · GET /v1/tenants/{tenant_id}
List (filters: status, mode; platform keys only) and get.
PATCH /v1/tenants/{tenant_id}
Updatable: name, timezone, policy (deep merge; null resets a field to its default), and status
(active | suspended). Suspension behaviour: FR-TEN-3.
Tenant object
{
"id": "ten_01J9…", "slug": "acme", "name": "Acme Car Hire", "mode": "live", "status": "active",
"address_suffix": ".acme", "timezone": "Europe/London",
"policy": { "...": "full effective policy" },
"created_at": "2026-10-09T10:00:00Z", "updated_at": "2026-10-09T10:00:00Z"
}
Tenants are deleted through an erasure request with scope: "tenant".
Identities
POST /v1/tenants/{tenant_id}/identities — identities:write
{
"username": "bookings",
"display_name": "Acme Car Hire",
"purpose": "bookings",
"owner": { "name": "Sam Patel", "email": "sam@acmecarhire.example" },
"signature": { "text": "Acme Car Hire · 0113 496 0000" },
"domain_id": "dom_01J9…",
"client_id": "acme:bookings",
"metadata": { "operator_id": "op_123" }
}
username:^[a-z0-9][a-z0-9._-]{0,23}$. Reserved and confusable names are refused (address_reserved).postmaster,abuse,noreplyand similar are reserved everywhere; the other RFC 2142 role names (support,sales,info,marketingand the rest) only where they would stand alone on the shared platform domain, that is, for the default tenant, whose suffix is empty (Identities and domains).- The primary address is
{username}{tenant.address_suffix}@{platform domain}, or{username}@{domain}whendomain_idnames a tenant domain that ishealthyordegraded. The full local part must be at most 64 characters with room for a thread token: the combined username and suffix can be at most 40. client_idmakes the create idempotent: the sameclient_idwith the same body returns200and the existing identity, and with a different body returns409 client_id_conflict.owneris required before the identity can send (identity_owner_required).- When the plan’s
inboxesallowance is spent, the request fails with402 billing_limit(details.feature: "inboxes"). A primary address that needs a literal routing rule whilePM_CF_API_TOKENis not set fails with422 cf_token_required.
Returns 201 with an Identity.
GET /v1/tenants/{tenant_id}/identities — identities:read
Filters: status, purpose, client_id.
GET /v1/identities — identities:read
Identities the key can reach. Filters: status (active, paused, deleting or deleted), purpose,
and for platform keys tenant_id; status and purpose work as on the tenant’s list above. The system
identity that sends PM_SYSTEM_FROM mail is never listed.
GET /v1/identities/lookup?address=bookings@acme.example.com — identities:read
Resolves any active or retiring address to its identity. Returns 404 identity_not_found for unknown,
retired or out-of-scope addresses.
GET /v1/identities/{identity_id} — identities:read
PATCH /v1/identities/{identity_id} — identities:write
Updatable: display_name, purpose, owner, signature, metadata, send_policy, and status
(active | paused). Setting status: "active" on an identity paused for abuse_threshold needs a
platform or tenant key and is audit-logged.
DELETE /v1/identities/{identity_id} — identities:write and erasure:manage
Returns 202 with an Erasure request of scope identity. The identity’s
addresses are tombstoned and can never be assigned to another identity. Its
signing keys are deleted and their key IDs tombstoned, so a deleted key ID
is never published again (O7). While the identity is deleting or deleted,
signing and its JWK Set return 404 identity_not_found.
Identity object
{
"id": "idn_01J9Z3K8V4…", "tenant_id": "ten_01J9…",
"username": "bookings", "display_name": "Acme Car Hire", "purpose": "bookings",
"status": "active", "pause_reason": null,
"primary_address": "bookings.acme@agents.example",
"addresses": [ { "...": "Address objects" } ],
"owner": { "name": "Sam Patel", "email": "sam@acmecarhire.example" },
"signature": { "text": "…", "html": null },
"send_policy": { "daily_cap": 500, "auto_reply": "allowed", "require_known_recipient": false },
"metadata": { "operator_id": "op_123" },
"client_id": "acme:bookings",
"created_at": "…", "updated_at": "…"
}
Addresses
GET /v1/identities/{identity_id}/addresses — identities:read
POST /v1/identities/{identity_id}/addresses — identities:write
{ "local_part": "bookings", "domain_id": "dom_01JA…" }
Creates an alias. local_part follows the username rules for a tenant domain: role names such as
support@ are allowed, postmaster and abuse are not. The status is pending until the domain is
healthy or degraded, then active. Only one pending
address per identity and domain is allowed; a newer request replaces an older pending one
(A11).
POST /v1/identities/{identity_id}/addresses/{address_id}/promote — identities:write
{ "retire_previous_after_days": 90 }
Makes the address primary. The previous primary becomes an alias with status retiring, and its
retire_at is set (default 90 days, range 0–365), with one exception: when the previous primary is the
identity’s platform address, it becomes an active alias instead. The platform address is the
fallback address for domain failures (FR-DOM-6), so it is never retired. Promoting a retiring address
(or the platform address) back cancels the change: this is how you roll back. Fails with
409 domain_not_ready unless the domain is healthy or degraded. Emits identity.address_promoted.
POST /v1/identities/{identity_id}/addresses/{address_id}/retire — identities:write
{ "after_days": 0 }
Moves an alias to retiring (or straight to retired when after_days is 0). The primary cannot be
retired (409 address_is_primary), and neither can the identity’s platform address
(409 address_in_use). Emits identity.address_retired when the address becomes retired.
DELETE /v1/identities/{identity_id}/addresses/{address_id} — identities:write
Only for pending addresses that never received mail. Otherwise 409 address_in_use (retire it
instead). The platform address can never be deleted.
POST /v1/identities/{identity_id}/addresses/{address_id}/test-forwarding — identities:write
No body. For an address on a domain with inbound: forward (method send_only, or smtp_relay with
inbound: forward), where the customer’s own mailbox forwards mail to the identity’s platform address.
Any other address returns 422 transport_unavailable with details.reason: "method_not_supported".
It sends a short message to the address, from mailer-daemon@{platform domain} with the subject
“Pylota Mail forwarding check” and a one-time token. If the token reaches the identity’s platform address
within 10 minutes, the address’s forwarding becomes ok; otherwise failed
(N12). The check is never stored as a message and does not count as a plan
send. Returns 202 with the Address; read the address again for the result. No
webhook event is sent.
Address object
{
"id": "adr_01J9…", "identity_id": "idn_01J9…", "address": "bookings@brightwell.example",
"local_part": "bookings", "domain_id": "dom_01JA…",
"role": "primary", "status": "active",
"retire_at": null, "retired_at": null,
"forwarding": "ok", "forwarding_checked_at": "2026-10-09T10:20:00Z",
"created_at": "…"
}
forwardingisnullwhen the address’s domain does not useinbound: forward. Otherwise it isunverified(no forwarding test and no forwarded message has arrived yet),ok(the last test passed, or mail arrived through forwarding) orfailed(the last test timed out).forwarding_checked_atis whenforwardinglast changed, ornull.
Identity keys and signatures
An identity can prove who it is outside email: with an agent assertion, a short-lived JWT signed by the identity’s own Ed25519 key that any service can check against the identity’s JWK Set, and with a signed HTTP request (Web Bot Auth), whose headers let a website tell which agent made the request. The design is in Agent signing keys; the integrator’s view is in Agents › Agent assertions.
- Each identity has at most one
activekey, which signs and is published, plusretiringkeys during an overlap after a rotation. A key is created on the identity’s first signing request, or withPOST …/keys. Private keys are generated, sealed and used inside the Worker; no endpoint returns them. - Key management (
…/keys, rotate, revoke) stays available while the identity is paused, so a suspected leak can be handled before it resumes. Signing does not: a paused identity, including every identity of a suspended tenant, gets409 identity_paused, and its JWK Set answers404 identity_not_founduntil it resumes (O1). Adeletingordeletedidentity gets404 identity_not_foundon every route here. - Creating, rotating and revoking keys is audit-logged (
identity_key.create,identity_key.rotate,identity_key.revoke) and emitsidentity.key_created,identity.key_rotatedoridentity.key_revoked(Webhook events). - Signing needs
identities:sign, which platform keys cannot hold. Both signing endpoints count against the signing rate limit (600 a minute per identity,429 rate_limitedover it), ignoreIdempotency-Key, and store nothing but a daily count (assertionsandhttp_signaturesinGET /v1/usage/daily). Signing is not metered against any plan allowance.
GET /v1/identities/{identity_id}/keys — identities:read
Every key the identity has, retired ones included, newest first. Filter: status (active,
retiring or retired). Paginated.
{
"data": [
{ "kid": "zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo", "identity_id": "idn_01J9Z3K8V4…",
"status": "active", "alg": "EdDSA",
"public_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "NjwMjIq2mTA1VpuDzRvkMIfQ0sCSHWavo0KT_4FcKO0",
"kid": "zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo", "alg": "EdDSA", "use": "sig" },
"created_at": "2026-10-09T09:00:00Z", "verify_until": null, "retired_at": null },
{ "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "identity_id": "idn_01J9Z3K8V4…",
"status": "retiring", "alg": "EdDSA",
"public_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "alg": "EdDSA", "use": "sig" },
"created_at": "2026-10-02T09:00:00Z", "verify_until": "2026-10-16T09:00:00Z", "retired_at": null }
],
"next_cursor": null
}
POST /v1/identities/{identity_id}/keys — identities:write
No body (an empty {} is accepted). Creates the identity’s first key and returns it with 201 when it
has no active key; otherwise returns the existing active key with 200 and changes nothing. A created
key emits identity.key_created, as does a key created lazily by a signing request; the 200 case emits
nothing. A thumbprint found among the key tombstones is never reused: a new seed is drawn instead.
Idempotency-Key is optional.
POST /v1/identities/{identity_id}/keys/rotate — identities:write
No body. Makes a new key active at once and moves the previous active key to retiring, with
verify_until set to now plus PM_IDENTITY_KEY_OVERLAP_DAYS (default 7 days). The retiring key stays
in the JWK Set and no longer signs, so an assertion signed just before the rotation still verifies until
then (O2). With no active key, it creates the first one and previous is
null. Emits identity.key_rotated. Returns 200:
{
"key": { "kid": "zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo", "status": "active",
"created_at": "2026-10-09T09:00:00Z", "verify_until": null, "retired_at": null,
"...": "the rest of the Identity key object" },
"previous": { "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "status": "retiring",
"created_at": "2026-10-02T09:00:00Z", "verify_until": "2026-10-16T09:00:00Z",
"retired_at": null, "...": "the rest of the Identity key object" }
}
POST /v1/identities/{identity_id}/keys/{kid}/revoke — identities:write
No body. Moves the key straight to retired, whatever its state, for a suspected compromise. It is gone
from the next JWK Set response, and verifiers cache the set for at most 5 minutes
(O3). Returns 200 with the key (status: "retired", retired_at set) and
emits identity.key_revoked. A key that is already retired is returned unchanged with 200, and no
event is emitted. An unknown kid returns 404 key_not_found. The row is kept until the identity is
deleted, so its thumbprint is never reused.
Identity key object
{
"kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "identity_id": "idn_01J9Z3K8V4…",
"status": "retiring", "alg": "EdDSA",
"public_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "alg": "EdDSA", "use": "sig" },
"created_at": "2026-10-02T09:00:00Z", "verify_until": "2026-10-16T09:00:00Z", "retired_at": null
}
| Field | Meaning |
|---|---|
kid | The key ID: the base64url RFC 7638 thumbprint of the public JWK (43 characters). It is also the JWS kid of every assertion the key signs |
status | active (signs and is published; at most one), retiring (published, does not sign, until verify_until) or retired (not published) |
alg | Always EdDSA (Ed25519) |
public_jwk | The public key exactly as published in the identity’s JWK Set |
verify_until | Set when the key becomes retiring: the rotation time plus PM_IDENTITY_KEY_OVERLAP_DAYS. Until then the key stays in the JWK Set, unless it is revoked. null while active |
retired_at | When the key became retired, or null |
POST /v1/identities/{identity_id}/assertions — tenant or identity key, identities:sign
Mints an agent assertion: a JWT signed with the identity’s active key. Each call mints a new token, so
Idempotency-Key is ignored and never recorded.
{ "audience": "https://portal.supplier.example",
"expires_in": 300,
"nonce": "b3f1c2d47a9e",
"ext": { "booking_ref": "BK-2291" } }
| Field | Rules |
|---|---|
audience | Required. 1–256 characters of printable ASCII: a URL or an identifier the verifier expects. Becomes aud (O4) |
expires_in | 60–600 seconds, default 300 (O5) |
nonce | Optional, 1–128 characters of printable ASCII, copied into the token for the verifier’s own challenge |
ext | Optional object, at most 2 KB as JSON, placed under the ext claim. Its members cannot use a registered or Pylota claim name (iss, sub, aud, iat, nbf, exp, jti, email, email_verified, name, org, accountable_human, ai_agent, nonce, ext) (O6) |
Returns 201:
{ "assertion": "eyJhbGciOiJFZERTQSIsInR5cCI6ImFnZW50LWFzc2VydGlvbitqd3QiLCJraWQiOiJ6TWtVbUFRT2xx…",
"kid": "zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo",
"expires_at": "2026-10-09T12:05:00Z",
"jwks_uri": "https://mail.example.com/.well-known/jwks/idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y.json" }
The token’s header is {"alg":"EdDSA","typ":"agent-assertion+jwt","kid":"<thumbprint>"}. Its claims:
{ "iss": "https://mail.example.com", "sub": "idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y",
"aud": "https://portal.supplier.example", "iat": 1791547200, "nbf": 1791547200, "exp": 1791547500,
"jti": "01M4G8HMG0Z6G25EVAN36PQG0H", "email": "bookings.acme@agents.example", "email_verified": true,
"name": "Acme Car Hire", "org": "Acme Car Hire", "accountable_human": true, "ai_agent": true,
"nonce": "b3f1c2d47a9e", "ext": { "booking_ref": "BK-2291" } }
issishttps://{PM_API_HOST},subthe identity ID,jtia new ULID,emailthe identity’s primary address,nameits display name andorgthe workspace name.accountable_humanistruewhen the identity has an accountable owner. The owner’s name and address are never in the token.- The token is never stored or logged. A verifier checks it as in
Agents › Verifying an assertion:
algandtyp, an issuer it trusts, the key from{iss}/.well-known/jwks/{sub}.json(cached for at most 5 minutes), the signature,aud,nbfandexpwith 60 seconds of skew, andjtiagainst replays (Agent signing keys § 4.3).
Errors: 400 invalid_request (O4–O6), 403 permission_denied,
403 scope_denied, 404 identity_not_found, 409 identity_paused and 429 rate_limited.
POST /v1/identities/{identity_id}/http-signatures — tenant or identity key, identities:sign
Returns the headers that make an HTTP request a Web Bot Auth signed request (RFC 9421), signed with the
deployment’s web_bot_auth key, with the identity’s address in a signed From header. The Worker never
makes the request itself, and nothing is created or stored. Idempotency-Key is ignored and never
recorded.
{ "url": "https://www.brightwell.example/fleet/availability?from=2026-10-12",
"method": "GET",
"expires_in": 60,
"components": ["@authority", "signature-agent", "from"] }
| Field | Rules |
|---|---|
url | Required, https only, at most 2,048 characters. An internationalised host is converted to its A-label for @authority (O10) |
method | Optional, an upper-case token. Signed only if @method is in components, and then required (400 invalid_request without it) |
expires_in | 30–300 seconds, default 60. Too short an expiry fails in transit (O11) |
components | Optional. Always includes @authority, signature-agent and from; may add @method, @path and @query. Any other component, or one whose value is not ASCII, returns 400 invalid_request |
Returns 200:
{ "headers": {
"Signature-Agent": "\"https://mail.example.com\"",
"From": "bookings.acme@agents.example",
"Signature-Input": "sig1=(\"@authority\" \"signature-agent\" \"from\");created=1791547200;expires=1791547260;keyid=\"poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U\";alg=\"ed25519\";nonce=\"e8N7S2MF…\";tag=\"web-bot-auth\"",
"Signature": "sig1=:jdq0SqOwHdyHr9+r5jw3iYZH6aNGKijYp/EstF4RQTQdi5N5YYKrD+mCT1HA1nZDsi6nJKuHxUi/5Syp3rLWBA==:" },
"expires_at": "2026-10-09T12:01:00Z" }
Signature-Agentnames the deployment’s origin; its key directory is at/.well-known/http-message-signatures-directory.Fromis the identity’s primary address (RFC 9110: whoever is responsible for the request).keyidis the deployment key’s JWK thumbprint,nonce64 random bytes (base64), andtagisweb-bot-auth.
Signed HTTP requests are off unless the operator sets PM_WEB_BOT_AUTH=on (allowed once spike S13 has
passed) and the tenant opts in. While PM_WEB_BOT_AUTH=off, this returns 422 web_bot_auth_disabled
(O9); while tenant policy web_bot_auth.allowed is false, the default,
403 policy_denied (O13;
Configuration › Tenant policy). Other errors as for assertions. The
operator side is in Self-hosting › Signed HTTP requests.
Domains
POST /v1/tenants/{tenant_id}/domains — domains:write
{ "name": "agents.brightwell.example", "method": "dns_records", "receiving": true, "sending": true, "replace_mx": false }
The connection method says what the customer changes at their DNS host. It fixes the domain’s kind,
inbound (how mail reaches identities) and transport (how mail is sent) (FR-DOM-7). The full model is in
Domains on any DNS host.
method | The customer changes | kind | inbound | transport |
|---|---|---|---|---|
cloudflare_zone | Nothing: the zone is in this Cloudflare account and the Worker writes the records | zone | routing | cloudflare |
nameservers | Two NS records at the registrar, for a domain used only for mail | zone | routing | cloudflare |
dns_records | One MX, three DKIM CNAMEs, a MAIL FROM MX and TXT, and the ownership TXT, at any DNS host | external | ses | ses |
send_only | Three DKIM CNAMEs, a MAIL FROM MX and TXT, and the ownership TXT; their own mailbox forwards to the agent | external | forward | ses |
smtp_relay | The ownership TXT, plus what their own mail provider already needs | external | forward or ses | smtp |
delegated_subdomain | NS records for one subdomain, for example agents.brightwell.example | delegated | routing | cloudflare |
| Field | Applies to | Meaning |
|---|---|---|
name | all | The domain, for example agents.brightwell.example |
method | all | One of the six methods. Required for new clients. When it is absent, the old kind is mapped: zone → cloudflare_zone, external → send_only. "kind": "zone" with "create_zone": true is the old spelling of nameservers |
receiving, sending | all | Default true |
replace_mx | cloudflare_zone (apex), dns_records | Default false. A name that already has MX records, none of them the expected host, is refused with 409 existing_mx unless this is true (H5). On a zone apex, enabling routing replaces the existing mail provider. On dns_records it means “I will replace these”: health reports mx_unexpected until the old records are gone |
confirm_dedicated | nameservers | Default false. Confirms that a website or mail on the name may stop (below) |
inbound | smtp_relay (required) | forward (the customer’s mailbox forwards) or ses (they also publish the SES MX and DKIM records) |
smtp | smtp_relay (required) | host (a DNS name, not an IP literal), port (465 or 587), username, password and probe_from (an address the relay accepts as sender; default postmaster@{name}). The credentials are sealed under PM_MASTER_KEY and never returned, logged or exported |
What each method checks before the domain is created:
cloudflare_zone,nameserversanddelegated_subdomainneedPM_CF_API_TOKENon the Worker; without it the request fails with422 cf_token_required. For an apexcloudflare_zone,pmail domains add --local-tokenwith your own Cloudflare token works instead (catch-all, no literal rules).nameserverscreates the zone in this account. Platform keys may always use it; tenant keys only when the tenant’s policy hasdomains.allow_create_zone: true(otherwise422 transport_unavailable,details.reason: "zone_creation_not_allowed"). Moving the nameservers hands the whole domain to this deployment, so when the name has A, AAAA or MX records, orwwwhas a CNAME, A or AAAA record, the request needs"confirm_dedicated": true; otherwise it fails with409 domain_not_dedicatedanddetails.recordslists what was found (N21). The response’srecordsare the zone’s nameservers, asNSrecords to set at the registrar. Cloudflare deletes a zone that is not activated within 28 days; the domain then becomesremovedwithstate_reason: "zone_expired"(N23).delegated_subdomainis off unlessPM_CF_SUBDOMAIN_SETUP=on(otherwise422 transport_unavailable,details.reason: "subdomain_setup_disabled"), and needs a Cloudflare Enterprise account. The response’srecordsareNSrecords for the subdomain, to add at the parent’s DNS host.- For both zone-creating methods, a Cloudflare zone hold returns
409 zone_hold(N24), and Cloudflare error 1105 (too many attempts to add a domain) returns429 upstream_rate_limitedwithRetry-After: 10800anddetails.retry_after: 10800(N22). dns_recordsandsend_onlyneed the SES transport (PM_SES_*); without it they fail with422 transport_unavailable,details.reason: "ses_not_configured".dns_records, andsmtp_relaywithinbound: ses, also need SES receiving (PM_SES_INBOUND_TOPIC_ARN, bucket and queue), otherwisedetails.reason: "ses_receiving_not_configured". Every method that needs an SES identity (dns_records,send_only,smtp_relaywithinbound: ses) fails withdetails.reason: "ses_identity_limit"once the SES region holds 10,000 identities.smtp_relay: aportother than465or587(port25included) returns400 smtp_port_not_allowed. Before it stores anything, the Worker connects to the relay once (EHLO, STARTTLS, AUTH, QUIT). No STARTTLS on 587 (or no TLS on 465) returns422 smtp_tls_required, and the credentials are not sent; a535answer to AUTH returns422 smtp_auth_failed; a connection that cannot be made returns502 upstream_error. The domain sends only after an alignment probe passes.
Also:
- A name already registered in this deployment returns
409 domain_exists. - When the plan’s
custom_domainsallowance is spent, the request fails with402 billing_limit(details.feature: "custom_domains"). - An apex whose merged SPF record would need more than 10 DNS lookups (or more than 2 void lookups) is
refused with
400 spf_lookup_limit;details.lookupsgives the count andfixnames the includes to flatten (H2).
Returns 201 with a Domain in pending state. Its records are read from the
provider APIs at that moment.
GET /v1/tenants/{tenant_id}/domains · GET /v1/domains/{domain_id} — domains:read
The platform domain is visible to every key, with tenant_id: null.
GET /v1/domains/{domain_id}/records — domains:read
Re-reads the expected records from the provider APIs and checks each against DNS:
{
"data": [
{ "type": "TXT", "name": "_pylota-mail.mail.acmecarhire.example", "host": "_pylota-mail.mail",
"value": "pm-verify=8f2k…", "purpose": "ownership", "required": true, "status": "ok",
"observed": ["pm-verify=8f2k…"] },
{ "type": "TXT", "name": "cf-bounce._domainkey.mail.acmecarhire.example", "host": "cf-bounce._domainkey.mail",
"value": "v=DKIM1; …", "purpose": "dkim", "required": true, "status": "missing", "observed": [] }
],
"checked_at": "2026-10-09T10:05:00Z"
}
nameis fully qualified.hostis the same name relative to the registrable domain (from the Public Suffix List), because DNS hosts differ in which of the two they ask for (N17).purposeisownership,mx,dkim,return_path,spf,dmarcorns.statusis one ofok,missing,mismatchorunexpected, whereunexpectedmeans an extra record that conflicts (for example a second SPF record).
PATCH /v1/domains/{domain_id} — domains:write
The body has transport, smtp or both. Returns 200 with the domain. Audit-logged.
transport, platform keys only (403 scope_denied for others):
{ "transport": "ses" }
Switches the transport that sends as a domain on Cloudflare: cloudflare or ses. This is the Email
Sending failover of J5. ses needs the SES transport configured
(422 transport_unavailable, details.reason: "ses_not_configured") and a verified SES identity for the
domain. A transport the domain’s method cannot use returns 422 transport_unavailable with
details.reason: "method_not_supported": dns_records and send_only domains send only through ses,
smtp_relay domains only through smtp, and the platform domain only through cloudflare. The change
applies to sends that reach the transport after it and starts a health check at once (alignment differs
per transport).
smtp, tenant or platform keys, smtp_relay domains only (otherwise method_not_supported):
{ "smtp": { "host": "smtp.provider.example", "port": 587, "username": "agents@brightwell.example",
"password": "…", "probe_from": "agents@brightwell.example" } }
Rotates the relay credentials or changes the relay. It takes the fields of smtp on domain create, with
the same port rule and connection test (400 smtp_port_not_allowed, 422 smtp_tls_required,
422 smtp_auth_failed, 502 upstream_error). The new values are kept pending until an alignment probe
with them passes; until then sends keep using the current values, which the domain’s smtp still shows.
The probe result arrives as a domain health change.
POST /v1/domains/{domain_id}/probe — domains:write
No body. Runs the alignment probe now, for a domain whose transport is smtp (otherwise
422 transport_unavailable, details.reason: "method_not_supported"). At most once a minute per domain
(429 rate_limited). Returns 202:
{ "probe_id": "prb_01JA…" }
The probe sends a message From: {probe_from} through the relay to an address on the platform domain. It
passes when the From header arrives unchanged and DMARC for the domain passes on Pylota Mail’s own
check. The result arrives as a domain health change within 15 minutes: in the domain’s probe, and on
failure as the issue smtp_unaligned, smtp_from_rewritten or smtp_probe_timeout
(Domains on any DNS host › The probe).
A probe also runs before the domain’s first send and every day after.
POST /v1/domains/{domain_id}/verify — domains:write
Runs a check now (rate-limited to one a minute per domain) and returns the domain.
GET /v1/domains/{domain_id}/health — domains:read
{
"state": "failing", "reason": "dkim_missing", "since": "…",
"issues": [ { "code": "dkim_missing", "record": "cf-bounce._domainkey…", "fix": "Add TXT … with value …" } ],
"checks": [ { "at": "…", "resolver": "cloudflare-doh", "outcome": "fail" } ],
"fallback_active": true
}
The issue codes and their levels are listed in Identities and domains › What each check verifies and, for each connection method, in Domains on any DNS host › Health checks per method.
POST /v1/domains/{domain_id}/reprove — domains:write
Issues a new ownership TXT value for a suspended domain. Returns the domain with the new record.
DELETE /v1/domains/{domain_id} — domains:write
Fails with 409 domain_in_use while any address on it is active or retiring. Otherwise it starts
removal: routing rules, sending onboarding and the event subscription are deleted, and for a domain with
an SES identity, the SES identity and the domain’s addresses in the retired-address receipt rules
(pm-retired-{n}). Returns 202. domain.removed follows with reason: "requested".
Domain object
{
"id": "dom_01JA…", "tenant_id": "ten_01J9…", "name": "agents.brightwell.example",
"method": "dns_records", "kind": "external", "inbound": "ses", "transport": "ses",
"is_apex": false, "routing_mode": "catch_all", "reply_token": "subaddress",
"receiving": true, "sending": true,
"ses_region": "eu-west-2", "mail_from_domain": "pm-bounce.agents.brightwell.example",
"smtp": null, "probe": null,
"state": "healthy", "state_reason": null, "state_changed_at": "…",
"delivery_events": "active", "details": null,
"records": [ "...as in /records..." ], "created_at": "…"
}
| Field | Values |
|---|---|
method | One of the six methods, or platform for the platform domain |
kind | platform, zone, delegated or external |
inbound | routing (Cloudflare Email Routing), ses, forward (the customer’s mailbox forwards) or none |
transport | cloudflare, ses or smtp |
routing_mode | catch_all, literal (one routing rule per address, on a zone subdomain) or forward |
ses_region | The SES region when inbound or transport is ses, otherwise null |
mail_from_domain | pm-bounce.{name} when SES sends for the domain, otherwise null. The local part pm-bounce is reserved on such domains |
smtp | smtp_relay only, otherwise null: { "host", "port", "username", "probe_from" }. Never the password |
probe | smtp transport only, otherwise null: { "last_at", "result" }. result is pass or the issue code of the failure (smtp_unaligned, smtp_from_rewritten, smtp_probe_timeout, smtp_auth_failed, smtp_tls_required); both are null before the first probe |
state_reason | The first issue code, or zone_expired on a nameservers domain whose zone Cloudflare deleted |
delivery_events | active (provider delivery events reach the service), manual (a Cloudflare-transport domain created without an event subscription: run pmail domains subscribe <domain>; until then statuses stop at sent), or none (sending: false). See Identities and domains › Kind zone |
details | null, or { "action": "run pmail domains subscribe <domain>" } while delivery_events is manual: the operator step that remains |
Threads and messages
GET /v1/identities/{identity_id}/threads — messages:read
Filters: label, category, needs_reply_gte (0–1; the search operator is:needs_reply uses 0.5),
is_unread, direction (of the last message), after, before, archived (default false). Sorted
by last_at descending.
Threads are built from visible mail only: quarantined, hidden and throttled messages are never listed or
counted here, whatever the key’s permissions. A key with quarantine:review reaches them through the
message list with an explicit status filter (below), or the quarantine list
(quarantined messages only).
{
"data": [{
"id": "thr_01J9…", "subject": "Booking BK-2291 — change of dates",
"participants": [ { "address": "jo@example.net", "name": "Jo Rivera" } ],
"message_count": 4, "unread_count": 1,
"first_at": "…", "last_at": "…", "last_inbound_at": "…", "last_direction": "inbound",
"snippet": "Could we move the pick-up to Friday…",
"labels": ["booking"], "category": "customer_request", "needs_reply": 0.92, "urgency": 2,
"hold": null
}],
"next_cursor": null
}
GET /v1/identities/{identity_id}/threads/{thread_id} — messages:read
Query: messages_limit (default 20, max 100), cursor, and include (comma list: quoted, html, headers).
Returns the thread summary plus messages (oldest first within the page). By default each message
carries extracted_text (quotes stripped) rather than the full text.
PATCH /v1/identities/{identity_id}/threads/{thread_id} — messages:write
{ "labels_add": ["claims"], "labels_remove": [], "read": true, "archived": false }
POST /v1/identities/{identity_id}/threads/{thread_id}/hold — erasure:manage
{ "reason": "PCN dispute WM12345678", "until": "2027-10-09T00:00:00Z" }
DELETE /v1/identities/{identity_id}/threads/{thread_id}/hold (erasure:manage) removes it. Both are
audit-logged.
GET /v1/identities/{identity_id}/messages — messages:read
Filters: thread_id, direction, status, label, after, before. Sorted newest first.
Quarantined, hidden and throttled messages are left out by default, whatever the key’s permissions. They
are listed only when the request filters on that status explicitly (status=quarantined, hidden or
throttled) and the key holds quarantine:review. A key without it that sends such a filter gets
200 with none of those messages, never 403, as search treats include_quarantined
(Security design § 5.3).
GET /v1/identities/{identity_id}/messages/{message_id} — messages:read
include takes html, headers and quoted. A quarantined, hidden or throttled message is
returned only to a key that holds quarantine:review; any other key gets 404 message_not_found, as for
a message that does not exist (Security). The same rule applies to its
attachments and raw MIME.
Message object
{
"id": "msg_01J9…", "thread_id": "thr_01J9…", "identity_id": "idn_01J9…",
"direction": "inbound", "status": "received",
"from": { "address": "accounts@brightwell.example", "name": "Brightwell Leeds" },
"to": [ { "address": "maintenance.acme@agents.example", "name": "" } ],
"cc": [], "bcc": [], "reply_to": [],
"delivered_to": "maintenance.acme@agents.example", "is_primary_recipient": true,
"subject": "Invoice 88213 – AB12 CDE",
"sent_at": "2026-09-14T08:12:00Z", "received_at": "2026-09-14T08:12:03Z",
"extracted_text": "Please find attached invoice 88213 for brake pads and discs…",
"text": null,
"html": null,
"attachments": [
{ "id": "att_01J9…", "filename": "INV-88213.pdf", "content_type": "application/pdf",
"size": 48213, "disposition": "attachment", "text_status": "ready", "pages": 2, "risk": null }
],
"labels": ["invoice"],
"kind": "normal",
"trust": {
"verdict": "pass", "spf": "pass", "dkim": "pass", "dmarc": "pass", "arc": "none",
"known_sender": true, "quarantined": false, "spam_score": 0.02,
"automated": false, "flags": []
},
"triage": {
"status": "done", "category": "billing", "needs_reply": 0.15, "urgency": 1,
"summary": "Brightwell invoice 88213 for AB12 CDE brake work, £412.80 inc VAT.",
"language": "en", "risk_flags": [], "model": "@cf/openai/gpt-oss-20b", "version": 3
},
"refs": [ { "kind": "uk_plate", "value": "AB12CDE" }, { "kind": "invoice", "value": "88213" } ],
"rfc_message_id": "CAF8a…@mail.brightwell.example",
"in_reply_to": null,
"deliveries": null,
"flags": [],
"metadata": {}
}
textis the full plain text. It is included withinclude=quoted.htmlis sanitised HTML. It is included withinclude=htmland is never rendered by the service.trust.flagscan holdhidden_text,display_name_spoof,lookalike_domain,reply_to_mismatchandthread_join_unverified.triage.statusispending,done,skippedorfailed.triage.reasonis present only forskipped(allowance,policy_disabled,not_eligible) andfailed(invalid_output,model_unavailable,input_unavailable) (Triage design). For example, mail that arrives after the workspace’striageallowance is spent is still stored, and its triage is skipped with reasonallowance; the built-in rules’ risk flags are kept and the model does not run (W7):{ "status": "skipped", "reason": "allowance", "category": null, "needs_reply": null, "urgency": null, "summary": null, "language": null, "risk_flags": ["unknown_sender"], "model": null, "version": 3 }.deliveriesis set on outbound messages:[{ "address", "field", "status", "smtp_code", "enhanced_code", "bounce_type", "updated_at" }](enhanced_codeis the RFC 3463 code, for example5.1.1, when the provider or relay gave one).- Message-level
flagsincludesent_via_fallback,parse_degraded,encrypted,message_id_conflict,reprocessed,reconciled,bcc,loopback(delivered inside the deployment for a test tenant, L3) andbody_truncated(a stored body was cut at its storage cap; the full message is in the raw MIME). is_primary_recipientistrueon exactly one copy when one message reached several identities of the tenant (A9).
All text fields (subject, display names, filenames, bodies) are untrusted content. Show them to a model inside a clearly delimited block, never as instructions.
GET /v1/identities/{identity_id}/messages/{message_id}/raw — messages:read
message/rfc822 bytes, available for raw_days (default 90). Then 410 raw_expired.
PATCH /v1/identities/{identity_id}/messages/{message_id} — messages:write
labels_add, labels_remove, read.
GET /v1/identities/{identity_id}/messages/{message_id}/attachments/{attachment_id} — attachments:read
Returns the bytes with Content-Disposition: attachment, X-Content-Type-Options: nosniff and
Content-Security-Policy: sandbox. Attachments with a risk need quarantine:review.
GET /v1/identities/{identity_id}/messages/{message_id}/attachments/{attachment_id}/text — attachments:read
Query: pages=1-3 (default: all, capped at 200 KB of text).
{ "status": "ready", "pages": [ { "page": 1, "text": "INVOICE 88213 …" } ], "total_pages": 2, "truncated": false }
status is one of pending, ready, unavailable (extraction failed or unsupported type) or
skipped (by policy or risk).
POST /v1/identities/{identity_id}/messages/{message_id}/triage — messages:write
Re-runs triage. Returns 202. A message.triaged event follows.
POST /v1/identities/{identity_id}/messages/{message_id}/release — quarantine:review
{ "reason": "Known supplier, DKIM key rotated" }
Moves a quarantined message to received, emits message.released and runs triage. Audit-logged. When
PM_QUARANTINE_KEY_RELEASE is off (Pylota Mail Cloud), every API key gets 403 permission_denied and
the release has to be done by a person in the console (FR-CON-6).
DELETE /v1/identities/{identity_id}/messages/{message_id} — erasure:manage
Returns 202 with an erasure request of scope message. If the message’s thread is under a legal hold,
it returns 423 legal_hold and creates nothing (an erasure request of a wider scope skips held threads
instead).
Sending
All four endpoints need messages:send and an Idempotency-Key. They return 202 Accepted with the
Message object (direction: "outbound", status: "queued") plus "deduplicated": false.
When the plan’s sends allowance is spent, send, reply, reply-all and forward fail with
402 billing_limit (details.feature: "sends"). Nothing is stored; after an upgrade or a top-up, retry
with the same Idempotency-Key.
Dry run. Add ?dry_run=true to send, reply, reply-all or forward to run every check (permissions,
policy, recipients, suppressions and lists, size) without sending or storing anything, taking quota or
locking the thread. The Idempotency-Key header is optional on a dry run and is never recorded. It
returns 200:
{ "would_send": true,
"recipients": [ { "address": "jo@example.net", "field": "to", "status": "queued" },
{ "address": "old@example.org", "field": "cc", "status": "suppressed", "reason": "hard_bounce" } ] }
or the error a real send would get, plus 422 all_recipients_suppressed and 422 recipient_blocked,
which only a dry run returns. A 200 always has would_send: true; each recipient’s status is
queued or suppressed (with reason: the suppression reason, or send_block, not_on_allowlist or
unknown_recipient).
POST /v1/identities/{identity_id}/messages
{
"to": [ { "address": "jo@example.net", "name": "Jo Rivera" } ],
"cc": [], "bcc": [],
"subject": "Your booking BK-2291 is confirmed",
"text": "Hi Jo, your Golf is booked for Friday 10:00…",
"html": "<p>Hi Jo, your Golf is booked for <b>Friday 10:00</b>…</p>",
"attachments": [
{ "filename": "BK-2291.pdf", "content_type": "application/pdf",
"content_base64": "JVBERi0xLjcK…", "disposition": "attachment" }
],
"kind": "transactional",
"thread_id": null,
"from_address": null,
"labels": ["booking"],
"headers": { "X-Booking-Ref": "BK-2291" },
"metadata": { "booking_id": "bk_2291" }
}
- Recipients can be strings (
"jo@example.net") or objects. At mostpolicy.max_recipients(default 10, hard maximum 49) acrossto,ccandbcc. Duplicates are removed. - At least one of
textandhtmlis required. Text is derived from HTML when it is missing. The identity’s signature and the tenant’s AI-disclosure footer are appended according to policy. kind:transactional(the default);marketing, which needs anunsubscribeobject ({ "url": "https://…", "mailto": "…" }) and the tenant’s consent attestation ("consent": { "basis": "opt_in", "recorded_at": "…" });auto_reply, which setsAuto-Submitted: auto-replied. It is only allowed in reply to a non-automated message.
thread_idcontinues an existing thread without quoting. References are set from the thread.from_addressmust be anactiveaddress of the identity, or aretiringone on a thread that already uses it (G7; withthread_id). Otherwise400 invalid_requestwithdetails.errors[0].path = "from_address". The default is the primary.headersaccepts onlyX-headers, plus the allow-listedImportance,Priority,Sensitivity,Keywords,CommentsandOrganization. Everything else is set by the service.- Attachments:
content_base64,disposition(attachmentorinline) andcontent_id(for inline). The total encoded message must fit the transport limit (5 MiB with Cloudflare) or the request fails with413 message_too_large. When the tenant enableslarge_attachments: "link", oversized attachments become expiring signed links instead.
POST /v1/identities/{identity_id}/messages/{message_id}/reply
{ "text": "Friday works. See you at 10.", "html": null, "attachments": [], "kind": "transactional" }
Replies to the sender of message_id (or its Reply-To, under the rules in
Sending). The subject gets one Re: prefix. The From is
the address the counterparty wrote to. In-Reply-To and References are set.
POST /v1/identities/{identity_id}/messages/{message_id}/reply-all
As reply, to the sender plus every To/Cc recipient except this identity’s own addresses. BCC
recipients of the original are never included (A10).
POST /v1/identities/{identity_id}/messages/{message_id}/forward
{ "to": ["claims@insurer.example"], "text": "Forwarding the photos for claim 7781.", "include_attachments": true }
POST /v1/identities/{identity_id}/messages/{message_id}/cancel — messages:send
Only while the message is queued, no transport attempt is in progress, and no recipient has been sent
to yet. Returns the message with status: "canceled". Otherwise
409 not_cancelable.
POST /v1/identities/{identity_id}/messages/{message_id}/resolve — messages:write
For uncertain messages only (otherwise 409 not_uncertain). The body is { "outcome": "sent" } or
{ "outcome": "not_sent" }. sent moves the message and its uncertain deliveries to submitted and
emits message.sent (with provider_message_id: null); later delivery events still apply. not_sent
marks the message failed with reason resolved_not_sent, after which you may send again with a
new Idempotency-Key. Audit-logged.
Outbound status
| Status | Meaning | Terminal |
|---|---|---|
queued | Accepted, waiting for the transport | no |
submitted | The transport accepted it. provider_message_id is set | no |
delivered | Every recipient is delivered | yes |
deferred | At least one recipient has a temporary failure and the provider is still retrying | no |
bounced | At least one recipient bounced and none remains in flight | yes |
complained | A recipient reported spam (can follow delivered) | yes |
rejected | The transport refused it, at submission or, for some recipients, when the recipient’s server rejected it after submission (validation, policy, a definitive recipient-server rejection) | yes |
failed | It could not be sent (quota exhausted after retries, or resolved as not sent) | yes |
uncertain | The outcome is unknown. It is never resent automatically | until resolved |
suppressed | Every recipient is suppressed. Nothing was sent | yes |
canceled | Cancelled while queued | yes |
The message status is a roll-up. Per-recipient status is in deliveries.
Search
POST /v1/identities/{identity_id}/search — search:read (search:agentic for mode: "agentic")
{
"q": "from:@brightwell.example ref:AB12CDE has:attachment newer_than:45d",
"mode": "hybrid",
"filters": { "direction": "inbound", "labels": [], "after": null, "before": null },
"group_by": "message",
"limit": 10,
"snippet_chars": 240,
"facets": true,
"include_quarantined": false,
"cursor": null
}
The operators, modes and ranking are explained in Search.
{
"query": { "parsed": "from:@brightwell.example ref:AB12CDE has:attachment newer_than:45d", "mode": "hybrid" },
"hits": [{
"message_id": "msg_01J…", "thread_id": "thr_01J…", "identity_id": "idn_01J…",
"date": "2026-09-14T08:12:00Z", "direction": "inbound",
"from": { "name": "Brightwell Leeds", "address": "accounts@brightwell.example" },
"subject": "Invoice 88213 – AB12 CDE",
"snippet": "…brake pads and discs, total £412.80 inc VAT…",
"score": 0.913,
"why": ["ref:AB12CDE (attachment p.1)", "from:brightwell.example", "type:pdf"],
"attachment_hits": [ { "attachment_id": "att_…", "filename": "INV-88213.pdf", "page": 1 } ],
"trust": { "verdict": "pass", "known_sender": true, "quarantined": false }
}],
"facets": {
"sender": { "accounts@brightwell.example": 3 }, "sender_domain": { "brightwell.example": 3 },
"month": { "2026-09": 2, "2026-08": 1 },
"label": { "invoice": 3 }, "attachment_type": { "pdf": 3 }, "category": { "billing": 3 }
},
"next_cursor": null, "truncated": false, "semantic_coverage": 0.998, "degraded": false,
"as_of": "2026-10-09T10:12:00Z"
}
With group_by: "thread", hits has one row per thread. Each row has thread_id, subject,
participants, message_count, last_at, the best snippet and why, and top_message_id.
facets has six keys: sender (the from address), sender_domain, month (in the tenant’s time zone),
label, attachment_type and category. Each lists the top 10 values by count (month: the 24 most
recent months). Facets are computed on the first page only: they are null on later pages and when the
request sets facets: false.
Agentic mode
{ "q": "Did the insurer accept the Golf claim after we sent the photos?", "mode": "agentic",
"budget": { "max_steps": 6, "max_seconds": 8 }, "stream": false }
{
"status": "answered",
"answer": {
"text": "Yes. Admiral accepted claim 7781 on 2 October, after the photos sent on 28 September [msg_01JA…][msg_01JB…].",
"sentences": [ { "text": "Yes. Admiral accepted claim 7781 on 2 October…", "citations": ["msg_01JA…", "msg_01JB…"] } ],
"confidence": 0.86
},
"evidence": [ { "...": "search hits, as above, with quotes": [ "we are pleased to confirm claim 7781 has been accepted" ] } ],
"trace": [
{ "step": 1, "action": "search", "q": "claim Golf photos", "mode": "hybrid", "hits": 7, "ms": 412 },
{ "step": 2, "action": "read_thread", "thread_id": "thr_01JA…", "ms": 38 },
{ "step": 3, "action": "answer", "removed_sentences": 0 }
],
"degraded": false,
"usage": { "steps": 3, "ms": 2810, "model": "@cf/qwen/qwen3.8-27b" }
}
statusis one ofanswered,insufficient_evidence,budget_exhausted(evidence returned, no answer or a partial one) ordegraded(hybrid results only, no answer).- When tenant policy turns agentic search off,
mode: "agentic"fails with422 agentic_disabled, on this endpoint and on tenant search. - With
stream: trueandAccept: text/event-stream, the response is a server-sent event stream:event: step(each trace entry),event: evidence(hits as they are found),event: answerandevent: done. A keep-alive comment is sent after every 10 seconds of silence.
POST /v1/tenants/{tenant_id}/search — tenant or platform key, search:read
The same body, plus an optional identity_ids filter. Runs across every identity of the tenant (up to
100; more returns 422 scope_too_large). Hits carry identity_id, and facet counts are summed across
identities. mode: "agentic" with agentic search off returns 422 agentic_disabled.
The response adds two fields, always present: partial and failed_identities (F15).
Each identity’s mailbox has 900 ms from the start of the fan-out to answer. One that errors or misses the
deadline is listed in failed_identities, partial is true, and its late result is discarded. When
every identity answered, they are false and [].
{ "query": { "...": "as above" }, "hits": [ "..." ], "facets": { "...": "summed" },
"next_cursor": null, "truncated": false, "semantic_coverage": 0.994, "degraded": false,
"as_of": "2026-10-09T10:12:00Z", "partial": true, "failed_identities": ["idn_01JA…"] }
GET /v1/identities/{identity_id}/messages/{message_id}/related — search:read
Query: limit (default 10, max 50). Returns semantically similar messages from other threads, as search hits.
GET /v1/identities/{identity_id}/contacts — search:read
Query: q (name, address or domain prefix), limit, cursor.
{ "data": [ { "address": "claims@admiral.example", "name": "Admiral Claims", "domain": "admiral.example",
"first_seen_at": "…", "last_seen_at": "…", "inbound_count": 6, "outbound_count": 4,
"last_thread_id": "thr_01JA…" } ], "next_cursor": null }
GET /v1/identities/{identity_id}/wait — search:read
Long-polls until a matching message arrives after the request started (or after since).
Query parameters:
from: an address or@domain;subject_contains;thread_id;kind:any,replyorverification;since;timeout: seconds, default 30, max 60.
{ "message": { "...": "Message object or null on timeout" },
"verification": { "code": "481 207", "link": "https://service.example/verify?t=…", "sender_domain": "service.example" },
"timed_out": false }
A verification code or link is released only when from names the expected sender domain and the
message passed authentication (verdict: pass). See E4. The handler polls
the mailbox every second and keeps the sender domain registered for unsolicited-OTP detection while it
waits; the full behaviour is in Inbound › The wait handler.
Quarantine
GET /v1/identities/{identity_id}/quarantine — quarantine:review
Quarantined messages, newest first, with quarantine_reason.
Releasing a message is POST …/messages/{message_id}/release (above).
Webhooks
The event types and payloads are in Webhook events.
Reads (GET) need webhooks:read; every other webhook route needs webhooks:manage, which includes
webhooks:read.
POST /v1/webhooks (platform key) · POST /v1/tenants/{tenant_id}/webhooks — webhooks:manage
{ "url": "https://api.example.com/webhooks/mail", "events": ["message.received", "message.bounced"],
"identity_ids": null, "description": "Production API" }
Returns 201 with the endpoint and "secret": "whsec_…". The secret is shown only once.
events: ["*"] subscribes to everything, including event types added later. A tenant, and the platform,
can have at most 20 endpoints; on both routes, the 21st returns 422 webhook_limit_reached.
GET /v1/webhooks · GET /v1/tenants/{tenant_id}/webhooks · GET|PATCH|DELETE /v1/webhooks/{webhook_id}
GET needs webhooks:read; PATCH and DELETE need webhooks:manage.
PATCH accepts url, events, identity_ids, description and enabled.
POST /v1/webhooks/{webhook_id}/rotate-secret
{ "overlap_hours": 24 } (0–168). Returns the new secret once. During the overlap, deliveries carry
both signatures.
POST /v1/webhooks/{webhook_id}/test
Sends a webhook.test event straight away and returns the delivery attempt.
GET /v1/webhooks/{webhook_id}/deliveries — webhooks:read
Filters: status (succeeded, failed, dead), event_type, after.
POST /v1/webhooks/{webhook_id}/replay
{ "event_ids": ["evt_01J…"] }
or
{ "since": "2026-10-08T00:00:00Z", "until": "2026-10-09T00:00:00Z", "status": "dead" }
An event can be replayed for 30 days from its occurred_at (or retention.events_days, if shorter,
because its payload is gone after that). The window never starts from when a delivery went dead, and
older events are not queued. Returns 202 with { "queued": 42 }.
Suppressions and lists — suppressions:manage
GET /v1/tenants/{tenant_id}/suppressions
Query: address (exact lookup), reason. Items show address_hint (masked), reason, created_at
and expires_at.
POST /v1/tenants/{tenant_id}/suppressions
{ "address": "jo@example.net", "reason": "manual", "note": "Asked not to be contacted" }
DELETE /v1/tenants/{tenant_id}/suppressions/{address}
Removes a manual, unsubscribe, hard_bounce or provider suppression. Removing a complaint
suppression needs "confirm_complaint_removal": true in the body and is audit-logged.
GET|PUT|DELETE /v1/tenants/{tenant_id}/lists/{direction}/{kind}/{entry}
direction is receive or send, kind is allow or block, and entry is user@example.com or
@example.com. GET /v1/tenants/{tenant_id}/lists/{direction}/{kind} lists the entries.
- Receive-block: mail is stored hidden and never shown to agents.
- Receive-allow: mail skips spam quarantine. It does not skip authentication quarantine.
- Send-block: a listed recipient is not sent to. The send is accepted and that recipient’s delivery
is
suppressedwithpolicy: send_block; a dry run reports422 recipient_blocked. - Send-allow: with
policy.send_allowlist_only, only listed recipients are sent to; the others aresuppressedwithpolicy: not_on_allowlist.
API keys — keys:manage
POST /v1/keys
{ "name": "bookings-agent", "level": "identity", "tenant_id": "ten_01J9…", "identity_id": "idn_01J9…",
"permissions": ["messages:read", "messages:send", "search:read", "attachments:read", "identities:sign"],
"expires_at": "2027-10-09T00:00:00Z" }
The new key’s level, tenant, identity and permissions must all lie within the caller’s own, otherwise
403 key_scope_exceeded. A tenant key’s mode follows its tenant. Returns 201 with
"secret": "pmk_live_…", shown only once.
permissionsis required at every level,platformincluded. There is no implicit full set: a missing or empty list returns400 invalid_request.- Each permission must be one the new key’s level can hold (Permissions), whoever the
caller is, otherwise
400 invalid_requestwithdetails.reason = "permission_not_allowed_for_level":tenants:manageandplatform:opsonly on platform keys;members:read,members:manage,suppressions:manage,audit:readandusage:readnever on identity keys;identities:signnever on platform keys. - Both checks come before the scope check, so a refused permission is
400, not403.
GET /v1/keys · GET /v1/keys/{key_id} · DELETE /v1/keys/{key_id}
DELETE revokes the key immediately.
POST /v1/keys/{key_id}/rotate
{ "overlap_hours": 24 } (0–168). Returns a new secret. The old one keeps working until the overlap ends.
Privacy — erasure:manage
POST /v1/erasure-requests
{ "tenant_id": "ten_01J9…", "scope": "counterparty", "counterparty_address": "jo@example.net",
"reason": "Data subject request DSR-1182" }
scope | Also needs | Deletes |
|---|---|---|
message | identity_id, message_id | One message, its attachments, text, index rows, vectors, raw copies |
thread | identity_id, thread_id | Every message in the thread |
counterparty | counterparty_address | Every message to or from that address, in every identity of the tenant |
identity | identity_id | The whole mailbox and the identity’s signing keys. Its addresses and key IDs are tombstoned |
tenant | none | Everything in the tenant, every identity’s signing keys included (their key IDs are tombstoned). Then the tenant is marked erased |
Held threads are skipped and listed in the receipt (FR-PRV-4): an erasure request is never refused
because of a hold (it never returns 423 legal_hold). The request’s status is queued, running,
completed, completed_with_holds (finished, but at least one held thread was skipped), failed, or
canceled (a tenant erasure superseded it). Returns 202 with:
Erasure request object
{
"id": "era_01J9…", "tenant_id": "ten_01J9…", "scope": "counterparty", "status": "completed",
"created_at": "…", "completed_at": "…", "created_by_key_id": "key_01J9…",
"receipt": {
"messages_deleted": 14, "attachments_deleted": 9, "r2_objects_deleted": 38,
"fts_rows_deleted": 14, "refs_deleted": 51, "vectors_deleted": 63,
"events_deleted": 31, "identities_affected": ["idn_01J9…", "idn_01JA…"],
"held": [ { "thread_id": "thr_01JA…", "reason": "PCN dispute WM12345678" } ],
"probe": { "keyword_hits": 0, "semantic_hits": 0 }
}
}
GET /v1/erasure-requests/{erasure_id} and GET /v1/erasure-requests (filters: tenant_id, status). An
erasure.completed event is emitted.
POST /v1/exports · GET /v1/exports/{export_id}
{ "tenant_id": "ten_01J9…", "scope": "counterparty", "counterparty_address": "jo@example.net" }
scope is counterparty (with counterparty_address: every message to or from it across the tenant’s
identities) or identity (with identity_id: the whole mailbox). Returns 202 with the export
(status: "queued").
{ "id": "exp_01JA4…", "tenant_id": "ten_01J9…", "scope": "counterparty", "status": "completed",
"size": 1843321, "created_at": "…", "expires_at": "…",
"download_url": "https://mail.example.com/v1/links/bDE6Mz…" }
status is queued, running, completed, failed, canceled (a tenant erasure superseded it) or
expired. The finished export has
download_url: a signed link valid until expires_at (7 days) to a ZIP holding
one .eml per message plus messages.json. The link is minted again on each GET. An
export.completed event is emitted.
Usage and audit
GET /v1/usage — usage:read (implicit for tenant and identity keys on their own workspace)
The workspace’s plan and the state of every allowance in the current period. Agents read it to know their
limits before they hit 402 billing_limit. Every tenant and identity key holds usage:read implicitly
for its own workspace, so it can always call this. A platform key must hold usage:read explicitly and
must pass tenant_id; without tenant_id it gets 400 invalid_request. The MCP tool mail_get_usage
is hidden from platform keys.
{
"billing": "metered",
"plan": { "plan_id": "developer", "status": "active", "current_period_end": "2026-11-01T00:00:00Z",
"cancel_at_period_end": false },
"features": [
{ "feature": "inboxes", "granted": 10, "used": 4, "remaining": 6, "unlimited": false, "resets_at": null },
{ "feature": "sends", "granted": 12000, "used": 8312, "remaining": 3688, "unlimited": false, "resets_at": "2026-11-01T00:00:00Z" },
{ "feature": "triage", "granted": 10000, "used": 2210, "remaining": 7790, "unlimited": false, "resets_at": "2026-11-01T00:00:00Z" },
{ "feature": "custom_domains", "granted": 5, "used": 1, "remaining": 4, "unlimited": false, "resets_at": null },
{ "feature": "storage_gb", "granted": 10, "used": 2, "remaining": 8, "unlimited": false, "resets_at": null },
{ "feature": "seats", "granted": 2, "used": 2, "remaining": 0, "unlimited": false, "resets_at": null }
],
"topups": { "inboxes": 0, "sends": 2, "triage": 0 },
"plans": [ { "plan_id": "free", "name": "Free", "price": 0, "currency": "gbp", "interval": "month",
"included": { "inboxes": 5, "sends": 1000, "triage": 500, "custom_domains": 0, "storage_gb": 1, "seats": 1 },
"topups": false, "support": "github_issues" } ]
}
billingismetered,exempt(no limits) ordisabled(self-hosted without billing;featuresshow counts withgranted: null,unlimited: true, plus any operator quota from tenant policy).usedforstorage_gbis measured, rounded up, and refreshed at least hourly.grantedincludes top-ups.plansis the whole catalog fromPM_PLAN_CATALOG.
GET /v1/usage/daily — usage:read, platform or tenant key
Query: tenant_id (platform keys), from, to (dates, at most 92 days apart). A tenant key holds
usage:read implicitly for its own tenant; a platform key needs it explicitly.
{ "data": [ { "day": "2026-10-08", "inbound": 312, "outbound": 128, "sends": 141, "triage": 298,
"search": 940, "agentic": 41, "assertions": 57, "http_signatures": 0, "ai_neurons": 18233,
"storage_bytes": 2147483648 } ] }
assertions and http_signatures count the agent assertions and HTTP signatures made that day. They
are counts only: signing is not metered against any plan allowance.
GET /v1/plans — no auth
The plan catalog, as in plans above. Returns { "billing_enabled": false, "data": [] } on a deployment
without billing.
GET /v1/tenants/{tenant_id}/billing · PATCH /v1/tenants/{tenant_id}/billing — platform key, tenants:manage
Read or change a workspace’s billing account. PATCH accepts mode (metered, exempt, disabled) and,
for workspaces without a Stripe subscription, plan_id (a complimentary plan). Plans paid through Stripe
change only through Stripe (409 plan_managed_by_stripe). Audit-logged. Both return:
{ "tenant_id": "ten_01J9…", "mode": "metered",
"plan": { "plan_id": "developer", "status": "active", "current_period_end": "2026-11-01T00:00:00Z",
"cancel_at_period_end": false },
"topups": { "inboxes": 0, "sends": 2, "triage": 0 } }
GET /v1/audit-events — audit:read
Filters: tenant_id, actor_key_id, action, target_id, after, before. Newest first.
{ "data": [ { "id": "aud_01JA…", "tenant_id": "ten_01J9…", "actor_key_id": "key_01J9…",
"actor_user_id": null, "action": "quarantine.release", "target_type": "message", "target_id": "msg_01JA…",
"details": {}, "request_id": "req_01JA…", "created_at": "…" } ], "next_cursor": null }
Audit rows cover administrative actions: keys, tenants, identity status, identity signing keys
(identity_key.create, identity_key.rotate, identity_key.revoke), quarantine releases, holds,
suppression removals, erasure, resolve, members, billing, and platform operations. Sends are not
audit rows: each send is recorded by its message, its events (message.sent and the delivery events)
and its per-recipient delivery log. To review what a key sent, list the outbound messages of the
identities it reaches for the period; request logs also carry the key ID for 7 days.
Members
Console users of a workspace. The console is the main way to manage them; these endpoints let an integrator provision people (for example, the owner of each customer workspace).
GET /v1/tenants/{tenant_id}/members — members:read
Not paginated: a workspace’s members and pending invitations are bounded by its seats.
{ "data": [ { "user_id": "usr_01JA…", "email": "sam@acmecarhire.example", "name": "Sam Patel",
"role": "owner", "last_login_at": "…", "created_at": "…" } ], "invitations": [ { "id": "inv_01JA…",
"email": "kim@acmecarhire.example", "role": "member", "invited_by": "usr_01JA…", "expires_at": "…" } ],
"seats": { "granted": 2, "used": 2 } }
POST /v1/tenants/{tenant_id}/invitations — members:manage
{ "email": "kim@acmecarhire.example", "role": "member" }. Sends an invitation email from the deployment’s
system identity (PM_SYSTEM_FROM), also when the console is off (PM_CONSOLE=off). A pending invitation uses a seat; with no seat left the request fails with
402 billing_limit (details.feature: "seats"). Roles: admin, member, viewer. The owner is set at
workspace creation (owner in POST /v1/tenants) or by an ownership transfer in the console. Returns
201 with the invitation (id, email, role, expires_at).
DELETE /v1/tenants/{tenant_id}/invitations/{invitation_id} · DELETE /v1/tenants/{tenant_id}/members/{user_id} — members:manage
Revokes an invitation, or removes a member and ends their sessions. Returns 204. The owner cannot be
removed (409 owner_required).
Platform operations
Platform keys with platform:ops. Every call is audit-logged.
POST /v1/platform/keys/{purpose}/rotate
purpose is one of:
purpose | Signs | Previous key verifies for |
|---|---|---|
thread | Thread tokens | 90 days |
link | Download links, console sign-in, invitation and session tokens, and OAuth state hashes | 7 days |
cursor | Search cursors (the cursor lifetime) | 24 hours |
web_bot_auth | Web Bot Auth HTTP signatures and the key directory. Its kid is the key’s 43-character JWK thumbprint, not one character | 7 days, during which it stays in the key directory |
Generates a new key inside the Worker and makes it current. No body. Rotating web_bot_auth while
PM_WEB_BOT_AUTH=off returns 422 web_bot_auth_disabled (O9). Returns 200:
{ "purpose": "thread", "kid": "4", "created_at": "2026-10-09T10:00:00Z",
"previous": { "kid": "3", "verify_until": "2027-01-07T10:00:00Z", "revoked": false } }
previous is null when the purpose had no key yet; the rotation then creates the first one.
?revoke_previous=true deletes the previous key in the same D1 batch, so what it signed stops
verifying at once: thread tokens fall back to header threading; open links, console sign-in tokens,
invitations, sessions and OAuth flows under it fail; open search cursors fail with 400 invalid_request;
a previous web_bot_auth key leaves the key directory. The response then has previous.verify_until
equal to the rotation time and previous.revoked: true.
Without it, a leaked key keeps verifying for its window. After a suspected leak, rotate with
revoke_previous=true, then rotate PM_MASTER_KEY. The audit action is signing_key.rotate for every
purpose, web_bot_auth included, with details.revoke_previous.
Key material is never returned, by this or any other endpoint. See Configuration › Thread and link keys.
GET /v1/platform/dlq
Dead-letter items, oldest first. Filters: queue (pm-inbound, pm-outbound, pm-delivery-events,
pm-webhooks, pm-index), status (open, the default, or redriven), tenant_id, cursor,
limit.
{ "data": [ { "id": "dlq_01JA…", "queue": "pm-inbound", "kind": "message", "tenant_id": "ten_01J9…",
"first_seen_at": "…", "redriven_at": null, "redrive_count": 0 } ], "next_cursor": null }
The stored body is not returned: it is a pointer, and inbound pointers carry envelope addresses. Items are kept for 14 days.
POST /v1/platform/dlq/{dlq_id}/redrive
Publishes the stored body back to its source queue and returns the item with redriven_at set and
redrive_count incremented. Every consumer is idempotent, so a redrive is safe to repeat.
Idempotency-Key is optional.
POST /v1/platform/jobs · GET /v1/platform/jobs/{job_id}
Starts a maintenance job (J3):
{ "kind": "reparse", "tenant_id": "ten_01J9…", "identity_ids": null,
"after": "2026-09-01T00:00:00Z", "before": null }
kind | Does |
|---|---|
reparse | Re-parses messages from raw MIME with the deployed parser and re-emits their events with reprocessed: true. Messages past raw_days are skipped and counted |
reembed | Re-chunks and re-embeds messages into Vectorize, for example after a model change |
reindex | Rebuilds the keyword index (FTS5 and references) of each mailbox |
tenant_id is required; identity_ids (default: every identity of the tenant), after and before
narrow it. Returns 202 with the job:
{ "id": "job_01JA…", "kind": "reparse", "tenant_id": "ten_01J9…", "status": "queued",
"created_at": "…", "completed_at": null, "result": null }
status is queued, running, completed, failed or canceled; result holds counts once it
ends. Idempotency-Key is optional. GET /v1/platform/jobs/{job_id} returns jobs started through this
endpoint; erasure and export jobs are read through their own requests.
POST /v1/platform/waitlist/invite
{ "count": 50, "plan": null }
Invites the oldest confirmed, not yet invited entries of the sign-up waitlist
(Cloud sign-up › The waitlist). count
is 1–500. plan (optional) invites only entries whose plan of interest is that plan. Each invited person
gets a sign-up link valid for 7 days. The audit action is waitlist.invite. Idempotency-Key is
optional. The CLI equivalent is pmail waitlist invite --count N [--plan P]. Returns 200 with the
number invited and the number of confirmed entries still waiting:
{ "invited": 50, "waiting": 262 }
Signed links and provider hooks
These routes need no API key.
GET /v1/links/{token}
Serves a signed link: a large attachment replaced by a link in an outbound message, or an export ZIP.
The token carries the signing key’s kid and an expiry, and is checked in constant time
(Security design). The response is the file with
Content-Disposition: attachment, X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox
and Cache-Control: private, no-store. Content-Type is application/zip for an export; for an
attachment it is the sniffed type when it is on the safe list of
Security § 8.5, otherwise
application/octet-stream. A bad, expired or unknown link, or a deleted target, returns
404 attachment_not_found or 404 export_not_found.
POST /hooks/ses
The Amazon SES delivery event endpoint, subscribed to the SNS topic PM_SES_SNS_TOPIC_ARN. A
subscription confirmation for that topic is confirmed; a notification becomes delivery events for the
matching messages and returns 200. An internal failure returns 500, so SNS retries (it retries 5xx
and 429) (Outbound design).
POST /hooks/ses/inbound
The Amazon SES inbound mail endpoint, for domains with inbound: ses. It is subscribed to the SNS
topic PM_SES_INBOUND_TOPIC_ARN. A notification names the S3 object SES stored and its recipients; each
recipient is queued once for the inbound pipeline, then the endpoint returns 200. Duplicates are dropped
by the ses_ingest ledger, so each object and recipient is ingested exactly once (FR-DOM-9). The SQS
queue PM_SES_INBOUND_QUEUE_URL is a backstop subscribed to the same topic: the every-minute cron feeds
its messages to the same handler. Mail to an unknown address on the domain is dropped without a bounce
(Domains on any DNS host › Inbound through SES).
An internal failure returns 500, so SNS retries.
Verification, on both endpoints. Only SNS messages that pass every check are accepted:
SignatureVersionis2(SHA256withRSA) and the signature verifies. Version1is refused; setup setsSignatureVersion=2on both topics.SigningCertURLishttpson the hostsns.{PM_SES_REGION}.amazonaws.com.TopicArnequals that endpoint’s topic.Timestampis within one hour (14 days for messages the backstop reads from SQS).
Anything else gets 403 invalid_signature.
Well-known
Served on the API host, with no API key.
| Path | Content |
|---|---|
/.well-known/security.txt | Security contact (from PM_SECURITY_CONTACT) |
/.well-known/jwks/{identity_id}.json | The identity’s JWK Set: its active and retiring signing keys, which verify its agent assertions. Content-Type: application/jwk-set+json, Cache-Control: public, max-age=300. An unknown, deleting, deleted, paused or suspended identity gets 404 identity_not_found (O1) |
/.well-known/http-message-signatures-directory | The Web Bot Auth key directory: the deployment’s active and retiring keys (at most three) as a JWK Set. Content-Type: application/http-message-signatures-directory+json, Cache-Control: max-age=86400. The response is signed once per listed key (Signature-Input and Signature, tag http-message-signatures-directory, component ("@authority";req)), so a copy served elsewhere does not verify (O12). 404 key_not_found while PM_WEB_BOT_AUTH=off |
An identity’s JWK Set during the overlap after a rotation (the first key is active, the second
retiring):
{ "keys": [
{ "kty": "OKP", "crv": "Ed25519", "x": "NjwMjIq2mTA1VpuDzRvkMIfQ0sCSHWavo0KT_4FcKO0",
"kid": "zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo", "alg": "EdDSA", "use": "sig" },
{ "kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", "alg": "EdDSA", "use": "sig" } ] }
Identity IDs are ULIDs, never derived from addresses, so the JWK Set path cannot be used to test whether an address exists. Registering the key directory with Cloudflare’s verified-bot programme is an optional operator step (Self-hosting › Signed HTTP requests); signatures verify for any Web Bot Auth verifier without it.