Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 URLhttps://<your-api-host>/v1, for example https://mail.example.com/v1
AuthAuthorization: Bearer pmk_live_… (or pmk_test_…)
FormatJSON (application/json; charset=utf-8). Times are RFC 3339 UTC. Sizes are bytes
Request IDEvery response carries a Request-Id header (req_…), also echoed in errors
VersioningBreaking 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-all and …/forward. A missing key returns 400 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 (…/assertions and …/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": true in mail responses and the header Idempotent-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_progress with retryable: true.

See Sending and safe retries.

Rate limits

BucketDefaultScope
All requests600 per minuteper API key
Search (keyword, semantic, hybrid, related messages, contacts)120 per minuteper API key
Agentic search20 per minuteper API key, plus a daily tenant cap
Send (accepted into queue)120 per minuteper identity, plus daily caps from policy
Signing (agent assertions and HTTP signatures together, binding RL_SIGN)600 per minuteper 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.

PermissionAllows
tenants:manageCreate, update and suspend tenants, and their billing accounts (platform keys only)
identities:read, identities:writeRead, 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:signMint 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:writeRead, and add, update, verify, probe or remove domains
messages:readThreads, messages, raw MIME, deliveries
messages:sendSend, reply, reply-all, forward, cancel
messages:writeLabels, read state, re-run triage, resolve uncertain sends
attachments:readAttachment bytes and extracted text
search:readKeyword, semantic, hybrid search, contacts, related, wait
search:agenticAgentic search
quarantine:reviewSee and release quarantined mail
webhooks:readRead webhook endpoints and their deliveries
webhooks:manageCreate, change, test, rotate and delete webhook endpoints, and replay. Includes webhooks:read
keys:manageAPI keys within the caller’s scope
erasure:manageErasure requests, legal holds, exports
suppressions:manageSuppressions and allow or block lists
usage:readPlan, 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:readAudit log
members:readList console members and pending invitations (tenant and platform keys; every console role holds it)
members:manageInvite, revoke, change roles and remove console members (tenant and platform keys). Includes members:read
platform:opsPlatform 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 with webhooks: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":

PermissionsKey levels that can hold them
tenants:manage, platform:opsplatform
members:read, members:manage, suppressions:manage, audit:read, usage:readplatform, tenant (an identity key holds usage:read implicitly for its own workspace, but cannot list it)
identities:signtenant, identity
Every other permissionplatform, 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.

RouteWhat it isIn openapi.yamlDesign
/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/connectConsole pages, sign-up and sign-in (session cookies)NoConsole 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 nothingNoNotifications
/billing/stripe/webhookStripe events (Stripe signature)NoBilling design
POST /hooks/ses, POST /hooks/ses/inboundAmazon SES delivery events and inbound mail, through SNS (SNS signature)YesSigned links and provider hooks
GET /v1/links/{token}Signed downloads (link signature)YesSigned 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.json and /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_suffix defaults to "." + slug. Only one tenant (the default tenant made by pmail setup) can have an empty suffix.
  • policy is 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.mode defaults to metered on a deployment with billing on (plan free) and to disabled otherwise.

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, noreply and similar are reserved everywhere; the other RFC 2142 role names (support, sales, info, marketing and 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} when domain_id names a tenant domain that is healthy or degraded. 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_id makes the create idempotent: the same client_id with the same body returns 200 and the existing identity, and with a different body returns 409 client_id_conflict.
  • owner is required before the identity can send (identity_owner_required).
  • When the plan’s inboxes allowance is spent, the request fails with 402 billing_limit (details.feature: "inboxes"). A primary address that needs a literal routing rule while PM_CF_API_TOKEN is not set fails with 422 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": "…"
}
  • forwarding is null when the address’s domain does not use inbound: forward. Otherwise it is unverified (no forwarding test and no forwarded message has arrived yet), ok (the last test passed, or mail arrived through forwarding) or failed (the last test timed out).
  • forwarding_checked_at is when forwarding last changed, or null.

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 active key, which signs and is published, plus retiring keys during an overlap after a rotation. A key is created on the identity’s first signing request, or with POST …/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, gets 409 identity_paused, and its JWK Set answers 404 identity_not_found until it resumes (O1). A deleting or deleted identity gets 404 identity_not_found on every route here.
  • Creating, rotating and revoking keys is audit-logged (identity_key.create, identity_key.rotate, identity_key.revoke) and emits identity.key_created, identity.key_rotated or identity.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_limited over it), ignore Idempotency-Key, and store nothing but a daily count (assertions and http_signatures in GET /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
}
FieldMeaning
kidThe 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
statusactive (signs and is published; at most one), retiring (published, does not sign, until verify_until) or retired (not published)
algAlways EdDSA (Ed25519)
public_jwkThe public key exactly as published in the identity’s JWK Set
verify_untilSet 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_atWhen 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" } }
FieldRules
audienceRequired. 1–256 characters of printable ASCII: a URL or an identifier the verifier expects. Becomes aud (O4)
expires_in60–600 seconds, default 300 (O5)
nonceOptional, 1–128 characters of printable ASCII, copied into the token for the verifier’s own challenge
extOptional 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" } }
  • iss is https://{PM_API_HOST}, sub the identity ID, jti a new ULID, email the identity’s primary address, name its display name and org the workspace name.
  • accountable_human is true when 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: alg and typ, an issuer it trusts, the key from {iss}/.well-known/jwks/{sub}.json (cached for at most 5 minutes), the signature, aud, nbf and exp with 60 seconds of skew, and jti against 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"] }
FieldRules
urlRequired, https only, at most 2,048 characters. An internationalised host is converted to its A-label for @authority (O10)
methodOptional, an upper-case token. Signed only if @method is in components, and then required (400 invalid_request without it)
expires_in30–300 seconds, default 60. Too short an expiry fails in transit (O11)
componentsOptional. 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-Agent names the deployment’s origin; its key directory is at /.well-known/http-message-signatures-directory.
  • From is the identity’s primary address (RFC 9110: whoever is responsible for the request).
  • keyid is the deployment key’s JWK thumbprint, nonce 64 random bytes (base64), and tag is web-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.

methodThe customer changeskindinboundtransport
cloudflare_zoneNothing: the zone is in this Cloudflare account and the Worker writes the recordszoneroutingcloudflare
nameserversTwo NS records at the registrar, for a domain used only for mailzoneroutingcloudflare
dns_recordsOne MX, three DKIM CNAMEs, a MAIL FROM MX and TXT, and the ownership TXT, at any DNS hostexternalsesses
send_onlyThree DKIM CNAMEs, a MAIL FROM MX and TXT, and the ownership TXT; their own mailbox forwards to the agentexternalforwardses
smtp_relayThe ownership TXT, plus what their own mail provider already needsexternalforward or sessmtp
delegated_subdomainNS records for one subdomain, for example agents.brightwell.exampledelegatedroutingcloudflare
FieldApplies toMeaning
nameallThe domain, for example agents.brightwell.example
methodallOne 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, sendingallDefault true
replace_mxcloudflare_zone (apex), dns_recordsDefault 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_dedicatednameserversDefault false. Confirms that a website or mail on the name may stop (below)
inboundsmtp_relay (required)forward (the customer’s mailbox forwards) or ses (they also publish the SES MX and DKIM records)
smtpsmtp_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, nameservers and delegated_subdomain need PM_CF_API_TOKEN on the Worker; without it the request fails with 422 cf_token_required. For an apex cloudflare_zone, pmail domains add --local-token with your own Cloudflare token works instead (catch-all, no literal rules).
  • nameservers creates the zone in this account. Platform keys may always use it; tenant keys only when the tenant’s policy has domains.allow_create_zone: true (otherwise 422 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, or www has a CNAME, A or AAAA record, the request needs "confirm_dedicated": true; otherwise it fails with 409 domain_not_dedicated and details.records lists what was found (N21). The response’s records are the zone’s nameservers, as NS records to set at the registrar. Cloudflare deletes a zone that is not activated within 28 days; the domain then becomes removed with state_reason: "zone_expired" (N23).
  • delegated_subdomain is off unless PM_CF_SUBDOMAIN_SETUP=on (otherwise 422 transport_unavailable, details.reason: "subdomain_setup_disabled"), and needs a Cloudflare Enterprise account. The response’s records are NS records 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) returns 429 upstream_rate_limited with Retry-After: 10800 and details.retry_after: 10800 (N22).
  • dns_records and send_only need the SES transport (PM_SES_*); without it they fail with 422 transport_unavailable, details.reason: "ses_not_configured". dns_records, and smtp_relay with inbound: ses, also need SES receiving (PM_SES_INBOUND_TOPIC_ARN, bucket and queue), otherwise details.reason: "ses_receiving_not_configured". Every method that needs an SES identity (dns_records, send_only, smtp_relay with inbound: ses) fails with details.reason: "ses_identity_limit" once the SES region holds 10,000 identities.
  • smtp_relay: a port other than 465 or 587 (port 25 included) returns 400 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) returns 422 smtp_tls_required, and the credentials are not sent; a 535 answer to AUTH returns 422 smtp_auth_failed; a connection that cannot be made returns 502 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_domains allowance is spent, the request fails with 402 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.lookups gives the count and fix names 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"
}
  • name is fully qualified. host is 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).
  • purpose is ownership, mx, dkim, return_path, spf, dmarc or ns.
  • status is one of ok, missing, mismatch or unexpected, where unexpected means 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": "…"
}
FieldValues
methodOne of the six methods, or platform for the platform domain
kindplatform, zone, delegated or external
inboundrouting (Cloudflare Email Routing), ses, forward (the customer’s mailbox forwards) or none
transportcloudflare, ses or smtp
routing_modecatch_all, literal (one routing rule per address, on a zone subdomain) or forward
ses_regionThe SES region when inbound or transport is ses, otherwise null
mail_from_domainpm-bounce.{name} when SES sends for the domain, otherwise null. The local part pm-bounce is reserved on such domains
smtpsmtp_relay only, otherwise null: { "host", "port", "username", "probe_from" }. Never the password
probesmtp 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_reasonThe first issue code, or zone_expired on a nameservers domain whose zone Cloudflare deleted
delivery_eventsactive (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
detailsnull, 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": {}
}
  • text is the full plain text. It is included with include=quoted.
  • html is sanitised HTML. It is included with include=html and is never rendered by the service.
  • trust.flags can hold hidden_text, display_name_spoof, lookalike_domain, reply_to_mismatch and thread_join_unverified.
  • triage.status is pending, done, skipped or failed. triage.reason is present only for skipped (allowance, policy_disabled, not_eligible) and failed (invalid_output, model_unavailable, input_unavailable) (Triage design). For example, mail that arrives after the workspace’s triage allowance is spent is still stored, and its triage is skipped with reason allowance; 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 }.
  • deliveries is set on outbound messages: [{ "address", "field", "status", "smtp_code", "enhanced_code", "bounce_type", "updated_at" }] (enhanced_code is the RFC 3463 code, for example 5.1.1, when the provider or relay gave one).
  • Message-level flags include sent_via_fallback, parse_degraded, encrypted, message_id_conflict, reprocessed, reconciled, bcc, loopback (delivered inside the deployment for a test tenant, L3) and body_truncated (a stored body was cut at its storage cap; the full message is in the raw MIME).
  • is_primary_recipient is true on 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 most policy.max_recipients (default 10, hard maximum 49) across to, cc and bcc. Duplicates are removed.
  • At least one of text and html is 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 an unsubscribe object ({ "url": "https://…", "mailto": "…" }) and the tenant’s consent attestation ("consent": { "basis": "opt_in", "recorded_at": "…" });
    • auto_reply, which sets Auto-Submitted: auto-replied. It is only allowed in reply to a non-automated message.
  • thread_id continues an existing thread without quoting. References are set from the thread.
  • from_address must be an active address of the identity, or a retiring one on a thread that already uses it (G7; with thread_id). Otherwise 400 invalid_request with details.errors[0].path = "from_address". The default is the primary.
  • headers accepts only X- headers, plus the allow-listed Importance, Priority, Sensitivity, Keywords, Comments and Organization. Everything else is set by the service.
  • Attachments: content_base64, disposition (attachment or inline) and content_id (for inline). The total encoded message must fit the transport limit (5 MiB with Cloudflare) or the request fails with 413 message_too_large. When the tenant enables large_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

StatusMeaningTerminal
queuedAccepted, waiting for the transportno
submittedThe transport accepted it. provider_message_id is setno
deliveredEvery recipient is deliveredyes
deferredAt least one recipient has a temporary failure and the provider is still retryingno
bouncedAt least one recipient bounced and none remains in flightyes
complainedA recipient reported spam (can follow delivered)yes
rejectedThe 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
failedIt could not be sent (quota exhausted after retries, or resolved as not sent)yes
uncertainThe outcome is unknown. It is never resent automaticallyuntil resolved
suppressedEvery recipient is suppressed. Nothing was sentyes
canceledCancelled while queuedyes

The message status is a roll-up. Per-recipient status is in deliveries.


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" }
}
  • status is one of answered, insufficient_evidence, budget_exhausted (evidence returned, no answer or a partial one) or degraded (hybrid results only, no answer).
  • When tenant policy turns agentic search off, mode: "agentic" fails with 422 agentic_disabled, on this endpoint and on tenant search.
  • With stream: true and Accept: text/event-stream, the response is a server-sent event stream: event: step (each trace entry), event: evidence (hits as they are found), event: answer and event: 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, reply or verification;
  • 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 suppressed with policy: send_block; a dry run reports 422 recipient_blocked.
  • Send-allow: with policy.send_allowlist_only, only listed recipients are sent to; the others are suppressed with policy: 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.

  • permissions is required at every level, platform included. There is no implicit full set: a missing or empty list returns 400 invalid_request.
  • Each permission must be one the new key’s level can hold (Permissions), whoever the caller is, otherwise 400 invalid_request with details.reason = "permission_not_allowed_for_level": tenants:manage and platform:ops only on platform keys; members:read, members:manage, suppressions:manage, audit:read and usage:read never on identity keys; identities:sign never on platform keys.
  • Both checks come before the scope check, so a refused permission is 400, not 403.

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" }
scopeAlso needsDeletes
messageidentity_id, message_idOne message, its attachments, text, index rows, vectors, raw copies
threadidentity_id, thread_idEvery message in the thread
counterpartycounterparty_addressEvery message to or from that address, in every identity of the tenant
identityidentity_idThe whole mailbox and the identity’s signing keys. Its addresses and key IDs are tombstoned
tenantnoneEverything 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" } ]
}
  • billing is metered, exempt (no limits) or disabled (self-hosted without billing; features show counts with granted: null, unlimited: true, plus any operator quota from tenant policy).
  • used for storage_gb is measured, rounded up, and refreshed at least hourly.
  • granted includes top-ups. plans is the whole catalog from PM_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:

purposeSignsPrevious key verifies for
threadThread tokens90 days
linkDownload links, console sign-in, invitation and session tokens, and OAuth state hashes7 days
cursorSearch cursors (the cursor lifetime)24 hours
web_bot_authWeb Bot Auth HTTP signatures and the key directory. Its kid is the key’s 43-character JWK thumbprint, not one character7 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 }
kindDoes
reparseRe-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
reembedRe-chunks and re-embeds messages into Vectorize, for example after a model change
reindexRebuilds 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 }

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:

  • SignatureVersion is 2 (SHA256withRSA) and the signature verifies. Version 1 is refused; setup sets SignatureVersion=2 on both topics.
  • SigningCertURL is https on the host sns.{PM_SES_REGION}.amazonaws.com.
  • TopicArn equals that endpoint’s topic.
  • Timestamp is 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.

PathContent
/.well-known/security.txtSecurity contact (from PM_SECURITY_CONTACT)
/.well-known/jwks/{identity_id}.jsonThe 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-directoryThe 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.