openapi: 3.1.0
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base
info:
  title: Pylota Mail API
  version: '1.0.0'
  summary: Email and identity for AI agents, running on your own Cloudflare account.
  description: |
    The machine-readable contract for the Pylota Mail REST API. `docs/src/reference/api.md` is the
    readable version; if the two disagree, this document is the contract. A running deployment serves
    the same document, generated from the Rust types (`crates/api-types`, utoipa), at `/openapi.json`.

    ## Conventions

    - **Base URL.** Versioned endpoints live under `https://{host}/v1`. `/health`, `/openapi.json`,
      `/hooks/ses`, `/hooks/ses/inbound` and the `/.well-known/*` documents live at the host root; their
      path items override `servers`.
    - **Authentication.** `Authorization: Bearer pmk_live_…` (or `pmk_test_…`). Only `/health`,
      `/openapi.json`, `/v1/plans`, `/.well-known/*`, `/v1/links/{token}`, `/hooks/ses` and
      `/hooks/ses/inbound` are unauthenticated. `/v1/links/{token}` is checked by its signature, and the
      two `/hooks/ses*` endpoints by the SNS signature (version 2 only).
    - **Not described here.** The server-rendered console (`/console/*`, including `/console/sign-in…`,
      `/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`,
      `/console/connect` and the notification unsubscribe pair `/console/notifications/unsubscribe`) and
      `/billing/stripe/webhook` (Stripe events) are served by the same Worker but are not part of the
      developer API: they use session cookies, OAuth state, unsubscribe tokens and Stripe signatures, not
      API keys.
    - **Hosts.** The console answers on `PM_CONSOLE_HOST`, which defaults to `PM_API_HOST`. When the two
      differ, console paths answer only on `PM_CONSOLE_HOST`, and the API host `PM_API_HOST` serves only
      the REST API (`/v1/*`), MCP (`/mcp`), `/openapi.json`, `/health`, `/.well-known/*`, signed links
      (`/v1/links/*`), `/hooks/*` and `/billing/stripe/webhook`; anything else returns `404`, and no
      cookie is set or read on the API host.
    - **Format.** JSON (`application/json; charset=utf-8`). Times are RFC 3339 UTC strings. Sizes are bytes.
    - **IDs** are a type prefix, an underscore and a 26-character upper-case Crockford base32 ULID
      (`ten_ idn_ adr_ dom_ thr_ msg_ att_ whk_ dlv_ evt_ key_ era_ exp_ job_ aud_ req_ usr_ inv_ dlq_ hld_ prb_ ptn_`).
    - **Pagination.** List endpoints take `limit` (default 25, max 100) and `cursor`, and return
      `{ "data": [...], "next_cursor": "…" | null }`. Cursors are opaque and expire after 24 hours
      (`410 cursor_expired`).
    - **Idempotency.** `Idempotency-Key` is required on send, reply, reply-all and forward (except on a
      dry run, `dry_run=true`, where it is optional and never recorded), and optional on every other
      `POST` except four (`x-idempotency: none`), which ignore it and never record it: the two signing
      endpoints (`…/assertions` and `…/http-signatures`) and the two SNS endpoints (`/hooks/ses` and
      `/hooks/ses/inbound`). Keys are kept for 30 days, scoped per
      identity for mail, and per calling API key and tenant (or partner, or deployment, when the request
      names no tenant) for everything else. A replay returns the original response with the header
      `Idempotent-Replayed: true` (and `"deduplicated": true` in mail responses); a response that carried
      a one-time secret is replayed without it and with `"secret_replayed": false`.
    - **Rate limits.** Every authenticated response carries `RateLimit-Limit`: the limit of the bucket that
      applied, per period. A `429` also carries `Retry-After` and `RateLimit-Reset`, the seconds to the end
      of the bucket's current period; for `rate_limited` the two are equal, and the other `429` codes set
      `Retry-After` to their own wait. `RateLimit-Remaining` is not sent: the Workers rate-limiting binding
      answers only allow or deny, so the Worker cannot know how many requests are left.
    - **Errors.** Every non-2xx response uses the `Error` envelope. Handle unknown error codes by HTTP status.
    - **Evolution.** Additive changes (new fields, new event types, new enum values) can happen within
      `/v1`. Clients must ignore unknown fields and handle unknown enum values, even where this document
      lists an `enum`.
    - **Untrusted content.** Every text field that comes from email (subjects, display names, filenames,
      bodies, attachment text, snippets) is untrusted. Show it to a model inside a clearly delimited block,
      never as instructions.

    ## Vendor extensions

    | Extension | On | Meaning |
    |---|---|---|
    | `x-required-permission` | operation | Array of permissions the key must hold, all of them. `[]` means any valid key |
    | `x-key-levels` | operation | Key levels (`platform`, `partner`, `tenant`, `identity`) that can call the operation at all. A `partner` key acts only on the tenants its partner's keys created |
    | `x-permission-notes` | operation | Conditional permission or scope rules that the two fields above cannot express |
    | `x-rate-limit-buckets` | operation | Rate-limit buckets the request counts against (`api`, `search`, `agentic`, `send`, `sign`, `partner`; `partner` only for a partner key's calls) |
    | `x-idempotency` | operation | `required`, `optional` or `none` for the `Idempotency-Key` header. `required` means a request without it fails with `400 idempotency_key_required`, except a dry run (`dry_run=true`). `none` means the header is ignored and never recorded |
    | `x-error-codes` | response | The `error.code` values this operation can return with that status |

    ## Licence

    Pylota Mail is licensed under FSL-1.1-ALv2 (Functional Source License, Version 1.1, ALv2 Future
    License): each release becomes available under Apache-2.0 two years after it is published. The text
    is in [`LICENSE.md`](https://github.com/PILOTAAI/pylota-mail/blob/main/LICENSE.md).
  license:
    name: FSL-1.1-ALv2
    identifier: FSL-1.1-ALv2
servers:
  - url: https://{host}/v1
    description: Versioned REST API of a deployment.
    variables:
      host:
        default: mail.example.com
        description: The deployment's API host (`PM_API_HOST`).
security:
  - apiKey: []
tags:
  - name: Meta
    description: Health, the OpenAPI document and the calling key.
  - name: Tenants
    description: >-
      Tenants and their policy. Keys with `tenants:manage`: platform keys for every tenant, partner keys for
      the tenants their partner's keys created.
  - name: Partners
    description: >-
      Partners: integrators whose partner keys create tenants and act only on those tenants. Platform keys
      with `partners:manage`.
  - name: Identities
    description: Agent identities (one mailbox each).
  - name: Addresses
    description: The addresses of an identity, with promotion to primary and retirement.
  - name: Identity keys
    description: >-
      An identity's Ed25519 signing keys, agent assertions (signed JWTs) and signed HTTP requests (Web Bot
      Auth). Private keys never leave the Worker and no endpoint returns them.
  - name: Domains
    description: The platform domain and tenant domains, DNS records and health.
  - name: Threads
    description: Threads in an identity's mailbox.
  - name: Messages
    description: Messages, raw MIME, attachments, triage and quarantine release.
  - name: Sending
    description: Send, reply, reply-all, forward, cancel and resolve. `Idempotency-Key` is required on the first four, except on a dry run (`dry_run=true`).
  - name: Search
    description: Keyword, semantic, hybrid and agentic search, related messages, contacts and `wait`.
  - name: Quarantine
    description: Quarantined mail.
  - name: Webhooks
    description: Webhook endpoints, deliveries and replay. Reads need `webhooks:read`; everything else needs `webhooks:manage`, which includes `webhooks:read`.
  - name: Suppressions
    description: Suppressed recipients.
  - name: Lists
    description: Receive and send allow and block lists.
  - name: Keys
    description: API keys.
  - name: Privacy
    description: Erasure requests and subject-access exports.
  - name: Usage
    description: Plan, allowances, usage figures, the plan catalog and workspace billing accounts.
  - name: Audit
    description: Audit log.
  - name: Members
    description: Console members and invitations of a workspace. `members:manage`, tenant, partner and platform keys.
  - name: Platform
    description: >-
      Platform operations: signing-key rotation, the dead-letter queue, maintenance jobs and waitlist
      invitations. Platform keys with `platform:ops`; every call is audit-logged.
  - name: Links and hooks
    description: Signed links and the two Amazon SES notification endpoints (delivery events and inbound mail). No API key.
  - name: Well-known
    description: >-
      Documents served at `/.well-known/` on the API host: the security contact, each identity's JWK Set
      and the Web Bot Auth key directory. No API key.
  - name: Events
    description: Webhook events delivered to your endpoints (Standard Webhooks).

paths:

  # ───────────────────────────────────────────── Meta ─────────────────────────────────────────────

  /health:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    get:
      operationId: getHealth
      tags: [Meta]
      summary: Health check
      description: >-
        No authentication. Reports that the Worker is serving, with its version, commit and `env`
        (`PM_ENV`). With an invalid configuration it returns `503 unavailable`.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The Worker is serving requests.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Health'}
              example:
                status: ok
                version: '1.0.0'
                commit: abc1234
                env: production
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /openapi.json:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    get:
      operationId: getOpenApiDocument
      tags: [Meta]
      summary: OpenAPI document
      description: No authentication. The OpenAPI 3.1 document for this deployment, generated from the Rust types.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: An OpenAPI 3.1.0 document equivalent to this one.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema:
                type: object
                description: An OpenAPI 3.1.0 document.
                required: [openapi, info, paths]
                properties:
                  openapi:
                    type: string
                    const: '3.1.0'
                  info:
                    type: object
                  paths:
                    type: object
        '500': {$ref: '#/components/responses/InternalError'}

  /me:
    get:
      operationId: getMe
      tags: [Meta]
      summary: Describe the calling key
      description: Any valid key. Describes the key that made the request.
      x-required-permission: []
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The calling key.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Me'}
              example:
                key_id: key_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                name: pylota-api
                level: tenant
                mode: live
                partner_id: null
                tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                identity_id: null
                permissions: ['identities:read', 'messages:send', 'search:read']
                expires_at: null
        '401': {$ref: '#/components/responses/Unauthorized'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Tenants ───────────────────────────────────────────

  /tenants:
    post:
      operationId: createTenant
      tags: [Tenants]
      summary: Create a tenant
      description: >-
        Platform and partner keys. `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 (built-in
        defaults, then `PM_DEFAULT_POLICY`). `owner` (optional) creates the workspace's console owner and
        emails them a sign-in link; without it, a platform or partner key can add an owner later with an invitation
        and an ownership transfer in the console. `billing.mode` defaults to `metered` (plan `free`) on a
        deployment with billing on, and to `disabled` otherwise. With a partner key, the tenant's
        `partner_id` is the key's partner, for good, and its billing mode is the partner's
        `default_billing_mode`; `billing` is platform-only. A partner key's `policy` is checked field by
        field (platform-only, lower-only and free fields, see `TenantPolicy`); it may set
        `quarantine.key_release`. A partner has at most `max_tenants` tenants that are not `erased`
        (`403 partner_tenant_limit`), and a partner key's creations count in the `partner` bucket
        (`RL_PARTNER`, 10 a minute per partner, shared with invitations). The audit row `tenant.create`
        records the `partner_id`.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform, partner]
      x-permission-notes: >-
        A partner key that sends `billing`, a platform-only policy field, or a lower-only policy field above
        its ceiling gets `403 scope_denied` (`details.field`); beyond the partner's `max_tenants`,
        `403 partner_tenant_limit`.
      x-rate-limit-buckets: [api, partner]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/TenantCreateRequest'}
            example:
              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
      responses:
        '201':
          description: The tenant was created.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Tenant'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403':
          description: >-
            As `Forbidden`, plus `partner_tenant_limit` when the partner already has `max_tenants` tenants
            that are not erased (`details.max_tenants`).
          x-error-codes: [permission_denied, scope_denied, partner_suspended, partner_tenant_limit]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: partner_tenant_limit
                  message: This partner already has 25 tenants that are not erased.
                  retryable: false
                  fix: Erase a tenant you no longer need, or ask the platform operator to raise max_tenants.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    max_tenants: 25
        '409':
          description: The slug or address suffix is taken, or the idempotency key is in use.
          x-error-codes: [slug_taken, suffix_taken, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listTenants
      tags: [Tenants]
      summary: List tenants
      description: A partner key lists only the tenants its partner's keys created.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform, partner]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: status
          in: query
          description: Only tenants with this status.
          schema: {$ref: '#/components/schemas/TenantStatus'}
        - name: partner_id
          in: query
          description: >-
            Only tenants created by this partner's keys. An unknown partner, or for a partner key any partner
            but its own, gets `404 partner_not_found`.
          schema: {$ref: '#/components/schemas/PartnerId'}
        - name: mode
          in: query
          description: Only tenants with this mode.
          schema: {$ref: '#/components/schemas/Mode'}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of tenants.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/TenantPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    get:
      operationId: getTenant
      tags: [Tenants]
      summary: Get a tenant
      description: >-
        Platform keys with `tenants:manage` can read any tenant, and partner keys with `tenants:manage` the
        tenants their partner's keys created, also while the tenant is `erasing` and after it is `erased`
        (its name is then `''`). A tenant key can read its own tenant.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform, partner, tenant]
      x-permission-notes: A tenant key can read its own tenant without `tenants:manage`.
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The tenant, with its full effective policy.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Tenant'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateTenant
      tags: [Tenants]
      summary: Update a tenant
      description: >-
        Updatable: `name`, `timezone`, `policy` (deep merge; `null` resets a field to its default) and
        `status` (`active` or `suspended`). While a tenant is suspended, inbound mail gets a temporary
        failure for up to five days and then a permanent one, and every send is refused (FR-TEN-3).
        `suspended_by` records which level suspended it; a partner key cannot lift a suspension a platform
        key made. Tenants are deleted through an erasure request with `scope: "tenant"`; once a tenant is
        `erasing` or `erased`, only the erasure job changes its status (`409 tenant_erased` for a platform
        key, `404 tenant_not_found` for any other key). `partner_id` and `mode` never change. Only a
        platform key, or the partner key of the tenant's own partner, can set
        `policy.quarantine.key_release` (a tenant key cannot call this operation: it can never hold
        `tenants:manage`). A value a platform key sets on a lower-only policy field becomes that field's
        ceiling for partner keys.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform, partner]
      x-permission-notes: >-
        A partner key updates only the tenants its partner's keys created. Each policy field present is
        checked by its class (see `TenantPolicy`): a platform-only field, or a lower-only field above
        min(deployment default, platform ceiling), gets `403 scope_denied` with `details.field`, and so does
        `status: "active"` on a tenant a platform key suspended (`details.field = "status"`).
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/TenantUpdateRequest'}
            example:
              policy:
                max_recipients: 20
                retention:
                  raw_days: null
      responses:
        '200':
          description: The updated tenant.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Tenant'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: A platform key changed the `status` of a tenant that is `erasing` or `erased`.
          x-error-codes: [tenant_erased]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Partners ──────────────────────────────────────────

  /partners:
    post:
      operationId: createPartner
      tags: [Partners]
      summary: Create a partner
      description: >-
        Platform keys with `partners:manage`. A partner is an integrator that creates tenants for its own
        customers and manages them with partner keys (FR-KEY-4), which a platform key then mints with
        `POST /v1/keys` (`level: partner`). Every tenant a partner key creates gets the partner's
        `partner_id` and `default_billing_mode`. `max_tenants` (default 25) and `ramp_exempt` (default
        `false`) bound what the partner's keys can create and send. Audit-logged (`partner.create`).
      x-required-permission: ['partners:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/PartnerCreateRequest'}
            example:
              name: Pylota
              default_billing_mode: exempt
      responses:
        '201':
          description: The partner was created.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Partner'}
              example:
                id: ptn_01JA2B3C4D5E6F7G8H9J0K1M2N
                name: Pylota
                status: active
                default_billing_mode: exempt
                max_tenants: 25
                ramp_exempt: false
                created_at: '2026-10-10T09:00:00Z'
                updated_at: '2026-10-10T09:00:00Z'
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listPartners
      tags: [Partners]
      summary: List partners
      x-required-permission: ['partners:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: status
          in: query
          description: Only partners with this status.
          schema: {$ref: '#/components/schemas/PartnerStatus'}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of partners.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/PartnerPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /partners/{partner_id}:
    parameters:
      - $ref: '#/components/parameters/PartnerIdPath'
    get:
      operationId: getPartner
      tags: [Partners]
      summary: Get a partner
      description: >-
        A deleted partner is returned with `status: "deleted"` and an empty `name`.
      x-required-permission: ['partners:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The partner.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Partner'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updatePartner
      tags: [Partners]
      summary: Update or suspend a partner
      description: >-
        Updatable: `name`, `status` (`active` or `suspended`), `default_billing_mode`, `max_tenants` and
        `ramp_exempt`. `suspended` refuses at once, with `403 partner_suspended` on every route, every key
        of the partner and every API key of its tenants, so nothing can send for them; the tenants' status
        does not change and their inbound mail is still stored. Deliveries to the partner's endpoints and
        to its tenants' endpoints are held and resume when the partner is `active` again ([J13]). A new
        `default_billing_mode` applies to tenants created afterwards; existing tenants keep theirs, which
        only a platform key changes (`PATCH /v1/tenants/{tenant_id}/billing`). A deleted partner gets
        `404 partner_not_found`. Audit-logged (`partner.update`).
      x-required-permission: ['partners:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/PartnerUpdateRequest'}
            example:
              status: suspended
      responses:
        '200':
          description: The updated partner.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Partner'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deletePartner
      tags: [Partners]
      summary: Delete a partner
      description: >-
        A soft delete: the partner stays with `status: "deleted"` and an empty `name`; its partner keys are
        revoked and deleted, and its partner webhook endpoints deleted with their deliveries. The erased
        tenants keep their `partner_id`. Refused with `409 partner_has_tenants` while any tenant with this
        `partner_id` is not `erased` (`active`, `suspended` or `erasing`); erase them first with an erasure
        request of scope `tenant` ([J12]). A deleted partner gets `404 partner_not_found`. Audit-logged
        (`partner.delete`).
      x-required-permission: ['partners:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The partner was deleted.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The partner still has tenants that are not erased. Nothing changed.
          x-error-codes: [partner_has_tenants]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: partner_has_tenants
                  message: This partner still has 3 tenants that are not erased.
                  retryable: false
                  fix: Erase each of the partner's tenants (POST /v1/erasure-requests with scope tenant), then delete the partner.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    tenants: 3
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ─────────────────────────────────────────── Identities ─────────────────────────────────────────

  /tenants/{tenant_id}/identities:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: createIdentity
      tags: [Identities]
      summary: Create an identity
      description: >-
        Creates an identity and its primary address: `{username}{tenant.address_suffix}@{platform domain}`,
        or `{username}@{domain}` when `domain_id` names a healthy tenant domain. The combined username and
        suffix can be at most 40 characters, leaving room for a thread token in the 64-character local
        part. `client_id` makes the create idempotent: the same `client_id` with the same body returns
        `200` and the existing identity; with a different body it returns `409 client_id_conflict`.
        `owner` is required before the identity can send (`identity_owner_required`).
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant]
      x-permission-notes: >-
        A `send_policy.daily_cap` above the tenant's effective `identity_daily_send_cap` needs a platform
        key (`403 scope_denied`, `details.field = "send_policy.daily_cap"`).
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityCreateRequest'}
            example:
              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_01JA2B3C4D5E6F7G8H9J0K1M2N
              client_id: acme:bookings
              metadata:
                operator_id: op_123
      responses:
        '201':
          description: The identity was created.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '200':
          description: An identity with this `client_id` and the same body already exists. It is returned unchanged.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '400':
          description: The request is invalid.
          x-error-codes: [invalid_request, invalid_idempotency_key, address_invalid, address_reserved, address_unsupported, local_part_too_long]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `inboxes` allowance is spent (`details.feature: "inboxes"`). Nothing was stored.'
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: >-
            The identity conflicts with an existing one, the idempotency key is in use, or the primary
            address on a `zone` subdomain would be the domain's 201st literal routing rule
            (`domain_in_use`, `details.reason = "routing_rule_limit"`).
          x-error-codes: [client_id_conflict, username_taken, address_taken, domain_not_ready, domain_in_use, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: The primary address on a `zone` subdomain needs a literal routing rule, and `PM_CF_API_TOKEN` is not set.
          x-error-codes: [cf_token_required]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listTenantIdentities
      tags: [Identities]
      summary: List a tenant's identities
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: status
          in: query
          description: Only identities with this status.
          schema: {$ref: '#/components/schemas/IdentityStatus'}
        - name: purpose
          in: query
          description: Only identities with this purpose tag.
          schema:
            type: string
        - name: client_id
          in: query
          description: Only the identity with this integrator `client_id`.
          schema:
            type: string
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of identities.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities:
    get:
      operationId: listIdentities
      tags: [Identities]
      summary: List the identities the key can reach
      description: >-
        A platform key sees every identity and can filter by `tenant_id`. A tenant key sees its tenant's
        identities. An identity key sees only its own identity. The system identity (the sender of
        `PM_SYSTEM_FROM`) is never listed.
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/TenantIdQuery'
        - name: status
          in: query
          description: Only identities with this status.
          schema: {$ref: '#/components/schemas/IdentityStatus'}
        - name: purpose
          in: query
          description: Only identities with this purpose tag.
          schema:
            type: string
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of identities.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/lookup:
    get:
      operationId: lookupIdentity
      tags: [Identities]
      summary: Find the identity that owns an address
      description: >-
        Resolves any `active` or `retiring` address to its identity. Unknown, retired and out-of-scope
        addresses all return `404 identity_not_found`.
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: address
          in: query
          required: true
          description: The address to resolve, for example `bookings@acme.example.com`.
          schema: {$ref: '#/components/schemas/EmailAddress'}
          example: bookings@acme.example.com
      responses:
        '200':
          description: The identity that owns the address.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: getIdentity
      tags: [Identities]
      summary: Get an identity
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The identity.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateIdentity
      tags: [Identities]
      summary: Update, pause or resume an identity
      description: >-
        Updatable: `display_name`, `purpose`, `owner`, `signature`, `metadata`, `send_policy` and `status`
        (`active` or `paused`). A paused identity still receives and stores mail and refuses every send with
        `identity_paused` (FR-IDN-3). Setting `status: "active"` on an identity paused for
        `abuse_threshold` needs a platform, partner or tenant key and is audit-logged; on a tenant a
        partner's key created it needs a platform key. A `send_policy.daily_cap` above the tenant's
        effective `identity_daily_send_cap` needs a platform key.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: >-
        Resuming an identity paused for `abuse_threshold` needs a platform, partner or tenant key, and is
        audit-logged; on a tenant a partner's key created, only a platform key (`403 scope_denied`
        otherwise). `send_policy.daily_cap` above the tenant's `identity_daily_send_cap` from any other key
        gets `403 scope_denied` (`details.field = "send_policy.daily_cap"`).
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityUpdateRequest'}
            example:
              status: paused
      responses:
        '200':
          description: The updated identity.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deleteIdentity
      tags: [Identities]
      summary: Delete an identity
      description: >-
        Starts an identity-scope erasure (FR-IDN-4). The identity's addresses are tombstoned and can never
        be assigned to another identity; later mail to them gets `550 5.1.1`. Its signing keys are deleted
        and their key IDs tombstoned, so they are never published again ([O7]). While the identity is
        `deleting` or `deleted`, signing and its JWK Set return `404 identity_not_found`.
      x-required-permission: ['identities:write', 'erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '202':
          description: The erasure request of scope `identity`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ─────────────────────────────────────────── Addresses ──────────────────────────────────────────

  /identities/{identity_id}/addresses:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: listAddresses
      tags: [Addresses]
      summary: List an identity's addresses
      description: Every address of the identity, in every status.
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of addresses.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AddressPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    post:
      operationId: createAddress
      tags: [Addresses]
      summary: Add an alias address
      description: >-
        Creates an `alias`. Its status is `pending` until the domain is healthy, then `active`. Only one
        pending address per identity and domain is allowed: a newer request replaces an older pending one
        ([A11]). Emits `identity.address_added`, and `identity.address_activated` once active.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AddressCreateRequest'}
            example:
              local_part: bookings
              domain_id: dom_01JA2B3C4D5E6F7G8H9J0K1M2N
      responses:
        '201':
          description: The alias was created.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Address'}
        '400':
          description: The request is invalid.
          x-error-codes: [invalid_request, invalid_idempotency_key, address_invalid, address_unsupported, address_reserved, local_part_too_long]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: >-
            The address is taken, the idempotency key is in use, or the address is on a `zone` subdomain that
            already has 200 literal routing rules (`domain_in_use`, `details.reason = "routing_rule_limit"`).
          x-error-codes: [address_taken, domain_in_use, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: >-
            The identity already has 20 addresses in any state, or the address needs a literal routing rule
            and `PM_CF_API_TOKEN` is not set.
          x-error-codes: [address_limit_reached, cf_token_required]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/addresses/{address_id}/promote:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/AddressIdPath'
    post:
      operationId: promoteAddress
      tags: [Addresses]
      summary: Make an address the primary
      description: >-
        Makes the address `primary`, so new threads send from it. 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. Existing threads keep replying from the address the counterparty wrote to
        (FR-ADR-2). Promoting a `retiring` address (or the platform address) back cancels the change: this
        is how you roll back (FR-ADR-4). Fails with `409 domain_not_ready` unless the domain is `healthy` or
        `degraded`. Emits `identity.address_promoted`.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/PromoteAddressRequest'}
            example:
              retire_previous_after_days: 90
      responses:
        '200':
          description: The identity, with the new primary and the previous primary now `retiring` (or an `active` alias when it is the platform address).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Identity'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The address's domain is not `healthy` or `degraded`, or the idempotency key is in use.
          x-error-codes: [domain_not_ready, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/addresses/{address_id}/retire:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/AddressIdPath'
    post:
      operationId: retireAddress
      tags: [Addresses]
      summary: Retire an alias
      description: >-
        Moves an alias to `retiring`, or straight to `retired` when `after_days` is 0. A retiring address
        keeps receiving mail into the same identity; a retired one refuses mail with `550 5.1.6`
        (FR-ADR-3). The primary cannot be retired (`409 address_is_primary`), and neither can the
        identity's platform address (`409 address_in_use`), which stays active as the fallback address for
        the identity's whole life. Emits `identity.address_retired` when the address becomes `retired`.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/RetireAddressRequest'}
            example:
              after_days: 0
      responses:
        '200':
          description: The address, now `retiring` or `retired`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Address'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The address is the primary or the identity's platform address, or the idempotency key is in use.
          x-error-codes: [address_is_primary, address_in_use, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/addresses/{address_id}:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/AddressIdPath'
    delete:
      operationId: deleteAddress
      tags: [Addresses]
      summary: Delete a pending address
      description: >-
        Only for `pending` addresses that never received mail. Any other address returns
        `409 address_in_use`: retire it instead. The platform address can never be deleted.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The pending address was deleted.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The address is the primary, or is not a `pending` address that never received mail.
          x-error-codes: [address_is_primary, address_in_use]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/addresses/{address_id}/test-forwarding:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/AddressIdPath'
    post:
      operationId: testAddressForwarding
      tags: [Addresses]
      summary: Test that the customer's mailbox forwards to the identity
      description: >-
        For an address on a domain with `inbound: forward` (method `send_only`, or `smtp_relay` with
        `inbound: forward`); any other address returns `422 transport_unavailable` with
        `details.reason: "method_not_supported"`. 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. No webhook event is sent: read the address again for the result.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '202':
          description: The test message was sent. The address is returned as it stands; `forwarding` changes when the result arrives.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Address'}
              example:
                id: adr_01JA2B3C4D5E6F7G8H9J0K1M2N
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                address: bookings@brightwell.example
                local_part: bookings
                domain_id: dom_01JA2B3C4D5E6F7G8H9J0K1M2N
                role: primary
                status: active
                retire_at: null
                retired_at: null
                forwarding: unverified
                forwarding_checked_at: null
                created_at: '2026-10-09T10:00:00Z'
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '422':
          description: 'The address''s domain does not use `inbound: forward`.'
          x-error-codes: [transport_unavailable]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ───────────────────────────────────────── Identity keys ────────────────────────────────────────

  /identities/{identity_id}/keys:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: listIdentityKeys
      tags: [Identity keys]
      summary: List an identity's signing keys
      description: >-
        Every key the identity has, `retired` ones included, newest first, with its state, `created_at`,
        `verify_until` and public JWK. Private keys are never returned. Key management stays available
        while the identity is paused (including every identity of a suspended tenant), so a suspected leak
        can be handled before it resumes. A `deleting` or `deleted` identity returns `404 identity_not_found`.
      x-required-permission: ['identities:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: status
          in: query
          description: Only keys in this state.
          schema: {$ref: '#/components/schemas/IdentityKeyStatus'}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of keys, newest first.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityKeyPage'}
              example:
                data:
                  - kid: zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo
                    identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    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_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    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
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    post:
      operationId: createIdentityKey
      tags: [Identity keys]
      summary: Create the identity's first signing key
      description: >-
        Creates the identity's first Ed25519 key when none is `active` and returns it with `201`; when an
        active key exists, returns it unchanged with `200`. No body is needed (an empty JSON object is
        accepted). Keys are also created lazily, on the identity's first signing request. The 32-byte seed
        comes from the platform CSPRNG, is sealed under `PM_MASTER_KEY` at once and never leaves the
        Worker. A thumbprint found in the key tombstones is refused and a new seed drawn. When a key is
        created, `identity.key_created` is emitted and the audit action is `identity_key.create`. Available
        while the identity is paused; a `deleting` or `deleted` identity returns `404 identity_not_found`.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '201':
          description: The identity had no active key; this one was created and is `active`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityKey'}
              example:
                kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                status: active
                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: null
                retired_at: null
        '200':
          description: The identity already has an active key. It is returned unchanged and no event is emitted.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityKey'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/keys/rotate:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    post:
      operationId: rotateIdentityKey
      tags: [Identity keys]
      summary: Rotate an identity's signing key
      description: >-
        Creates a new `active` key at once and moves the previous active key to `retiring`, with
        `verify_until` = now + `PM_IDENTITY_KEY_OVERLAP_DAYS` (default 7 days). The retiring key stays in
        the JWK Set and does not sign, so an assertion signed just before the rotation still verifies until
        then ([O2]); new assertions use the new key. With no active key, it creates the first one and
        `previous` is `null`. No body. Emits `identity.key_rotated`; the audit action is
        `identity_key.rotate`. Available while the identity is paused; a `deleting` or `deleted` identity
        returns `404 identity_not_found`.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The new active key, and the previous key, now `retiring` (or `null`).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityKeyRotation'}
              example:
                key:
                  kid: zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo
                  identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                  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
                previous:
                  kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
                  identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                  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
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/keys/{kid}/revoke:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/IdentityKeyKidPath'
    post:
      operationId: revokeIdentityKey
      tags: [Identity keys]
      summary: Revoke an identity's signing key
      description: >-
        Moves the key straight to `retired`, whatever its state, for a suspected compromise. It is absent
        from the next JWK Set response; verifiers cache the set for at most 5 minutes, so they stop
        accepting it within that time ([O3]). The row is kept until the identity is deleted, so its
        thumbprint is never reused. An unknown `kid` returns `404 key_not_found`. A key that is already
        `retired` is returned unchanged with `200`, and no event is emitted. Otherwise emits
        `identity.key_revoked`; the audit action is `identity_key.revoke`. No body. Available while the
        identity is paused.
      x-required-permission: ['identities:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The key, now `retired` with `retired_at` set.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IdentityKey'}
              example:
                kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                status: retired
                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: '2026-10-09T10:30:00Z'
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404':
          description: >-
            The identity is unknown, out of scope, `deleting` or `deleted` (`identity_not_found`), or it has
            no key with this `kid` (`key_not_found`).
          x-error-codes: [identity_not_found, key_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/assertions:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    post:
      operationId: createAssertion
      tags: [Identity keys]
      summary: Mint an agent assertion
      description: |
        Mints a short-lived JWT, signed with the identity's active Ed25519 key, that any service can verify
        against the identity's JWK Set (`/.well-known/jwks/{identity_id}.json`). The identity's first
        signing request creates its key when it has none.

        - **Header**: `{"alg":"EdDSA","typ":"agent-assertion+jwt","kid":"<thumbprint>"}`.
        - **Claims**: `iss` (`https://{PM_API_HOST}`), `sub` (the identity ID), `aud` (the requested
          audience), `iat` and `nbf` (now), `exp` (now + `expires_in`), `jti` (a new ULID), `email` (the
          identity's primary address), `email_verified` (`true`), `name` (the display name), `org` (the
          workspace name), `accountable_human` (`true` when the identity has an accountable owner; the
          owner's name and address are never included), `ai_agent` (`true`), and `nonce` and `ext` when given.
        - **Not stored.** Each call mints a new token, so `Idempotency-Key` is ignored and never recorded.
          The token is never stored or logged; only a count is kept (`usage_daily` metric `assertions`).
          Signing is not metered against any plan allowance.
        - **Refusals**: `400 invalid_request` for a missing or invalid `audience`, an `expires_in` outside
          60–600 or an invalid `ext` ([O4], [O5], [O6]); `403 tenant_suspended` when the tenant is suspended (checked first, as on sends);
          `409 identity_paused` for a paused identity; `404 identity_not_found` for a `deleting` or
          `deleted` identity ([O1]).

        A verifier checks `alg` and `typ`, trusts only the issuers it knows, fetches
        `{iss}/.well-known/jwks/{sub}.json` (caching it for at most 5 minutes), verifies the signature, and
        checks `aud`, `nbf` and `exp` with 60 seconds of clock skew and `jti` against replays
        (`project/design/agent-keys.md`, section 4.3). The Rust SDK does this in `verify_assertion`.
      x-required-permission: ['identities:sign']
      x-key-levels: [tenant, identity]
      x-permission-notes: Platform keys cannot hold `identities:sign`. An identity key signs only as its own identity.
      x-rate-limit-buckets: [api, sign]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AssertionRequest'}
            example:
              audience: https://portal.supplier.example
              expires_in: 300
              nonce: b3f1c2d47a9e
              ext:
                booking_ref: BK-2291
      responses:
        '201':
          description: The assertion. It is not stored, so keep it until it expires.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AssertionResponse'}
              example:
                assertion: eyJhbGciOiJFZERTQSIsInR5cCI6ImFnZW50LWFzc2VydGlvbitqd3QiLCJraWQiOiJ6TWtVbUFRT2xxOUp0RlB6VEsxWElOWmRXZDdnbWhYeGdBOFBoN2NOS0hvIn0.eyJpc3MiOiJodHRwczovL21haWwuZXhhbXBsZS5jb20iLCJzdWIiOiJpZG5fMDFKOVozSzhWNFFXN1gyTTVONlA4UjBUMVkiLCJhdWQiOiJodHRwczovL3BvcnRhbC5zdXBwbGllci5leGFtcGxlIiwiaWF0IjoxNzkxNTQ3MjAwLCJuYmYiOjE3OTE1NDcyMDAsImV4cCI6MTc5MTU0NzUwMCwianRpIjoiMDFNNEc4SE1HMFo2RzI1RVZBTjM2UFFHMEgiLCJlbWFpbCI6ImJvb2tpbmdzLmFjbWVAYWdlbnRzLmV4YW1wbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IkFjbWUgQ2FyIEhpcmUiLCJvcmciOiJBY21lIENhciBIaXJlIiwiYWNjb3VudGFibGVfaHVtYW4iOnRydWUsImFpX2FnZW50Ijp0cnVlLCJub25jZSI6ImIzZjFjMmQ0N2E5ZSIsImV4dCI6eyJib29raW5nX3JlZiI6IkJLLTIyOTEifX0.zXMLhbxydQao1zuFx3DOdVpo9gXgDCn9iYUcCDtJdGak3-oQkHl58mKW0YIT3AUbMyKK-7KYZXjWiYVKsa_pDQ
                kid: zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo
                expires_at: '2026-10-09T12:05:00Z'
                jwks_uri: https://mail.example.com/.well-known/jwks/idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y.json
        '400':
          description: >-
            The request is invalid: `audience` missing, longer than 256 characters or not printable ASCII;
            `expires_in` outside 60–600; `nonce` invalid; or `ext` larger than 2 KB as JSON or using a claim
            name the service sets ([O4], [O5], [O6]). `details.errors[]` names the field. Nothing is signed.
          x-error-codes: [invalid_request]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403':
          description: The key lacks `identities:sign` (platform keys can never hold it), or the identity is outside the key's scope, or the tenant is suspended (checked before the identity's pause, as on sends).
          x-error-codes: [permission_denied, scope_denied, tenant_suspended]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '404':
          description: The identity is unknown, out of scope, `deleting` or `deleted`.
          x-error-codes: [identity_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '409':
          description: >-
            The identity is paused. Nothing is signed ([O1]). An identity of a suspended tenant gets
            `403 tenant_suspended` first.
          x-error-codes: [identity_paused]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: >-
            Over a rate limit: the key's request limit, or `RL_SIGN`, 600 signing calls a minute per identity
            for assertions and HTTP signatures together.
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/http-signatures:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    post:
      operationId: createHttpSignature
      tags: [Identity keys]
      summary: Sign an HTTP request (Web Bot Auth)
      description: |
        Returns the headers that turn an HTTP request into a Web Bot Auth signed request (RFC 9421), signed
        with the deployment's active `web_bot_auth` key, with the identity's primary address in a signed
        `From` header. The Worker never makes the request itself, and nothing is created or stored.

        - **`Signature-Agent`** is a structured-field string, in double quotes, naming the deployment's
          origin; its key directory is at `/.well-known/http-message-signatures-directory` there.
        - **`From`** carries the identity's primary address (RFC 9110: whoever is responsible for the request).
        - **`Signature-Input`** and **`Signature`**: the signature base is built as in RFC 9421 section 2.5,
          with `alg="ed25519"`, `keyid` (the deployment key's JWK thumbprint), a `nonce` of 64 random bytes
          (base64), `tag="web-bot-auth"`, `created` and `expires`.
        - An internationalised host becomes its A-label in `@authority`; a component whose value is not
          ASCII is refused with `400 invalid_request` ([O10]). An `expires_in` outside 30–300 seconds is
          refused ([O11]).
        - `Idempotency-Key` is ignored and never recorded. Only a count is kept (`usage_daily` metric
          `http_signatures`); signatures are not logged. Signing is not metered against any plan allowance.
        - **Refusals**: `422 web_bot_auth_disabled` while `PM_WEB_BOT_AUTH=off` ([O9]); `403 policy_denied`
          while tenant policy `web_bot_auth.allowed` is `false`, the default ([O13]); `403 tenant_suspended`
          when the tenant is suspended (checked first, as on sends); `409 identity_paused` for a paused identity; `404 identity_not_found` for
          a `deleting` or `deleted` identity ([O1]).

        `PM_WEB_BOT_AUTH` can be turned on only after spike S13 has passed; until then signed HTTP requests
        are off and assertions are unaffected.
      x-required-permission: ['identities:sign']
      x-key-levels: [tenant, identity]
      x-permission-notes: Platform keys cannot hold `identities:sign`. An identity key signs only as its own identity. The tenant must also have opted in (`policy.web_bot_auth.allowed`).
      x-rate-limit-buckets: [api, sign]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/HttpSignatureRequest'}
            example:
              url: https://www.brightwell.example/fleet/availability?from=2026-10-12
              method: GET
              expires_in: 60
              components: ['@authority', signature-agent, from]
      responses:
        '200':
          description: The four headers to attach to the request, and when the signature expires.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/HttpSignatureResponse'}
              example:
                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="e8N7S2MFd/qrd6T2R3tdfAuuANngKI7LFtKYI/vowzk4lAZYadIX6wW25MwG7DCT9RUKAJ0qVkU0mEeLElW1qg==";tag="web-bot-auth"'
                  Signature: 'sig1=:jdq0SqOwHdyHr9+r5jw3iYZH6aNGKijYp/EstF4RQTQdi5N5YYKrD+mCT1HA1nZDsi6nJKuHxUi/5Syp3rLWBA==:'
                expires_at: '2026-10-09T12:01:00Z'
        '400':
          description: >-
            The request is invalid: `url` missing, not `https` or longer than 2,048 characters; `method` not an
            upper-case token, or missing while `components` has `@method`; `expires_in` outside 30–300 ([O11]); a component that is not allowed, or whose
            value is not ASCII ([O10]). `details.errors[]` names the field. Nothing is signed.
          x-error-codes: [invalid_request]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403':
          description: >-
            The key lacks `identities:sign` (platform keys can never hold it) or the identity is outside its
            scope; or the tenant has not opted in to signed HTTP requests (`policy_denied`: tenant policy
            `web_bot_auth.allowed` is `false`, [O13]); or the tenant is suspended (`tenant_suspended`, checked
            before the identity's pause, as on sends).
          x-error-codes: [permission_denied, scope_denied, policy_denied, tenant_suspended]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: policy_denied
                  message: This workspace has not turned on signed HTTP requests.
                  retryable: false
                  fix: Ask a platform operator to set web_bot_auth.allowed to true in the tenant's policy.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
        '404':
          description: The identity is unknown, out of scope, `deleting` or `deleted`.
          x-error-codes: [identity_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '409':
          description: >-
            The identity is paused. Nothing is signed ([O1]). An identity of a suspended tenant gets
            `403 tenant_suspended` first.
          x-error-codes: [identity_paused]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: Signed HTTP requests are turned off on this deployment (`PM_WEB_BOT_AUTH=off`, the default) ([O9]).
          x-error-codes: [web_bot_auth_disabled]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: >-
            Over a rate limit: the key's request limit, or `RL_SIGN`, 600 signing calls a minute per identity
            for assertions and HTTP signatures together.
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Domains ───────────────────────────────────────────

  /tenants/{tenant_id}/domains:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: createDomain
      tags: [Domains]
      summary: Add a tenant domain
      description: |
        Adds a domain with one of six connection methods (FR-DOM-7) and returns it in `pending` state. Its
        `records` are read from the provider APIs at that moment, never from templates (FR-DOM-3). The
        method fixes the domain's `kind`, `inbound` and `transport` (see `DomainMethod`).

        `method` is 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`.

        - **`cloudflare_zone`, `nameservers`, `delegated_subdomain`** need `PM_CF_API_TOKEN`
          (`422 cf_token_required`). For an apex `cloudflare_zone`, `pmail domains add --local-token` with the
          operator's own Cloudflare token works instead (catch-all, no literal rules).
        - **Zone permission** (tenant and partner keys, before any Cloudflare call): `cloudflare_zone` and
          `replace_mx` work only on a zone this deployment created for the tenant (`nameservers`,
          `delegated_subdomain`) or one listed in its platform-only policy `domains.cloudflare_zones` (names
          strictly under a listed zone: its apex, and `replace_mx` there, stay platform-only). A zone
          created for another tenant, and any name under the zones of the platform domain, the API host or
          the console host, is refused, as is a `nameservers` or `delegated_subdomain` name inside such a
          zone: `403 scope_denied` with `details.reason = "zone_not_allowed"` ([H8]). Platform keys may use
          any zone.
        - **`nameservers`** creates the zone. Tenant keys need the policy `domains.allow_create_zone: true`
          (`422 transport_unavailable`, `zone_creation_not_allowed`). A name with A, AAAA or MX records, or a
          `www` CNAME, A or AAAA record, needs `confirm_dedicated: true` (`409 domain_not_dedicated`, with
          `details.records`) ([N21]). The response's `records` are the zone's `NS` records for the
          registrar. A zone not activated within 28 days is deleted by Cloudflare and the domain becomes
          `removed` (`zone_expired`) ([N23]).
        - **`delegated_subdomain`** needs `PM_CF_SUBDOMAIN_SETUP=on` (`422 transport_unavailable`,
          `subdomain_setup_disabled`) and a Cloudflare Enterprise account.
        - Both zone-creating methods: a zone hold returns `409 zone_hold` ([N24]); Cloudflare error 1105
          returns `429 upstream_rate_limited` with `Retry-After` and `details.retry_after` of `10800`
          ([N22]).
        - **`dns_records`, `send_only`** need the SES transport (`ses_not_configured`); `dns_records`, and
          `smtp_relay` with `inbound: ses`, also need SES receiving (`ses_receiving_not_configured`). A
          method that needs an SES identity fails with `ses_identity_limit` once the region holds 10,000.
          All are `422 transport_unavailable`.
        - **`smtp_relay`**: `smtp.port` must be `465` or `587` (`400 smtp_port_not_allowed`). The Worker
          connects once (EHLO, STARTTLS, AUTH, QUIT) before it stores anything: no STARTTLS on 587 (or TLS
          on 465) returns `422 smtp_tls_required` without sending the credentials, a `535` to AUTH returns
          `422 smtp_auth_failed`, and a connection that cannot be made returns `502 upstream_error`. The
          domain sends only after an alignment probe passes.
        - `replace_mx` (default `false`): a `cloudflare_zone` apex or a `dns_records` domain that already
          has MX records is refused with `409 existing_mx` unless this is `true` ([H5]).
        - 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]).
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-permission-notes: >-
        `nameservers` (or the old `create_zone: true`) needs a platform key, or a tenant or partner key whose
        tenant's policy has `domains.allow_create_zone: true`. For a tenant or partner key, the zone must be
        one the tenant may use (`403 scope_denied`, `details.reason = "zone_not_allowed"`).
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainCreateRequest'}
            examples:
              dns_records:
                summary: A subdomain whose DNS stays at the customer's DNS host
                value:
                  name: agents.brightwell.example
                  method: dns_records
                  receiving: true
                  sending: true
                  replace_mx: false
              nameservers:
                summary: A new domain used only for mail
                value:
                  name: brightwell-agents.example
                  method: nameservers
                  confirm_dedicated: false
              smtp_relay:
                summary: Sending through the customer's own provider
                value:
                  name: brightwell.example
                  method: smtp_relay
                  inbound: forward
                  smtp:
                    host: smtp.provider.example
                    port: 587
                    username: agents@brightwell.example
                    password: example-app-password
                    probe_from: agents@brightwell.example
              legacy_kind:
                summary: Old spelling (kind zone, mapped to cloudflare_zone)
                value:
                  name: mail.brightwell.example
                  kind: zone
                  receiving: true
                  sending: true
                  replace_mx: false
      responses:
        '201':
          description: The domain was added, in `pending` state.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '400':
          description: >-
            The request is invalid, the apex's merged SPF record would need too many DNS lookups
            (`spf_lookup_limit`, with `details.lookups`), or `smtp.port` is not `465` or `587`
            (`smtp_port_not_allowed`).
          x-error-codes: [invalid_request, invalid_idempotency_key, spf_lookup_limit, smtp_port_not_allowed]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `custom_domains` allowance is spent (`details.feature: "custom_domains"`). Nothing was stored.'
        '403':
          description: >-
            As `Forbidden`. `scope_denied` with `details.reason = "zone_not_allowed"`: a tenant or partner key
            named a Cloudflare zone its tenant may not use ([H8]).
          x-error-codes: [permission_denied, scope_denied, tenant_suspended, partner_suspended]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: scope_denied
                  message: This key cannot use the Cloudflare zone brightwell.example.
                  retryable: false
                  fix: Use a zone created for this tenant, or ask the platform operator to add it to domains.cloudflare_zones.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    reason: zone_not_allowed
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: >-
            The domain is already registered in this deployment; the name already has MX records and
            `replace_mx` is not `true`; a `nameservers` name is not dedicated to mail and
            `confirm_dedicated` is not `true` (`details.records`); Cloudflare refused the zone because of a
            zone hold; or the idempotency key is in use.
          x-error-codes: [domain_exists, existing_mx, domain_not_dedicated, zone_hold, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: domain_not_dedicated
                  message: brightwell-agents.example already has a website and mail.
                  retryable: false
                  fix: Moving the nameservers would stop the website and mail on this domain. Use a domain only for mail, or send confirm_dedicated true.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    records:
                      - type: A
                        name: brightwell-agents.example
                        value: 203.0.113.10
                      - type: MX
                        name: brightwell-agents.example
                        value: 10 mx.mailhost.example
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: >-
            The deployment or the method cannot add this domain (`transport_unavailable`, with
            `details.reason`); the SMTP relay does not offer TLS (`smtp_tls_required`) or refused the
            credentials (`smtp_auth_failed`); or the method needs `PM_CF_API_TOKEN`, which is not set
            (`cf_token_required`).
          x-error-codes: [transport_unavailable, smtp_tls_required, smtp_auth_failed, cf_token_required]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: transport_unavailable
                  message: This deployment does not receive mail through Amazon SES.
                  retryable: false
                  fix: Run pmail setup ses to configure SES receiving, or use another connection method.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    reason: ses_receiving_not_configured
        '429':
          description: >-
            A rate limit was hit (`rate_limited`), or a provider's limit stopped the create
            (`upstream_rate_limited`): Cloudflare refused to create a zone with error 1105 (`Retry-After` and
            `details.retry_after` are `10800`), or the deployment's SES control-plane budget of one call per
            second had no slot within 5 seconds (`Retry-After` is the wait).
          x-error-codes: [rate_limited, upstream_rate_limited]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '500': {$ref: '#/components/responses/InternalError'}
        '502':
          $ref: '#/components/responses/UpstreamError'
          description: A Cloudflare or SES API returned an unexpected error, or the SMTP relay could not be reached (`smtp_relay`). Retry with backoff.
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}
    get:
      operationId: listTenantDomains
      tags: [Domains]
      summary: List a tenant's domains
      description: >-
        The tenant's domains. The platform domain is visible to every key, with `tenant_id: null`, and is
        included in this list. An identity key with `domains:read` can read its tenant's domains.
      x-required-permission: ['domains:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of domains.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DomainPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /domains/{domain_id}:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    get:
      operationId: getDomain
      tags: [Domains]
      summary: Get a domain
      description: 'The platform domain is visible to every key, with `tenant_id: null`.'
      x-required-permission: ['domains:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The domain.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateDomain
      tags: [Domains]
      summary: Switch a domain's transport or change its SMTP relay
      description: |
        The body has `transport`, `smtp` or both. Audit-logged.

        - **`transport`** (platform keys only; partner and tenant keys get `403 scope_denied`) 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`,
          `ses_not_configured`) and an SES identity for the domain (`ses_region` set): a `cloudflare_zone`,
          `nameservers` or `delegated_subdomain` domain gets one, with its three DKIM records, during
          onboarding when the SES transport is configured; a domain onboarded without one gets
          `422 transport_unavailable`. `dns_records` and `send_only`
          domains send only through `ses`, `smtp_relay` domains only through `smtp`, and the platform domain
          only through `cloudflare`; another value returns `422 transport_unavailable`,
          `method_not_supported`. The change applies to sends that reach the transport after it and starts
          a health check at once (alignment differs per transport).
        - **`smtp`** (tenant, partner or platform keys; `smtp_relay` domains only, otherwise `method_not_supported`)
          rotates the relay credentials or changes the relay. The port rule and the one-off connection of
          domain create apply (`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 `smtp` in the
          response still shows. The probe result arrives as a domain health change.
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-permission-notes: '`transport` needs a platform key: a partner or tenant key gets `403 scope_denied`. `smtp` is allowed for tenant, partner and platform keys.'
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainUpdateRequest'}
            examples:
              transport:
                summary: Fail over to SES (platform key)
                value:
                  transport: ses
              smtp:
                summary: Rotate the relay credentials
                value:
                  smtp:
                    host: smtp.provider.example
                    port: 587
                    username: agents@brightwell.example
                    password: example-app-password-2
                    probe_from: agents@brightwell.example
      responses:
        '200':
          description: The domain, with its new `transport`, or with its current `smtp` while the new values wait for a passing probe.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '400':
          description: The request is invalid, or `smtp.port` is not `465` or `587`.
          x-error-codes: [invalid_request, smtp_port_not_allowed]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: >-
            The domain cannot use the requested transport or has no SMTP relay (`transport_unavailable`,
            `method_not_supported`), the transport is not configured (`ses_not_configured`), or the new
            relay does not offer TLS (`smtp_tls_required`) or refused the credentials (`smtp_auth_failed`).
          x-error-codes: [transport_unavailable, smtp_tls_required, smtp_auth_failed]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429':
          description: >-
            A rate limit was hit (`rate_limited`), or the deployment's SES control-plane budget of one call per
            second had no slot within 5 seconds (`upstream_rate_limited`; `Retry-After` is the wait).
          x-error-codes: [rate_limited, upstream_rate_limited]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: upstream_rate_limited
                  message: The Amazon SES control-plane budget had no free slot within 5 seconds.
                  retryable: true
                  fix: Wait for the number of seconds in the Retry-After header, then retry.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    retry_after: 3
        '500': {$ref: '#/components/responses/InternalError'}
        '502':
          $ref: '#/components/responses/UpstreamError'
          description: The new SMTP relay could not be reached. Nothing was stored. Retry with backoff.
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deleteDomain
      tags: [Domains]
      summary: Remove a domain
      description: >-
        Fails with `409 domain_in_use` while any address on the domain is `active` or `retiring`.
        Otherwise 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}`). The domain moves to `removing`, then `removed`,
        and `domain.removed` is emitted with `reason: requested`.
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '202':
          description: Removal started. The domain is returned in state `removing`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: An address on the domain is still `active` or `retiring`.
          x-error-codes: [domain_in_use]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429':
          description: >-
            A rate limit was hit (`rate_limited`), or the deployment's SES control-plane budget of one call per
            second had no slot within 5 seconds (`upstream_rate_limited`; `Retry-After` is the wait).
          x-error-codes: [rate_limited, upstream_rate_limited]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: upstream_rate_limited
                  message: The Amazon SES control-plane budget had no free slot within 5 seconds.
                  retryable: true
                  fix: Wait for the number of seconds in the Retry-After header, then retry.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    retry_after: 3
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /domains/{domain_id}/records:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    get:
      operationId: getDomainRecords
      tags: [Domains]
      summary: Read and check the domain's DNS records
      description: >-
        Re-reads the expected records from the provider APIs and checks each against DNS. `name` is fully
        qualified and `host` is the same name relative to the registrable domain ([N17]). `status` is
        `ok`, `missing`, `mismatch` or `unexpected` (an extra record that conflicts, for example a second
        SPF record).
      x-required-permission: ['domains:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The expected records and what DNS returned for each.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DomainRecords'}
              example:
                data:
                  - type: TXT
                    name: _pylota-mail.mail.acmecarhire.example
                    host: _pylota-mail.mail
                    value: pm-verify=8f2k3m9q
                    purpose: ownership
                    required: true
                    status: ok
                    observed: [pm-verify=8f2k3m9q]
                  - type: TXT
                    name: cf-bounce._domainkey.mail.acmecarhire.example
                    host: cf-bounce._domainkey.mail
                    value: v=DKIM1; k=rsa; p=MIIBIjANBgkq
                    purpose: dkim
                    required: true
                    status: missing
                    observed: []
                checked_at: '2026-10-09T10:05:00Z'
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '502': {$ref: '#/components/responses/UpstreamError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /domains/{domain_id}/probe:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    post:
      operationId: probeDomain
      tags: [Domains]
      summary: Run the SMTP alignment probe now
      description: >-
        For a domain whose transport is `smtp` (otherwise `422 transport_unavailable`,
        `details.reason: "method_not_supported"`). 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`. At most once a minute per domain
        (`429 rate_limited`). A probe also runs before the domain's first send and every day after.
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '202':
          description: The probe was sent.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ProbeAccepted'}
              example:
                probe_id: prb_01JA2B3C4D5E6F7G8H9J0K1M2N
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '422':
          description: The domain's transport is not `smtp`.
          x-error-codes: [transport_unavailable]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: transport_unavailable
                  message: This domain does not send through an SMTP relay.
                  retryable: false
                  fix: Probes run only for domains added with method smtp_relay.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    reason: method_not_supported
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: A probe already ran for this domain in the last minute, or another rate limit was hit. Wait `Retry-After` seconds.
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /domains/{domain_id}/verify:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    post:
      operationId: verifyDomain
      tags: [Domains]
      summary: Check a domain now
      description: >-
        Runs a verification and health check now and returns the domain. Rate-limited to one a minute per
        domain (`429 rate_limited`). A state change still needs two consecutive agreeing results from two
        resolvers (FR-DOM-4).
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The domain after the check.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '502': {$ref: '#/components/responses/UpstreamError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /domains/{domain_id}/health:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    get:
      operationId: getDomainHealth
      tags: [Domains]
      summary: Get a domain's health
      description: The current health state, open issues with exact fixes, recent checks and whether fallback sending is active.
      x-required-permission: ['domains:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The domain's health.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DomainHealth'}
              example:
                state: failing
                reason: dkim_missing
                since: '2026-10-09T09:30:00Z'
                issues:
                  - code: dkim_missing
                    record: cf-bounce._domainkey.mail.acmecarhire.example
                    fix: Add a TXT record at cf-bounce._domainkey.mail.acmecarhire.example with the value shown in /records.
                checks:
                  - at: '2026-10-09T09:45:00Z'
                    resolver: cloudflare-doh
                    outcome: fail
                  - at: '2026-10-09T09:45:00Z'
                    resolver: google-doh
                    outcome: fail
                fallback_active: true
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /domains/{domain_id}/reprove:
    parameters:
      - $ref: '#/components/parameters/DomainIdPath'
    post:
      operationId: reproveDomain
      tags: [Domains]
      summary: Re-prove ownership of a suspended domain
      description: >-
        Issues a new ownership TXT value for a `suspended` domain. Returns the domain with the new
        `_pylota-mail` record in `records`. The domain leaves `suspended` once the new record is observed.
        Any other state returns `409 domain_not_suspended`.
      x-required-permission: ['domains:write']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The domain with its new ownership record.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Domain'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The domain is not `suspended`, or the idempotency key is in use.
          x-error-codes: [domain_not_suspended, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '502': {$ref: '#/components/responses/UpstreamError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Threads ───────────────────────────────────────────

  /identities/{identity_id}/threads:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: listThreads
      tags: [Threads]
      summary: List threads
      description: >-
        Threads in the mailbox, sorted by `last_at` descending. 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 `GET …/messages` with an explicit `status` filter,
        or `GET …/quarantine` (quarantined messages only).
      x-required-permission: ['messages:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: label
          in: query
          description: Only threads with a message carrying this label.
          schema: {$ref: '#/components/schemas/Label'}
        - name: category
          in: query
          description: Only threads whose latest triaged inbound message has this category (built-in or custom).
          schema:
            type: string
        - name: needs_reply_gte
          in: query
          description: Only threads whose `needs_reply` is at least this value.
          schema:
            type: number
            minimum: 0
            maximum: 1
        - name: is_unread
          in: query
          description: '`true` for threads with unread messages, `false` for fully read threads.'
          schema:
            type: boolean
        - name: direction
          in: query
          description: Only threads whose last message has this direction.
          schema: {$ref: '#/components/schemas/Direction'}
        - $ref: '#/components/parameters/After'
        - $ref: '#/components/parameters/Before'
        - name: archived
          in: query
          description: '`false` (the default) lists threads that are not archived; `true` lists archived threads.'
          schema:
            type: boolean
            default: false
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of threads.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ThreadPage'}
              example:
                data:
                  - id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    subject: Booking BK-2291 — change of dates
                    participants:
                      - address: jo@example.net
                        name: Jo Rivera
                    message_count: 4
                    unread_count: 1
                    first_at: '2026-10-07T09:00:00Z'
                    last_at: '2026-10-09T08:12:00Z'
                    last_inbound_at: '2026-10-09T08:12:00Z'
                    last_direction: inbound
                    snippet: Could we move the pick-up to Friday…
                    labels: [booking]
                    category: customer_request
                    needs_reply: 0.92
                    urgency: 2
                    archived: false
                    hold: null
                next_cursor: null
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/threads/{thread_id}:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/ThreadIdPath'
    get:
      operationId: getThread
      tags: [Threads]
      summary: Get a thread with its messages
      description: >-
        The thread summary plus a page of `messages`, oldest first within the page. By default each
        message carries `extracted_text` (quotes and signature removed) rather than the full `text`; use
        `include` for more. `next_cursor` pages through the messages.
      x-required-permission: ['messages:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: messages_limit
          in: query
          description: Messages per page.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Include'
      responses:
        '200':
          description: The thread and a page of its messages.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Thread'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateThread
      tags: [Threads]
      summary: Label, mark read or archive a thread
      description: >-
        `labels_add` and `labels_remove` apply to every message in the thread. `read: true` marks every
        message read. `archived` archives or unarchives the thread.
      x-required-permission: ['messages:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThreadUpdateRequest'}
            example:
              labels_add: [claims]
              labels_remove: []
              read: true
              archived: false
      responses:
        '200':
          description: The updated thread summary.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ThreadSummary'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/threads/{thread_id}/hold:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/ThreadIdPath'
    post:
      operationId: setThreadHold
      tags: [Threads]
      summary: Place a legal hold on a thread
      description: >-
        A held thread is skipped by retention and erasure, and erasure receipts list it (FR-PRV-4).
        Replaces any existing hold on the thread. Audit-logged.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThreadHoldRequest'}
            example:
              reason: PCN dispute WM12345678
              until: '2027-10-09T00:00:00Z'
      responses:
        '200':
          description: The thread summary with its `hold`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ThreadSummary'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: removeThreadHold
      tags: [Threads]
      summary: Remove a thread's legal hold
      description: Audit-logged.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The thread summary with `hold` set to `null`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ThreadSummary'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ─────────────────────────────────────────── Messages ───────────────────────────────────────────

  /identities/{identity_id}/messages:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: listMessages
      tags: [Messages]
      summary: List messages
      description: >-
        Messages in the mailbox, newest first. Each item is a Message object in its default form
        (`extracted_text`, no `text`, `html` or `headers`). Quarantined, hidden and throttled messages are
        excluded 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`.
      x-required-permission: ['messages:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `status` filter of `quarantined`, `hidden` or `throttled` returns those messages only if the key also holds `quarantine:review`; without it the page has none of them (never `403`).
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: thread_id
          in: query
          description: Only messages in this thread.
          schema: {$ref: '#/components/schemas/ThreadId'}
        - name: direction
          in: query
          description: Only messages with this direction.
          schema: {$ref: '#/components/schemas/Direction'}
        - name: status
          in: query
          description: >-
            Only messages with this status. Without it, quarantined, hidden and throttled messages are left
            out. `quarantined`, `hidden` and `throttled` return messages only to a key that holds
            `quarantine:review`; for any other key the page has none of them.
          schema: {$ref: '#/components/schemas/MessageStatus'}
        - name: label
          in: query
          description: Only messages carrying this label.
          schema: {$ref: '#/components/schemas/Label'}
        - $ref: '#/components/parameters/After'
        - $ref: '#/components/parameters/Before'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of messages.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MessagePage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    post:
      operationId: sendMessage
      tags: [Sending]
      summary: Send a new message
      description: |
        Validates the request, runs policy (identity and tenant active, accountable human, daily caps,
        suppressions and lists, recipient count, size, automated-mail rule: FR-OUT-3), stores the message
        as `queued` and returns `202` with the Message object (`direction: "outbound"`,
        `status: "queued"`) plus `"deduplicated": false`.

        - **Idempotency** (FR-OUT-1): the `Idempotency-Key` header is required. The same key with the same
          body returns the original response with `"deduplicated": true` and `Idempotent-Replayed: true`;
          a different body returns `409 idempotency_conflict`; a request still running returns
          `409 request_in_progress` (retryable). Always reuse the same key when retrying the same message.
        - **Dry run**: `?dry_run=true` runs 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` with a
          `DryRunResult`, or the error a real send would get, plus `422 all_recipients_suppressed` and
          `422 recipient_blocked`, which only a dry run returns.
        - **Recipients**: strings or objects, at most `policy.max_recipients` (default 10, hard maximum 49)
          across `to`, `cc` and `bcc` (`400 too_many_recipients`). Duplicates are removed. A send whose every
          recipient is suppressed is accepted and ends `suppressed`.
        - **Body**: at least one of `text` and `html`. Text is derived from HTML when missing. The identity's
          signature and the tenant's AI-disclosure footer are appended according to policy.
        - **`kind`**: `transactional` (default); `marketing`, which needs `unsubscribe` and `consent`
          (`400 marketing_requirements_missing`); `auto_reply`, which sets `Auto-Submitted: auto-replied` and
          is only allowed in reply to a non-automated message (`409 auto_reply_not_allowed`).
        - **`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-` names matching `^X-[A-Za-z0-9_-]+$` plus `Importance`, `Priority`,
          `Sensitivity`, `Keywords`, `Comments` and `Organization`, matched case-insensitively
          (`400 header_not_allowed`).
          `Importance` must be `high`, `normal` or `low`, `Priority` `normal`, `non-urgent` or `urgent`, and
          `Sensitivity` `personal`, `private` or `company-confidential` (`400 invalid_request`). Everything
          else is set by the service.
        - **Size**: the encoded message must fit the transport limit (5 MiB with Cloudflare), otherwise
          `413 message_too_large`. With `policy.large_attachments: "link"`, oversized attachments become
          expiring signed links instead.

        Failures after `202` arrive as message status and events (`message.rejected`, `message.failed`,
        `message.uncertain`, `message.bounced`), never as HTTP errors.
      x-required-permission: ['messages:send']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api, send]
      x-idempotency: required
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeySend'
        - $ref: '#/components/parameters/DryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SendRequest'}
            example:
              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
      responses:
        '200': {$ref: '#/components/responses/DryRunOk'}
        '202':
          description: Accepted into the queue, or the original response when the request is a replay.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SendResponse'}
        '400': {$ref: '#/components/responses/SendBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `sends` allowance is spent (`details.feature: "sends"`). Nothing was stored: upgrade or add a top-up, then retry with the same `Idempotency-Key`.'
        '403': {$ref: '#/components/responses/SendForbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/SendConflict'}
        '413': {$ref: '#/components/responses/SendPayloadTooLarge'}
        '422': {$ref: '#/components/responses/SendUnprocessable'}
        '429': {$ref: '#/components/responses/SendTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /identities/{identity_id}/messages/{message_id}:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: >-
        By default the message carries `extracted_text` and not `text`, `html` or `headers`. `include`
        adds them. Quarantined messages need `quarantine:review`.
      x-required-permission: ['messages:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `quarantined`, `hidden` or `throttled` message needs `quarantine:review` as well; without it the message is `404 message_not_found`, as if it did not exist.
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Include'
      responses:
        '200':
          description: The message.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Message'}
              example:
                id: msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                thread_id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                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_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    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: CAF8a7d2e91@mail.brightwell.example
                in_reply_to: null
                deliveries: null
                flags: []
                metadata: {}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateMessage
      tags: [Messages]
      summary: Label a message or mark it read
      x-required-permission: ['messages:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageUpdateRequest'}
            example:
              labels_add: [invoice]
              labels_remove: []
              read: true
      responses:
        '200':
          description: The updated message.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Message'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deleteMessage
      tags: [Messages]
      summary: Erase a message
      description: Starts an erasure request of scope `message` for this message. A message in a thread under a legal hold is refused with `423 legal_hold` and nothing is created; an erasure request (`createErasureRequest`) instead skips held threads and lists them in its receipt.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '202':
          description: The erasure request of scope `message`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '423': {$ref: '#/components/responses/LegalHold'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/raw:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    get:
      operationId: getMessageRaw
      tags: [Messages]
      summary: Download the raw MIME
      description: >-
        The raw `message/rfc822` bytes: the message as received, or for an outbound message the composed
        MIME that was sent. Available for `policy.retention.raw_days` (default 90), then `410 raw_expired`.
      x-required-permission: ['messages:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `quarantined`, `hidden` or `throttled` message needs `quarantine:review` as well; without it the message is `404 message_not_found`, as if it did not exist.
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The raw message.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            message/rfc822:
              schema:
                type: string
                contentMediaType: message/rfc822
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/RawExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/attachments/{attachment_id}:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
      - $ref: '#/components/parameters/AttachmentIdPath'
    get:
      operationId: getAttachment
      tags: [Messages]
      summary: Download an attachment
      description: >-
        The attachment bytes, always as a download: `Content-Disposition: attachment`,
        `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`. The `Content-Type` is the
        attachment's type; when the declared type and the type sniffed from the bytes disagree, the sniffed
        type wins ([B10]). Attachments with a `risk` need `quarantine:review`.
      x-required-permission: ['attachments:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `quarantined`, `hidden` or `throttled` message needs `quarantine:review` as well; without it the attachment is `404 message_not_found`, as if the message did not exist. That check comes first; then an attachment with a non-null `risk` needs `quarantine:review` too (`403 permission_denied`).
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The attachment bytes.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Content-Disposition: {$ref: '#/components/headers/Content-Disposition-Attachment'}
            X-Content-Type-Options: {$ref: '#/components/headers/X-Content-Type-Options'}
            Content-Security-Policy: {$ref: '#/components/headers/Content-Security-Policy'}
          content:
            '*/*':
              schema:
                type: string
                contentMediaType: application/octet-stream
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/attachments/{attachment_id}/text:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
      - $ref: '#/components/parameters/AttachmentIdPath'
    get:
      operationId: getAttachmentText
      tags: [Messages]
      summary: Get an attachment's extracted text
      description: >-
        Text extracted from the attachment, per page. `status` is `pending`, `ready`, `unavailable`
        (extraction failed or the type is unsupported) or `skipped` (by policy or risk). Without `pages`,
        every page is returned, capped at 200 KB of text (`truncated: true` when cut).
      x-required-permission: ['attachments:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `quarantined`, `hidden` or `throttled` message needs `quarantine:review` as well; without it the message is `404 message_not_found`, as if it did not exist.
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: pages
          in: query
          description: A page or an inclusive page range, for example `1-3` or `2`. Default all pages.
          schema:
            type: string
            pattern: '^[1-9][0-9]*(-[1-9][0-9]*)?$'
          example: 1-3
      responses:
        '200':
          description: The extracted text.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AttachmentText'}
              example:
                status: ready
                pages:
                  - page: 1
                    text: INVOICE 88213 …
                total_pages: 2
                truncated: false
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/triage:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: rerunTriage
      tags: [Messages]
      summary: Re-run triage on a message
      description: Queues triage again. A `message.triaged` event follows when it finishes (or fails).
      x-required-permission: ['messages:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: A `quarantined`, `hidden` or `throttled` message needs `quarantine:review` as well; without it the message is `404 message_not_found`, as if it did not exist.
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '202':
          description: Triage was queued. The response has no body.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/release:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: releaseMessage
      tags: [Quarantine]
      summary: Release a quarantined message
      description: >-
        Moves a quarantined message to `received`, emits `message.released` and runs triage. Audit-logged
        (`quarantine.release`, with the key). 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), unless the message's tenant has `policy.quarantine.key_release: true`. Then any key with
        `quarantine:review` that reaches the message may release it, the tenant's partner key included.
      x-required-permission: ['quarantine:review']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: >-
        Allowed for API keys when `PM_QUARANTINE_KEY_RELEASE` is `on` (or `PM_CONSOLE=off`), or when the
        tenant's `policy.quarantine.key_release` is `true`; otherwise `403 permission_denied`. Only a platform
        key, or the partner key of the tenant's own partner, can set `quarantine.key_release`.
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReleaseRequest'}
            example:
              reason: Known supplier, DKIM key rotated
      responses:
        '200':
          description: The released message, now `received`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Message'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403':
          description: >-
            The key lacks `quarantine:review`; or release by API keys is off for this tenant
            (`PM_QUARANTINE_KEY_RELEASE=off` and `policy.quarantine.key_release` is `false`), so only a person
            can release, in the console (`permission_denied`); or the partner is suspended.
          x-error-codes: [permission_denied, scope_denied, tenant_suspended, partner_suspended]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The message is not quarantined, or the idempotency key is in use.
          x-error-codes: [not_quarantined, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Sending ───────────────────────────────────────────

  /identities/{identity_id}/messages/{message_id}/reply:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: replyToMessage
      tags: [Sending]
      summary: Reply to a message
      description: >-
        Replies to the sender of `message_id`, or to its `Reply-To` when that shares the sender's
        organisational domain or is a known contact ([D3]). The subject gets one `Re:` prefix. The `From` is
        the address the counterparty wrote to, unless that address is retired (FR-OUT-5). `In-Reply-To` and
        `References` are set (the first plus the 19 most recent references are kept, [C2]). Idempotency,
        `dry_run`, policy, size and response behave as in `sendMessage`. `kind: "auto_reply"` is refused for
        automated mail or over the automatic-exchange limit (`409 auto_reply_not_allowed`, [D6]).
      x-required-permission: ['messages:send']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: 'The message replied to or forwarded must be visible to the key: a `quarantined` one needs `quarantine:review` as well, and a `hidden` or `throttled` one can never be the target; otherwise `404 message_not_found`, as if it did not exist.'
      x-rate-limit-buckets: [api, send]
      x-idempotency: required
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeySend'
        - $ref: '#/components/parameters/DryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReplyRequest'}
            example:
              text: Friday works. See you at 10.
              html: null
              attachments: []
              kind: transactional
      responses:
        '200': {$ref: '#/components/responses/DryRunOk'}
        '202':
          description: Accepted into the queue, or the original response when the request is a replay.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SendResponse'}
        '400': {$ref: '#/components/responses/SendBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `sends` allowance is spent (`details.feature: "sends"`). Nothing was stored: upgrade or add a top-up, then retry with the same `Idempotency-Key`.'
        '403': {$ref: '#/components/responses/SendForbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/SendConflict'}
        '413': {$ref: '#/components/responses/SendPayloadTooLarge'}
        '422': {$ref: '#/components/responses/SendUnprocessable'}
        '429': {$ref: '#/components/responses/SendTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /identities/{identity_id}/messages/{message_id}/reply-all:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: replyAllToMessage
      tags: [Sending]
      summary: Reply to everyone on a message
      description: >-
        As `replyToMessage`, addressed to the sender plus every `To` and `Cc` recipient except this
        identity's own addresses. BCC recipients of the original are never included, and the reply never
        reveals that the identity was BCC'd ([A10]). The combined recipients count against
        `policy.max_recipients`.
      x-required-permission: ['messages:send']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: 'The message replied to or forwarded must be visible to the key: a `quarantined` one needs `quarantine:review` as well, and a `hidden` or `throttled` one can never be the target; otherwise `404 message_not_found`, as if it did not exist.'
      x-rate-limit-buckets: [api, send]
      x-idempotency: required
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeySend'
        - $ref: '#/components/parameters/DryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReplyRequest'}
            example:
              text: Thanks both, Friday at 10 is confirmed.
              kind: transactional
      responses:
        '200': {$ref: '#/components/responses/DryRunOk'}
        '202':
          description: Accepted into the queue, or the original response when the request is a replay.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SendResponse'}
        '400': {$ref: '#/components/responses/SendBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `sends` allowance is spent (`details.feature: "sends"`). Nothing was stored: upgrade or add a top-up, then retry with the same `Idempotency-Key`.'
        '403': {$ref: '#/components/responses/SendForbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/SendConflict'}
        '413': {$ref: '#/components/responses/SendPayloadTooLarge'}
        '422': {$ref: '#/components/responses/SendUnprocessable'}
        '429': {$ref: '#/components/responses/SendTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /identities/{identity_id}/messages/{message_id}/forward:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: forwardMessage
      tags: [Sending]
      summary: Forward a message
      description: >-
        Forwards `message_id` to new recipients with an optional note. `References` are kept ([C6]).
        `include_attachments` (default `true`) attaches the original's attachments; attachments with a
        `risk` are never forwarded. Idempotency, `dry_run`, policy, size and response behave as in
        `sendMessage`.
      x-required-permission: ['messages:send']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: 'The message replied to or forwarded must be visible to the key: a `quarantined` one needs `quarantine:review` as well, and a `hidden` or `throttled` one can never be the target; otherwise `404 message_not_found`, as if it did not exist.'
      x-rate-limit-buckets: [api, send]
      x-idempotency: required
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeySend'
        - $ref: '#/components/parameters/DryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ForwardRequest'}
            example:
              to: [claims@insurer.example]
              text: Forwarding the photos for claim 7781.
              include_attachments: true
      responses:
        '200': {$ref: '#/components/responses/DryRunOk'}
        '202':
          description: Accepted into the queue, or the original response when the request is a replay.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SendResponse'}
        '400': {$ref: '#/components/responses/SendBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'The plan''s `sends` allowance is spent (`details.feature: "sends"`). Nothing was stored: upgrade or add a top-up, then retry with the same `Idempotency-Key`.'
        '403': {$ref: '#/components/responses/SendForbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/SendConflict'}
        '413': {$ref: '#/components/responses/SendPayloadTooLarge'}
        '422': {$ref: '#/components/responses/SendUnprocessable'}
        '429': {$ref: '#/components/responses/SendTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /identities/{identity_id}/messages/{message_id}/cancel:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: cancelMessage
      tags: [Sending]
      summary: Cancel a queued message
      description: >-
        Only while the message is `queued` (FR-OUT-11). Returns the message with `status: "canceled"` and
        emits `message.canceled`. Any later status returns `409 not_cancelable`.
      x-required-permission: ['messages:send']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The message, now `canceled`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Message'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The message is past `queued`, or the idempotency key is in use.
          x-error-codes: [not_cancelable, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/messages/{message_id}/resolve:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    post:
      operationId: resolveMessage
      tags: [Sending]
      summary: Resolve an uncertain send
      description: >-
        For `uncertain` messages only (otherwise `409 not_uncertain`). `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.
      x-required-permission: ['messages:write']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ResolveRequest'}
            example:
              outcome: not_sent
      responses:
        '200':
          description: The message with its resolved status.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Message'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The message is not `uncertain`, or the idempotency key is in use.
          x-error-codes: [not_uncertain, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Search ────────────────────────────────────────────

  /identities/{identity_id}/search:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    post:
      operationId: searchIdentity
      tags: [Search]
      summary: Search an identity's mailbox
      description: |
        One request shape and one result shape for four modes (FR-SRCH-1): `keyword` (FTS5 BM25 plus exact
        references), `semantic` (Vectorize), `hybrid` (the default: reciprocal-rank fusion, then reranking)
        and `agentic` (a bounded plan, search, judge and refine loop with a cited answer whose citations are
        checked by code).

        `q` uses the query language: `from:` `to:` `participant:` `subject:` `ref:` `label:`
        `has:attachment` `filename:` `type:` `after:` `before:` `newer_than:` `older_than:`
        `in:inbound|outbound` `thread:` `is:unread|needs_reply|quarantined` `category:`, quoted phrases,
        `OR` and `-` negation. A query that cannot be parsed returns `400 invalid_query`.

        - When a mode's dependency is unavailable, search degrades and sets `degraded: true`, unless the
          request set `require_mode: true`, in which case it returns `503 search_degraded`.
        - Quarantined mail is excluded unless `include_quarantined: true` and the key holds
          `quarantine:review` ([F7]).
        - Results respect `limit`, `snippet_chars` and a 256 KB response cap that sets `truncated` ([F8]).
          Cursors pin `as_of`, so pages stay stable while mail arrives (FR-SRCH-6).

        **Agentic mode** (`mode: "agentic"`, an `AgenticRequest`) returns an `AgenticResponse`. With
        `stream: true` and `Accept: text/event-stream`, the response is a server-sent event stream instead.
      x-required-permission: ['search:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: '`mode: "agentic"` needs `search:agentic`. `include_quarantined: true` returns quarantined mail only if the key also holds `quarantine:review`.'
      x-rate-limit-buckets: [api, search, agentic]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/SearchRequest'
                - $ref: '#/components/schemas/AgenticRequest'
            examples:
              hybrid:
                summary: Hybrid search with operators
                value:
                  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
              agentic:
                summary: Agentic search
                value:
                  q: Did the insurer accept the Golf claim after we sent the photos?
                  mode: agentic
                  budget:
                    max_steps: 6
                    max_seconds: 8
                  stream: false
      responses:
        '200':
          description: >-
            A `SearchResponse` for `keyword`, `semantic` and `hybrid`; an `AgenticResponse` for `agentic`;
            or, for `agentic` with `stream: true` and `Accept: text/event-stream`, an event stream.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SearchResponse'
                  - $ref: '#/components/schemas/AgenticResponse'
              examples:
                hybrid:
                  summary: Hybrid search response
                  value:
                    query:
                      parsed: from:@brightwell.example ref:AB12CDE has:attachment newer_than:45d
                      mode: hybrid
                    hits:
                      - message_id: msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        thread_id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        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_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                            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'
                agentic:
                  summary: Agentic search response
                  value:
                    status: answered
                    answer:
                      text: Yes. Admiral accepted claim 7781 on 2 October, after the photos sent on 28 September [msg_01JA2B3C4D5E6F7G8H9J0K1M2N][msg_01JB2B3C4D5E6F7G8H9J0K1M2N].
                      sentences:
                        - text: Yes. Admiral accepted claim 7781 on 2 October, after the photos sent on 28 September.
                          citations: [msg_01JA2B3C4D5E6F7G8H9J0K1M2N, msg_01JB2B3C4D5E6F7G8H9J0K1M2N]
                      confidence: 0.86
                    evidence:
                      - message_id: msg_01JA2B3C4D5E6F7G8H9J0K1M2N
                        thread_id: thr_01JA2B3C4D5E6F7G8H9J0K1M2N
                        identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        date: '2026-10-02T14:03:00Z'
                        direction: inbound
                        from:
                          name: Admiral Claims
                          address: claims@admiral.example
                        subject: 'RE: Claim 7781 – VW Golf'
                        snippet: …we are pleased to confirm claim 7781 has been accepted…
                        score: 0.88
                        why: ['ref:7781', 'from:admiral.example']
                        attachment_hits: []
                        trust:
                          verdict: pass
                          known_sender: true
                          quarantined: false
                        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_01JA2B3C4D5E6F7G8H9J0K1M2N
                        ms: 38
                      - step: 3
                        action: answer
                        removed_sentences: 0
                    degraded: false
                    usage:
                      steps: 3
                      ms: 2810
                      model: '@cf/qwen/qwen3.8-27b'
            text/event-stream:
              schema:
                type: string
                description: |
                  Sent only for `mode: "agentic"` with `stream: true` and `Accept: text/event-stream`. Each
                  event is `event: <name>` followed by one `data:` line holding a JSON document:

                  | Event | `data` | When |
                  |---|---|---|
                  | `step` | `AgenticTraceStep` | As each trace entry completes |
                  | `evidence` | `AgenticEvidence` (one hit) | As hits are found |
                  | `answer` | `AgenticAnswer` | After citation verification, when there is an answer |
                  | `done` | `AgenticResponse` (the complete result, as in the JSON response) | Last event; the stream then ends |

                  An SSE comment line (starting with `:`) is sent every 10 seconds as a keep-alive.
              x-sse-events:
                step: {$ref: '#/components/schemas/AgenticTraceStep'}
                evidence: {$ref: '#/components/schemas/AgenticEvidence'}
                answer: {$ref: '#/components/schemas/AgenticAnswer'}
                done: {$ref: '#/components/schemas/AgenticResponse'}
              example: |
                event: step
                data: {"step":1,"action":"search","q":"claim Golf photos","mode":"hybrid","hits":7,"ms":412}

                event: done
                data: {"status":"answered","answer":{"text":"…","sentences":[],"confidence":0.86},"evidence":[],"trace":[],"degraded":false,"usage":{"steps":3,"ms":2810,"model":"@cf/qwen/qwen3.8-27b"}}
        '400': {$ref: '#/components/responses/SearchBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: Agentic search is turned off by tenant policy.
          x-error-codes: [agentic_disabled]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/SearchTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/SearchUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /tenants/{tenant_id}/search:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: searchTenant
      tags: [Search]
      summary: Search across a tenant's identities
      description: >-
        The same body and modes as `searchIdentity`, plus an optional `identity_ids` filter. Runs across
        every identity of the tenant, up to 100; more without an `identity_ids` filter returns
        `422 scope_too_large`. Hits carry `identity_id`. Needs a tenant, partner or platform key: an identity key
        gets `403 scope_denied` ([F3], FR-SRCH-10). Facet counts are summed across identities. The response
        always carries `partial` and `failed_identities`: each identity's mailbox has 900 ms from the start
        of the fan-out, and one that errors or misses it is listed in `failed_identities` with
        `partial: true` ([F15]); otherwise they are `false` and `[]`. `mode: "agentic"` with agentic search
        turned off by tenant policy returns `422 agentic_disabled`.
      x-required-permission: ['search:read']
      x-key-levels: [platform, partner, tenant]
      x-permission-notes: '`mode: "agentic"` needs `search:agentic`. `include_quarantined: true` returns quarantined mail only if the key also holds `quarantine:review`.'
      x-rate-limit-buckets: [api, search, agentic]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/TenantSearchRequest'
                - $ref: '#/components/schemas/TenantAgenticRequest'
            example:
              q: 'ref:AB12CDE'
              mode: hybrid
              identity_ids: [idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y, idn_01JA2B3C4D5E6F7G8H9J0K1M2N]
              group_by: thread
      responses:
        '200':
          description: >-
            A `TenantSearchResponse` (hits carry `identity_id`; `partial` and `failed_identities` always
            present) or an `AgenticResponse`, or an event stream for streamed agentic search, as for
            `searchIdentity`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TenantSearchResponse'
                  - $ref: '#/components/schemas/AgenticResponse'
              examples:
                partial:
                  summary: One mailbox missed the deadline
                  value:
                    query:
                      parsed: ref:AB12CDE
                      mode: hybrid
                    hits:
                      - thread_id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                        subject: Invoice 88213 – AB12 CDE
                        participants:
                          - name: Brightwell Leeds
                            address: accounts@brightwell.example
                        message_count: 3
                        last_at: '2026-09-14T08:12:00Z'
                        snippet: …brake pads and discs for AB12 CDE…
                        why: ['ref:AB12CDE']
                        top_message_id: msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    facets:
                      sender: {accounts@brightwell.example: 3}
                      sender_domain: {brightwell.example: 3}
                      month: {'2026-09': 3}
                      label: {invoice: 3}
                      attachment_type: {pdf: 3}
                      category: {billing: 3}
                    next_cursor: null
                    truncated: false
                    semantic_coverage: 0.994
                    degraded: false
                    as_of: '2026-10-09T10:12:00Z'
                    partial: true
                    failed_identities: [idn_01JA2B3C4D5E6F7G8H9J0K1M2N]
            text/event-stream:
              schema:
                type: string
                description: The same event stream as `searchIdentity` (`step`, `evidence`, `answer`, `done`, keep-alive every 10 seconds).
              x-sse-events:
                step: {$ref: '#/components/schemas/AgenticTraceStep'}
                evidence: {$ref: '#/components/schemas/AgenticEvidence'}
                answer: {$ref: '#/components/schemas/AgenticAnswer'}
                done: {$ref: '#/components/schemas/AgenticResponse'}
        '400': {$ref: '#/components/responses/SearchBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: >-
            More than 100 identities in scope without an `identity_ids` filter, or agentic search is turned
            off by tenant policy.
          x-error-codes: [scope_too_large, agentic_disabled]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/SearchTooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/SearchUnavailable'}
        '504': {$ref: '#/components/responses/GatewayTimeout'}

  /identities/{identity_id}/messages/{message_id}/related:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
      - $ref: '#/components/parameters/MessageIdPath'
    get:
      operationId: findRelatedMessages
      tags: [Search]
      summary: Find related messages
      description: Semantically similar messages from other threads of the same identity, as search hits.
      x-required-permission: ['search:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api, search]
      x-idempotency: none
      parameters:
        - name: limit
          in: query
          description: Maximum number of hits.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
      responses:
        '200':
          description: Related messages, best first.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SearchHitList'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/contacts:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: searchContacts
      tags: [Search]
      summary: Search contacts
      description: People and services the identity has exchanged mail with.
      x-required-permission: ['search:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api, search]
      x-idempotency: none
      parameters:
        - name: q
          in: query
          description: A name, address or domain prefix. Empty or absent lists every contact, most recent first.
          schema:
            type: string
            maxLength: 100
          example: admiral
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of contacts.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactPage'}
              example:
                data:
                  - address: claims@admiral.example
                    name: Admiral Claims
                    domain: admiral.example
                    first_seen_at: '2026-08-01T09:00:00Z'
                    last_seen_at: '2026-10-02T14:03:00Z'
                    inbound_count: 6
                    outbound_count: 4
                    last_thread_id: thr_01JA2B3C4D5E6F7G8H9J0K1M2N
                next_cursor: null
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /identities/{identity_id}/wait:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: waitForMessage
      tags: [Search]
      summary: Wait for a matching message
      description: >-
        Long-polls until a matching message arrives after the request started (or after `since`), or until
        `timeout`. A timeout is not an error: it returns `200` with `timed_out: true` and `message: null`.
        A verification code or link is released only when `from` names the expected sender domain and the
        message passed authentication (`verdict: pass`) ([E4]). An active `wait` also marks OTP mail from
        that sender domain as solicited for 30 minutes ([E5]).
      x-required-permission: ['search:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: from
          in: query
          description: An address (`noreply@service.example`) or a domain (`@service.example`).
          schema:
            type: string
            pattern: '^([^@\s]+@[^@\s]+|@[^@\s]+)$'
          example: '@service.example'
        - name: subject_contains
          in: query
          description: Only messages whose subject contains this text (case-insensitive).
          schema:
            type: string
        - name: thread_id
          in: query
          description: Only messages in this thread.
          schema: {$ref: '#/components/schemas/ThreadId'}
        - name: kind
          in: query
          description: '`any` message, a `reply` (a message that joins an existing thread), or a `verification` message (a code or link was found).'
          schema:
            type: string
            enum: [any, reply, verification]
            default: any
        - name: since
          in: query
          description: Match messages received after this time instead of after the request started.
          schema:
            type: string
            format: date-time
        - name: timeout
          in: query
          description: Seconds to wait.
          schema:
            type: integer
            minimum: 1
            maximum: 60
            default: 30
      responses:
        '200':
          description: A matching message, or a timeout.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WaitResponse'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ────────────────────────────────────────── Quarantine ──────────────────────────────────────────

  /identities/{identity_id}/quarantine:
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: listQuarantine
      tags: [Quarantine]
      summary: List quarantined messages
      description: >-
        Quarantined messages, newest first, each with its `quarantine_reason`. Release one with
        `releaseMessage`.
      x-required-permission: ['quarantine:review']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of quarantined messages.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MessagePage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Webhooks ──────────────────────────────────────────

  /webhooks:
    post:
      operationId: createPlatformWebhook
      tags: [Webhooks]
      summary: Create a platform or partner webhook endpoint
      description: >-
        Platform and partner keys. With a platform key it creates a platform endpoint (`scope: "platform"`),
        which receives events from every tenant; with a partner key, a partner endpoint
        (`scope: "partner"`), which receives only the events of the tenants its partner's keys created. Both
        are filtered by `events` and `identity_ids`. The response carries `secret` (`whsec_…`), **shown only
        once**. `events: ["*"]` subscribes to everything, including event types added later. The URL must be
        HTTPS; private, loopback and reserved addresses are refused (FR-WH-5).
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookCreateRequest'}
            example:
              url: https://api.example.com/webhooks/mail
              events: [message.received, message.bounced]
              identity_ids: null
              description: Production API
      responses:
        '201':
          description: The endpoint, with its signing secret (shown only once).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookCreated'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: The tenant (or the partner, or the platform) already has 20 webhook endpoints.
          x-error-codes: [webhook_limit_reached]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook endpoints
      description: >-
        With a platform key: the platform endpoints. With a partner key: its partner's endpoints. With a
        tenant key: the tenant's endpoints. An identity key reads its tenant's endpoints, read-only.
      x-required-permission: ['webhooks:read']
      x-permission-notes: '`webhooks:manage` includes `webhooks:read`. An identity key can read its tenant''s endpoints and deliveries with `webhooks:read`.'
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of webhook endpoints (never with secrets).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/webhooks:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: createTenantWebhook
      tags: [Webhooks]
      summary: Create a tenant webhook endpoint
      description: >-
        A tenant endpoint receives the tenant's events (filtered by `events` and `identity_ids`). The
        response carries `secret` (`whsec_…`), **shown only once**. `events: ["*"]` subscribes to
        everything, including event types added later. The URL must be HTTPS; private, loopback and
        reserved addresses are refused (FR-WH-5).
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookCreateRequest'}
            example:
              url: https://api.example.com/webhooks/mail
              events: ['*']
              identity_ids: [idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
              description: Bookings agent
      responses:
        '201':
          description: The endpoint, with its signing secret (shown only once).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookCreated'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '422':
          description: The tenant (or the partner, or the platform) already has 20 webhook endpoints.
          x-error-codes: [webhook_limit_reached]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listTenantWebhooks
      tags: [Webhooks]
      summary: List a tenant's webhook endpoints
      description: An identity key can read its tenant's endpoints read-only.
      x-required-permission: ['webhooks:read']
      x-permission-notes: '`webhooks:manage` includes `webhooks:read`. An identity key can read its tenant''s endpoints and deliveries with `webhooks:read`.'
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of webhook endpoints (never with secrets).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /webhooks/{webhook_id}:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Get a webhook endpoint
      x-required-permission: ['webhooks:read']
      x-permission-notes: '`webhooks:manage` includes `webhooks:read`. An identity key can read its tenant''s endpoints and deliveries with `webhooks:read`.'
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The endpoint (never with its secret).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Webhook'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Update a webhook endpoint
      description: >-
        Accepts `url`, `events`, `identity_ids`, `description` and `enabled`. `enabled: true` re-enables a
        disabled endpoint; `enabled: false` disables it with `disabled_reason: manual`.
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookUpdateRequest'}
            example:
              events: [message.received, message.quarantined, message.bounced]
              enabled: true
      responses:
        '200':
          description: The updated endpoint.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Webhook'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      description: Deletes the endpoint. Its delivery log is deleted with it.
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The endpoint was deleted.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /webhooks/{webhook_id}/rotate-secret:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    post:
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      summary: Rotate a webhook endpoint's secret
      description: >-
        Issues a new secret and returns it once. During the overlap (0–168 hours, default 24) every
        delivery carries both signatures in `webhook-signature` (FR-WH-2).
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/RotateRequest'}
            example:
              overlap_hours: 24
      responses:
        '200':
          description: The endpoint with its new secret (shown only once).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookCreated'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /webhooks/{webhook_id}/test:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Send a test event
      description: Sends a `webhook.test` event straight away and returns the delivery attempt.
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The delivery attempt, with the endpoint's HTTP status or error.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookDelivery'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /webhooks/{webhook_id}/deliveries:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      summary: List delivery attempts
      description: Delivery attempts to this endpoint, newest first. Kept for 30 days.
      x-required-permission: ['webhooks:read']
      x-permission-notes: '`webhooks:manage` includes `webhooks:read`. An identity key can read its tenant''s endpoints and deliveries with `webhooks:read`.'
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: status
          in: query
          description: Only attempts with this status.
          schema: {$ref: '#/components/schemas/WebhookDeliveryStatus'}
        - name: event_type
          in: query
          description: Only attempts for this event type.
          schema: {$ref: '#/components/schemas/EventType'}
        - $ref: '#/components/parameters/After'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of delivery attempts.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookDeliveryPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /webhooks/{webhook_id}/replay:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    post:
      operationId: replayWebhookEvents
      tags: [Webhooks]
      summary: Replay events to an endpoint
      description: >-
        Queues events for redelivery to this endpoint, either by ID or by time range (optionally only those
        whose last delivery is in a given status, for example `dead`). An event can be replayed for 30 days
        from its `occurred_at` (or `retention.events_days`, if shorter, because the payloads are gone after
        that); the window never starts from when a delivery went `dead`. Older events are not queued.
        Returns the number of events queued.
      x-required-permission: ['webhooks:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReplayRequest'}
            examples:
              by_id:
                summary: Replay specific events
                value:
                  event_ids: [evt_01J9Z5K8V4QW7X2M5N6P8R0T1Y]
              by_range:
                summary: Replay dead deliveries in a time range
                value:
                  since: '2026-10-08T00:00:00Z'
                  until: '2026-10-09T00:00:00Z'
                  status: dead
      responses:
        '202':
          description: The events were queued for redelivery.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ReplayResponse'}
              example:
                queued: 42
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ────────────────────────────────────────── Suppressions ────────────────────────────────────────

  /tenants/{tenant_id}/suppressions:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    get:
      operationId: listSuppressions
      tags: [Suppressions]
      summary: List suppressions
      description: >-
        Suppressed recipients of the tenant. Addresses are stored only as a keyed hash, so items show a
        masked `address_hint`; use `address` for an exact lookup.
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: address
          in: query
          description: Exact lookup of one address.
          schema: {$ref: '#/components/schemas/EmailAddress'}
        - name: reason
          in: query
          description: Only suppressions with this reason.
          schema: {$ref: '#/components/schemas/SuppressionReason'}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of suppressions.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuppressionPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    post:
      operationId: createSuppression
      tags: [Suppressions]
      summary: Suppress an address
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SuppressionCreateRequest'}
            example:
              address: jo@example.net
              reason: manual
              note: Asked not to be contacted
      responses:
        '201':
          description: The suppression.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Suppression'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/suppressions/{address}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
      - name: address
        in: path
        required: true
        description: The suppressed address, percent-encoded where needed.
        schema: {$ref: '#/components/schemas/EmailAddress'}
    delete:
      operationId: deleteSuppression
      tags: [Suppressions]
      summary: Remove a suppression
      description: >-
        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.
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SuppressionDeleteRequest'}
            example:
              confirm_complaint_removal: true
      responses:
        '204':
          description: The suppression was removed.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ───────────────────────────────────────────── Lists ────────────────────────────────────────────

  /tenants/{tenant_id}/lists/{direction}/{kind}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
      - $ref: '#/components/parameters/ListDirectionPath'
      - $ref: '#/components/parameters/ListKindPath'
    get:
      operationId: listListEntries
      tags: [Lists]
      summary: List the entries of an allow or block list
      description: |
        - **Receive-block**: mail is stored hidden and never shown to agents ([D7]).
        - **Receive-allow**: mail skips spam quarantine. It does not skip authentication quarantine.
        - **Send-block**: refused per recipient.
        - **Send-allow**: with `policy.send_allowlist_only`, only listed recipients are allowed.
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of list entries.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ListEntryPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/lists/{direction}/{kind}/{entry}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
      - $ref: '#/components/parameters/ListDirectionPath'
      - $ref: '#/components/parameters/ListKindPath'
      - $ref: '#/components/parameters/ListEntryPath'
    get:
      operationId: getListEntry
      tags: [Lists]
      summary: Get a list entry
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The entry.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ListEntry'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    put:
      operationId: putListEntry
      tags: [Lists]
      summary: Add or update a list entry
      description: Adds the entry, or updates its `note` if it already exists.
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ListEntryPutRequest'}
            example:
              note: Insurer claims desk
      responses:
        '200':
          description: The entry.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ListEntry'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: deleteListEntry
      tags: [Lists]
      summary: Remove a list entry
      x-required-permission: ['suppressions:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The entry was removed.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ───────────────────────────────────────────── Keys ─────────────────────────────────────────────

  /keys:
    post:
      operationId: createKey
      tags: [Keys]
      summary: Create an API key
      description: >-
        The new key's level, tenant, identity and permissions must all lie within the caller's own,
        otherwise `403 key_scope_exceeded` (FR-KEY-1). A tenant or identity key's `mode` follows its tenant;
        platform and partner keys are `live`.
        The response carries `secret` (`pmk_live_…` or `pmk_test_…`), **shown only once**; only a keyed
        hash is stored (FR-KEY-2).

        - **Explicit permissions.** `permissions` is required at every level, `platform` included: there is
          no implicit full set. A missing or empty list is `400 invalid_request`.
        - **Permissions tied to levels.** A permission the new key's level cannot hold is refused, whoever
          the caller is, with `400 invalid_request` and `details.reason = "permission_not_allowed_for_level"`: `platform:ops`
          and `partners:manage` on any key but a platform key; `tenants:manage` on a tenant or identity key;
          `members:read`, `members:manage`, `suppressions:manage`, `audit:read` and `usage:read` on an
          identity key (an identity key holds `usage:read` implicitly for its own workspace's
          `GET /v1/usage`); `identities:sign` on a platform or partner key. The table is under `Permission`.
        - **Partner keys** (FR-KEY-4). `level: partner` needs `partner_id` and no `tenant_id` or
          `identity_id`, and only a platform key may ask for it (an unknown partner is
          `404 partner_not_found`); other levels refuse `partner_id`. A partner key mints only `tenant` and
          `identity` keys of the tenants its partner's keys created; a `partner` or `platform` key, or another
          tenant, is `403 key_scope_exceeded`.
        - **Order.** These two checks run first; the scope check (`403 key_scope_exceeded`) comes after them.
          Minting is audit-logged (`key.create`, with `level` and, for a partner key, `partner_id`).
      x-required-permission: ['keys:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ApiKeyCreateRequest'}
            example:
              name: bookings-agent
              level: identity
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              permissions: ['messages:read', 'messages:send', 'search:read', 'attachments:read', 'identities:sign']
              expires_at: '2027-10-09T00:00:00Z'
      responses:
        '201':
          description: The key, with its secret (shown only once).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ApiKeyCreated'}
        '400':
          description: >-
            The request is invalid (`details.errors[]` holds `{path, message}`): for example `permissions`
            missing or empty, or `tenant_id` or `identity_id` missing for the level. A permission the new
            key's level cannot hold is `invalid_request` with `details.reason =
            "permission_not_allowed_for_level"`.
          x-error-codes: [invalid_request, invalid_idempotency_key]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: invalid_request
                  message: A platform key cannot hold identities:sign.
                  retryable: false
                  fix: Remove identities:sign, or create a tenant or identity key to sign as an identity.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    reason: permission_not_allowed_for_level
                    errors:
                      - path: permissions[2]
                        message: identities:sign cannot be held by a platform key
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403':
          description: The key lacks `keys:manage`, the target is out of scope, the new key would be wider than the caller, or the partner is suspended.
          x-error-codes: [permission_denied, scope_denied, key_scope_exceeded, tenant_suspended, partner_suspended]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listKeys
      tags: [Keys]
      summary: List API keys
      description: >-
        Keys within the caller's scope (never with secrets). A partner key lists only tenant and identity
        keys of its own tenants.
      x-required-permission: ['keys:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of keys.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ApiKeyPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /keys/{key_id}:
    parameters:
      - $ref: '#/components/parameters/KeyIdPath'
    get:
      operationId: getKey
      tags: [Keys]
      summary: Get an API key
      x-required-permission: ['keys:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The key (never with its secret).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ApiKey'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    delete:
      operationId: revokeKey
      tags: [Keys]
      summary: Revoke an API key
      description: Revokes the key immediately. Later requests with it get `401 key_revoked`.
      x-required-permission: ['keys:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The key was revoked.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /keys/{key_id}/rotate:
    parameters:
      - $ref: '#/components/parameters/KeyIdPath'
    post:
      operationId: rotateKey
      tags: [Keys]
      summary: Rotate an API key
      description: >-
        Issues a new secret for the same key and returns it once. The old secret keeps working until the
        overlap ends (0–168 hours, default 24).
      x-required-permission: ['keys:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/RotateRequest'}
            example:
              overlap_hours: 24
      responses:
        '200':
          description: The key with its new secret (shown only once).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ApiKeyCreated'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Privacy ───────────────────────────────────────────

  /erasure-requests:
    post:
      operationId: createErasureRequest
      tags: [Privacy]
      summary: Request an erasure
      description: |
        Deletes data for one scope and returns a receipt counting what was deleted in each store
        (FR-PRV-3). Keyword rows, references and vectors are removed together, and a probe query afterwards
        must return nothing (FR-SRCH-11). Held threads are skipped and listed in the receipt (FR-PRV-4); a
        hold never makes this request fail (no `423`).
        An `erasure.completed` event is emitted.

        | `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. Its addresses are tombstoned |
        | `tenant` | none | Everything in the tenant. Then the tenant is marked `erased` |

        A `tenant` request for a tenant that is already `erasing` returns the existing request with `200`
        (the same `era_` ID) and starts nothing; for an `erased` tenant it returns `409 tenant_erased`. Any
        other erasure request naming an `erasing` or `erased` tenant, like every write to such a tenant,
        gets `404 tenant_not_found` from a non-platform key.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: An identity key can only erase within its own identity (`message`, `thread` and `identity` scopes). `counterparty` needs a tenant, partner or platform key; `tenant` needs a platform key, the partner key of the tenant's own partner, or the tenant's own key.
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ErasureRequestCreate'}
            example:
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              scope: counterparty
              counterparty_address: jo@example.net
              reason: Data subject request DSR-1182
      responses:
        '202':
          description: The erasure request. It completes asynchronously (within 24 hours, NFR-PRV-1).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequest'}
        '200':
          description: >-
            A `tenant` request for a tenant already being erased: the existing tenant-scope request, unchanged.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequest'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: >-
            The tenant is already `erased` (`tenant_erased`, for a `tenant` request), or the idempotency key
            is in use.
          x-error-codes: [tenant_erased, idempotency_conflict, request_in_progress]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    get:
      operationId: listErasureRequests
      tags: [Privacy]
      summary: List erasure requests
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/TenantIdQuery'
        - name: status
          in: query
          description: Only requests with this status.
          schema: {$ref: '#/components/schemas/ErasureStatus'}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of erasure requests.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequestPage'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /erasure-requests/{erasure_id}:
    parameters:
      - $ref: '#/components/parameters/ErasureIdPath'
    get:
      operationId: getErasureRequest
      tags: [Privacy]
      summary: Get an erasure request
      description: >-
        The request and its status. `completed_with_holds` means the erasure finished but skipped held
        threads, which the receipt lists in `held`. The partner key of an erased tenant's partner can still
        read the tenant's erasure requests and receipts.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The erasure request, with its receipt once complete.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErasureRequest'}
              example:
                id: era_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                scope: counterparty
                status: completed_with_holds
                created_at: '2026-10-09T10:00:00Z'
                completed_at: '2026-10-09T10:04:12Z'
                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_01J9Z3K8V4QW7X2M5N6P8R0T1Y, idn_01JA2B3C4D5E6F7G8H9J0K1M2N]
                  held:
                    - thread_id: thr_01JA2B3C4D5E6F7G8H9J0K1M2N
                      reason: PCN dispute WM12345678
                  probe:
                    keyword_hits: 0
                    semantic_hits: 0
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /exports:
    post:
      operationId: createExport
      tags: [Privacy]
      summary: Start a subject-access export
      description: >-
        Builds a ZIP holding one `.eml` per message plus `messages.json`. `scope` is `counterparty` (with
        `counterparty_address`: every message to or from it across the tenant's identities, FR-PRV-5) or
        `identity` (with `identity_id`: the whole mailbox). Returns `202` with the export
        (`status: "queued"`). When finished, the export has a `download_url` (a signed link valid until
        `expires_at`, 7 days) and an `export.completed` event is emitted.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: '`counterparty` scope needs a tenant, partner or platform key. An identity key can export only its own identity.'
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ExportCreate'}
            example:
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              scope: counterparty
              counterparty_address: jo@example.net
      responses:
        '202':
          description: The export, `queued`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Export'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /exports/{export_id}:
    parameters:
      - $ref: '#/components/parameters/ExportIdPath'
    get:
      operationId: getExport
      tags: [Privacy]
      summary: Get an export
      description: >-
        `status` is `queued`, `running`, `completed`, `failed` or `expired`. A `completed` export has
        `download_url`, a signed link (`getSignedLink`) valid until `expires_at`. The link is minted again on
        each `GET`.
      x-required-permission: ['erasure:manage']
      x-key-levels: [platform, partner, tenant, identity]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The export, with `download_url` once `completed`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Export'}
              example:
                id: exp_01JA4B3C4D5E6F7G8H9J0K1M2N
                tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                scope: counterparty
                status: completed
                size: 1843321
                created_at: '2026-10-09T10:00:00Z'
                expires_at: '2026-10-16T10:00:00Z'
                download_url: https://mail.example.com/v1/links/bDE6Mz
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────── Usage and audit ───────────────────────────────────────

  /usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Get the plan and allowances
      description: |
        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`.

        - **Who can read it.** Every tenant and identity key holds `usage:read` implicitly for its own
          workspace, so they can always call this without listing it. A platform or partner key must hold
          `usage:read` explicitly and must pass `tenant_id`; without `tenant_id` it gets `400 invalid_request`.
        - `billing` is `metered`, `exempt` (no limits) or `disabled` (self-hosted without billing; `features`
          show the real `used` with `granted: null`, `remaining: null` and `unlimited: true`).
        - `used` for `storage_gb` is measured, rounded up, and refreshed at least hourly.
        - `granted` includes top-ups. `plans` is the catalog from `PM_PLAN_CATALOG`.
      x-required-permission: ['usage:read']
      x-key-levels: [platform, partner, tenant, identity]
      x-permission-notes: >-
        Tenant and identity keys hold `usage:read` implicitly for their own workspace. Platform and partner
        keys must hold it explicitly and pass `tenant_id` (`400 invalid_request` without it).
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: tenant_id
          in: query
          description: Required for platform keys (`400 invalid_request` without it). Other keys always read their own workspace.
          schema: {$ref: '#/components/schemas/TenantId'}
      responses:
        '200':
          description: The plan, every allowance and the plan catalog.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/UsageSummary'}
              example:
                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
        '400':
          description: >-
            The request is invalid, or a platform key did not pass `tenant_id` (`invalid_request`,
            `details.errors[0].path = "tenant_id"`).
          x-error-codes: [invalid_request]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error:
                  code: invalid_request
                  message: 'tenant_id: required for a platform key.'
                  retryable: false
                  fix: Pass the workspace's tenant_id.
                  request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                  details:
                    errors:
                      - path: tenant_id
                        message: required for a platform key
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /usage/daily:
    get:
      operationId: getDailyUsage
      tags: [Usage]
      summary: Get daily usage
      description: >-
        Usage per UTC day. `from` and `to` are dates at most 92 days apart. A platform or partner key can name
        a tenant with `tenant_id` and needs `usage:read` explicitly; a tenant key gets its own tenant and holds
        `usage:read` implicitly for it. `assertions` and `http_signatures` count signing calls, which are
        not metered against any plan allowance.
      x-required-permission: ['usage:read']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/TenantIdQuery'
        - name: from
          in: query
          description: First day, inclusive (UTC).
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: Last day, inclusive (UTC). At most 92 days after `from`.
          schema:
            type: string
            format: date
      responses:
        '200':
          description: One row per day.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DailyUsage'}
              example:
                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
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /plans:
    get:
      operationId: listPlans
      tags: [Usage]
      summary: List the plan catalog
      description: >-
        No authentication. The plan catalog from `PM_PLAN_CATALOG`, as in the `plans` array of `getUsage`.
        A deployment without billing returns `{ "billing_enabled": false, "data": [] }`.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The plan catalog.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/PlanCatalog'}
              example:
                billing_enabled: true
                data:
                  - 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
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/billing:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    get:
      operationId: getTenantBilling
      tags: [Usage]
      summary: Get a workspace's billing account
      description: Platform keys, and partner keys for the tenants their partner's keys created.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform, partner]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The workspace's billing account.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BillingAccount'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
    patch:
      operationId: updateTenantBilling
      tags: [Usage]
      summary: Change a workspace's billing mode or plan
      description: >-
        Platform keys only: a partner key gets `403 scope_denied` on its own tenants, so only a platform key
        changes a partnered tenant's billing mode. 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: a `plan_id` on such a workspace returns `409 plan_managed_by_stripe`. Audit-logged.
      x-required-permission: ['tenants:manage']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BillingAccountUpdateRequest'}
            example:
              plan_id: developer
      responses:
        '200':
          description: The updated billing account.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BillingAccount'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The workspace's plan is paid through Stripe, so `plan_id` cannot be set here.
          x-error-codes: [plan_managed_by_stripe]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /audit-events:
    get:
      operationId: listAuditEvents
      tags: [Audit]
      summary: List audit events
      description: >-
        Audit entries, newest first. They never hold message content or clear-text addresses. Audit rows
        cover administrative actions: keys (`key.create`, `key.rotate`, `key.revoke`), partners
        (`partner.create`, `partner.update`, `partner.delete`), tenants (`tenant.create`, with the
        `partner_id` when a partner key created it), 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. A partner
        key reads the rows of its own tenants only; rows about a partner itself have no `tenant_id` and are
        for platform keys.
      x-required-permission: ['audit:read']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - $ref: '#/components/parameters/TenantIdQuery'
        - name: actor_key_id
          in: query
          description: Only entries made by this key.
          schema: {$ref: '#/components/schemas/KeyId'}
        - name: action
          in: query
          description: Only entries with this action, for example `key.create` or `quarantine.release`.
          schema:
            type: string
        - name: target_id
          in: query
          description: Only entries about this resource ID.
          schema:
            type: string
        - $ref: '#/components/parameters/After'
        - $ref: '#/components/parameters/Before'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of audit events.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AuditEventPage'}
              example:
                data:
                  - id: aud_01JA2B3C4D5E6F7G8H9J0K1M2N
                    tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    actor_key_id: key_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    actor_user_id: null
                    action: quarantine.release
                    target_type: message
                    target_id: msg_01JA2B3C4D5E6F7G8H9J0K1M2N
                    details: {}
                    request_id: req_01JA2B3C4D5E6F7G8H9J0K1M2N
                    created_at: '2026-10-09T10:20:00Z'
                next_cursor: null
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ──────────────────────────────────────────── Members ───────────────────────────────────────────

  /tenants/{tenant_id}/members:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    get:
      operationId: listMembers
      tags: [Members]
      summary: List members and pending invitations
      description: >-
        Console users of the workspace, its pending invitations and its seats. The console is the main way
        to manage members; these endpoints let an integrator provision people (for example, the owner of
        each customer workspace). Needs `members:read`, which every console role holds and `members:manage`
        includes.
      x-required-permission: ['members:read']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The members, pending invitations and seats.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/MemberList'}
              example:
                data:
                  - user_id: usr_01JA2B3C4D5E6F7G8H9J0K1M2N
                    email: sam@acmecarhire.example
                    name: Sam Patel
                    role: owner
                    last_login_at: '2026-10-09T10:05:00Z'
                    created_at: '2026-10-09T10:00:00Z'
                invitations:
                  - id: inv_01JA2B3C4D5E6F7G8H9J0K1M2N
                    email: kim@acmecarhire.example
                    role: member
                    invited_by: usr_01JA2B3C4D5E6F7G8H9J0K1M2N
                    expires_at: '2026-10-16T10:00:00Z'
                seats:
                  granted: 2
                  used: 2
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/invitations:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: createInvitation
      tags: [Members]
      summary: Invite a member
      description: >-
        Sends an invitation email from the deployment's platform identity. 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 `createTenant`) or by
        an ownership transfer in the console. A partner key's invitations count in the `partner` bucket
        (`RL_PARTNER`, 10 a minute per partner, shared with tenant creation).
      x-required-permission: ['members:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api, partner]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/InvitationCreateRequest'}
            example:
              email: kim@acmecarhire.example
              role: member
      responses:
        '201':
          description: The pending invitation.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Invitation'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402':
          $ref: '#/components/responses/BillingLimit'
          description: 'No seat is left: a pending invitation uses a seat (`details.feature: "seats"`).'
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/invitations/{invitation_id}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
      - $ref: '#/components/parameters/InvitationIdPath'
    delete:
      operationId: revokeInvitation
      tags: [Members]
      summary: Revoke an invitation
      description: Revokes a pending invitation and frees its seat.
      x-required-permission: ['members:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The invitation was revoked.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /tenants/{tenant_id}/members/{user_id}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
      - $ref: '#/components/parameters/UserIdPath'
    delete:
      operationId: removeMember
      tags: [Members]
      summary: Remove a member
      description: >-
        Removes a member from the workspace and ends their sessions. The owner cannot be removed
        (`409 owner_required`): transfer ownership in the console first.
      x-required-permission: ['members:manage']
      x-key-levels: [platform, partner, tenant]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '204':
          description: The member was removed.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: The member is the workspace owner.
          x-error-codes: [owner_required]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ────────────────────────────────────── Platform operations ─────────────────────────────────────

  /platform/keys/{purpose}/rotate:
    parameters:
      - $ref: '#/components/parameters/SigningKeyPurposePath'
    post:
      operationId: rotateSigningKey
      tags: [Platform]
      summary: Rotate a thread, link, cursor or Web Bot Auth signing key
      description: |
        Generates a new key inside the Worker and makes it current. No body. Key material is never
        returned, by this or any other endpoint.

        | `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, where the other purposes use one character | 7 days (it stays in the key directory) |

        With `revoke_previous=true`, the previous key is deleted 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`. After a suspected
        leak, rotate with `revoke_previous=true`, then rotate `PM_MASTER_KEY`. Rotating `web_bot_auth` while
        `PM_WEB_BOT_AUTH=off` returns `422 web_bot_auth_disabled` ([O9]). Audit action `signing_key.rotate`
        (for every purpose, `web_bot_auth` included), with `details.revoke_previous`.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
        - name: revoke_previous
          in: query
          description: Delete the previous key now instead of letting it verify for its window.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: The new current key's ID and the previous key's verification window.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SigningKeyRotation'}
              examples:
                rotate:
                  summary: A normal rotation
                  value:
                    purpose: thread
                    kid: '4'
                    created_at: '2026-10-09T10:00:00Z'
                    previous:
                      kid: '3'
                      verify_until: '2027-01-07T10:00:00Z'
                      revoked: false
                revoke_previous:
                  summary: With revoke_previous=true, after a suspected leak
                  value:
                    purpose: cursor
                    kid: 'B'
                    created_at: '2026-10-09T10:00:00Z'
                    previous:
                      kid: 'A'
                      verify_until: '2026-10-09T10:00:00Z'
                      revoked: true
                web_bot_auth:
                  summary: The Web Bot Auth deployment key
                  value:
                    purpose: web_bot_auth
                    kid: poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U
                    created_at: '2026-10-09T10:00:00Z'
                    previous:
                      kid: 521omx3NZ4BMQZPNvThhZbY7uITn_wjh09-53ZFC0wk
                      verify_until: '2026-10-16T10:00:00Z'
                      revoked: false
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '422':
          description: '`purpose` is `web_bot_auth` and signed HTTP requests are turned off (`PM_WEB_BOT_AUTH=off`) ([O9]).'
          x-error-codes: [web_bot_auth_disabled]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /platform/dlq:
    get:
      operationId: listDlqItems
      tags: [Platform]
      summary: List dead-letter items
      description: >-
        Dead-letter items, oldest first. The stored body is not returned: it is a pointer, and inbound
        pointers carry envelope addresses. Items are kept for 14 days. Audit-logged.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      parameters:
        - name: queue
          in: query
          description: Only items from this source queue.
          schema: {$ref: '#/components/schemas/DlqQueue'}
        - name: status
          in: query
          description: '`open` (the default) or `redriven`.'
          schema:
            type: string
            enum: [open, redriven]
            default: open
        - $ref: '#/components/parameters/TenantIdQuery'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of dead-letter items.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DlqItemPage'}
              example:
                data:
                  - id: dlq_01JA2B3C4D5E6F7G8H9J0K1M2N
                    queue: pm-inbound
                    kind: message
                    tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                    first_seen_at: '2026-10-09T09:00:00Z'
                    redriven_at: null
                    redrive_count: 0
                next_cursor: null
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '410': {$ref: '#/components/responses/CursorExpired'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /platform/dlq/{dlq_id}/redrive:
    parameters:
      - $ref: '#/components/parameters/DlqIdPath'
    post:
      operationId: redriveDlqItem
      tags: [Platform]
      summary: Redrive a dead-letter item
      description: >-
        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.
        Audit-logged.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      responses:
        '200':
          description: The item, with `redriven_at` set and `redrive_count` incremented.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DlqItem'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /platform/jobs:
    post:
      operationId: createJob
      tags: [Platform]
      summary: Start a maintenance job
      description: |
        Starts a maintenance job ([J3]). Audit-logged.

        | `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.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/JobCreateRequest'}
            example:
              kind: reparse
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_ids: null
              after: '2026-09-01T00:00:00Z'
              before: null
      responses:
        '202':
          description: The job, `queued`.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
              example:
                id: job_01JA2B3C4D5E6F7G8H9J0K1M2N
                kind: reparse
                tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                status: queued
                created_at: '2026-10-09T10:00:00Z'
                completed_at: null
                result: null
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '413': {$ref: '#/components/responses/PayloadTooLarge'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /platform/jobs/{job_id}:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
    get:
      operationId: getJob
      tags: [Platform]
      summary: Get a maintenance job
      description: >-
        `status` is `queued`, `running`, `completed`, `failed` or `canceled`; `result` holds counts once the
        job ends. Audit-logged.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: none
      responses:
        '200':
          description: The job.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Job'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /platform/waitlist/invite:
    post:
      operationId: inviteWaitlist
      tags: [Platform]
      summary: Invite people from the sign-up waitlist
      description: >-
        Invites the oldest confirmed, not yet invited entries of the sign-up waitlist (FR-CON-8). `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. Returns the number invited and the number of confirmed
        entries still waiting. Audit action `waitlist.invite`. CLI: `pmail waitlist invite --count N
        [--plan P]`.
      x-required-permission: ['platform:ops']
      x-key-levels: [platform]
      x-rate-limit-buckets: [api]
      x-idempotency: optional
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WaitlistInviteRequest'}
            example:
              count: 50
              plan: null
      responses:
        '200':
          description: The entries were invited.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
            Idempotent-Replayed: {$ref: '#/components/headers/Idempotent-Replayed'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WaitlistInviteResult'}
              example:
                invited: 50
                waiting: 262
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/IdempotencyConflict'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  # ─────────────────────────────────────── Links and hooks ────────────────────────────────────────

  /links/{token}:
    parameters:
      - $ref: '#/components/parameters/LinkTokenPath'
    get:
      operationId: getSignedLink
      tags: [Links and hooks]
      summary: Download a signed link
      description: >-
        No API key. 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. The response is always a download. A bad, expired or unknown link, a link signed by a retired
        key, or a deleted target returns `404 attachment_not_found` or `404 export_not_found`.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The file.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Content-Disposition: {$ref: '#/components/headers/Content-Disposition-Attachment'}
            X-Content-Type-Options: {$ref: '#/components/headers/X-Content-Type-Options'}
            Content-Security-Policy: {$ref: '#/components/headers/Content-Security-Policy'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-No-Store'}
          content:
            '*/*':
              schema:
                type: string
                contentMediaType: application/octet-stream
        '404':
          description: The link is bad, expired, unknown or signed by a retired key, or its target was deleted.
          x-error-codes: [attachment_not_found, export_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}

  /hooks/ses:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    post:
      operationId: receiveSesNotification
      tags: [Links and hooks]
      summary: Amazon SES delivery events (SNS)
      description: >-
        No API key. The Amazon SES delivery event endpoint, subscribed to the SNS topic
        `PM_SES_SNS_TOPIC_ARN`. It accepts only SNS messages with `SignatureVersion` `2` whose signature
        verifies, whose `SigningCertURL` is `https` on `sns.{PM_SES_REGION}.amazonaws.com`, whose `TopicArn`
        equals `PM_SES_SNS_TOPIC_ARN` and whose `Timestamp` is within one hour; anything else gets
        `403 invalid_signature`. Version `1` is refused. 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`).
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      requestBody:
        required: true
        description: An SNS HTTP(S) message. SNS sends it with `Content-Type text/plain; charset=UTF-8` and a JSON body.
        content:
          text/plain:
            schema:
              type: string
              contentMediaType: application/json
              contentSchema: {$ref: '#/components/schemas/SnsMessage'}
      responses:
        '200':
          description: The subscription was confirmed, or the notification became delivery events.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
        '403':
          description: The signature version, signature, signing-certificate host, `TopicArn` or `Timestamp` check failed.
          x-error-codes: [invalid_signature]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '500': {$ref: '#/components/responses/InternalError'}

  /hooks/ses/inbound:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    post:
      operationId: receiveSesInboundNotification
      tags: [Links and hooks]
      summary: Amazon SES inbound mail notifications (SNS)
      description: >-
        No API key. The Amazon SES inbound mail endpoint for domains with `inbound: ses`, subscribed to the
        SNS topic `PM_SES_INBOUND_TOPIC_ARN`. The same checks as `/hooks/ses` apply, against
        `PM_SES_INBOUND_TOPIC_ARN`: `SignatureVersion` `2` only, `SigningCertURL` `https` on
        `sns.{PM_SES_REGION}.amazonaws.com`, `TopicArn` equal to the topic, `Timestamp` within one hour;
        anything else gets `403 invalid_signature`. A notification names the S3 object SES stored and its
        recipients. Each recipient is recorded once in the `ses_ingest` ledger and queued for the inbound
        pipeline, so each object and recipient is ingested exactly once (FR-DOM-9); duplicates are
        dropped. The SQS queue `PM_SES_INBOUND_QUEUE_URL`, subscribed to the same topic, is a backstop: the
        every-minute cron feeds its messages to the same handler (with a 14-day `Timestamp` window). Mail to
        an unknown address is dropped without a bounce. Returns `200` after the enqueue; an internal failure
        returns `500`, so SNS retries.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      requestBody:
        required: true
        description: An SNS HTTP(S) message. SNS sends it with `Content-Type text/plain; charset=UTF-8` and a JSON body.
        content:
          text/plain:
            schema:
              type: string
              contentMediaType: application/json
              contentSchema: {$ref: '#/components/schemas/SnsMessage'}
      responses:
        '200':
          description: The subscription was confirmed, or every recipient of the notification was queued (or was already ingested).
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
        '403':
          description: The signature version, signature, signing-certificate host, `TopicArn` or `Timestamp` check failed.
          x-error-codes: [invalid_signature]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '500': {$ref: '#/components/responses/InternalError'}

  # ─────────────────────────────────────────── Well-known ─────────────────────────────────────────

  /.well-known/security.txt:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    get:
      operationId: getSecurityTxt
      tags: [Well-known]
      summary: Security contact
      description: RFC 9116 `security.txt`, built from `PM_SECURITY_CONTACT`. `404` when the variable is unset.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The security.txt document.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            text/plain:
              schema:
                type: string
              example: |
                Contact: mailto:security@example.com
                Expires: 2027-10-09T00:00:00Z
        '404':
          description: '`PM_SECURITY_CONTACT` is not set.'
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}

  /.well-known/jwks/{identity_id}.json:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    parameters:
      - $ref: '#/components/parameters/IdentityIdPath'
    get:
      operationId: getIdentityJwks
      tags: [Well-known]
      summary: Public signing keys of an identity
      description: >-
        No authentication. The JSON Web Key Set (RFC 7517) that verifiers of the identity's agent assertions
        fetch: its `active` and `retiring` Ed25519 keys (`alg: EdDSA`). A revoked or `retired` key is absent
        from the next response ([O3]). An unknown, `deleting`, `deleted`, paused or suspended identity gets
        the same `404 identity_not_found`, so the endpoint reveals nothing beyond what a valid assertion
        already names; pausing withdraws the set, which is the kill switch ([O1]). Identity IDs are ULIDs
        and never derived from addresses, so the endpoint cannot test whether an address exists.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The identity's JWK Set.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-Jwks'}
          content:
            application/jwk-set+json:
              schema: {$ref: '#/components/schemas/Jwks'}
              example:
                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
        '404':
          description: The identity is unknown, `deleting`, `deleted`, paused, or in a suspended tenant.
          x-error-codes: [identity_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}

  /.well-known/http-message-signatures-directory:
    servers:
      - url: https://{host}
        description: Deployment root (unversioned).
        variables:
          host:
            default: mail.example.com
            description: The deployment's API host (`PM_API_HOST`).
    get:
      operationId: getHttpMessageSignaturesDirectory
      tags: [Well-known]
      summary: Web Bot Auth key directory
      description: >-
        No authentication, HTTPS only. With `PM_WEB_BOT_AUTH=on`, the deployment's Web Bot Auth keys
        (`active` and `retiring`) as a JWK Set, as draft-meunier-http-message-signatures-directory-03 and
        Cloudflare's Web Bot Auth page require (read 2026-10-09). The response is signed once per listed
        key: `Signature-Input` covers `("@authority";req)` with `alg="ed25519"`, `keyid` (the key's JWK
        thumbprint), a 64-byte random `nonce`, `tag="http-message-signatures-directory"`, `created` (now)
        and `expires` (now + 300), so a copy served from another host does not verify. At most three keys
        are listed: one active and two retiring ([O12]). After a rotation the previous key stays listed for
        7 days, unless the rotation set `revoke_previous=true`. With `PM_WEB_BOT_AUTH=off` (the default) it returns `404 key_not_found`. Registering the
        directory with Cloudflare's verified-bot programme is an optional operator step; signatures verify
        for any Web Bot Auth verifier without it.
      security: []
      x-rate-limit-buckets: []
      x-idempotency: none
      responses:
        '200':
          description: The deployment's Web Bot Auth keys.
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-Directory'}
            Signature-Input: {$ref: '#/components/headers/Directory-Signature-Input'}
            Signature: {$ref: '#/components/headers/Directory-Signature'}
          content:
            application/http-message-signatures-directory+json:
              schema: {$ref: '#/components/schemas/HttpMessageSignaturesDirectory'}
              example:
                keys:
                  - kty: OKP
                    crv: Ed25519
                    x: JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs
                  - kty: OKP
                    crv: Ed25519
                    x: O6a8lanqRcSF-wEOHWvY3W6blE9tdjJqKHercAsamF8
        '404':
          description: Signed HTTP requests are turned off (`PM_WEB_BOT_AUTH=off`), so there is no directory ([O9]).
          x-error-codes: [key_not_found]
          headers:
            Request-Id: {$ref: '#/components/headers/Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}

webhooks:

  # Every event type is delivered as a signed POST (Standard Webhooks) to subscribed endpoints.
  # The request body is the event envelope; `type` names the event and `data` carries the payload.

  message.received:
    post:
      operationId: onMessageReceived
      tags: [Events]
      summary: 'message.received'
      description: 'An inbound message is stored and visible.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageReceivedEvent'}
            example:
              id: evt_01J9Z5K8V4QW7X2M5N6P8R0T1Y
              type: message.received
              api_version: '2026-10-01'
              occurred_at: '2026-10-09T10:12:03.412Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              sequence: 1842
              data:
                message:
                  id: msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                  thread_id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                  direction: inbound
                  status: received
                  from:
                    address: jo@example.net
                    name: Jo Rivera
                  to:
                    - address: bookings.acme@agents.example
                      name: ''
                  cc: []
                  delivered_to: bookings.acme@agents.example
                  is_primary_recipient: true
                  subject: Change of dates for BK-2291
                  sent_at: '2026-10-09T10:11:58Z'
                  received_at: '2026-10-09T10:12:03Z'
                  kind: normal
                  labels: []
                  in_reply_to: 0f3e9a1c.bk2291@agents.example
                  flags: []
                thread_id: thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                trust:
                  verdict: pass
                  spf: pass
                  dkim: pass
                  dmarc: pass
                  arc: none
                  known_sender: true
                  quarantined: false
                  spam_score: 0.01
                  automated: false
                  flags: []
                extracted_text: Could we move the pick-up to Friday at 10?
                extracted_text_truncated: false
                attachments: []
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.quarantined:
    post:
      operationId: onMessageQuarantined
      tags: [Events]
      summary: 'message.quarantined'
      description: 'An inbound message is stored but quarantined.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageQuarantinedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.released:
    post:
      operationId: onMessageReleased
      tags: [Events]
      summary: 'message.released'
      description: 'A quarantined message was released.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageReleasedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.triaged:
    post:
      operationId: onMessageTriaged
      tags: [Events]
      summary: 'message.triaged'
      description: 'Triage finished (or failed).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageTriagedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.sent:
    post:
      operationId: onMessageSent
      tags: [Events]
      summary: 'message.sent'
      description: 'The transport accepted an outbound message.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageSentEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.delivered:
    post:
      operationId: onMessageDelivered
      tags: [Events]
      summary: 'message.delivered'
      description: 'A recipient''s server accepted the message.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageDeliveredEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.deferred:
    post:
      operationId: onMessageDeferred
      tags: [Events]
      summary: 'message.deferred'
      description: 'A temporary failure; the provider is retrying.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageDeferredEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.bounced:
    post:
      operationId: onMessageBounced
      tags: [Events]
      summary: 'message.bounced'
      description: 'A permanent failure, or retries exhausted.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageBouncedEvent'}
            example:
              id: evt_01J9Z6K8V4QW7X2M5N6P8R0T1Y
              type: message.bounced
              api_version: '2026-10-01'
              occurred_at: '2026-10-09T10:20:41.007Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              sequence: 1845
              data:
                message_id: msg_01J9Z4K8V4QW7X2M5N6P8R0T1Y
                recipient: jo@example.net
                bounce_type: hard
                smtp_code: '550'
                smtp_response: 5.1.1 Mailbox does not exist
                suppressed: true
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.complained:
    post:
      operationId: onMessageComplained
      tags: [Events]
      summary: 'message.complained'
      description: 'A recipient reported spam.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageComplainedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.rejected:
    post:
      operationId: onMessageRejected
      tags: [Events]
      summary: 'message.rejected'
      description: 'The transport refused the message before sending.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageRejectedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.failed:
    post:
      operationId: onMessageFailed
      tags: [Events]
      summary: 'message.failed'
      description: 'The message could not be sent.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageFailedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.uncertain:
    post:
      operationId: onMessageUncertain
      tags: [Events]
      summary: 'message.uncertain'
      description: 'The outcome is unknown; the message is never resent automatically.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageUncertainEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.reconciled:
    post:
      operationId: onMessageReconciled
      tags: [Events]
      summary: 'message.reconciled'
      description: 'An uncertain send was matched to a provider event.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageReconciledEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.suppressed:
    post:
      operationId: onMessageSuppressed
      tags: [Events]
      summary: 'message.suppressed'
      description: 'Every recipient is suppressed; nothing was sent.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageSuppressedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  message.canceled:
    post:
      operationId: onMessageCanceled
      tags: [Events]
      summary: 'message.canceled'
      description: 'The message was cancelled while queued.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MessageCanceledEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  verification.received:
    post:
      operationId: onVerificationReceived
      tags: [Events]
      summary: 'verification.received'
      description: 'A verification code or link was found in authenticated mail. The value itself is only available through `wait`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/VerificationReceivedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.created:
    post:
      operationId: onIdentityCreated
      tags: [Events]
      summary: 'identity.created'
      description: 'An identity was created.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityCreatedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.updated:
    post:
      operationId: onIdentityUpdated
      tags: [Events]
      summary: 'identity.updated'
      description: 'An identity was updated.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityUpdatedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.paused:
    post:
      operationId: onIdentityPaused
      tags: [Events]
      summary: 'identity.paused'
      description: 'An identity was paused (manually, for abuse, or because its tenant was suspended).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityPausedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.resumed:
    post:
      operationId: onIdentityResumed
      tags: [Events]
      summary: 'identity.resumed'
      description: 'A paused identity was resumed.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityResumedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.deleted:
    post:
      operationId: onIdentityDeleted
      tags: [Events]
      summary: 'identity.deleted'
      description: 'An identity was deleted: emitted once, when the identity-scope erasure that deletes it completes and the identity becomes `deleted` (after held threads are released, if any). It comes from the erasure job with `identity_id` set.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityDeletedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.address_added:
    post:
      operationId: onIdentityAddressAdded
      tags: [Events]
      summary: 'identity.address_added'
      description: 'An address was added to an identity.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityAddressAddedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.address_activated:
    post:
      operationId: onIdentityAddressActivated
      tags: [Events]
      summary: 'identity.address_activated'
      description: 'A pending address became active once its domain was healthy.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityAddressActivatedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.address_promoted:
    post:
      operationId: onIdentityAddressPromoted
      tags: [Events]
      summary: 'identity.address_promoted'
      description: 'An address became the identity''s primary.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityAddressPromotedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.address_retired:
    post:
      operationId: onIdentityAddressRetired
      tags: [Events]
      summary: 'identity.address_retired'
      description: 'An address became retired.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityAddressRetiredEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.key_created:
    post:
      operationId: onIdentityKeyCreated
      tags: [Events]
      summary: 'identity.key_created'
      description: 'A signing key was created for an identity that had no active key, lazily by a signing request or by `POST …/keys`. Not sent when `POST …/keys` returns the existing active key; the new key of a rotation emits `identity.key_rotated` instead.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityKeyCreatedEvent'}
            example:
              id: evt_01J9Z5K8V4QW7X2M5N6P8R0T1Y
              type: identity.key_created
              api_version: '2026-10-01'
              occurred_at: '2026-10-02T09:00:00.120Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              sequence: 1203
              data:
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.key_rotated:
    post:
      operationId: onIdentityKeyRotated
      tags: [Events]
      summary: 'identity.key_rotated'
      description: 'An identity signing key was rotated: `kid` is the new active key and `previous_kid` the key now `retiring` (`null` when the rotation created the first key).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityKeyRotatedEvent'}
            example:
              id: evt_01JA2B3C4D5E6F7G8H9J0K1M2N
              type: identity.key_rotated
              api_version: '2026-10-01'
              occurred_at: '2026-10-09T09:00:00.088Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              sequence: 1856
              data:
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                kid: zMkUmAQOlq9JtFPzTK1XINZdWd7gmhXxgA8Ph7cNKHo
                previous_kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  identity.key_revoked:
    post:
      operationId: onIdentityKeyRevoked
      tags: [Events]
      summary: 'identity.key_revoked'
      description: 'An identity signing key was revoked and is `retired`; it has left the identity''s JWK Set. Not sent for a key that was already `retired`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdentityKeyRevokedEvent'}
            example:
              id: evt_01JA2B3C4D5E6F7G8H9J0K1M2P
              type: identity.key_revoked
              api_version: '2026-10-01'
              occurred_at: '2026-10-09T10:30:00.051Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              sequence: 1861
              data:
                identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
                kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.created:
    post:
      operationId: onDomainCreated
      tags: [Events]
      summary: 'domain.created'
      description: 'A domain was added.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainCreatedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.verified:
    post:
      operationId: onDomainVerified
      tags: [Events]
      summary: 'domain.verified'
      description: 'A domain became healthy for the first time.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainVerifiedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.degraded:
    post:
      operationId: onDomainDegraded
      tags: [Events]
      summary: 'domain.degraded'
      description: 'A domain became degraded.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainDegradedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.failing:
    post:
      operationId: onDomainFailing
      tags: [Events]
      summary: 'domain.failing'
      description: 'A domain started failing; sending falls back to platform addresses unless disabled by policy.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainFailingEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.suspended:
    post:
      operationId: onDomainSuspended
      tags: [Events]
      summary: 'domain.suspended'
      description: 'A domain was suspended; ownership must be re-proved.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainSuspendedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.recovered:
    post:
      operationId: onDomainRecovered
      tags: [Events]
      summary: 'domain.recovered'
      description: 'A domain returned to healthy.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainRecoveredEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.reminder:
    post:
      operationId: onDomainReminder
      tags: [Events]
      summary: 'domain.reminder'
      description: 'A domain is still unhealthy (sent at 24 h, 72 h and 7 days in the state).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainReminderEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  domain.removed:
    post:
      operationId: onDomainRemoved
      tags: [Events]
      summary: 'domain.removed'
      description: 'A domain was removed, on request (`reason: requested`) or because Cloudflare deleted a `nameservers` zone that was never activated (`reason: zone_expired`).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DomainRemovedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  erasure.completed:
    post:
      operationId: onErasureCompleted
      tags: [Events]
      summary: 'erasure.completed'
      description: 'An erasure request completed, with its receipt.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ErasureCompletedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  erasure.failed:
    post:
      operationId: onErasureFailed
      tags: [Events]
      summary: 'erasure.failed'
      description: 'An erasure step failed after the job runner''s own retries.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ErasureFailedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  export.completed:
    post:
      operationId: onExportCompleted
      tags: [Events]
      summary: 'export.completed'
      description: 'A subject-access export is ready. Fetch the download link from the API.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ExportCompletedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  suppression.created:
    post:
      operationId: onSuppressionCreated
      tags: [Events]
      summary: 'suppression.created'
      description: 'An address was suppressed.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SuppressionCreatedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  quota.warning:
    post:
      operationId: onQuotaWarning
      tags: [Events]
      summary: 'quota.warning'
      description: 'A quota reached 80% or 100% of its limit.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/QuotaWarningEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  webhook.disabled:
    post:
      operationId: onWebhookDisabled
      tags: [Events]
      summary: 'webhook.disabled'
      description: 'A webhook endpoint was disabled. Sent to platform endpoints and, when the disabled endpoint belongs to a partner (a partner endpoint, or an endpoint of a tenant a partner''s key created), to that partner''s other endpoints; never to tenant endpoints or to the endpoint itself.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookDisabledEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  webhook.test:
    post:
      operationId: onWebhookTest
      tags: [Events]
      summary: 'webhook.test'
      description: 'A test event sent by `POST /v1/webhooks/{webhook_id}/test`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookTestEvent'}
            example:
              id: evt_01J9Z7K8V4QW7X2M5N6P8R0T1Y
              type: webhook.test
              api_version: '2026-10-01'
              occurred_at: '2026-10-09T10:30:00.000Z'
              tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
              identity_id: null
              sequence: null
              data:
                message: hello
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  member.invited:
    post:
      operationId: onMemberInvited
      tags: [Events]
      summary: 'member.invited'
      description: 'An invitation to the workspace was created or re-sent.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberInvitedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  member.joined:
    post:
      operationId: onMemberJoined
      tags: [Events]
      summary: 'member.joined'
      description: 'An invitation was accepted; the user is now a member.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberJoinedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  member.role_changed:
    post:
      operationId: onMemberRoleChanged
      tags: [Events]
      summary: 'member.role_changed'
      description: 'A member''s role changed (an ownership transfer sends two).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberRoleChangedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  member.removed:
    post:
      operationId: onMemberRemoved
      tags: [Events]
      summary: 'member.removed'
      description: 'A member was removed, or left.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/MemberRemovedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  billing.plan_changed:
    post:
      operationId: onBillingPlanChanged
      tags: [Events]
      summary: 'billing.plan_changed'
      description: 'The workspace''s plan changed.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BillingPlanChangedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  billing.payment_failed:
    post:
      operationId: onBillingPaymentFailed
      tags: [Events]
      summary: 'billing.payment_failed'
      description: 'A payment failed; the workspace keeps its plan until `grace_until`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BillingPaymentFailedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

  billing.limit_reached:
    post:
      operationId: onBillingLimitReached
      tags: [Events]
      summary: 'billing.limit_reached'
      description: 'A plan allowance is spent. Sent once per feature per period, when the first `402 billing_limit` is returned.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdHeader'
        - $ref: '#/components/parameters/WebhookTimestampHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
        - $ref: '#/components/parameters/WebhookUserAgentHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/BillingLimitReachedEvent'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAccepted'}
        '410': {$ref: '#/components/responses/WebhookGone'}
        default: {$ref: '#/components/responses/WebhookFailed'}

components:

  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: pmk_live_<lookup12>_<secret> | pmk_test_<lookup12>_<secret>
      description: >-
        An API key sent as a bearer token: `Authorization: Bearer pmk_live_…`. Keys have a level
        (`platform`, `tenant` or `identity`) and a list of permissions; scope always comes from the key,
        never from the request body (FR-KEY-3). Only a keyed hash of the secret is stored.

  parameters:

    Limit:
      name: limit
      in: query
      description: Page size.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` of the previous page. Opaque; expires after 24 hours (`410 cursor_expired`).
      schema:
        type: string
        minLength: 1
    After:
      name: after
      in: query
      description: Only items at or after this time (RFC 3339).
      schema:
        type: string
        format: date-time
    Before:
      name: before
      in: query
      description: Only items before this time (RFC 3339).
      schema:
        type: string
        format: date-time
    Include:
      name: include
      in: query
      description: >-
        Comma-separated extras for each message: `quoted` adds the full plain `text`, `html` adds the
        sanitised `html`, `headers` adds the original `headers`.
      style: form
      explode: false
      schema:
        type: array
        uniqueItems: true
        items:
          type: string
          enum: [quoted, html, headers]
      example: [html, headers]
    TenantIdQuery:
      name: tenant_id
      in: query
      description: >-
        Platform and partner keys only (a partner key, one of its own tenants). Restrict to one tenant. Other
        keys are always restricted to their own tenant.
      schema: {$ref: '#/components/schemas/TenantId'}
    IdempotencyKeySend:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Required on every real send, reply, reply-all and forward; optional on a dry run (`dry_run=true`),
        where it is never recorded. 1–255 printable ASCII characters, unique per message. Kept for 30 days,
        scoped to the identity. Reuse the same key on every retry of the same request. Missing:
        `400 idempotency_key_required`; malformed: `400 invalid_idempotency_key`.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[\x20-\x7E]{1,255}$'
      example: bk-2291-confirm
    DryRun:
      name: dry_run
      in: query
      required: false
      description: >-
        `true` runs every check (permissions, policy, recipients, suppressions and lists, size) without
        sending or storing anything, taking quota or locking the thread, and returns `200` with a
        `DryRunResult`. `Idempotency-Key` is optional and never recorded.
      schema:
        type: boolean
        default: false
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Optional. 1–255 printable ASCII characters. Kept for 30 days, scoped to the calling API key and the
        tenant (or, for a request that names no tenant, to the partner for a partner key and to the platform
        for a platform key), so another key never receives this key's replay. The same key with the same
        request returns the original response with `Idempotent-Replayed: true`; with a different request,
        `409 idempotency_conflict`. A response that carried a one-time secret (`createKey`, `rotateKey`,
        `createWebhook`, `createTenantWebhook`, `rotateWebhookSecret`) is replayed without it and with
        `"secret_replayed": false`.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[\x20-\x7E]{1,255}$'

    TenantIdPath:
      name: tenant_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/TenantId'}
    PartnerIdPath:
      name: partner_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/PartnerId'}
    IdentityIdPath:
      name: identity_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/IdentityId'}
    AddressIdPath:
      name: address_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/AddressId'}
    DomainIdPath:
      name: domain_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/DomainId'}
    ThreadIdPath:
      name: thread_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/ThreadId'}
    MessageIdPath:
      name: message_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/MessageId'}
    AttachmentIdPath:
      name: attachment_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/AttachmentId'}
    WebhookIdPath:
      name: webhook_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/WebhookId'}
    KeyIdPath:
      name: key_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/KeyId'}
    ErasureIdPath:
      name: erasure_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/ErasureRequestId'}
    ExportIdPath:
      name: export_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/ExportId'}
    InvitationIdPath:
      name: invitation_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/InvitationId'}
    UserIdPath:
      name: user_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/UserId'}
    DlqIdPath:
      name: dlq_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/DlqItemId'}
    JobIdPath:
      name: job_id
      in: path
      required: true
      schema: {$ref: '#/components/schemas/JobId'}
    IdentityKeyKidPath:
      name: kid
      in: path
      required: true
      description: The identity key's ID, its RFC 7638 JWK thumbprint (base64url, 43 characters).
      schema: {$ref: '#/components/schemas/IdentityKeyKid'}
    SigningKeyPurposePath:
      name: purpose
      in: path
      required: true
      description: '`thread` (thread tokens), `link` (download links, console sign-in, invitation and session tokens, OAuth state hashes), `cursor` (search cursors) or `web_bot_auth` (Web Bot Auth HTTP signatures and the key directory).'
      schema: {$ref: '#/components/schemas/SigningKeyPurpose'}
    LinkTokenPath:
      name: token
      in: path
      required: true
      description: The signed-link token. It carries the signing key's kid and an expiry.
      schema:
        type: string
        minLength: 1
    ListDirectionPath:
      name: direction
      in: path
      required: true
      description: '`receive` or `send`.'
      schema: {$ref: '#/components/schemas/ListDirection'}
    ListKindPath:
      name: kind
      in: path
      required: true
      description: '`allow` or `block`.'
      schema: {$ref: '#/components/schemas/ListKind'}
    ListEntryPath:
      name: entry
      in: path
      required: true
      description: An address (`user@example.com`) or a whole domain (`@example.com`), percent-encoded where needed.
      schema: {$ref: '#/components/schemas/ListEntryValue'}

    WebhookIdHeader:
      name: webhook-id
      in: header
      required: true
      description: The event ID. Delivery is at least once, so deduplicate on it.
      schema: {$ref: '#/components/schemas/EventId'}
    WebhookTimestampHeader:
      name: webhook-timestamp
      in: header
      required: true
      description: Unix time in seconds when the delivery was signed. Reject requests more than 5 minutes from your clock.
      schema:
        type: integer
        minimum: 0
      example: 1791540000
    WebhookSignatureHeader:
      name: webhook-signature
      in: header
      required: true
      description: >-
        Space-separated `v1,<base64>` signatures: `base64(HMAC-SHA256(secret_bytes, "{webhook-id}.{webhook-timestamp}.{raw body}"))`,
        where `secret_bytes` is the base64-decoded part of `whsec_…` after the prefix. Two signatures are sent
        during a secret rotation overlap. Compare in constant time and accept on any match.
      schema:
        type: string
        pattern: '^v1,[A-Za-z0-9+/]+={0,2}( v1,[A-Za-z0-9+/]+={0,2})*$'
      example: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
    WebhookUserAgentHeader:
      name: User-Agent
      in: header
      required: true
      description: Identifies the sender.
      schema:
        type: string
      example: PylotaMail/1.0 (+https://github.com/PILOTAAI/pylota-mail)

  headers:

    Request-Id:
      description: The request ID (`req_…`). Every response carries it, and errors echo it as `error.request_id`.
      schema: {$ref: '#/components/schemas/RequestId'}
    RateLimit-Limit:
      description: >-
        The limit of the rate-limit bucket that applied to this request, per period (for example `600` for
        the per-key bucket of 600 requests a minute). Every authenticated response carries it.
      schema:
        type: integer
        minimum: 0
    RateLimit-Reset:
      description: >-
        Sent only with a `429`: seconds until the end of the bucket's current period. There is no
        `RateLimit-Remaining` header, because the rate-limiting binding answers only allow or deny.
      schema:
        type: integer
        minimum: 0
    Retry-After:
      description: Seconds to wait before retrying. On a `429 rate_limited` it equals `RateLimit-Reset`.
      schema:
        type: integer
        minimum: 0
    Idempotent-Replayed:
      description: Present, with the value `true`, when the response is a replay of an earlier request with the same `Idempotency-Key`.
      schema:
        type: string
        enum: ['true']
    Content-Disposition-Attachment:
      description: Always `attachment`, with the sanitised filename, for example `attachment; filename="INV-88213.pdf"`.
      schema:
        type: string
        pattern: '^attachment(;.*)?$'
    X-Content-Type-Options:
      description: Always `nosniff`.
      schema:
        type: string
        enum: [nosniff]
    Content-Security-Policy:
      description: Always `sandbox`.
      schema:
        type: string
        enum: [sandbox]
    Cache-Control-No-Store:
      description: Always `private, no-store`.
      schema:
        type: string
        enum: ['private, no-store']
    Cache-Control-Jwks:
      description: Always `public, max-age=300`. Verifiers cache an identity's JWK Set for at most 5 minutes.
      schema:
        type: string
        enum: ['public, max-age=300']
    Cache-Control-Directory:
      description: Always `max-age=86400`.
      schema:
        type: string
        enum: ['max-age=86400']
    Directory-Signature-Input:
      description: >-
        One RFC 9421 signature per listed key, each over `("@authority";req)` with `alg="ed25519"`, `keyid`
        (the key's JWK thumbprint), a 64-byte random `nonce`, `tag="http-message-signatures-directory"`,
        `created` and `expires` (`created` + 300).
      schema:
        type: string
      example: 'sig1=("@authority";req);alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";nonce="ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==";tag="http-message-signatures-directory";created=1791547200;expires=1791547500, sig2=("@authority";req);alg="ed25519";keyid="521omx3NZ4BMQZPNvThhZbY7uITn_wjh09-53ZFC0wk";nonce="enN8xXfGRxQLlICS8VZg3pIsSGnRkOvRFSiH10a4TS4v/hyiFG3AWhUObSlUdRZ6jaf4I7fUvl1zbaMYOA0v6g==";tag="http-message-signatures-directory";created=1791547200;expires=1791547500'
    Directory-Signature:
      description: The signatures named in `Signature-Input`, one per listed key.
      schema:
        type: string
      example: 'sig1=:TD5arhV1ved6xtx63cUIFCMONT248cpDeVUAljLgkdozbjMNpJGr/WAx4PzHj+WeG0xMHQF1BOdFLDsfjdjvBA==:, sig2=:pWhbkxEjP/MBnlByqa5/AWesMuWGG7SUVe1jlM0KFpeYt14tMetCabzPnowCg1wLJGVwK0EfqafEsJLCrASjVQ==:'

  responses:

    BadRequest:
      description: The request is malformed or violates the schema. `details.errors[]` holds `{path, message}`.
      x-error-codes: [invalid_request, invalid_idempotency_key]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: invalid_request
              message: 'limit: must be between 1 and 100.'
              retryable: false
              fix: Send a limit between 1 and 100.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                errors:
                  - path: limit
                    message: must be between 1 and 100
    SendBadRequest:
      description: >-
        The send request is invalid. `too_many_recipients`: more than `policy.max_recipients` (hard maximum
        49: Cloudflare allows 50 and one is kept for the hidden journal copy). A `from_address` that is not
        allowed is `invalid_request` with `details.errors[0].path = "from_address"`.
      x-error-codes: [invalid_request, idempotency_key_required, invalid_idempotency_key, address_invalid, address_unsupported, header_not_allowed, marketing_requirements_missing, too_many_recipients]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: idempotency_key_required
              message: This endpoint needs an Idempotency-Key header.
              retryable: false
              fix: Send a unique Idempotency-Key per message and reuse it on every retry of that message.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
    SearchBadRequest:
      description: The search request is invalid, or `q` could not be parsed (`details.position`, `details.expected`).
      x-error-codes: [invalid_request, invalid_query, invalid_idempotency_key]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: invalid_query
              message: Unterminated quoted phrase.
              retryable: false
              fix: Close the quoted phrase that starts at position 5.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                position: 5
                expected: closing quote
    Unauthorized:
      description: No key, a malformed or unknown key, or an expired or revoked key.
      x-error-codes: [unauthenticated, key_expired, key_revoked]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: unauthenticated
              message: No valid API key was provided.
              retryable: false
              fix: 'Send Authorization: Bearer pmk_live_… with a key created by pmail keys create.'
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
    Forbidden:
      description: >-
        The key lacks the permission (`details.required` names it), the resource is outside the key's
        tenant or identity, the tenant is suspended, or the key's partner is suspended
        (`partner_suspended`, on every route: a partner key, or a tenant or identity key of one of the
        partner's tenants).
      x-error-codes: [permission_denied, scope_denied, tenant_suspended, partner_suspended]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: permission_denied
              message: This key does not have the messages:send permission.
              retryable: false
              fix: Use a key that holds messages:send.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                required: 'messages:send'
    SendForbidden:
      description: >-
        As `Forbidden`, plus `test_mode_recipient` when a test tenant sends outside `*@simulator.invalid`
        and this deployment ([L1]).
      x-error-codes: [permission_denied, scope_denied, tenant_suspended, partner_suspended, test_mode_recipient]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    NotFound:
      description: >-
        The resource does not exist **or** is outside the key's scope (deliberately indistinguishable). A
        `404` always carries one of this service's own codes; a `404` without this envelope came from
        something else and must not be read as "already deleted".
      x-error-codes: [tenant_not_found, identity_not_found, address_not_found, domain_not_found, thread_not_found, message_not_found, attachment_not_found, webhook_not_found, key_not_found, erasure_not_found, export_not_found, suppression_not_found, list_entry_not_found, member_not_found, invitation_not_found, job_not_found, dlq_item_not_found, partner_not_found]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: message_not_found
              message: No such message for this identity.
              retryable: false
              fix: Check the message ID and that the key can reach this identity.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
    IdempotencyConflict:
      description: >-
        The `Idempotency-Key` was used with a different request (`idempotency_conflict`), or the first
        request with it is still running (`request_in_progress`, retryable).
      x-error-codes: [idempotency_conflict, request_in_progress]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    SendConflict:
      description: >-
        The send conflicts with the idempotency record or with the identity, thread or domain state.
        `idempotency_conflict` carries `details.original_message_id` when known; `identity_paused` carries
        `details.reason`; `thread_busy` carries `details.retry_after`. A recipient refused by
        `send_policy.require_known_recipient` is not an error: its delivery is `suppressed` with
        `policy: unknown_recipient` ([E2]).
      x-error-codes: [idempotency_conflict, request_in_progress, identity_paused, identity_owner_required, thread_busy, domain_not_ready, auto_reply_not_allowed]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            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_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                original_message_id: msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y
    RawExpired:
      description: The raw MIME is past `policy.retention.raw_days`.
      x-error-codes: [raw_expired]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    CursorExpired:
      description: The pagination cursor is older than 24 hours. Start again from the first page.
      x-error-codes: [cursor_expired]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    PayloadTooLarge:
      description: The request body exceeds 7 MiB.
      x-error-codes: [payload_too_large]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    SendPayloadTooLarge:
      description: >-
        The composed message exceeds the transport limit (5 MiB with Cloudflare) and the tenant does not use
        `large_attachments: link`, or the request body exceeds 7 MiB.
      x-error-codes: [message_too_large, payload_too_large]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    SendUnprocessable:
      description: >-
        The sending domain's transport is not configured (for example SES for an `external` domain), or a
        `kind: marketing` message comes from a domain whose transport is `cloudflare` (`details.reason =
        "marketing_needs_ses"`: Cloudflare Email Service is for transactional mail only). Only a
        dry run (`dry_run=true`) returns `all_recipients_suppressed` (a real send whose every recipient is
        suppressed is accepted and ends `suppressed`) and `recipient_blocked` (a recipient is on the
        send-block list).
      x-error-codes: [transport_unavailable, all_recipients_suppressed, recipient_blocked]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    BillingLimit:
      description: >-
        A plan allowance is spent (`details`: `feature`, `granted`, `used`, `resets_at`, `upgrade_url`).
        Nothing was stored: upgrade or add a top-up, then retry with the **same** `Idempotency-Key`. Never
        returned for inbound mail or for the replay of a request that already completed.
      x-error-codes: [billing_limit]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: billing_limit
              message: This workspace has used all 12000 sends in its plan for this period.
              retryable: false
              fix: Upgrade the plan or add a sends top-up, then retry with the same Idempotency-Key.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                feature: sends
                granted: 12000
                used: 12000
                resets_at: '2026-11-01T00:00:00Z'
                upgrade_url: https://mail.example.com/console/plan
    DryRunOk:
      description: >-
        Dry run only (`dry_run=true`): every check passed. Nothing was sent or stored, no quota was taken
        and the thread was not locked.
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
        RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/DryRunResult'}
          example:
            would_send: true
            recipients:
              - address: jo@example.net
                field: to
                status: queued
              - address: old@example.org
                field: cc
                status: suppressed
                reason: hard_bounce
    LegalHold:
      description: The operation would delete data under a legal hold.
      x-error-codes: [legal_hold]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    TooManyRequests:
      description: A rate limit was hit. Wait `Retry-After` seconds, then retry.
      x-error-codes: [rate_limited]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
        Retry-After: {$ref: '#/components/headers/Retry-After'}
        RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error:
              code: rate_limited
              message: Too many requests for this API key.
              retryable: true
              fix: Wait for the number of seconds in the Retry-After header, then retry with the same Idempotency-Key.
              request_id: req_01J9Z4K8V4QW7X2M5N6P8R0T1Y
              details:
                retry_after: 12
    SendTooManyRequests:
      description: >-
        A per-key or per-identity rate limit (`rate_limited`, with `Retry-After`), or a tenant or identity
        daily cap (`daily_cap_reached`, with `details.resets_at`). Retry with the same `Idempotency-Key`.
      x-error-codes: [rate_limited, daily_cap_reached]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
        Retry-After: {$ref: '#/components/headers/Retry-After'}
        RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    SearchTooManyRequests:
      description: >-
        A search rate limit (`rate_limited`, with `Retry-After`), or the tenant's daily agentic-search budget
        is spent (`agentic_budget_exhausted`).
      x-error-codes: [rate_limited, agentic_budget_exhausted]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
        Retry-After: {$ref: '#/components/headers/Retry-After'}
        RateLimit-Limit: {$ref: '#/components/headers/RateLimit-Limit'}
        RateLimit-Reset: {$ref: '#/components/headers/RateLimit-Reset'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    InternalError:
      description: A bug. It is logged with the `request_id`. Retry with backoff.
      x-error-codes: [internal_error]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    UpstreamError:
      description: A Cloudflare or SES API returned an unexpected error during a synchronous call. Retry with backoff.
      x-error-codes: [upstream_error]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    ServiceUnavailable:
      description: A dependency is temporarily unavailable (D1, an overloaded Durable Object). Retry with backoff.
      x-error-codes: [unavailable]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    SearchUnavailable:
      description: >-
        A dependency is unavailable (`unavailable`), or the request set `require_mode: true` and the
        requested mode is unavailable (`search_degraded`). Without `require_mode`, search degrades and sets
        `degraded: true` instead.
      x-error-codes: [unavailable, search_degraded]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    GatewayTimeout:
      description: >-
        An internal deadline was exceeded. For sends this happens before the message is queued, so a retry
        with the same `Idempotency-Key` is safe.
      x-error-codes: [timeout]
      headers:
        Request-Id: {$ref: '#/components/headers/Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}

    WebhookAccepted:
      description: Any `2xx` within 15 seconds counts as delivered. The response body is ignored (read up to 4 KB).
    WebhookGone:
      description: '`410 Gone` disables the endpoint immediately.'
    WebhookFailed:
      description: >-
        Anything else (including a timeout, TLS or DNS error, redirect or `3xx`) is a failure. Failures retry
        at about 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h, 12 h, 12 h and 19 h (±10% jitter,
        about 72 hours in all), then the delivery is `dead`. The event can be replayed for 30 days from its
        `occurred_at` (or `retention.events_days`, if shorter), not from when the delivery went dead. After 100
        consecutive failures over at least 24 hours the endpoint is disabled (`disabled_reason: failing`).

  schemas:

    # ── Identifiers ──────────────────────────────────────────────────────────────────────────────

    TenantId:
      type: string
      description: Tenant ID (`ten_` + ULID).
      pattern: '^ten_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    PartnerId:
      type: string
      description: Partner ID (`ptn_` + ULID).
      pattern: '^ptn_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [ptn_01JA2B3C4D5E6F7G8H9J0K1M2N]
    IdentityId:
      type: string
      description: Identity ID (`idn_` + ULID).
      pattern: '^idn_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    AddressId:
      type: string
      description: Address ID (`adr_` + ULID).
      pattern: '^adr_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [adr_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    DomainId:
      type: string
      description: Domain ID (`dom_` + ULID).
      pattern: '^dom_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [dom_01JA2B3C4D5E6F7G8H9J0K1M2N]
    ThreadId:
      type: string
      description: Thread ID (`thr_` + ULID).
      pattern: '^thr_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [thr_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    MessageId:
      type: string
      description: Message ID (`msg_` + ULID).
      pattern: '^msg_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    AttachmentId:
      type: string
      description: Attachment ID (`att_` + ULID).
      pattern: '^att_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [att_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    WebhookId:
      type: string
      description: Webhook endpoint ID (`whk_` + ULID).
      pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [whk_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    DeliveryId:
      type: string
      description: Webhook delivery attempt ID (`dlv_` + ULID).
      pattern: '^dlv_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [dlv_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    EventId:
      type: string
      description: Event ID (`evt_` + ULID). Also sent as the `webhook-id` header.
      pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [evt_01J9Z5K8V4QW7X2M5N6P8R0T1Y]
    KeyId:
      type: string
      description: API key ID (`key_` + ULID). Not secret.
      pattern: '^key_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [key_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    ErasureRequestId:
      type: string
      description: Erasure request ID (`era_` + ULID).
      pattern: '^era_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [era_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    ExportId:
      type: string
      description: Export ID (`exp_` + ULID).
      pattern: '^exp_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [exp_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    AuditEventId:
      type: string
      description: Audit entry ID (`aud_` + ULID).
      pattern: '^aud_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [aud_01J9Z3K8V4QW7X2M5N6P8R0T1Y]
    UserId:
      type: string
      description: Console user ID (`usr_` + ULID).
      pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [usr_01JA2B3C4D5E6F7G8H9J0K1M2N]
    InvitationId:
      type: string
      description: Invitation ID (`inv_` + ULID).
      pattern: '^inv_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [inv_01JA2B3C4D5E6F7G8H9J0K1M2N]
    DlqItemId:
      type: string
      description: Dead-letter item ID (`dlq_` + ULID).
      pattern: '^dlq_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [dlq_01JA2B3C4D5E6F7G8H9J0K1M2N]
    JobId:
      type: string
      description: Job ID (`job_` + ULID).
      pattern: '^job_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [job_01JA2B3C4D5E6F7G8H9J0K1M2N]
    RequestId:
      type: string
      description: Request ID (`req_` + ULID).
      pattern: '^req_[0-9A-HJKMNP-TV-Z]{26}$'
      examples: [req_01J9Z4K8V4QW7X2M5N6P8R0T1Y]

    # ── Primitives ───────────────────────────────────────────────────────────────────────────────

    EmailAddress:
      type: string
      format: email
      maxLength: 254
      description: >-
        An RFC 5321 address. Stored lower-cased with the domain as an IDNA A-label; dots in the local part
        are significant ([A1]). SMTPUTF8 (non-ASCII) local parts are refused with `address_unsupported`.
      examples: [jo@example.net]
    MailboxAddress:
      type: object
      description: An address with its display name. Display names are untrusted content.
      required: [address, name]
      properties:
        address: {$ref: '#/components/schemas/EmailAddress'}
        name:
          type: string
          description: The display name, or an empty string when there is none.
    Label:
      type: string
      description: A label. At most 64 per message.
      pattern: '^[a-z0-9][a-z0-9_:-]{0,63}$'
      examples: [invoice]
    Metadata:
      type: object
      description: Integrator metadata. At most 16 keys; each value is a string of at most 512 bytes.
      maxProperties: 16
      additionalProperties:
        type: string
        maxLength: 512
    Direction:
      type: string
      enum: [inbound, outbound]

    # ── Errors ───────────────────────────────────────────────────────────────────────────────────

    Error:
      type: object
      description: The envelope of every non-2xx response.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable, fix, request_id]
          properties:
            code: {$ref: '#/components/schemas/ErrorCode'}
            message:
              type: string
              description: Human-readable. It can change. Never parse it.
            retryable:
              type: boolean
              description: >-
                `true`: the same request can succeed later unchanged (retry with backoff, and with the same
                `Idempotency-Key` for sends). `false`: the request has to change.
            fix:
              type: string
              description: One sentence telling a developer, or an agent, what to do.
            request_id: {$ref: '#/components/schemas/RequestId'}
            details:
              $ref: '#/components/schemas/ErrorDetails'
    ErrorDetails:
      type: object
      description: Optional, code-specific details. Known keys are listed; other keys can appear.
      properties:
        required:
          description: '`permission_denied`: the permission the key lacks.'
          $ref: '#/components/schemas/Permission'
        errors:
          type: array
          description: '`invalid_request`: one entry per schema violation.'
          items:
            type: object
            required: [path, message]
            properties:
              path:
                type: string
                description: Where in the request the problem is.
              message:
                type: string
        position:
          type: integer
          minimum: 0
          description: '`invalid_query`: the character offset in `q` where parsing failed.'
        expected:
          description: '`invalid_query`: what the parser expected at `position`.'
        original_message_id:
          description: '`idempotency_conflict`: the message created by the original request, when known.'
          $ref: '#/components/schemas/MessageId'
        reason:
          description: >-
            `identity_paused`: why the identity is paused. `transport_unavailable`: why the deployment or
            the domain's method cannot do what was asked. `invalid_request` on `POST /v1/keys`:
            `permission_not_allowed_for_level` when `permissions` lists one the new key's level cannot hold.
            `scope_denied` on a domain create: `zone_not_allowed` when a non-platform key names a Cloudflare
            zone its tenant may not use (`cloudflare_zone`, `replace_mx`; see `TenantPolicy.domains`).
          anyOf:
            - $ref: '#/components/schemas/PauseReason'
            - $ref: '#/components/schemas/TransportUnavailableReason'
            - type: string
              const: permission_not_allowed_for_level
            - type: string
              const: zone_not_allowed
        field:
          type: string
          description: >-
            `scope_denied`: the request field the key may not set to that value, as a dotted path: a
            platform-only or lower-only policy field written by a partner key (`policy.tenant_daily_send_cap`
            is reported as `tenant_daily_send_cap`), `send_policy.daily_cap` above the tenant's identity cap,
            or `status` when a partner key tries to lift a platform suspension.
          examples: [tenant_daily_send_cap, send_policy.daily_cap, status]
        max_tenants:
          type: integer
          minimum: 0
          description: '`partner_tenant_limit`: the partner''s `max_tenants`.'
        records:
          type: array
          description: >-
            `domain_not_dedicated`: the records found at the name (A, AAAA, MX) and at `www` (CNAME, A)
            that moving the nameservers would take over.
          items:
            type: object
            required: [type, name, value]
            properties:
              type:
                type: string
                enum: [A, AAAA, MX, CNAME]
              name:
                type: string
                examples: [brightwell-agents.example]
              value:
                type: string
                examples: [203.0.113.10]
        retry_after:
          type: integer
          minimum: 0
          description: >-
            `rate_limited` and `thread_busy`: seconds to wait before retrying. `upstream_rate_limited`:
            as in `Retry-After`: `10800` (3 hours) after a Cloudflare 1105, or the wait for the next SES
            control-plane slot.
        resets_at:
          type: [string, 'null']
          format: date-time
          description: '`daily_cap_reached` and `billing_limit`: when the cap or allowance resets (`null` for counts such as `inboxes`).'
        feature:
          description: '`billing_limit`: the spent allowance.'
          $ref: '#/components/schemas/UsageFeatureName'
        granted:
          type: [integer, 'null']
          minimum: 0
          description: '`billing_limit`: the allowance, top-ups included.'
        used:
          type: integer
          minimum: 0
          description: '`billing_limit`: how much of it is used.'
        upgrade_url:
          type: [string, 'null']
          format: uri
          description: '`billing_limit`: the console plan page, or `null` when the console is off.'
        tenants:
          type: integer
          minimum: 1
          description: '`partner_has_tenants`: how many of the partner''s tenants are not erased yet.'
        lookups:
          type: integer
          minimum: 0
          description: '`spf_lookup_limit`: the DNS lookups the merged SPF record would need.'
      additionalProperties: true
    ErrorCode:
      type: string
      description: >-
        Stable, machine-readable error code. New codes can be added, so handle unknown codes by HTTP status.
        The catalogue, with HTTP status and retryability, is in `docs/src/reference/errors.md`.
      enum:
        # Authentication and access
        - unauthenticated
        - key_expired
        - key_revoked
        - permission_denied
        - scope_denied
        - key_scope_exceeded
        - tenant_suspended
        - partner_suspended
        - partner_tenant_limit
        - test_mode_recipient
        - invalid_signature
        - policy_denied
        # Validation
        - invalid_request
        - idempotency_key_required
        - invalid_idempotency_key
        - invalid_query
        - address_invalid
        - address_unsupported
        - address_reserved
        - local_part_too_long
        - header_not_allowed
        - marketing_requirements_missing
        - too_many_recipients
        - smtp_port_not_allowed
        - spf_lookup_limit
        - message_too_large
        - payload_too_large
        - scope_too_large
        # Resources and state
        - tenant_not_found
        - identity_not_found
        - address_not_found
        - domain_not_found
        - thread_not_found
        - message_not_found
        - attachment_not_found
        - webhook_not_found
        - key_not_found
        - erasure_not_found
        - export_not_found
        - suppression_not_found
        - list_entry_not_found
        - member_not_found
        - invitation_not_found
        - job_not_found
        - dlq_item_not_found
        - partner_not_found
        - idempotency_conflict
        - request_in_progress
        - client_id_conflict
        - username_taken
        - slug_taken
        - suffix_taken
        - not_quarantined
        - domain_not_suspended
        - existing_mx
        - domain_not_dedicated
        - zone_hold
        - owner_required
        - partner_has_tenants
        - tenant_erased
        - plan_managed_by_stripe
        - address_taken
        - address_is_primary
        - address_in_use
        - domain_not_ready
        - domain_in_use
        - domain_exists
        - identity_paused
        - identity_owner_required
        - thread_busy
        - not_cancelable
        - not_uncertain
        - auto_reply_not_allowed
        - raw_expired
        - cursor_expired
        - legal_hold
        # Policy and limits
        - all_recipients_suppressed
        - recipient_blocked
        - transport_unavailable
        - smtp_tls_required
        - smtp_auth_failed
        - cf_token_required
        - agentic_disabled
        - address_limit_reached
        - webhook_limit_reached
        - web_bot_auth_disabled
        - billing_limit
        - rate_limited
        - daily_cap_reached
        - agentic_budget_exhausted
        - upstream_rate_limited
        # Server and dependencies
        - internal_error
        - upstream_error
        - unavailable
        - search_degraded
        - timeout
    TransportUnavailableReason:
      type: string
      description: |
        `details.reason` of `422 transport_unavailable`.

        | Reason | When |
        |---|---|
        | `ses_not_configured` | The SES transport (`PM_SES_*`) is not configured: `dns_records`, `send_only`, or `PATCH` with `transport: ses` |
        | `ses_receiving_not_configured` | `dns_records`, or `smtp_relay` with `inbound: ses`, without `PM_SES_INBOUND_TOPIC_ARN` (and the bucket and queue) |
        | `ses_identity_limit` | The SES region already has 10,000 identities, and the method needs one (`dns_records`, `send_only`, `smtp_relay` with `inbound: ses`) |
        | `subdomain_setup_disabled` | `delegated_subdomain` while `PM_CF_SUBDOMAIN_SETUP` is not `on` |
        | `zone_creation_not_allowed` | `nameservers` by a tenant or partner key whose tenant's policy lacks `domains.allow_create_zone: true` |
        | `method_not_supported` | The domain's method does not support the operation: `PATCH` with a transport it cannot use, `probe` on a domain whose transport is not `smtp`, `test-forwarding` on an address whose domain does not use `inbound: forward` |
        | `marketing_needs_ses` | A `kind: marketing` send or reply from a domain whose transport is `cloudflare`, the platform domain included: Cloudflare Email Service is for transactional mail only. Use a domain with the `ses` or `smtp` transport |
      enum: [ses_not_configured, ses_receiving_not_configured, ses_identity_limit, subdomain_setup_disabled, zone_creation_not_allowed, method_not_supported, marketing_needs_ses]

    # ── Keys and access ──────────────────────────────────────────────────────────────────────────

    Permission:
      type: string
      description: |
        A permission a key can hold.

        | Permission | Allows |
        |---|---|
        | `tenants:manage` | Create, update and suspend tenants, and their billing accounts. Platform keys, for every tenant; partner keys, for the tenants their partner's keys created, without changing billing |
        | `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 and partner 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. A partner key manages only tenant and identity keys of its own tenants |
        | `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; platform and partner keys must hold it explicitly and pass `tenant_id` |
        | `audit:read` | Audit log |
        | `members:read` | List console members and pending invitations (tenant, partner and platform keys; every console role holds it) |
        | `members:manage` | Invite, revoke, change roles and remove console members (tenant, partner and platform keys). Includes `members:read` |
        | `partners:manage` | Create, list, read, update and delete partners (platform keys only) |
        | `platform:ops` | Platform operations: signing-key rotation, the dead-letter queue, maintenance jobs, waitlist invitations (platform keys only) |

        Which key levels can hold a permission. `POST /v1/keys` refuses any other combination with
        `400 invalid_request` and `details.reason = "permission_not_allowed_for_level"`:

        | Permissions | Key levels |
        |---|---|
        | `platform:ops`, `partners:manage` | platform |
        | `tenants:manage` | platform, partner |
        | `members:read`, `members:manage`, `suppressions:manage`, `audit:read`, `usage:read` | platform, partner, tenant |
        | `identities:sign` | tenant, identity |
        | Every other permission | platform, partner, tenant, identity |
      enum:
        - 'tenants:manage'
        - 'partners:manage'
        - 'identities:read'
        - 'identities:write'
        - 'identities:sign'
        - 'domains:read'
        - 'domains:write'
        - 'messages:read'
        - 'messages:send'
        - 'messages:write'
        - 'attachments:read'
        - 'search:read'
        - 'search:agentic'
        - 'quarantine:review'
        - 'webhooks:read'
        - 'webhooks:manage'
        - 'keys:manage'
        - 'erasure:manage'
        - 'suppressions:manage'
        - 'usage:read'
        - 'audit:read'
        - 'members:read'
        - 'members:manage'
        - 'platform:ops'
    KeyLevel:
      type: string
      description: >-
        From widest to narrowest: `platform` reaches every tenant; `partner` reaches the tenants its
        partner's keys created and its partner's webhook endpoints (FR-KEY-4); `tenant` reaches its own
        tenant; `identity` reaches its own identity, plus its tenant's domains read-only with `domains:read`
        and its tenant's webhook endpoints and deliveries read-only with `webhooks:read`. A route or field
        that needs a higher level returns `403 scope_denied`.
      enum: [platform, partner, tenant, identity]
    Mode:
      type: string
      description: '`live` or `test`. Mail a `test` tenant sends never leaves the deployment (FR-TEN-2).'
      enum: [live, test]
    Me:
      type: object
      description: The key that made the request.
      required: [key_id, name, level, mode, partner_id, tenant_id, identity_id, permissions, expires_at]
      properties:
        key_id: {$ref: '#/components/schemas/KeyId'}
        name:
          type: string
        level: {$ref: '#/components/schemas/KeyLevel'}
        mode: {$ref: '#/components/schemas/Mode'}
        partner_id:
          description: '`null` unless the key is partner-level.'
          oneOf:
            - $ref: '#/components/schemas/PartnerId'
            - type: 'null'
        tenant_id:
          description: '`null` for platform and partner keys.'
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        identity_id:
          description: '`null` unless the key is identity-level.'
          oneOf:
            - $ref: '#/components/schemas/IdentityId'
            - type: 'null'
        permissions:
          type: array
          uniqueItems: true
          items: {$ref: '#/components/schemas/Permission'}
        expires_at:
          type: [string, 'null']
          format: date-time

    Health:
      type: object
      required: [status, version, commit, env]
      properties:
        status:
          type: string
          enum: [ok]
        version:
          type: string
          description: The deployed release, for example `1.0.0`.
        commit:
          type: string
          description: The short commit hash of the build.
        env:
          type: string
          description: '`PM_ENV` of the deployment.'
          enum: [production, staging, local]

    # ── Tenants ──────────────────────────────────────────────────────────────────────────────────

    TenantStatus:
      type: string
      description: '`erasing` and `erased` are set by a tenant-scope erasure.'
      enum: [active, suspended, erasing, erased]
    TenantSlug:
      type: string
      pattern: '^[a-z0-9][a-z0-9-]{1,31}$'
      examples: [acme]
    AddressSuffix:
      type: string
      description: >-
        Appended to usernames on the platform domain (`bookings` + `.acme` → `bookings.acme@agents.example`).
        Defaults to `"." + slug`. Only the default tenant made by `pmail setup` can have an empty suffix.
      pattern: '^(\.[a-z0-9][a-z0-9-]{1,31})?$'
      examples: [.acme]
    Tenant:
      type: object
      required: [id, slug, name, mode, status, suspended_by, partner_id, address_suffix, timezone, policy, created_at, updated_at]
      properties:
        id: {$ref: '#/components/schemas/TenantId'}
        slug: {$ref: '#/components/schemas/TenantSlug'}
        name:
          type: string
          examples: [Acme Car Hire]
        mode: {$ref: '#/components/schemas/Mode'}
        status: {$ref: '#/components/schemas/TenantStatus'}
        suspended_by:
          type: [string, 'null']
          description: >-
            Who suspended the tenant while `status` is `suspended`: `platform` (a platform key; a partner key
            cannot lift it) or `partner` (the tenant's partner key). `null` otherwise.
          enum: [platform, partner, null]
        partner_id:
          description: >-
            The partner whose key created the tenant, or `null` (created by a platform key, by `pmail setup` or
            by self-serve sign-up). Set at creation and never changed, also after the tenant is erased and
            the partner deleted.
          oneOf:
            - $ref: '#/components/schemas/PartnerId'
            - type: 'null'
        address_suffix: {$ref: '#/components/schemas/AddressSuffix'}
        timezone:
          type: string
          description: IANA time zone. Daily caps and date filters resolve in it.
          examples: [Europe/London]
        policy:
          $ref: '#/components/schemas/TenantPolicy'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TenantCreateRequest:
      type: object
      required: [slug, name, mode]
      properties:
        slug: {$ref: '#/components/schemas/TenantSlug'}
        name:
          type: string
          minLength: 1
        mode: {$ref: '#/components/schemas/Mode'}
        timezone:
          type: string
          description: IANA time zone name (for example `Europe/London`). An unknown name is refused with `400 invalid_request` (path `timezone`).
          default: UTC
        address_suffix: {$ref: '#/components/schemas/AddressSuffix'}
        policy:
          $ref: '#/components/schemas/TenantPolicyPatch'
        owner:
          type: object
          description: >-
            Creates the workspace's console owner and emails them a sign-in link. Without it, a platform or
            partner key can add an owner later with an invitation and an ownership transfer in the console.
          required: [email]
          properties:
            email: {$ref: '#/components/schemas/EmailAddress'}
            name:
              type: string
              minLength: 1
              examples: [Sam Patel]
        billing:
          type: object
          description: >-
            The workspace's billing account. Platform keys only: a partner key gets `403 scope_denied`, and its
            tenants take the partner's `default_billing_mode`.
          properties:
            mode:
              description: Defaults to `metered` (plan `free`) on a deployment with billing on, and to `disabled` otherwise.
              $ref: '#/components/schemas/BillingMode'
    TenantUpdateRequest:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
        timezone:
          type: string
          description: IANA time zone name (for example `Europe/London`). An unknown name is refused with `400 invalid_request` (path `timezone`).
        policy:
          $ref: '#/components/schemas/TenantPolicyPatch'
        status:
          type: string
          description: >-
            `suspended` suspends the tenant (FR-TEN-3) and records `suspended_by`; `active` resumes it, except
            that a partner key cannot resume a tenant a platform key suspended (`403 scope_denied`).
          enum: [active, suspended]

    TenantPolicy:
      type: object
      description: >-
        The full effective policy of a tenant: the built-in defaults shown here, then `PM_DEFAULT_POLICY`,
        then the tenant's own values, deep-merged. Who may write each field (docs › Configuration › Who may
        change a field): platform keys write every field. For a partner key, `web_bot_auth.allowed`,
        `domains.allow_create_zone` and `domains.cloudflare_zones` are platform-only;
        `quarantine.key_release` is allowed on its own tenants; the lower-only fields
        (`identity_daily_send_cap`, `tenant_daily_send_cap`, `max_recipients`, `auto_reply.allowed`,
        `auto_reply.max_automatic_exchanges`, `inbound.per_sender_per_hour`, `inbound.extract_image_text`,
        `retention.raw_days`, `retention.events_days`, `triage.enabled`, `search.agentic_enabled`,
        `search.agentic_daily_cap`, `search.agentic_max_steps`, `search.agentic_max_seconds` and the `abuse`
        thresholds) may be set at most to min(deployment default, platform ceiling), a switch to `true`
        only when both allow it; every other field is free. A refused field gets `403 scope_denied` with
        `details.field` and nothing is stored. Tenant and identity keys cannot write the policy.
      required: [identity_daily_send_cap, tenant_daily_send_cap, max_recipients, send_allowlist_only, large_attachments, link_ttl_hours, ai_disclosure, auto_reply, quarantine, inbound, retention, triage, search, webhook_text_bytes, abuse, domains, domain_fallback, web_bot_auth]
      properties:
        identity_daily_send_cap:
          type: integer
          minimum: 0
          default: 500
          description: Sends per identity per day (tenant time zone). Then `429 daily_cap_reached`.
        tenant_daily_send_cap:
          type: integer
          minimum: 0
          default: 5000
          description: >-
            Sends per tenant per day (tenant time zone). Then `429 daily_cap_reached`. With billing on, a new
            workspace on the Free plan has an effective cap of the lower of this value and 50 until its
            new-workspace send ramp is lifted (at the earliest on day 7, or at once on a paid plan).
        max_recipients:
          type: integer
          minimum: 1
          maximum: 49
          default: 10
          description: >-
            Recipients per message across `to`, `cc` and `bcc`, 1–49. Cloudflare allows 50 recipients per
            message, and one is kept for the hidden journal copy. Then `400 too_many_recipients`.
        send_allowlist_only:
          type: boolean
          default: false
          description: Only recipients on the send-allow list are allowed.
        large_attachments:
          type: string
          enum: [refuse, link]
          default: refuse
          description: '`refuse` returns `413 message_too_large`; `link` turns oversized attachments into expiring signed links.'
        link_ttl_hours:
          type: integer
          minimum: 1
          maximum: 168
          default: 72
          description: Lifetime of signed attachment links.
        ai_disclosure:
          type: object
          required: [mode, text]
          properties:
            mode:
              type: string
              enum: [none, footer, header]
              default: none
              description: '`footer` appends `text` to text and HTML bodies; `header` adds `X-AI-Generated: true`.'
            text:
              type: string
              default: This message was written with the help of an AI assistant.
          default:
            mode: none
            text: This message was written with the help of an AI assistant.
        auto_reply:
          type: object
          required: [allowed, max_automatic_exchanges]
          properties:
            allowed:
              type: boolean
              default: true
            max_automatic_exchanges:
              type: integer
              minimum: 0
              default: 2
              description: Automatic replies allowed per thread before a human must act ([D6]).
          default:
            allowed: true
            max_automatic_exchanges: 2
        quarantine:
          type: object
          required: [on_auth_fail, spam_threshold, unsolicited_otp, key_release]
          properties:
            on_auth_fail:
              type: boolean
              default: true
              description: Quarantine mail that fails authentication.
            spam_threshold:
              type: number
              minimum: 0
              maximum: 1
              default: 0.8
              description: Quarantine mail whose spam score is at or above this value.
            unsolicited_otp:
              type: boolean
              default: true
              description: Quarantine password-reset and OTP mail that no `wait` asked for ([E5]).
            key_release:
              type: boolean
              default: false
              description: >-
                Lets keys with `quarantine:review` that reach this tenant (its partner key included) release
                quarantined mail even when `PM_QUARANTINE_KEY_RELEASE` is `off`. Only a platform key, or the
                partner key of the tenant's own partner, can set it, at creation or later ([J14], [J16]).
          default:
            on_auth_fail: true
            spam_threshold: 0.8
            unsolicited_otp: true
            key_release: false
        inbound:
          type: object
          required: [per_sender_per_hour, extract_attachment_text, extract_image_text, ses_bounce_retired]
          properties:
            per_sender_per_hour:
              type: integer
              minimum: 1
              default: 60
              description: Inbound messages per sender per identity per hour; the excess is stored `throttled` ([D5]).
            extract_attachment_text:
              type: array
              uniqueItems: true
              items:
                type: string
                enum: [pdf, office, text, html]
              default: [pdf, office, text, html]
              description: Attachment families whose text is extracted.
            extract_image_text:
              type: boolean
              default: false
              description: Extract text from images.
            ses_bounce_retired:
              type: boolean
              default: true
              description: >-
                On SES-receiving domains, `true` bounces mail to retired addresses with `550 5.1.6` through SES
                receipt rules; `false` drops it without a bounce.
          default:
            per_sender_per_hour: 60
            extract_attachment_text: [pdf, office, text, html]
            extract_image_text: false
            ses_bounce_retired: true
        retention:
          type: object
          required: [raw_days, message_days, events_days]
          properties:
            raw_days:
              type: integer
              minimum: 1
              default: 90
              description: Days raw MIME is kept. Then `410 raw_expired`.
            message_days:
              type: [integer, 'null']
              minimum: 1
              default: null
              description: >-
                `null` keeps parsed messages indefinitely. A number deletes messages, attachments, index rows
                and vectors after that age, except held threads.
            events_days:
              type: integer
              minimum: 1
              maximum: 365
              default: 30
              description: >-
                Days events, delivery logs and replayable event payloads are kept. Webhook replay reaches back
                30 days from an event's `occurred_at`, or this many days if fewer.
          default:
            raw_days: 90
            message_days: null
            events_days: 30
        triage:
          type: object
          required: [enabled, categories, rules]
          properties:
            enabled:
              type: boolean
              default: true
            categories:
              type: [array, 'null']
              maxItems: 20
              items: {$ref: '#/components/schemas/TriageCategoryDefinition'}
              default: null
              description: '`null` uses the built-in categories. Otherwise up to 20 custom categories that replace them.'
            rules:
              type: array
              maxItems: 50
              items: {$ref: '#/components/schemas/TriageRule'}
              default: []
              description: Deterministic rules that run before the model (FR-TRI-2), at most 50.
          default:
            enabled: true
            categories: null
            rules: []
        search:
          type: object
          required: [agentic_enabled, agentic_daily_cap, agentic_max_steps, agentic_max_seconds, refs_packs, custom_refs]
          properties:
            agentic_enabled:
              type: boolean
              default: true
            agentic_daily_cap:
              type: integer
              minimum: 0
              default: 500
              description: Agentic searches per tenant per day. Then `429 agentic_budget_exhausted`.
            agentic_max_steps:
              type: integer
              minimum: 2
              maximum: 10
              default: 6
              description: The default, and the maximum, `budget.max_steps`.
            agentic_max_seconds:
              type: integer
              minimum: 3
              maximum: 30
              default: 8
              description: The default, and the maximum, `budget.max_seconds`.
            refs_packs:
              type: array
              uniqueItems: true
              items:
                type: string
                enum: [core, uk_vehicle]
              default: [core]
              description: >-
                `core`: amounts, phones, emails, domains, dates, invoice and order numbers. `uk_vehicle`:
                plates and PCNs.
            custom_refs:
              type: array
              maxItems: 20
              items: {$ref: '#/components/schemas/CustomRefPattern'}
              default: []
          default:
            agentic_enabled: true
            agentic_daily_cap: 500
            agentic_max_steps: 6
            agentic_max_seconds: 8
            refs_packs: [core]
            custom_refs: []
        webhook_text_bytes:
          type: integer
          minimum: 0
          maximum: 65536
          default: 16384
          description: Bytes of `extracted_text` included in `message.received` payloads.
        abuse:
          type: object
          required: [complaint_rate_pause, bounce_rate_pause]
          properties:
            complaint_rate_pause:
              type: number
              minimum: 0
              maximum: 1
              default: 0.003
              description: Pause an identity whose complaint rate over its last 1,000 sends exceeds this (FR-DLV-3).
            bounce_rate_pause:
              type: number
              minimum: 0
              maximum: 1
              default: 0.05
              description: Pause an identity whose bounce rate over its last 200 sends exceeds this (FR-DLV-3).
          default:
            complaint_rate_pause: 0.003
            bounce_rate_pause: 0.05
        domains:
          type: object
          required: [allow_create_zone, cloudflare_zones]
          properties:
            allow_create_zone:
              type: boolean
              default: false
              description: >-
                Lets the tenant's own keys, and its partner key, use the `nameservers` method, which creates a
                Cloudflare zone. Without it, they get `422 transport_unavailable` (`zone_creation_not_allowed`).
                Platform keys may always use it. Pylota Mail Cloud sets it to `true` in `PM_DEFAULT_POLICY`.
                Platform-only.
            cloudflare_zones:
              type: array
              maxItems: 50
              uniqueItems: true
              default: []
              items:
                type: string
                pattern: '^[a-z0-9.-]{1,253}$'
              description: >-
                Zones of the deployment's Cloudflare account, by A-label name, that the tenant's own keys and
                its partner key may use with `cloudflare_zone` and `replace_mx`, besides the zones this
                deployment created for the tenant (`nameservers`, `delegated_subdomain`). A zone created for
                another tenant, or under the zones of the deployment's platform domain, API host or console
                host, is refused even when listed (`403 scope_denied`, `details.reason = "zone_not_allowed"`).
                Platform keys may use any zone. Platform-only.
          default:
            allow_create_zone: false
            cloudflare_zones: []
        domain_fallback:
          type: boolean
          default: true
          description: '`false` fails sends on a failing domain (`domain_failing_no_fallback`) instead of using the platform address.'
        web_bot_auth:
          type: object
          required: [allowed]
          properties:
            allowed:
              type: boolean
              default: false
              description: >-
                Lets the tenant's identities sign HTTP requests (Web Bot Auth). While it is `false`,
                `POST …/http-signatures` returns `403 policy_denied` ([O13]). It has no effect while the
                deployment has `PM_WEB_BOT_AUTH=off`. Agent assertions do not depend on it. Platform-only.
          default:
            allowed: false

    TenantPolicyPatch:
      type: object
      description: >-
        A partial `TenantPolicy`, deep-merged over the current policy. Any field set to `null` resets to its
        default (for `retention.message_days` and `triage.categories`, whose default is `null`, this is the
        same as setting `null`). Arrays replace, they do not merge.
      properties:
        identity_daily_send_cap:
          type: [integer, 'null']
          minimum: 0
        tenant_daily_send_cap:
          type: [integer, 'null']
          minimum: 0
        max_recipients:
          type: [integer, 'null']
          minimum: 1
          maximum: 49
        send_allowlist_only:
          type: [boolean, 'null']
        large_attachments:
          type: [string, 'null']
          enum: [refuse, link, null]
        link_ttl_hours:
          type: [integer, 'null']
          minimum: 1
          maximum: 168
        ai_disclosure:
          type: [object, 'null']
          properties:
            mode:
              type: [string, 'null']
              enum: [none, footer, header, null]
            text:
              type: [string, 'null']
        auto_reply:
          type: [object, 'null']
          properties:
            allowed:
              type: [boolean, 'null']
            max_automatic_exchanges:
              type: [integer, 'null']
              minimum: 0
        quarantine:
          type: [object, 'null']
          properties:
            on_auth_fail:
              type: [boolean, 'null']
            spam_threshold:
              type: [number, 'null']
              minimum: 0
              maximum: 1
            unsolicited_otp:
              type: [boolean, 'null']
            key_release:
              type: [boolean, 'null']
              description: Platform keys, and the partner key of the tenant's own partner, only.
        inbound:
          type: [object, 'null']
          properties:
            per_sender_per_hour:
              type: [integer, 'null']
              minimum: 1
            extract_attachment_text:
              type: [array, 'null']
              uniqueItems: true
              items:
                type: string
                enum: [pdf, office, text, html]
            extract_image_text:
              type: [boolean, 'null']
            ses_bounce_retired:
              type: [boolean, 'null']
        retention:
          type: [object, 'null']
          properties:
            raw_days:
              type: [integer, 'null']
              minimum: 1
            message_days:
              type: [integer, 'null']
              minimum: 1
            events_days:
              type: [integer, 'null']
              minimum: 1
              maximum: 365
        triage:
          type: [object, 'null']
          properties:
            enabled:
              type: [boolean, 'null']
            categories:
              type: [array, 'null']
              maxItems: 20
              items: {$ref: '#/components/schemas/TriageCategoryDefinition'}
            rules:
              type: [array, 'null']
              maxItems: 50
              items: {$ref: '#/components/schemas/TriageRule'}
        search:
          type: [object, 'null']
          properties:
            agentic_enabled:
              type: [boolean, 'null']
            agentic_daily_cap:
              type: [integer, 'null']
              minimum: 0
            agentic_max_steps:
              type: [integer, 'null']
              minimum: 2
              maximum: 10
            agentic_max_seconds:
              type: [integer, 'null']
              minimum: 3
              maximum: 30
            refs_packs:
              type: [array, 'null']
              uniqueItems: true
              items:
                type: string
                enum: [core, uk_vehicle]
            custom_refs:
              type: [array, 'null']
              maxItems: 20
              items: {$ref: '#/components/schemas/CustomRefPattern'}
        webhook_text_bytes:
          type: [integer, 'null']
          minimum: 0
          maximum: 65536
        abuse:
          type: [object, 'null']
          properties:
            complaint_rate_pause:
              type: [number, 'null']
              minimum: 0
              maximum: 1
            bounce_rate_pause:
              type: [number, 'null']
              minimum: 0
              maximum: 1
        domains:
          type: [object, 'null']
          properties:
            allow_create_zone:
              type: [boolean, 'null']
            cloudflare_zones:
              type: [array, 'null']
              description: >-
                Platform-only. Zones a tenant or partner key may use with `cloudflare_zone`, beyond the zones
                created for the tenant. A listed zone grants names strictly under it: its apex, and
                `replace_mx` there, stay platform-only.
              maxItems: 50
              uniqueItems: true
              items:
                type: string
                pattern: '^[a-z0-9.-]{1,253}$'
        domain_fallback:
          type: [boolean, 'null']
        web_bot_auth:
          type: [object, 'null']
          properties:
            allowed:
              type: [boolean, 'null']
    TriageCategoryDefinition:
      type: object
      description: A custom triage category. The description is given to the triage model.
      required: [name, description]
      properties:
        name:
          type: string
          minLength: 1
          examples: [pcn]
        description:
          type: string
          minLength: 1
          examples: [Penalty charge notices from councils]
    TriageRule:
      type: object
      description: >-
        A deterministic tenant triage rule (`policy.triage.rules`, at most 50), as in the Triage design
        (`project/design/triage.md`, sections 4 and 5) and the Triage guide. Tenant rules run first, in array
        order, before the built-in rules and the model (FR-TRI-2). For every rule whose `match` holds:
        `category` and `needs_reply` are set by the first rule that sets them (first match wins),
        `urgency_min` takes the highest value, `labels_add` accumulates (at most 10 in all), and
        `skip_model` is on if any matching rule turns it on. A matching rule with `stop: true` ends tenant
        rules. Tenant rules cannot add or remove risk flags. With `skip_model`, triage ends with a rules-only
        record (`model: "rules"`); otherwise the values set here are fixed and the model fills the rest.
        Strings are NFKC-folded and lower-cased when the policy is written. Rules apply to mail that arrives
        afterwards; re-run triage to apply them to a message already triaged.
      required: [id, match, set]
      additionalProperties: false
      properties:
        id:
          type: string
          pattern: '^[a-z0-9][a-z0-9_-]{0,47}$'
          description: Unique in the list. Recorded (stored only) in the IDs of the rules that matched.
          examples: [pcn-council]
        match: {$ref: '#/components/schemas/TriageRuleMatch'}
        set: {$ref: '#/components/schemas/TriageRuleSet'}
        stop:
          type: boolean
          default: false
          description: '`true` ends tenant rules when this rule matches. Built-in rules still run.'
      examples:
        - id: pcn-council
          match:
            from_domain: [westminster.example, leeds.example]
            subject_contains: [penalty charge, pcn]
          set:
            category: legal_compliance
            labels_add: [pcn]
            urgency_min: 2
            needs_reply: 0.9
          stop: true
        - id: garage-invoices
          match:
            from: [accounts@brightwell.example]
            has_attachment: true
            subject_contains: [invoice]
          set:
            category: billing
            labels_add: [invoice]
            urgency_min: 1
            needs_reply: 0.1
            skip_model: true
    TriageRuleMatch:
      type: object
      description: >-
        The conditions of a tenant triage rule. At least one field. **All** listed fields must hold; within a
        field's list, **any** value may match. Each list has at most 20 entries and each string at most 200
        characters.
      minProperties: 1
      additionalProperties: false
      properties:
        from:
          type: array
          maxItems: 20
          description: The From address is one of these exact addresses (case-insensitive).
          items: {$ref: '#/components/schemas/EmailAddress'}
        from_domain:
          type: array
          maxItems: 20
          description: The From address is at one of these domains or any of their subdomains.
          items:
            type: string
            minLength: 1
            maxLength: 200
            examples: [leeds.example]
        to_identity:
          type: array
          maxItems: 20
          description: The message was delivered to one of these identities of the tenant (identity IDs or usernames).
          items:
            type: string
            minLength: 1
            maxLength: 200
        subject_contains:
          type: array
          maxItems: 20
          description: The folded subject contains one of these strings.
          items:
            type: string
            minLength: 1
            maxLength: 200
        body_contains:
          type: array
          maxItems: 20
          description: The first 64 KB of `extracted_text` contains one of these strings (case-insensitive).
          items:
            type: string
            minLength: 1
            maxLength: 200
        has_attachment:
          type: boolean
          description: A non-inline attachment exists (`true`) or none does (`false`).
        label:
          type: array
          maxItems: 20
          description: The message carries one of these labels at triage time, including labels added by earlier tenant rules in the same run.
          items: {$ref: '#/components/schemas/Label'}
    TriageRuleSet:
      type: object
      description: The triage fields a matching tenant rule sets. At least one field.
      minProperties: 1
      additionalProperties: false
      properties:
        category:
          type: string
          minLength: 1
          description: Must be in the effective category list (the built-in categories, or the tenant's custom ones).
          examples: [billing]
        labels_add:
          type: array
          maxItems: 10
          items: {$ref: '#/components/schemas/Label'}
        urgency_min:
          type: integer
          minimum: 0
          maximum: 3
          description: The lowest urgency the message can get; the model may raise it.
        needs_reply:
          type: number
          minimum: 0
          maximum: 1
          description: Fixes `needs_reply`; the model's value is not used.
        skip_model:
          type: boolean
          description: '`true` finishes triage with a rules-only record, without the model.'
    CustomRefPattern:
      type: object
      description: >-
        A tenant-defined reference pattern, matched exactly at ingest and searchable with
        `ref:` (stored with kind `custom:<name>`). Patterns use the Rust `regex` crate syntax: linear time, no
        back-references, compiled size capped at 64 KB.
      required: [name, pattern]
      properties:
        name:
          type: string
          minLength: 1
          examples: [booking]
        pattern:
          type: string
          minLength: 1
          examples: ['BK-\d{4,6}']
        normalise:
          type: string
          description: How matched values are normalised before storage and comparison.
          examples: [upper]

    # ── Partners ─────────────────────────────────────────────────────────────────────────────────

    PartnerStatus:
      type: string
      description: >-
        `suspended` refuses every key of the partner, and every API key of its tenants, with
        `403 partner_suspended`, and holds deliveries to its and its tenants' endpoints; the tenants' status
        does not change. `deleted` is set by `DELETE /v1/partners/{partner_id}` and is final.
      enum: [active, suspended, deleted]
    PartnerBillingMode:
      type: string
      description: >-
        The billing mode given to every tenant a partner key creates (`billing_accounts.mode`). Only a
        platform key changes a tenant's mode afterwards.
      enum: [exempt, metered]
    Partner:
      type: object
      description: >-
        An integrator whose partner keys create tenants and act only on those tenants (FR-KEY-4). A partner
        holds nothing but its name and these settings. A deleted partner stays, with `status: "deleted"`
        and an empty `name`.
      required: [id, name, status, default_billing_mode, max_tenants, ramp_exempt, created_at, updated_at]
      properties:
        id: {$ref: '#/components/schemas/PartnerId'}
        name:
          type: string
          description: '`""` once the partner is deleted.'
          examples: [Pylota]
        status: {$ref: '#/components/schemas/PartnerStatus'}
        default_billing_mode: {$ref: '#/components/schemas/PartnerBillingMode'}
        max_tenants: {$ref: '#/components/schemas/PartnerMaxTenants'}
        ramp_exempt: {$ref: '#/components/schemas/PartnerRampExempt'}
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PartnerCreateRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
        default_billing_mode:
          description: Defaults to `metered`.
          allOf:
            - $ref: '#/components/schemas/PartnerBillingMode'
          default: metered
        max_tenants: {$ref: '#/components/schemas/PartnerMaxTenants'}
        ramp_exempt: {$ref: '#/components/schemas/PartnerRampExempt'}
    PartnerUpdateRequest:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
        status:
          type: string
          description: '`deleted` cannot be set here: use `DELETE /v1/partners/{partner_id}`.'
          enum: [active, suspended]
        default_billing_mode: {$ref: '#/components/schemas/PartnerBillingMode'}
        max_tenants: {$ref: '#/components/schemas/PartnerMaxTenants'}
        ramp_exempt: {$ref: '#/components/schemas/PartnerRampExempt'}
    PartnerMaxTenants:
      type: integer
      minimum: 0
      default: 25
      description: >-
        The most tenants that are not `erased` the partner may have; the next `POST /v1/tenants` by a partner
        key gets `403 partner_tenant_limit`. Default 25: with the default `tenant_daily_send_cap` of 5,000,
        one partner can send at most 125,000 messages a day (1,250 while its new tenants are on the send
        ramp). Lowering it below the current count refuses new tenants and changes no existing one.
    PartnerRampExempt:
      type: boolean
      default: false
      description: >-
        `true` lets the partner's new tenants skip the new-workspace send ramp (a tenant daily cap of 50 for
        at least 7 days). `false`, the default: they follow it whatever their billing mode.

    # ── Identities ───────────────────────────────────────────────────────────────────────────────

    IdentityStatus:
      type: string
      description: '`deleting` and `deleted` are set by identity deletion.'
      enum: [active, paused, deleting, deleted]
    PauseReason:
      type: string
      enum: [manual, abuse_threshold, tenant_suspended]
    Username:
      type: string
      description: >-
        The stored form of a username: the lower-case local-part stem. Requests send a `UsernameInput`,
        which the service validates and lower-cases into this form.
      pattern: '^[a-z0-9][a-z0-9._-]{0,23}$'
      examples: [bookings]
    UsernameInput:
      type: string
      description: >-
        A username as sent in a request. The schema does not constrain it: the service validates it with
        the username rules ([A1], [A3], [A4], [A12]) and returns their own codes, never a generic
        `invalid_request`. A reserved name, a confusable one (homoglyphs, mixed scripts, right-to-left
        characters) or a name that folds to a reserved one gets `400 address_reserved`; any other non-ASCII
        character `400 address_unsupported`; a name whose lower-cased form does not match the stored form
        `^[a-z0-9][a-z0-9._-]{0,23}$` (at most 24 characters, no `..`, not ending in `.`) gets
        `400 address_invalid`; username plus tenant suffix over 40 characters gets `400 local_part_too_long`.
      examples: [bookings]
    DisplayName:
      type: string
      description: At most 78 characters, no CR or LF. Unicode is allowed.
      minLength: 1
      maxLength: 78
      pattern: '^[^\r\n]*$'
      examples: [Acme Car Hire]
    IdentityOwner:
      type: object
      description: The accountable human for the identity (FR-IDN-2). Required before the identity can send.
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 1
          examples: [Sam Patel]
        email: {$ref: '#/components/schemas/EmailAddress'}
    IdentitySignature:
      type: object
      description: Appended to outbound messages according to policy. `html` is sanitised on write.
      properties:
        text:
          type: [string, 'null']
        html:
          type: [string, 'null']
    IdentitySendPolicy:
      type: object
      description: Per-identity sending rules, applied on top of the tenant policy.
      properties:
        daily_cap:
          type: integer
          minimum: 0
          description: >-
            Sends per day for this identity. Defaults to the tenant's `identity_daily_send_cap`. Only a
            platform key may set it above that cap (`403 scope_denied` with `details.field`).
        auto_reply:
          type: string
          enum: [allowed, denied]
          default: allowed
          description: >-
            Whether the identity may send `kind: "auto_reply"` messages. `denied` refuses them
            (`409 auto_reply_not_allowed`); `allowed`, the default, leaves the decision to the tenant's
            `policy.auto_reply.allowed` and the exchange cap.
        require_known_recipient:
          type: boolean
          default: false
          description: Refuse recipients the identity has never exchanged mail with ([E2]).
    Identity:
      type: object
      required: [id, tenant_id, username, display_name, purpose, status, pause_reason, primary_address, addresses, owner, signature, send_policy, metadata, client_id, created_at, updated_at]
      properties:
        id: {$ref: '#/components/schemas/IdentityId'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        username: {$ref: '#/components/schemas/Username'}
        display_name: {$ref: '#/components/schemas/DisplayName'}
        purpose:
          type: [string, 'null']
          description: A free tag, for example `bookings`.
        status: {$ref: '#/components/schemas/IdentityStatus'}
        pause_reason:
          description: Set when `status` is `paused`.
          oneOf:
            - $ref: '#/components/schemas/PauseReason'
            - type: 'null'
        primary_address: {$ref: '#/components/schemas/EmailAddress'}
        addresses:
          type: array
          description: Every address of the identity, in every status.
          items: {$ref: '#/components/schemas/Address'}
        owner:
          oneOf:
            - $ref: '#/components/schemas/IdentityOwner'
            - type: 'null'
        signature: {$ref: '#/components/schemas/IdentitySignature'}
        send_policy: {$ref: '#/components/schemas/IdentitySendPolicy'}
        metadata: {$ref: '#/components/schemas/Metadata'}
        client_id:
          type: [string, 'null']
          description: The integrator's idempotent-create key.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    IdentityCreateRequest:
      type: object
      required: [username, display_name]
      properties:
        username: {$ref: '#/components/schemas/UsernameInput'}
        display_name: {$ref: '#/components/schemas/DisplayName'}
        purpose:
          type: string
          description: A free tag, for example `bookings`.
        owner: {$ref: '#/components/schemas/IdentityOwner'}
        signature: {$ref: '#/components/schemas/IdentitySignature'}
        domain_id:
          description: >-
            A healthy tenant domain for the primary address (`{username}@{domain}`). Omitted: the platform
            domain (`{username}{suffix}@{platform}`).
          $ref: '#/components/schemas/DomainId'
        client_id:
          type: string
          minLength: 1
          description: Makes the create idempotent within the tenant.
          examples: ['acme:bookings']
        metadata: {$ref: '#/components/schemas/Metadata'}
    IdentityUpdateRequest:
      type: object
      minProperties: 1
      properties:
        display_name: {$ref: '#/components/schemas/DisplayName'}
        purpose:
          type: [string, 'null']
        owner: {$ref: '#/components/schemas/IdentityOwner'}
        signature: {$ref: '#/components/schemas/IdentitySignature'}
        metadata: {$ref: '#/components/schemas/Metadata'}
        send_policy: {$ref: '#/components/schemas/IdentitySendPolicy'}
        status:
          type: string
          description: >-
            `paused` pauses with reason `manual`; `active` resumes (an `abuse_threshold` pause on a tenant a
            partner's key created needs a platform key).
          enum: [active, paused]

    # ── Addresses ────────────────────────────────────────────────────────────────────────────────

    AddressRole:
      type: string
      enum: [primary, alias]
    AddressStatus:
      type: string
      description: >-
        `pending` until the domain is healthy; `active`; `retiring` (still receives, until `retire_at`);
        `retired` (inbound refused with `550 5.1.6`).
      enum: [pending, active, retiring, retired]
    AddressForwarding:
      type: [string, 'null']
      description: >-
        `null` when the address's domain does not use `inbound: forward`. Otherwise `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 after 10 minutes).
      enum: [unverified, ok, failed, null]
    Address:
      type: object
      required: [id, identity_id, address, local_part, domain_id, role, status, retire_at, retired_at, forwarding, forwarding_checked_at, created_at]
      properties:
        id: {$ref: '#/components/schemas/AddressId'}
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        address: {$ref: '#/components/schemas/EmailAddress'}
        local_part:
          type: string
        domain_id: {$ref: '#/components/schemas/DomainId'}
        role: {$ref: '#/components/schemas/AddressRole'}
        status: {$ref: '#/components/schemas/AddressStatus'}
        retire_at:
          type: [string, 'null']
          format: date-time
          description: When a `retiring` address becomes `retired`.
        retired_at:
          type: [string, 'null']
          format: date-time
        forwarding: {$ref: '#/components/schemas/AddressForwarding'}
        forwarding_checked_at:
          type: [string, 'null']
          format: date-time
          description: When `forwarding` last changed.
        created_at:
          type: string
          format: date-time
    AddressCreateRequest:
      type: object
      required: [local_part, domain_id]
      properties:
        local_part:
          type: string
          description: >-
            The local part on the tenant domain. The schema does not constrain it: the service validates it
            with the username rules for a tenant domain, with a maximum of 40 characters (room for a thread
            token), and returns their own codes, never a generic `invalid_request`: `400 address_reserved`
            for a reserved or confusable name, `400 address_unsupported` for any other non-ASCII character,
            and `400 address_invalid` when the lower-cased form does not match the stored form
            `^[a-z0-9][a-z0-9._-]{0,39}$` (no `..`, not ending in `.`).
          examples: [bookings]
        domain_id: {$ref: '#/components/schemas/DomainId'}
    PromoteAddressRequest:
      type: object
      properties:
        retire_previous_after_days:
          type: integer
          minimum: 0
          maximum: 365
          default: 90
          description: Days the previous primary stays `retiring` before it becomes `retired`.
    RetireAddressRequest:
      type: object
      properties:
        after_days:
          type: integer
          minimum: 0
          maximum: 365
          default: 90
          description: Days the alias stays `retiring`. `0` retires it immediately.

    # ── Domains ──────────────────────────────────────────────────────────────────────────────────

    DomainMethod:
      type: string
      description: |
        How the domain is connected (FR-DOM-7). The method fixes `kind`, `inbound` and `transport`; `platform`
        is the deployment's shared domain only.

        | `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 | `delegated` | `routing` | `cloudflare` |
      enum: [platform, cloudflare_zone, nameservers, dns_records, send_only, smtp_relay, delegated_subdomain]
    DomainKind:
      type: string
      description: >-
        Where the domain's DNS lives relative to this deployment. `platform` is the deployment's shared
        domain; `zone` a zone in this Cloudflare account; `delegated` a child zone for a subdomain whose
        parent stays at another DNS host; `external` DNS at any other host.
      enum: [platform, zone, delegated, external]
    DomainInbound:
      type: string
      description: >-
        How mail to the domain's addresses reaches identities: `routing` (Cloudflare Email Routing), `ses`
        (Amazon SES receiving, through `/hooks/ses/inbound`), `forward` (the customer's own mailbox
        forwards to each identity's platform address) or `none`.
      enum: [routing, ses, forward, none]
    DomainState:
      type: string
      description: >-
        `pending` → `verifying` → `healthy` ⇄ `degraded` → `failing` → `suspended`; `removing` → `removed`
        after deletion. A return to `healthy` emits `domain.recovered`.
      enum: [pending, verifying, healthy, degraded, failing, suspended, removing, removed]
    RoutingMode:
      type: string
      description: '`catch_all` (zone apex, or SES receiving), `literal` (one routing rule per address, zone subdomains) or `forward` (forwarding domains).'
      enum: [catch_all, literal, forward]
    Transport:
      type: string
      description: How mail from the domain's addresses is sent.
      enum: [cloudflare, ses, smtp]
    ReplyTokenMode:
      type: string
      description: '`subaddress` puts the thread token in the `Reply-To` sub-address (FR-OUT-6).'
      enum: [subaddress, none]
    SmtpSettings:
      type: object
      description: >-
        SMTP relay settings (`smtp_relay` only). The Worker connects once (EHLO, STARTTLS, AUTH, QUIT)
        before it stores them. The credentials are sealed under `PM_MASTER_KEY` and never returned, logged
        or exported.
      required: [host, port, username, password]
      properties:
        host:
          type: string
          format: hostname
          description: A DNS name, not an IP literal. It must resolve to public addresses.
          examples: [smtp.provider.example]
        port:
          type: integer
          description: '`465` (implicit TLS) or `587` (STARTTLS). Any other port, `25` included, returns `400 smtp_port_not_allowed`.'
          examples: [587]
        username:
          type: string
          examples: [agents@brightwell.example]
        password:
          type: string
          format: password
          writeOnly: true
        probe_from:
          type: string
          format: email
          description: An address the relay accepts as sender, used by the alignment probe. Defaults to `postmaster@{name}`.
          examples: [agents@brightwell.example]
    DomainSmtp:
      type: object
      description: The relay settings as stored, never the password.
      required: [host, port, username, probe_from]
      properties:
        host:
          type: string
          examples: [smtp.provider.example]
        port:
          type: integer
          enum: [465, 587]
        username:
          type: string
          examples: [agents@brightwell.example]
        probe_from:
          type: string
          format: email
          examples: [agents@brightwell.example]
    DomainProbe:
      type: object
      description: The last alignment probe of an `smtp` domain.
      required: [last_at, result]
      properties:
        last_at:
          type: [string, 'null']
          format: date-time
          description: '`null` before the first probe.'
        result:
          type: [string, 'null']
          description: >-
            `pass`, or the issue code of the failure. `null` before the first probe.
          enum: [pass, smtp_unaligned, smtp_from_rewritten, smtp_probe_timeout, smtp_auth_failed, smtp_tls_required, null]
    Domain:
      type: object
      required: [id, tenant_id, name, method, kind, inbound, transport, is_apex, routing_mode, reply_token, receiving, sending, ses_region, mail_from_domain, smtp, probe, state, state_reason, state_changed_at, delivery_events, details, records, created_at]
      properties:
        id: {$ref: '#/components/schemas/DomainId'}
        tenant_id:
          description: '`null` for the platform domain.'
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        name:
          type: string
          format: hostname
          description: The domain as an IDNA A-label, lower case.
          examples: [agents.brightwell.example]
        method: {$ref: '#/components/schemas/DomainMethod'}
        kind: {$ref: '#/components/schemas/DomainKind'}
        inbound: {$ref: '#/components/schemas/DomainInbound'}
        transport: {$ref: '#/components/schemas/Transport'}
        is_apex:
          type: boolean
        routing_mode: {$ref: '#/components/schemas/RoutingMode'}
        reply_token: {$ref: '#/components/schemas/ReplyTokenMode'}
        receiving:
          type: boolean
        sending:
          type: boolean
        ses_region:
          type: [string, 'null']
          description: >-
            The region of the domain's SES identity: set when `inbound` or `transport` is `ses`, and on a
            Cloudflare-method domain that has an SES identity for the [J5] failover; otherwise `null`.
          examples: [eu-west-2]
        mail_from_domain:
          type: [string, 'null']
          description: >-
            The custom MAIL FROM domain, `pm-bounce.{name}`, of a `dns_records` or `send_only` domain, whose
            mail SES sends. The local part `pm-bounce` is reserved on such domains. Otherwise `null`, including
            a Cloudflare-method domain sending through its [J5] failover identity after a `PATCH` to `ses`:
            that identity has no custom MAIL FROM.
          examples: [pm-bounce.agents.brightwell.example]
        smtp:
          description: '`smtp_relay` only, otherwise `null`. Never the password.'
          oneOf:
            - $ref: '#/components/schemas/DomainSmtp'
            - type: 'null'
        probe:
          description: '`smtp` transport only, otherwise `null`.'
          oneOf:
            - $ref: '#/components/schemas/DomainProbe'
            - type: 'null'
        state: {$ref: '#/components/schemas/DomainState'}
        state_reason:
          type: [string, 'null']
          description: Machine code for the state, for example `dkim_missing`, or `zone_expired` on a `nameservers` domain whose zone Cloudflare deleted.
        state_changed_at:
          type: string
          format: date-time
        delivery_events:
          type: string
          enum: [active, manual, none]
          description: >-
            `active`: provider delivery events reach the service. `manual`: a Cloudflare-transport domain was
            created without an Email Sending event subscription (spike S9 fallback); delivery statuses stop at
            `submitted` until `pmail domains subscribe <domain>` has run. `none`: `sending` is false.
        details:
          description: '`null`, or the operator step that remains while `delivery_events` is `manual`.'
          oneOf:
            - type: object
              required: [action]
              properties:
                action:
                  type: string
                  examples: ['run pmail domains subscribe agents.brightwell.example']
            - type: 'null'
        records:
          type: array
          description: The records last read from the provider APIs, with their last check result.
          items: {$ref: '#/components/schemas/DnsRecord'}
        created_at:
          type: string
          format: date-time
      examples:
        - id: dom_01JA2B3C4D5E6F7G8H9J0K1M2N
          tenant_id: ten_01J9Z3K8V4QW7X2M5N6P8R0T1Y
          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: pending
          state_reason: null
          state_changed_at: '2026-10-09T10:00:00Z'
          delivery_events: active
          details: null
          records:
            - type: TXT
              name: _pylota-mail.agents.brightwell.example
              host: _pylota-mail.agents
              value: pm-verify=8f2k3m9q
              purpose: ownership
              required: true
            - type: MX
              name: agents.brightwell.example
              host: agents
              value: 10 inbound-smtp.eu-west-2.amazonaws.com
              purpose: mx
              required: true
          created_at: '2026-10-09T10:00:00Z'
    DomainCreateRequest:
      type: object
      description: >-
        `method` is 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`.
      required: [name]
      anyOf:
        - required: [method]
        - required: [kind]
      properties:
        name:
          type: string
          format: hostname
          examples: [agents.brightwell.example]
        method:
          type: string
          enum: [cloudflare_zone, nameservers, dns_records, send_only, smtp_relay, delegated_subdomain]
        kind:
          type: string
          deprecated: true
          description: 'Old spelling, used only when `method` is absent: `zone` → `cloudflare_zone`, `external` → `send_only`.'
          enum: [zone, external]
        create_zone:
          type: boolean
          default: false
          deprecated: true
          description: 'Old spelling: `kind: zone` with `create_zone: true` means `method: nameservers`.'
        receiving:
          type: boolean
          default: true
        sending:
          type: boolean
          default: true
        replace_mx:
          type: boolean
          default: false
          description: >-
            `cloudflare_zone` apex and `dns_records`. 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", and health reports `mx_unexpected` until the old records are gone.
        confirm_dedicated:
          type: boolean
          default: false
          description: >-
            `nameservers` only. Confirms that a website or mail on the name may stop. Without it, a name with
            A, AAAA or MX records, or a `www` CNAME, A or AAAA record, returns `409 domain_not_dedicated` ([N21]).
        inbound:
          type: string
          description: '`smtp_relay` only, and required there: `forward` (the customer''s mailbox forwards) or `ses` (they also publish the SES MX and DKIM records).'
          enum: [forward, ses]
        smtp:
          description: '`smtp_relay` only, and required there.'
          $ref: '#/components/schemas/SmtpSettings'
      if:
        properties:
          method: {const: smtp_relay}
        required: [method]
      then:
        required: [inbound, smtp]
    DomainUpdateRequest:
      type: object
      description: '`transport` (platform keys only), `smtp` (`smtp_relay` domains), or both.'
      minProperties: 1
      properties:
        transport:
          type: string
          description: >-
            Platform keys only. The transport that sends as a domain on Cloudflare (the failover of [J5]).
            `ses` needs the SES transport configured and a verified SES identity for the domain.
            `dns_records` and `send_only` domains send only through `ses`, `smtp_relay` domains only through
            `smtp`, and the platform domain only through `cloudflare`; another value returns
            `422 transport_unavailable` with `details.reason: "method_not_supported"`.
          enum: [cloudflare, ses]
        smtp:
          description: >-
            `smtp_relay` domains, tenant or platform keys. New relay settings, tested with one connection
            before they are stored and kept pending until an alignment probe with them passes; until then
            sends keep using the current values.
          $ref: '#/components/schemas/SmtpSettings'
    ProbeAccepted:
      type: object
      required: [probe_id]
      properties:
        probe_id:
          type: string
          pattern: '^prb_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The probe's ID. The result arrives as a domain health change.
          examples: [prb_01JA2B3C4D5E6F7G8H9J0K1M2N]
    DnsRecordStatus:
      type: string
      description: '`unexpected` means an extra record that conflicts, for example a second SPF record.'
      enum: [ok, missing, mismatch, unexpected]
    DnsRecord:
      type: object
      required: [type, name, host, value, purpose, required]
      properties:
        type:
          type: string
          enum: [TXT, MX, CNAME, NS]
        name:
          type: string
          description: The fully qualified name.
          examples: [_pylota-mail.mail.acmecarhire.example]
        host:
          type: string
          description: >-
            The same name relative to the registrable domain (from the Public Suffix List), for DNS hosts
            that ask for only that part ([N17]).
          examples: [_pylota-mail.mail]
        value:
          type: string
          examples: [pm-verify=8f2k3m9q]
        purpose:
          type: string
          description: What the record is for, for example `ownership`, `mx`, `dkim`, `return_path`, `spf`, `dmarc` or `ns`.
          examples: [ownership]
        required:
          type: boolean
        status:
          $ref: '#/components/schemas/DnsRecordStatus'
        observed:
          type: array
          description: The values DNS returned at the last check.
          items:
            type: string
    DomainRecords:
      type: object
      required: [data, checked_at]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/DnsRecord'}
        checked_at:
          type: string
          format: date-time
    DomainIssue:
      type: object
      required: [code, record, fix]
      properties:
        code:
          type: string
          description: Machine code, for example `dkim_missing`.
        record:
          type: string
          description: The record name the issue is about.
        fix:
          type: string
          description: The exact change to make.
    DomainCheck:
      type: object
      required: [at, resolver, outcome]
      properties:
        at:
          type: string
          format: date-time
        resolver:
          type: string
          enum: [cloudflare-doh, google-doh]
        outcome:
          type: string
          enum: [pass, degraded, fail, ownership_changed, error]
    DomainHealth:
      type: object
      required: [state, reason, since, issues, checks, fallback_active]
      properties:
        state: {$ref: '#/components/schemas/DomainState'}
        reason:
          type: [string, 'null']
          description: Machine code for the state, for example `dkim_missing`.
        since:
          type: string
          format: date-time
        issues:
          type: array
          items: {$ref: '#/components/schemas/DomainIssue'}
        checks:
          type: array
          description: Recent checks, newest first.
          items: {$ref: '#/components/schemas/DomainCheck'}
        fallback_active:
          type: boolean
          description: Sends from this domain currently go out from platform addresses (`sent_via_fallback`).

    # ── Threads ──────────────────────────────────────────────────────────────────────────────────

    ThreadHold:
      type: object
      description: A legal hold. Retention and erasure skip the thread while it is set (FR-PRV-4).
      required: [reason, until, set_by, set_at]
      properties:
        reason:
          type: string
          examples: [PCN dispute WM12345678]
        until:
          type: [string, 'null']
          format: date-time
          description: When the hold lapses. `null` holds until removed.
        set_by:
          description: The key that set the hold.
          oneOf:
            - $ref: '#/components/schemas/KeyId'
            - type: 'null'
        set_at:
          type: string
          format: date-time
    ThreadHoldRequest:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          minLength: 1
        until:
          type: [string, 'null']
          format: date-time
          description: When the hold lapses. Omitted or `null` holds until removed.
    ThreadSummary:
      type: object
      required: [id, subject, participants, message_count, unread_count, first_at, last_at, last_inbound_at, last_direction, snippet, labels, category, needs_reply, urgency, hold]
      properties:
        id: {$ref: '#/components/schemas/ThreadId'}
        subject:
          type: string
          description: The normalised subject of the first message (untrusted content).
        participants:
          type: array
          description: Everyone on the thread, capped at 50.
          maxItems: 50
          items: {$ref: '#/components/schemas/MailboxAddress'}
        message_count:
          type: integer
          minimum: 0
        unread_count:
          type: integer
          minimum: 0
        first_at:
          type: string
          format: date-time
        last_at:
          type: string
          format: date-time
        last_inbound_at:
          type: [string, 'null']
          format: date-time
          description: When the latest inbound message arrived. Use it to detect new mail before approving a draft ([C5]).
        last_direction: {$ref: '#/components/schemas/Direction'}
        snippet:
          type: string
          description: Up to 240 characters of the latest message's `extracted_text` (untrusted content).
        labels:
          type: array
          description: Labels on any message in the thread.
          items: {$ref: '#/components/schemas/Label'}
        category:
          type: [string, 'null']
          description: The triage category of the latest triaged inbound message.
        needs_reply:
          type: [number, 'null']
          minimum: 0
          maximum: 1
        urgency:
          type: [integer, 'null']
          minimum: 0
          maximum: 3
        archived:
          type: boolean
        hold:
          oneOf:
            - $ref: '#/components/schemas/ThreadHold'
            - type: 'null'
    Thread:
      description: A thread summary with a page of its messages, oldest first within the page.
      allOf:
        - $ref: '#/components/schemas/ThreadSummary'
        - type: object
          required: [messages, next_cursor]
          properties:
            messages:
              type: array
              items: {$ref: '#/components/schemas/Message'}
            next_cursor:
              type: [string, 'null']
              description: Cursor for the next page of messages, or `null` on the last page.
    ThreadUpdateRequest:
      type: object
      minProperties: 1
      properties:
        labels_add:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        labels_remove:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        read:
          type: boolean
        archived:
          type: boolean

    # ── Messages ─────────────────────────────────────────────────────────────────────────────────

    MessageStatus:
      type: string
      description: >-
        Inbound: `received`, `quarantined`, `throttled` (over the per-sender limit, hidden from agents) and
        `hidden` (receive-blocked or suppressed sender, kept for audit). Outbound: see `OutboundStatus`.
      enum: [received, quarantined, throttled, hidden, queued, submitted, delivered, deferred, bounced, complained, rejected, failed, uncertain, suppressed, canceled]
    OutboundStatus:
      type: string
      description: |
        The roll-up status of an outbound message. Per-recipient status is in `deliveries`.

        | 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 before sending (validation, policy) | 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 |
      enum: [queued, submitted, delivered, deferred, bounced, complained, rejected, failed, uncertain, suppressed, canceled]
    MessageKind:
      type: string
      description: >-
        Inbound: `normal`, `automated`, `dsn`, `list`, `calendar` or `mdn`. Outbound: `transactional`,
        `marketing` or `auto_reply`.
      enum: [normal, automated, dsn, list, calendar, mdn, transactional, marketing, auto_reply]
    MessageFlag:
      type: string
      description: >-
        `sent_via_fallback` (sent from the platform address because the domain was failing), `parse_degraded`,
        `encrypted`, `message_id_conflict`, `reprocessed`, `reconciled`, `bcc` (the identity was BCC'd),
        `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).
      enum: [sent_via_fallback, parse_degraded, encrypted, message_id_conflict, reprocessed, reconciled, bcc, loopback, body_truncated]
    QuarantineReason:
      type: string
      enum: [auth_failed, auth_unverified, spam, risky_attachment, blocked_sender, otp_unsolicited]
    MessageHeader:
      type: object
      description: One header field as received, in order (untrusted content).
      required: [name, value]
      properties:
        name:
          type: string
        value:
          type: string
    Message:
      type: object
      description: >-
        A message. Every text field (subject, display names, filenames, bodies) is untrusted content: show
        it to a model inside a clearly delimited block, never as instructions.
      required: [id, thread_id, identity_id, direction, status, from, to, cc, bcc, reply_to, delivered_to, is_primary_recipient, subject, sent_at, received_at, extracted_text, text, html, attachments, labels, kind, trust, triage, refs, rfc_message_id, in_reply_to, deliveries, flags, metadata]
      properties:
        id: {$ref: '#/components/schemas/MessageId'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        direction: {$ref: '#/components/schemas/Direction'}
        status: {$ref: '#/components/schemas/MessageStatus'}
        from:
          description: The first parseable `From` mailbox, or `null` when there is none ([B13]).
          oneOf:
            - $ref: '#/components/schemas/MailboxAddress'
            - type: 'null'
        to:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        cc:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        bcc:
          type: array
          description: Outbound only. Always empty for inbound messages.
          items: {$ref: '#/components/schemas/MailboxAddress'}
        reply_to:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        delivered_to:
          description: Inbound only. The envelope recipient this copy was delivered to.
          oneOf:
            - $ref: '#/components/schemas/EmailAddress'
            - type: 'null'
        is_primary_recipient:
          type: [boolean, 'null']
          description: >-
            Inbound only. `true` on exactly one copy when one message reached several identities of the
            tenant; act only on that copy ([A9]).
        subject:
          type: [string, 'null']
          maxLength: 998
        sent_at:
          type: [string, 'null']
          format: date-time
          description: The `Date` header, or the submit time for outbound messages.
        received_at:
          type: string
          format: date-time
          description: When the service stored the message (its own clock).
        extracted_text:
          type: [string, 'null']
          description: The new content, with quoted history, signatures and hidden text removed.
        text:
          type: [string, 'null']
          description: The full plain text (derived from HTML when the message has none). Included with `include=quoted`, otherwise `null`.
        html:
          type: [string, 'null']
          description: Sanitised HTML, never rendered by the service. Included with `include=html`, otherwise `null`.
        headers:
          type: array
          description: The original header fields. Present only with `include=headers`.
          items: {$ref: '#/components/schemas/MessageHeader'}
        attachments:
          type: array
          items: {$ref: '#/components/schemas/Attachment'}
        labels:
          type: array
          maxItems: 64
          items: {$ref: '#/components/schemas/Label'}
        kind: {$ref: '#/components/schemas/MessageKind'}
        trust:
          description: Authentication and trust metadata of an inbound message. `null` for outbound messages.
          oneOf:
            - $ref: '#/components/schemas/Trust'
            - type: 'null'
        triage:
          description: Triage of an inbound message. `null` for outbound messages, and for a quarantined message until it is released (triage then runs).
          oneOf:
            - $ref: '#/components/schemas/Triage'
            - type: 'null'
        quarantine_reason:
          description: Set when `status` is `quarantined`.
          oneOf:
            - $ref: '#/components/schemas/QuarantineReason'
            - type: 'null'
        refs:
          type: array
          description: Exact references extracted at ingest (FR-SRCH-4).
          items: {$ref: '#/components/schemas/Ref'}
        rfc_message_id:
          type: [string, 'null']
          description: The `Message-ID` header, normalised, without angle brackets.
        in_reply_to:
          type: [string, 'null']
          description: The `In-Reply-To` header, normalised, without angle brackets.
        provider_message_id:
          type: [string, 'null']
          description: Outbound only. The transport's message ID, set once the message is `submitted`.
        deliveries:
          description: Outbound only. Per-recipient delivery status; `null` for inbound messages.
          oneOf:
            - type: array
              items: {$ref: '#/components/schemas/Delivery'}
            - type: 'null'
        flags:
          type: array
          items: {$ref: '#/components/schemas/MessageFlag'}
        metadata: {$ref: '#/components/schemas/Metadata'}
    MessageSummary:
      type: object
      description: The thin message summary carried in webhook events.
      required: [id, thread_id, direction, status, from, to, cc, delivered_to, is_primary_recipient, subject, sent_at, received_at, kind, labels, in_reply_to, flags]
      properties:
        id: {$ref: '#/components/schemas/MessageId'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        direction: {$ref: '#/components/schemas/Direction'}
        status: {$ref: '#/components/schemas/MessageStatus'}
        from:
          oneOf:
            - $ref: '#/components/schemas/MailboxAddress'
            - type: 'null'
        to:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        cc:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        delivered_to:
          oneOf:
            - $ref: '#/components/schemas/EmailAddress'
            - type: 'null'
        is_primary_recipient:
          type: [boolean, 'null']
          description: >-
            Inbound only. `true` on exactly one copy when one message reached several identities of the
            tenant; act only on that copy ([A9]).
        subject:
          type: [string, 'null']
        sent_at:
          type: [string, 'null']
          format: date-time
        received_at:
          type: string
          format: date-time
        kind: {$ref: '#/components/schemas/MessageKind'}
        labels:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        in_reply_to:
          type: [string, 'null']
        flags:
          type: array
          items: {$ref: '#/components/schemas/MessageFlag'}
    MessageUpdateRequest:
      type: object
      minProperties: 1
      properties:
        labels_add:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        labels_remove:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        read:
          type: boolean

    AttachmentRisk:
      type: string
      description: Why an attachment is unsafe ([B10]). A message with a risky attachment is quarantined.
      enum: [executable, macro, encrypted_archive, archive_bomb, type_mismatch, encrypted_document]
    AttachmentTextStatus:
      type: string
      description: '`unavailable`: extraction failed or the type is unsupported. `skipped`: by policy or risk.'
      enum: [pending, ready, unavailable, skipped]
    Attachment:
      type: object
      required: [id, filename, content_type, size, disposition, text_status, pages, risk]
      properties:
        id: {$ref: '#/components/schemas/AttachmentId'}
        filename:
          type: [string, 'null']
          description: Sanitised (no path, at most 255 bytes). Untrusted content.
        content_type:
          type: string
          examples: [application/pdf]
        size:
          type: integer
          minimum: 0
          description: Bytes.
        disposition:
          type: [string, 'null']
          enum: [attachment, inline, null]
        text_status: {$ref: '#/components/schemas/AttachmentTextStatus'}
        pages:
          type: [integer, 'null']
          minimum: 0
          description: Pages of extracted text, when known.
        risk:
          oneOf:
            - $ref: '#/components/schemas/AttachmentRisk'
            - type: 'null'
    AttachmentText:
      type: object
      required: [status, pages, total_pages, truncated]
      properties:
        status: {$ref: '#/components/schemas/AttachmentTextStatus'}
        pages:
          type: array
          description: The requested pages (untrusted content). Empty unless `status` is `ready`.
          items:
            type: object
            required: [page, text]
            properties:
              page:
                type: integer
                minimum: 1
              text:
                type: string
        total_pages:
          type: [integer, 'null']
          minimum: 0
        truncated:
          type: boolean
          description: '`true` when the text was cut at 200 KB.'

    AuthVerdict:
      type: string
      description: >-
        `unverified`: the sender's DMARC policy is `quarantine` or `reject`, no aligned DKIM signature
        passed, and SPF alignment could not be read because no trusted `Authentication-Results` header was
        available (`PM_TRUSTED_AUTHSERV_ID` not yet set). It quarantines with `auth_unverified`.
      enum: [pass, fail, softfail, none, unaligned, unverified]
    AuthResult:
      type: string
      description: An RFC 8601 authentication result for one method.
      enum: [pass, fail, softfail, neutral, none, temperror, permerror, policy]
    TrustFlag:
      type: string
      enum: [hidden_text, display_name_spoof, lookalike_domain, reply_to_mismatch, thread_join_unverified]
    Trust:
      type: object
      description: Authentication and trust metadata (FR-IN-4).
      required: [verdict, spf, dkim, dmarc, arc, known_sender, quarantined, spam_score, automated, flags]
      properties:
        verdict: {$ref: '#/components/schemas/AuthVerdict'}
        spf: {$ref: '#/components/schemas/AuthResult'}
        dkim: {$ref: '#/components/schemas/AuthResult'}
        dmarc: {$ref: '#/components/schemas/AuthResult'}
        arc: {$ref: '#/components/schemas/AuthResult'}
        known_sender:
          type: boolean
          description: The sender is a known contact of this identity.
        quarantined:
          type: boolean
        spam_score:
          type: [number, 'null']
          minimum: 0
          maximum: 1
        automated:
          type: boolean
          description: Auto-reply, mailing list, bounce or read receipt (FR-IN-6). Never auto-reply to it.
        flags:
          type: array
          items: {$ref: '#/components/schemas/TrustFlag'}
    TrustSummary:
      type: object
      required: [verdict, known_sender, quarantined]
      properties:
        verdict: {$ref: '#/components/schemas/AuthVerdict'}
        known_sender:
          type: boolean
        quarantined:
          type: boolean

    TriageStatus:
      type: string
      enum: [pending, done, skipped, failed]
    BuiltinTriageCategory:
      type: string
      description: The built-in triage categories, used when the tenant has no custom list.
      enum: [customer_request, vendor, billing, legal_compliance, verification, notification, newsletter, marketing, auto_reply, personal, spam, other]
    RiskFlag:
      type: string
      enum: [payment_change_request, credential_request, prompt_injection_suspected, phishing_suspected, impersonation_suspected, urgent_pressure, unknown_sender, auth_failed, attachment_risky, hidden_text]
    Triage:
      type: object
      description: Advisory triage (FR-TRI-1). It never sends, deletes or releases anything.
      required: [status, category, needs_reply, urgency, summary, language, risk_flags, model, version]
      properties:
        status: {$ref: '#/components/schemas/TriageStatus'}
        category:
          description: A built-in category, or one of the tenant's custom categories.
          anyOf:
            - $ref: '#/components/schemas/BuiltinTriageCategory'
            - type: string
            - type: 'null'
        needs_reply:
          type: [number, 'null']
          minimum: 0
          maximum: 1
        urgency:
          type: [integer, 'null']
          minimum: 0
          maximum: 3
        summary:
          type: [string, 'null']
          maxLength: 280
        language:
          type: [string, 'null']
          description: BCP 47 language tag.
          examples: [en]
        risk_flags:
          type: array
          items: {$ref: '#/components/schemas/RiskFlag'}
        model:
          type: [string, 'null']
          description: 'The model that produced the triage, `"rules"` when rules alone decided (`skip_model`), or `null` while it is pending or when it was skipped or failed.'
          examples: ['@cf/openai/gpt-oss-20b']
        version:
          type: integer
          minimum: 0
          description: Increases each time triage is re-run.
        reason:
          type: string
          description: >-
            Present only when `status` is `skipped` (`allowance`: the workspace's triage allowance is spent,
            edge case W7; `policy_disabled`; `not_eligible`: hidden, throttled, DSN or MDN) or `failed`
            (`invalid_output`, `model_unavailable`, `input_unavailable`).
          enum: [allowance, policy_disabled, not_eligible, invalid_output, model_unavailable, input_unavailable]

    DeliveryStatus:
      type: string
      enum: [queued, suppressed, submitted, delivered, deferred, bounced, complained, rejected, failed, uncertain]
    Delivery:
      type: object
      description: The delivery status of one recipient of an outbound message.
      required: [address, field, status, smtp_code, enhanced_code, bounce_type, updated_at]
      properties:
        address: {$ref: '#/components/schemas/EmailAddress'}
        field:
          type: string
          enum: [to, cc, bcc]
        status: {$ref: '#/components/schemas/DeliveryStatus'}
        smtp_code:
          type: [string, 'null']
          examples: ['250']
        enhanced_code:
          type: [string, 'null']
          description: The RFC 3463 enhanced status code from the provider or relay, when it gave one.
          examples: ['5.1.1']
        bounce_type:
          type: [string, 'null']
          enum: [hard, soft, null]
        updated_at:
          type: string
          format: date-time
    Ref:
      type: object
      description: An exact reference extracted from the subject, body or attachment text.
      required: [kind, value]
      properties:
        kind:
          description: >-
            A built-in kind, or `custom:<name>` for a tenant pattern (a booking reference, for example, is
            `custom:booking`).
          anyOf:
            - type: string
              enum: [uk_plate, pcn, invoice, order, amount, phone, email, domain, date]
            - type: string
              pattern: '^custom:.+$'
        value:
          type: string
          description: The normalised value, for example `AB12CDE`, `+447700900123` or `GBP:412.80`.

    # ── Sending ──────────────────────────────────────────────────────────────────────────────────

    Recipient:
      description: A recipient, as a bare address or as an object with a display name.
      oneOf:
        - $ref: '#/components/schemas/EmailAddress'
        - type: object
          required: [address]
          properties:
            address: {$ref: '#/components/schemas/EmailAddress'}
            name:
              type: string
              description: Display name. No CR or LF.
              pattern: '^[^\r\n]*$'
    AttachmentInput:
      type: object
      required: [filename, content_type, content_base64]
      properties:
        filename:
          type: string
          minLength: 1
          maxLength: 255
        content_type:
          type: string
          examples: [application/pdf]
        content_base64:
          type: string
          contentEncoding: base64
          description: The file bytes, base64-encoded.
        disposition:
          type: string
          enum: [attachment, inline]
          default: attachment
        content_id:
          type: string
          description: The Content-ID for an `inline` attachment referenced as `cid:` from the HTML body.
    Unsubscribe:
      type: object
      description: One-click unsubscribe (RFC 8058) for marketing mail. Sets the List-Unsubscribe headers and a visible link.
      required: [url]
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
        mailto:
          type: string
          description: An unsubscribe mailbox, for the `mailto:` form of List-Unsubscribe.
    Consent:
      type: object
      description: The tenant's attestation of the recipient's consent to marketing mail.
      required: [basis, recorded_at]
      properties:
        basis:
          type: string
          examples: [opt_in]
        recorded_at:
          type: string
          format: date-time
    SendHeaders:
      type: object
      description: >-
        Custom headers, checked when the request arrives, never later at the transport. Names are matched
        case-insensitively, as Cloudflare matches them. A name must be an `X-` name matching
        `^X-[A-Za-z0-9_-]+$`, the prefix in either case (at most 100 bytes, sent as given; `X-Pylota-*` and
        `X-AI-Generated` are reserved in any case), or one of `Importance`, `Priority`, `Sensitivity`,
        `Keywords`, `Comments` and `Organization` in any case, which is sent in that casing; any other name
        is `400 header_not_allowed` (the schema leaves names unconstrained, so that this code and not a
        generic `invalid_request` is returned). Two names that differ only in case are `400 invalid_request`.
        The enums below are listed under the canonical names and apply to every casing. `Importance` takes
        only `high`, `normal` or `low`, `Priority` only `normal`, `non-urgent` or `urgent`, and
        `Sensitivity` only `personal`, `private` or `company-confidential`; another value, an empty value or
        one with CR or LF is `400 invalid_request`. Everything else is set by the service. At most 16 KB in
        total; each value at most 2,048 bytes.
      additionalProperties:
        type: string
        minLength: 1
        maxLength: 2048
        pattern: '^[^\r\n]+$'
      properties:
        Importance:
          type: string
          enum: [high, normal, low]
        Priority:
          type: string
          enum: [normal, non-urgent, urgent]
        Sensitivity:
          type: string
          enum: [personal, private, company-confidential]
      examples:
        - X-Booking-Ref: BK-2291
    SendRequest:
      type: object
      description: >-
        A new message. At most `policy.max_recipients` (default 10, hard maximum 49) across `to`, `cc` and
        `bcc` (`400 too_many_recipients`); duplicates are removed.
      required: [to, subject]
      anyOf:
        - required: [text]
        - required: [html]
      properties:
        to:
          type: array
          minItems: 1
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        cc:
          type: array
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        bcc:
          type: array
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        subject:
          type: string
          maxLength: 998
        text:
          type: [string, 'null']
          description: Plain-text body. Derived from `html` when missing.
        html:
          type: [string, 'null']
          description: HTML body.
        attachments:
          type: array
          maxItems: 32
          items: {$ref: '#/components/schemas/AttachmentInput'}
        kind:
          type: string
          enum: [transactional, marketing, auto_reply]
          default: transactional
        unsubscribe:
          $ref: '#/components/schemas/Unsubscribe'
        consent:
          $ref: '#/components/schemas/Consent'
        thread_id:
          description: Continue this thread without quoting; `References` are set from the thread.
          oneOf:
            - $ref: '#/components/schemas/ThreadId'
            - type: 'null'
        from_address:
          description: >-
            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"`.
            Default the primary.
          oneOf:
            - $ref: '#/components/schemas/EmailAddress'
            - type: 'null'
        labels:
          type: array
          maxItems: 64
          items: {$ref: '#/components/schemas/Label'}
        headers: {$ref: '#/components/schemas/SendHeaders'}
        metadata: {$ref: '#/components/schemas/Metadata'}
    ReplyRequest:
      type: object
      description: >-
        The body of a reply or reply-all. Recipients, subject, `From`, `In-Reply-To` and `References` are
        derived from the original message. At least one of `text` and `html` is required.
      anyOf:
        - required: [text]
        - required: [html]
      properties:
        text:
          type: [string, 'null']
        html:
          type: [string, 'null']
        attachments:
          type: array
          maxItems: 32
          items: {$ref: '#/components/schemas/AttachmentInput'}
        kind:
          type: string
          enum: [transactional, marketing, auto_reply]
          default: transactional
        unsubscribe:
          $ref: '#/components/schemas/Unsubscribe'
        consent:
          $ref: '#/components/schemas/Consent'
        labels:
          type: array
          maxItems: 64
          items: {$ref: '#/components/schemas/Label'}
        headers: {$ref: '#/components/schemas/SendHeaders'}
        metadata: {$ref: '#/components/schemas/Metadata'}
    ForwardRequest:
      type: object
      description: >-
        The body of a forward. The original message is attached with its `References` kept. Recipients
        count against `policy.max_recipients` as for a new message.
      required: [to]
      properties:
        to:
          type: array
          minItems: 1
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        cc:
          type: array
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        bcc:
          type: array
          maxItems: 49
          items: {$ref: '#/components/schemas/Recipient'}
        text:
          type: [string, 'null']
          description: A note placed above the forwarded message.
        html:
          type: [string, 'null']
        include_attachments:
          type: boolean
          default: true
          description: Attach the original's attachments (never those with a `risk`).
        attachments:
          type: array
          maxItems: 32
          description: Extra attachments, at most 32 as on a send.
          items: {$ref: '#/components/schemas/AttachmentInput'}
        labels:
          type: array
          maxItems: 64
          items: {$ref: '#/components/schemas/Label'}
        headers: {$ref: '#/components/schemas/SendHeaders'}
        metadata: {$ref: '#/components/schemas/Metadata'}
    SendResponse:
      description: >-
        The outbound Message object (`direction: "outbound"`, `status: "queued"` on first acceptance) plus
        `deduplicated`.
      allOf:
        - $ref: '#/components/schemas/Message'
        - type: object
          required: [deduplicated]
          properties:
            deduplicated:
              type: boolean
              description: '`true` when this is the stored response of an earlier request with the same `Idempotency-Key`.'
    DryRunResult:
      type: object
      description: The result of a dry run (`dry_run=true`). Nothing was sent or stored.
      required: [would_send, recipients]
      properties:
        would_send:
          type: boolean
          const: true
          description: Always `true` in a `200`; a send that would be refused returns its error instead.
        recipients:
          type: array
          description: Each recipient after duplicates are removed, with what a real send would do with it.
          items:
            type: object
            required: [address, field, status]
            properties:
              address: {$ref: '#/components/schemas/EmailAddress'}
              field:
                type: string
                enum: [to, cc, bcc]
              status:
                type: string
                description: '`queued` (it would be sent) or `suppressed` (it would be skipped; see `reason`).'
                enum: [queued, suppressed]
              reason:
                type: string
                description: >-
                  Only for `suppressed`: the suppression reason (`hard_bounce`, `complaint`, `unsubscribe`,
                  `manual`, `provider`), or `send_block`, `not_on_allowlist` or `unknown_recipient`.
                examples: [hard_bounce]
    ResolveRequest:
      type: object
      required: [outcome]
      properties:
        outcome:
          type: string
          description: >-
            `sent` moves the message and its uncertain deliveries to `submitted` and emits `message.sent`;
            `not_sent` marks the message `failed` with reason `resolved_not_sent`.
          enum: [sent, not_sent]
    ReleaseRequest:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          minLength: 1

    # ── Search ───────────────────────────────────────────────────────────────────────────────────

    SearchFilters:
      type: object
      description: Structured filters, combined with the operators in `q`. Dates resolve in the tenant time zone ([F9]).
      properties:
        direction:
          oneOf:
            - $ref: '#/components/schemas/Direction'
            - type: 'null'
        labels:
          type: array
          items: {$ref: '#/components/schemas/Label'}
        after:
          type: [string, 'null']
          format: date-time
        before:
          type: [string, 'null']
          format: date-time
    SearchRequest:
      type: object
      description: A `keyword`, `semantic` or `hybrid` search.
      required: [q]
      properties:
        q:
          type: string
          maxLength: 1024
          description: The query, in the query language described on `searchIdentity`. At most 1,024 characters; `""` matches all messages.
        mode:
          type: string
          enum: [keyword, semantic, hybrid]
          default: hybrid
        filters: {$ref: '#/components/schemas/SearchFilters'}
        group_by:
          type: string
          enum: [message, thread]
          default: message
          description: '`thread` returns one `ThreadHit` per thread.'
        limit:
          type: integer
          minimum: 1
          maximum: 50
          default: 10
        snippet_chars:
          type: integer
          minimum: 40
          maximum: 1000
          default: 240
          description: Maximum characters per snippet.
        facets:
          type: boolean
          default: true
          description: Include `facets` in the response.
        include_quarantined:
          type: boolean
          default: false
          description: Include quarantined mail. Needs `quarantine:review`.
        cursor:
          type: [string, 'null']
          description: The `next_cursor` of the previous page.
        require_mode:
          type: boolean
          default: false
          description: Fail with `503 search_degraded` instead of degrading when the requested mode is unavailable.
    TenantSearchRequest:
      description: A `keyword`, `semantic` or `hybrid` search across a tenant's identities.
      allOf:
        - $ref: '#/components/schemas/SearchRequest'
        - type: object
          properties:
            identity_ids:
              $ref: '#/components/schemas/IdentityIdFilter'
    IdentityIdFilter:
      type: array
      description: Restrict a tenant search to these identities (at most 100).
      uniqueItems: true
      minItems: 1
      maxItems: 100
      items: {$ref: '#/components/schemas/IdentityId'}
    AgenticBudget:
      type: object
      properties:
        max_steps:
          type: integer
          minimum: 2
          maximum: 10
          default: 6
          description: Defaults to, and is capped by, `policy.search.agentic_max_steps` (a larger value is lowered to it, not refused). Outside 2–10 is `400 invalid_request`.
        max_seconds:
          type: integer
          minimum: 3
          maximum: 30
          default: 8
          description: Defaults to, and is capped by, `policy.search.agentic_max_seconds` (a larger value is lowered to it, not refused). Outside 3–30 is `400 invalid_request`.
    AgenticRequest:
      type: object
      description: An agentic search. The planner's tools are read-only and cannot widen the caller's scope or filters ([F10]).
      required: [q, mode]
      properties:
        q:
          type: string
          minLength: 1
          maxLength: 1024
          description: The question, in natural language, at most 1,024 characters.
        mode:
          type: string
          const: agentic
        budget: {$ref: '#/components/schemas/AgenticBudget'}
        stream:
          type: boolean
          default: false
          description: 'With `Accept: text/event-stream`, stream progress as server-sent events.'
        filters: {$ref: '#/components/schemas/SearchFilters'}
        include_quarantined:
          type: boolean
          default: false
          description: Include quarantined mail. Needs `quarantine:review`.
        require_mode:
          type: boolean
          default: false
          description: Fail with `503 search_degraded` instead of degrading to hybrid results when the model is unavailable.
    TenantAgenticRequest:
      description: An agentic search across a tenant's identities.
      allOf:
        - $ref: '#/components/schemas/AgenticRequest'
        - type: object
          properties:
            identity_ids:
              $ref: '#/components/schemas/IdentityIdFilter'
    SearchMode:
      type: string
      enum: [keyword, semantic, hybrid, agentic]
    SearchResponse:
      type: object
      required: [query, hits, facets, next_cursor, truncated, semantic_coverage, degraded, as_of]
      properties:
        query:
          type: object
          required: [parsed, mode]
          properties:
            parsed:
              type: string
              description: The query as parsed (normalised).
            mode:
              type: string
              enum: [keyword, semantic, hybrid]
              description: The mode that ran.
        hits:
          type: array
          description: '`SearchHit` rows, or `ThreadHit` rows with `group_by: "thread"`.'
          items:
            oneOf:
              - $ref: '#/components/schemas/SearchHit'
              - $ref: '#/components/schemas/ThreadHit'
        facets:
          description: 'Computed on the first page only: `null` on later pages (with a `cursor`) and when the request set `facets: false`.'
          oneOf:
            - $ref: '#/components/schemas/Facets'
            - type: 'null'
        next_cursor:
          type: [string, 'null']
        truncated:
          type: boolean
          description: '`true` when results were cut to fit the 256 KB response cap.'
        semantic_coverage:
          type: [number, 'null']
          minimum: 0
          maximum: 1
          description: The share of the mailbox that is embedded (FR-SRCH-7).
        degraded:
          type: boolean
          description: A mode's dependency was unavailable and search fell back.
        as_of:
          type: string
          format: date-time
          description: The point in time the results (and cursor) are pinned to.
        partial:
          type: boolean
          description: >-
            Tenant search only, and always present there. `true` when at least one identity's mailbox errored
            or missed the 900 ms deadline from the start of the fan-out ([F15]).
        failed_identities:
          type: array
          description: >-
            Tenant search only, and always present there (`[]` when every identity answered). The identities
            whose mailboxes errored or did not answer in time; their late results are discarded.
          items: {$ref: '#/components/schemas/IdentityId'}
    TenantSearchResponse:
      description: A `SearchResponse` from tenant search, where `partial` and `failed_identities` are always present.
      allOf:
        - $ref: '#/components/schemas/SearchResponse'
        - type: object
          required: [partial, failed_identities]
    AttachmentHit:
      type: object
      required: [attachment_id, filename, page]
      properties:
        attachment_id: {$ref: '#/components/schemas/AttachmentId'}
        filename:
          type: [string, 'null']
        page:
          type: [integer, 'null']
          minimum: 1
    SearchHit:
      type: object
      required: [message_id, thread_id, identity_id, date, direction, from, subject, snippet, score, why, attachment_hits, trust]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        date:
          type: string
          format: date-time
        direction: {$ref: '#/components/schemas/Direction'}
        from:
          oneOf:
            - $ref: '#/components/schemas/MailboxAddress'
            - type: 'null'
        subject:
          type: [string, 'null']
        snippet:
          type: string
          description: Untrusted content, at most `snippet_chars` characters.
        score:
          type: number
        why:
          type: array
          description: Why the hit matched, for example `ref:AB12CDE (attachment p.1)` or `attachment_text_unavailable`.
          items:
            type: string
        attachment_hits:
          type: array
          items: {$ref: '#/components/schemas/AttachmentHit'}
        trust: {$ref: '#/components/schemas/TrustSummary'}
    ThreadHit:
      type: object
      description: 'One row per thread, with `group_by: "thread"`.'
      required: [thread_id, subject, participants, message_count, last_at, snippet, why, top_message_id]
      properties:
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        identity_id:
          description: Set on tenant search.
          $ref: '#/components/schemas/IdentityId'
        subject:
          type: string
        participants:
          type: array
          items: {$ref: '#/components/schemas/MailboxAddress'}
        message_count:
          type: integer
          minimum: 0
        last_at:
          type: string
          format: date-time
        snippet:
          type: string
          description: The best snippet in the thread.
        why:
          type: array
          items:
            type: string
        top_message_id: {$ref: '#/components/schemas/MessageId'}
    FacetCounts:
      type: object
      description: Value → number of matching messages.
      additionalProperties:
        type: integer
        minimum: 0
    Facets:
      type: object
      description: >-
        Six keys, each the top 10 values by count (ties by value ascending); `month` lists the 24 most recent
        months that have messages, in the tenant time zone. Tenant search sums each identity's counts and
        re-applies the caps (FR-SRCH-5).
      required: [sender, sender_domain, month, label, attachment_type, category]
      properties:
        sender:
          description: The from address.
          $ref: '#/components/schemas/FacetCounts'
        sender_domain:
          description: The from address's domain.
          $ref: '#/components/schemas/FacetCounts'
        month:
          $ref: '#/components/schemas/FacetCounts'
        label:
          $ref: '#/components/schemas/FacetCounts'
        attachment_type:
          $ref: '#/components/schemas/FacetCounts'
        category:
          $ref: '#/components/schemas/FacetCounts'
    AgenticStatus:
      type: string
      description: >-
        `answered`; `insufficient_evidence` (the evidence does not answer the question); `budget_exhausted`
        (evidence returned, no answer or a partial one); `degraded` (hybrid results only, no answer).
      enum: [answered, insufficient_evidence, budget_exhausted, degraded]
    AgenticSentence:
      type: object
      required: [text, citations]
      properties:
        text:
          type: string
        citations:
          type: array
          description: Message IDs that support the sentence. Each is in the evidence set (verified by code).
          items: {$ref: '#/components/schemas/MessageId'}
    AgenticAnswer:
      type: object
      required: [text, sentences, confidence]
      properties:
        text:
          type: string
          description: The answer with inline citations like `[msg_…]`.
        sentences:
          type: array
          description: The sentences that survived citation verification.
          items: {$ref: '#/components/schemas/AgenticSentence'}
        confidence:
          type: number
          minimum: 0
          maximum: 1
    AgenticEvidence:
      description: A search hit used as evidence, with the quoted phrases the answer relies on.
      allOf:
        - $ref: '#/components/schemas/SearchHit'
        - type: object
          required: [quotes]
          properties:
            quotes:
              type: array
              description: Phrases quoted from the source. Each appears verbatim in it (verified by code).
              items:
                type: string
    AgenticTraceStep:
      type: object
      description: >-
        One step of the agentic loop. `action` is one of the planner's read-only tools (for example `search`
        or `read_thread`) or `answer`. Steering attempts found in mail are flagged in the trace ([F10]).
      required: [step, action]
      properties:
        step:
          type: integer
          minimum: 1
        action:
          type: string
          examples: [search, read_thread, answer]
        q:
          type: string
        mode: {$ref: '#/components/schemas/SearchMode'}
        hits:
          type: integer
          minimum: 0
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        message_id: {$ref: '#/components/schemas/MessageId'}
        removed_sentences:
          type: integer
          minimum: 0
          description: '`answer` step: sentences removed by citation verification.'
        ms:
          type: integer
          minimum: 0
      additionalProperties: true
    AgenticUsage:
      type: object
      required: [steps, ms, model]
      properties:
        steps:
          type: integer
          minimum: 0
        ms:
          type: integer
          minimum: 0
        model:
          type: string
          examples: ['@cf/qwen/qwen3.8-27b']
    AgenticResponse:
      type: object
      required: [status, answer, evidence, trace, degraded, usage]
      properties:
        status: {$ref: '#/components/schemas/AgenticStatus'}
        answer:
          description: '`null` unless there is an answer (always `null` for `insufficient_evidence` and `degraded`).'
          oneOf:
            - $ref: '#/components/schemas/AgenticAnswer'
            - type: 'null'
        evidence:
          type: array
          items: {$ref: '#/components/schemas/AgenticEvidence'}
        trace:
          type: array
          items: {$ref: '#/components/schemas/AgenticTraceStep'}
        degraded:
          type: boolean
        usage: {$ref: '#/components/schemas/AgenticUsage'}
        partial:
          type: boolean
          description: Tenant search only. `true` when some identities missed the deadline ([F15]).
        failed_identities:
          type: array
          description: Tenant search only.
          items: {$ref: '#/components/schemas/IdentityId'}
    SearchHitList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/SearchHit'}

    Contact:
      type: object
      required: [address, name, domain, first_seen_at, last_seen_at, inbound_count, outbound_count, last_thread_id]
      properties:
        address: {$ref: '#/components/schemas/EmailAddress'}
        name:
          type: [string, 'null']
        domain:
          type: string
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
        inbound_count:
          type: integer
          minimum: 0
        outbound_count:
          type: integer
          minimum: 0
        last_thread_id:
          oneOf:
            - $ref: '#/components/schemas/ThreadId'
            - type: 'null'
    Verification:
      type: object
      description: A verification code or link, released only for authenticated mail from the expected sender domain ([E4]).
      required: [code, link, sender_domain]
      properties:
        code:
          type: [string, 'null']
          examples: [481 207]
        link:
          type: [string, 'null']
          format: uri
        sender_domain:
          type: string
          examples: [service.example]
    WaitResponse:
      type: object
      required: [message, verification, timed_out]
      properties:
        message:
          description: The matching message, or `null` on timeout.
          oneOf:
            - $ref: '#/components/schemas/Message'
            - type: 'null'
        verification:
          description: Set only when a code or link was found and the release conditions hold.
          oneOf:
            - $ref: '#/components/schemas/Verification'
            - type: 'null'
        timed_out:
          type: boolean

    # ── Webhooks ─────────────────────────────────────────────────────────────────────────────────

    WebhookEventFilter:
      description: An event type, or `*` for every event type, including types added later.
      oneOf:
        - $ref: '#/components/schemas/EventType'
        - type: string
          const: '*'
    Webhook:
      type: object
      required: [id, scope, partner_id, tenant_id, url, events, identity_ids, description, enabled, disabled_reason, created_at, updated_at]
      properties:
        id: {$ref: '#/components/schemas/WebhookId'}
        scope:
          type: string
          description: >-
            Whose events the endpoint receives: `platform`, every tenant's; `partner`, only those of the
            tenants its partner's keys created; `tenant`, its tenant's. Set by the key that created it.
          enum: [platform, partner, tenant]
        partner_id:
          description: The partner of a `partner` endpoint; `null` otherwise.
          oneOf:
            - $ref: '#/components/schemas/PartnerId'
            - type: 'null'
        tenant_id:
          description: '`null` for a platform or partner endpoint.'
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        url:
          type: string
          format: uri
        events:
          type: array
          items: {$ref: '#/components/schemas/WebhookEventFilter'}
        identity_ids:
          type: [array, 'null']
          description: Only events of these identities. `null` means every identity.
          items: {$ref: '#/components/schemas/IdentityId'}
        description:
          type: [string, 'null']
        enabled:
          type: boolean
        disabled_reason:
          type: [string, 'null']
          enum: [manual, failing, null]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhookCreated:
      description: >-
        An endpoint with its signing secret. The secret is shown only in this response: an idempotent replay
        (`Idempotent-Replayed: true`) returns the same body without `secret` and with
        `"secret_replayed": false`, because the stored response never holds it.
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            secret:
              type: string
              pattern: '^whsec_[A-Za-z0-9+/]+={0,2}$'
              description: '`whsec_` followed by the base64-encoded signing key.'
            secret_replayed:
              type: boolean
              const: false
              description: Present only on an idempotent replay, which carries no secret. Rotate the secret if it was lost.
          oneOf:
            - required: [secret]
            - required: [secret_replayed]
    WebhookCreateRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
          description: HTTPS only. Private, loopback and reserved addresses are refused; redirects are not followed.
          examples: ['https://api.example.com/webhooks/mail']
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: {$ref: '#/components/schemas/WebhookEventFilter'}
        identity_ids:
          type: [array, 'null']
          uniqueItems: true
          items: {$ref: '#/components/schemas/IdentityId'}
        description:
          type: [string, 'null']
    WebhookUpdateRequest:
      type: object
      minProperties: 1
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: {$ref: '#/components/schemas/WebhookEventFilter'}
        identity_ids:
          type: [array, 'null']
          uniqueItems: true
          items: {$ref: '#/components/schemas/IdentityId'}
        description:
          type: [string, 'null']
        enabled:
          type: boolean
    RotateRequest:
      type: object
      properties:
        overlap_hours:
          type: integer
          minimum: 0
          maximum: 168
          default: 24
          description: Hours the previous secret stays valid.
    WebhookDeliveryStatus:
      type: string
      enum: [succeeded, failed, dead]
    WebhookDelivery:
      type: object
      description: One delivery attempt of one event to one endpoint.
      required: [id, webhook_id, event_id, event_type, attempt, status, http_status, error, duration_ms, next_attempt_at, created_at]
      properties:
        id: {$ref: '#/components/schemas/DeliveryId'}
        webhook_id: {$ref: '#/components/schemas/WebhookId'}
        event_id: {$ref: '#/components/schemas/EventId'}
        event_type: {$ref: '#/components/schemas/EventType'}
        attempt:
          type: integer
          minimum: 1
        status: {$ref: '#/components/schemas/WebhookDeliveryStatus'}
        http_status:
          type: [integer, 'null']
          description: The endpoint's HTTP status, when it answered.
        error:
          type: [string, 'null']
          description: Machine code when the attempt failed without a usable answer, for example `timeout`, `tls`, `dns` or `status_5xx`.
        duration_ms:
          type: [integer, 'null']
          minimum: 0
        next_attempt_at:
          type: [string, 'null']
          format: date-time
          description: When the next retry is scheduled, if any.
        created_at:
          type: string
          format: date-time
    ReplayRequest:
      description: Replay events by ID, or by time range.
      oneOf:
        - $ref: '#/components/schemas/ReplayByIds'
        - $ref: '#/components/schemas/ReplayByRange'
    ReplayByIds:
      type: object
      required: [event_ids]
      properties:
        event_ids:
          type: array
          minItems: 1
          uniqueItems: true
          items: {$ref: '#/components/schemas/EventId'}
    ReplayByRange:
      type: object
      required: [since]
      properties:
        since:
          type: string
          format: date-time
          description: >-
            Events whose `occurred_at` is at or after this time. Only events at most 30 days old (or
            `retention.events_days`, if shorter) can be replayed.
        until:
          type: string
          format: date-time
        status:
          description: Only events whose latest delivery to this endpoint has this status.
          $ref: '#/components/schemas/WebhookDeliveryStatus'
    ReplayResponse:
      type: object
      required: [queued]
      properties:
        queued:
          type: integer
          minimum: 0

    # ── Suppressions and lists ───────────────────────────────────────────────────────────────────

    SuppressionReason:
      type: string
      enum: [hard_bounce, complaint, unsubscribe, manual, provider]
    Suppression:
      type: object
      description: A suppressed recipient. Only a keyed hash of the address is stored, so it is shown masked.
      required: [address_hint, reason, created_at, expires_at]
      properties:
        address_hint:
          type: string
          examples: [j***@example.net]
        reason: {$ref: '#/components/schemas/SuppressionReason'}
        created_at:
          type: string
          format: date-time
        expires_at:
          type: [string, 'null']
          format: date-time
          description: '`null` means permanent.'
    SuppressionCreateRequest:
      type: object
      required: [address]
      properties:
        address: {$ref: '#/components/schemas/EmailAddress'}
        reason:
          description: Defaults to `manual`.
          $ref: '#/components/schemas/SuppressionReason'
        note:
          type: string
    SuppressionDeleteRequest:
      type: object
      properties:
        confirm_complaint_removal:
          type: boolean
          default: false
          description: Required (`true`) to remove a `complaint` suppression. Audit-logged.
    ListDirection:
      type: string
      enum: [receive, send]
    ListKind:
      type: string
      enum: [allow, block]
    ListEntryValue:
      type: string
      description: An address (`user@example.com`) or a whole domain (`@example.com`).
      pattern: '^([^@\s]+@[^@\s]+|@[^@\s]+)$'
      examples: ['@example.com']
    ListEntry:
      type: object
      required: [direction, kind, entry, note, created_at]
      properties:
        direction: {$ref: '#/components/schemas/ListDirection'}
        kind: {$ref: '#/components/schemas/ListKind'}
        entry: {$ref: '#/components/schemas/ListEntryValue'}
        note:
          type: [string, 'null']
        created_at:
          type: string
          format: date-time
    ListEntryPutRequest:
      type: object
      properties:
        note:
          type: [string, 'null']

    # ── API keys ─────────────────────────────────────────────────────────────────────────────────

    ApiKey:
      type: object
      required: [id, name, level, mode, partner_id, tenant_id, identity_id, permissions, expires_at, revoked_at, last_used_at, created_at]
      properties:
        id: {$ref: '#/components/schemas/KeyId'}
        name:
          type: string
        level: {$ref: '#/components/schemas/KeyLevel'}
        mode: {$ref: '#/components/schemas/Mode'}
        partner_id:
          description: '`null` unless the key is partner-level.'
          oneOf:
            - $ref: '#/components/schemas/PartnerId'
            - type: 'null'
        tenant_id:
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        identity_id:
          oneOf:
            - $ref: '#/components/schemas/IdentityId'
            - type: 'null'
        permissions:
          type: array
          uniqueItems: true
          items: {$ref: '#/components/schemas/Permission'}
        expires_at:
          type: [string, 'null']
          format: date-time
        revoked_at:
          type: [string, 'null']
          format: date-time
        last_used_at:
          type: [string, 'null']
          format: date-time
          description: Updated at most once a minute.
        created_at:
          type: string
          format: date-time
    ApiKeyCreated:
      description: >-
        A key with its secret. The secret is shown only in this response: an idempotent replay
        (`Idempotent-Replayed: true`) returns the same body without `secret` and with
        `"secret_replayed": false`, because the stored response never holds it (FR-KEY-2).
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          properties:
            secret:
              type: string
              pattern: '^pmk_(live|test)_.{12}_.+$'
              description: '`pmk_live_<lookup12>_<secret>` or `pmk_test_<lookup12>_<secret>`.'
            secret_replayed:
              type: boolean
              const: false
              description: Present only on an idempotent replay, which carries no secret. Rotate or revoke the key if the secret was lost.
          oneOf:
            - required: [secret]
            - required: [secret_replayed]
    ApiKeyCreateRequest:
      type: object
      description: >-
        `tenant_id` is required for `tenant` and `identity` keys, and `identity_id` for `identity` keys;
        neither is allowed on `platform` or `partner` keys. `partner_id` is required for `partner` keys, which
        only a platform key may create, and refused on every other level. `permissions` is required at every
        level, `platform` included (no implicit full set). Level, tenant, identity and permissions must lie within the
        caller's own (`403 key_scope_exceeded`), and each permission must be one the level can hold
        (`400 invalid_request`, `details.reason = "permission_not_allowed_for_level"`; see `Permission`).
      required: [name, level, permissions]
      properties:
        name:
          type: string
          minLength: 1
        level: {$ref: '#/components/schemas/KeyLevel'}
        partner_id: {$ref: '#/components/schemas/PartnerId'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        permissions:
          type: array
          minItems: 1
          uniqueItems: true
          items: {$ref: '#/components/schemas/Permission'}
        expires_at:
          type: [string, 'null']
          format: date-time

    # ── Privacy ──────────────────────────────────────────────────────────────────────────────────

    ErasureScope:
      type: string
      enum: [message, thread, counterparty, identity, tenant]
    ErasureStatus:
      type: string
      description: '`completed_with_holds`: the erasure finished but skipped held threads, listed in the receipt''s `held`. `canceled`: a tenant erasure superseded it.'
      enum: [queued, running, completed, completed_with_holds, failed, canceled]
    ErasureRequestCreate:
      description: An erasure request. `scope` selects which other fields are required.
      oneOf:
        - $ref: '#/components/schemas/ErasureRequestCreateMessage'
        - $ref: '#/components/schemas/ErasureRequestCreateThread'
        - $ref: '#/components/schemas/ErasureRequestCreateCounterparty'
        - $ref: '#/components/schemas/ErasureRequestCreateIdentity'
        - $ref: '#/components/schemas/ErasureRequestCreateTenant'
      discriminator:
        propertyName: scope
        mapping:
          message: '#/components/schemas/ErasureRequestCreateMessage'
          thread: '#/components/schemas/ErasureRequestCreateThread'
          counterparty: '#/components/schemas/ErasureRequestCreateCounterparty'
          identity: '#/components/schemas/ErasureRequestCreateIdentity'
          tenant: '#/components/schemas/ErasureRequestCreateTenant'
    ErasureRequestCreateMessage:
      type: object
      required: [tenant_id, scope, identity_id, message_id, reason]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: message
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        message_id: {$ref: '#/components/schemas/MessageId'}
        reason:
          type: string
          minLength: 1
    ErasureRequestCreateThread:
      type: object
      required: [tenant_id, scope, identity_id, thread_id, reason]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: thread
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        reason:
          type: string
          minLength: 1
    ErasureRequestCreateCounterparty:
      type: object
      required: [tenant_id, scope, counterparty_address, reason]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: counterparty
        counterparty_address: {$ref: '#/components/schemas/EmailAddress'}
        reason:
          type: string
          minLength: 1
          examples: [Data subject request DSR-1182]
    ErasureRequestCreateIdentity:
      type: object
      required: [tenant_id, scope, identity_id, reason]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: identity
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        reason:
          type: string
          minLength: 1
    ErasureRequestCreateTenant:
      type: object
      required: [tenant_id, scope, reason]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: tenant
        reason:
          type: string
          minLength: 1
    ErasureHeldItem:
      type: object
      required: [thread_id, reason]
      properties:
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        reason:
          type: string
    ErasureReceipt:
      type: object
      description: What was deleted in each store (FR-PRV-3), the held items skipped (FR-PRV-4), and the post-erasure probe.
      required: [messages_deleted, attachments_deleted, r2_objects_deleted, fts_rows_deleted, refs_deleted, vectors_deleted, events_deleted, identities_affected, held, probe]
      properties:
        messages_deleted:
          type: integer
          minimum: 0
        attachments_deleted:
          type: integer
          minimum: 0
        r2_objects_deleted:
          type: integer
          minimum: 0
        fts_rows_deleted:
          type: integer
          minimum: 0
        refs_deleted:
          type: integer
          minimum: 0
        vectors_deleted:
          type: integer
          minimum: 0
        events_deleted:
          type: integer
          minimum: 0
        identities_affected:
          type: array
          items: {$ref: '#/components/schemas/IdentityId'}
        held:
          type: array
          items: {$ref: '#/components/schemas/ErasureHeldItem'}
        probe:
          type: object
          description: Results of keyword and semantic probe queries run after the erasure. Both must be 0 (FR-SRCH-11).
          required: [keyword_hits, semantic_hits]
          properties:
            keyword_hits:
              type: integer
              minimum: 0
            semantic_hits:
              type: integer
              minimum: 0
    ErasureRequest:
      type: object
      required: [id, tenant_id, scope, status, created_at, completed_at, receipt]
      properties:
        id: {$ref: '#/components/schemas/ErasureRequestId'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope: {$ref: '#/components/schemas/ErasureScope'}
        status: {$ref: '#/components/schemas/ErasureStatus'}
        created_at:
          type: string
          format: date-time
        completed_at:
          type: [string, 'null']
          format: date-time
        receipt:
          description: Set once the request has completed.
          oneOf:
            - $ref: '#/components/schemas/ErasureReceipt'
            - type: 'null'
        created_by_key_id:
          type: [string, 'null']
          description: The API key that started the request (`null` when a person started it in the console or the system started it, for example after a legal hold ended). Lets an operator list what a compromised key did ([J6]).
    ExportScope:
      type: string
      enum: [counterparty, identity]
    ExportStatus:
      type: string
      description: '`expired` once the download is no longer available (7 days after creation). `canceled`: a tenant erasure superseded it.'
      enum: [queued, running, completed, failed, canceled, expired]
    ExportCreate:
      description: A subject-access export. `scope` selects which other field is required.
      oneOf:
        - $ref: '#/components/schemas/ExportCreateCounterparty'
        - $ref: '#/components/schemas/ExportCreateIdentity'
      discriminator:
        propertyName: scope
        mapping:
          counterparty: '#/components/schemas/ExportCreateCounterparty'
          identity: '#/components/schemas/ExportCreateIdentity'
    ExportCreateCounterparty:
      type: object
      required: [tenant_id, scope, counterparty_address]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: counterparty
        counterparty_address: {$ref: '#/components/schemas/EmailAddress'}
    ExportCreateIdentity:
      type: object
      required: [tenant_id, scope, identity_id]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope:
          type: string
          const: identity
        identity_id: {$ref: '#/components/schemas/IdentityId'}
    Export:
      type: object
      required: [id, tenant_id, scope, status, size, created_at, expires_at, download_url]
      properties:
        id: {$ref: '#/components/schemas/ExportId'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        scope: {$ref: '#/components/schemas/ExportScope'}
        status: {$ref: '#/components/schemas/ExportStatus'}
        size:
          type: [integer, 'null']
          minimum: 0
          description: Bytes of the ZIP, once `completed`.
        download_url:
          type: [string, 'null']
          format: uri
          description: >-
            Set when `completed`: a signed link (`getSignedLink`), valid until `expires_at` (7 days), to a ZIP
            holding one `.eml` per message plus `messages.json`. Minted again on each `GET`.
        expires_at:
          type: [string, 'null']
          format: date-time
          description: When the download link expires.
        created_at:
          type: string
          format: date-time

    # ── Usage and audit ──────────────────────────────────────────────────────────────────────────

    UsageDay:
      type: object
      required: [day, inbound, outbound, sends, triage, search, agentic, assertions, http_signatures, ai_neurons, storage_bytes]
      properties:
        day:
          type: string
          format: date
          description: The UTC day.
        inbound:
          type: integer
          minimum: 0
        outbound:
          type: integer
          minimum: 0
        sends:
          type: integer
          minimum: 0
          description: Metered sends (one per recipient the transport accepted).
        triage:
          type: integer
          minimum: 0
          description: Stored triage analyses.
        search:
          type: integer
          minimum: 0
        agentic:
          type: integer
          minimum: 0
        assertions:
          type: integer
          minimum: 0
          description: Agent assertions minted (`POST …/assertions`). Counted only; not metered against a plan allowance.
        http_signatures:
          type: integer
          minimum: 0
          description: Web Bot Auth HTTP signatures made (`POST …/http-signatures`). Counted only; not metered against a plan allowance.
        ai_neurons:
          type: integer
          minimum: 0
          description: Workers AI usage in neurons.
        storage_bytes:
          type: integer
          minimum: 0
    DailyUsage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/UsageDay'}
    BillingMode:
      type: string
      description: >-
        `metered` (the plan's allowances plus top-ups), `exempt` (no limits) or `disabled` (self-hosted
        without billing; only the daily caps in tenant policy apply).
      enum: [metered, exempt, disabled]
    UsageFeatureName:
      type: string
      description: A metered allowance.
      enum: [inboxes, sends, triage, custom_domains, storage_gb, seats]
    UsageFeature:
      type: object
      description: The state of one allowance in the current period.
      required: [feature, granted, used, remaining, unlimited, resets_at]
      properties:
        feature: {$ref: '#/components/schemas/UsageFeatureName'}
        granted:
          type: [integer, 'null']
          minimum: 0
          description: The allowance, top-ups included. `null` when unlimited.
        used:
          type: integer
          minimum: 0
          description: For `storage_gb`, measured, rounded up, and refreshed at least hourly.
        remaining:
          type: [integer, 'null']
          minimum: 0
          description: '`null` when unlimited.'
        unlimited:
          type: boolean
        resets_at:
          type: [string, 'null']
          format: date-time
          description: When a monthly allowance resets; `null` for counts.
    PlanState:
      type: object
      description: The workspace's current plan.
      required: [plan_id, status, current_period_end, cancel_at_period_end]
      properties:
        plan_id:
          type: string
          pattern: '^[a-z][a-z0-9_]{0,31}$'
          examples: [developer]
        status:
          type: string
          enum: [active, trialing, past_due, canceled, incomplete]
        current_period_end:
          type: string
          format: date-time
        cancel_at_period_end:
          type: boolean
    Plan:
      type: object
      description: A plan of the catalog (`PM_PLAN_CATALOG`). Stripe price IDs are never returned.
      required: [plan_id, name, price, currency, interval, included, topups, support]
      properties:
        plan_id:
          type: string
          pattern: '^[a-z][a-z0-9_]{0,31}$'
          examples: [free]
        name:
          type: string
          examples: [Free]
        price:
          type: number
          minimum: 0
          description: Display only.
        currency:
          type: string
          examples: [gbp]
        interval:
          type: string
          examples: [month]
        included:
          type: object
          description: The allowance of each feature; `null` means unlimited.
          required: [inboxes, sends, triage, custom_domains, storage_gb, seats]
          properties:
            inboxes:
              type: [integer, 'null']
              minimum: 0
            sends:
              type: [integer, 'null']
              minimum: 0
            triage:
              type: [integer, 'null']
              minimum: 0
            custom_domains:
              type: [integer, 'null']
              minimum: 0
            storage_gb:
              type: [integer, 'null']
              minimum: 0
            seats:
              type: [integer, 'null']
              minimum: 0
        topups:
          type: boolean
          description: Whether top-ups can be bought on this plan.
        support:
          type: string
          enum: [github_issues, email, priority_email]
    Topups:
      type: object
      description: Top-up units held, per feature that has top-ups.
      required: [inboxes, sends, triage]
      properties:
        inboxes:
          type: integer
          minimum: 0
        sends:
          type: integer
          minimum: 0
        triage:
          type: integer
          minimum: 0
    UsageSummary:
      type: object
      required: [billing, plan, features, topups, plans]
      properties:
        billing: {$ref: '#/components/schemas/BillingMode'}
        plan: {$ref: '#/components/schemas/PlanState'}
        features:
          type: array
          items: {$ref: '#/components/schemas/UsageFeature'}
        topups: {$ref: '#/components/schemas/Topups'}
        plans:
          type: array
          description: The plan catalog.
          items: {$ref: '#/components/schemas/Plan'}
    PlanCatalog:
      type: object
      required: [billing_enabled, data]
      properties:
        billing_enabled:
          type: boolean
          description: '`false` on a deployment without billing, with an empty `data`.'
        data:
          type: array
          items: {$ref: '#/components/schemas/Plan'}
    BillingAccount:
      type: object
      description: A workspace's billing account.
      required: [tenant_id, mode, plan, topups]
      properties:
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        mode: {$ref: '#/components/schemas/BillingMode'}
        plan: {$ref: '#/components/schemas/PlanState'}
        topups: {$ref: '#/components/schemas/Topups'}
    BillingAccountUpdateRequest:
      type: object
      minProperties: 1
      properties:
        mode: {$ref: '#/components/schemas/BillingMode'}
        plan_id:
          type: string
          pattern: '^[a-z][a-z0-9_]{0,31}$'
          description: >-
            A complimentary plan from the catalog. Only for workspaces without a Stripe subscription
            (`409 plan_managed_by_stripe` otherwise).
    AuditEvent:
      type: object
      required: [id, tenant_id, actor_key_id, actor_user_id, action, target_type, target_id, details, request_id, created_at]
      properties:
        id: {$ref: '#/components/schemas/AuditEventId'}
        tenant_id:
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        actor_key_id:
          description: The key that acted, or `null` for a console user or the system (for example retention purges).
          oneOf:
            - $ref: '#/components/schemas/KeyId'
            - type: 'null'
        actor_user_id:
          description: The console user who acted, or `null` for an API key or the system.
          oneOf:
            - $ref: '#/components/schemas/UserId'
            - type: 'null'
        action:
          type: string
          examples: [key.create, quarantine.release]
        target_type:
          type: [string, 'null']
        target_id:
          type: [string, 'null']
        details:
          type: [object, 'null']
          description: Never message content or clear-text addresses.
        request_id:
          oneOf:
            - $ref: '#/components/schemas/RequestId'
            - type: 'null'
        created_at:
          type: string
          format: date-time

    # ── Members ──────────────────────────────────────────────────────────────────────────────────

    MemberRole:
      type: string
      description: The owner is set at workspace creation or by an ownership transfer in the console.
      enum: [owner, admin, member, viewer]
    InvitationRole:
      type: string
      enum: [admin, member, viewer]
    Member:
      type: object
      required: [user_id, email, name, role, last_login_at, created_at]
      properties:
        user_id: {$ref: '#/components/schemas/UserId'}
        email: {$ref: '#/components/schemas/EmailAddress'}
        name:
          type: [string, 'null']
        role: {$ref: '#/components/schemas/MemberRole'}
        last_login_at:
          type: [string, 'null']
          format: date-time
          description: The person's last console sign-in, from `users.last_login_at`; `null` before the first.
        created_at:
          type: string
          format: date-time
    Invitation:
      type: object
      description: A pending invitation. It uses a seat until it is accepted, revoked or expires.
      required: [id, email, role, invited_by, expires_at]
      properties:
        id: {$ref: '#/components/schemas/InvitationId'}
        email: {$ref: '#/components/schemas/EmailAddress'}
        role: {$ref: '#/components/schemas/InvitationRole'}
        invited_by:
          description: The console user who sent it, or `null` when an API key created it.
          oneOf:
            - $ref: '#/components/schemas/UserId'
            - type: 'null'
        expires_at:
          type: string
          format: date-time
    InvitationCreateRequest:
      type: object
      required: [email, role]
      properties:
        email: {$ref: '#/components/schemas/EmailAddress'}
        role: {$ref: '#/components/schemas/InvitationRole'}
    MemberList:
      type: object
      required: [data, invitations, seats]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Member'}
        invitations:
          type: array
          description: Pending invitations.
          items: {$ref: '#/components/schemas/Invitation'}
        seats:
          type: object
          description: Members plus pending invitations count as used seats.
          required: [granted, used]
          properties:
            granted:
              type: [integer, 'null']
              minimum: 0
              description: '`null` when unlimited.'
            used:
              type: integer
              minimum: 0

    # ── Platform operations ──────────────────────────────────────────────────────────────────────

    SigningKeyPurpose:
      type: string
      description: >-
        What a deployment signing key signs: `thread` (thread tokens), `link` (download links, console
        sign-in, invitation and session tokens, OAuth state hashes), `cursor` (search cursors) or
        `web_bot_auth` (Web Bot Auth HTTP signatures and the key directory).
      enum: [thread, link, cursor, web_bot_auth]
    SigningKeyRotation:
      type: object
      description: The result of a signing-key rotation. Key material is never returned.
      required: [purpose, kid, created_at, previous]
      properties:
        purpose: {$ref: '#/components/schemas/SigningKeyPurpose'}
        kid:
          type: string
          description: >-
            The new current key's ID: one character for `thread`, `link` and `cursor`; the 43-character
            JWK thumbprint (base64url) for `web_bot_auth`.
          examples: ['4', poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U]
        created_at:
          type: string
          format: date-time
        previous:
          type: [object, 'null']
          description: >-
            The key it replaced, and until when it keeps verifying (90 days for `thread`, 7 days for `link`
            and `web_bot_auth`, 24 hours for `cursor`; the rotation time with `revoke_previous=true`). A
            previous `web_bot_auth` key stays in the key directory until then. `null` when the purpose had no
            key yet: the rotation then creates the first one.
          required: [kid, verify_until, revoked]
          properties:
            kid:
              type: string
              examples: ['3']
            verify_until:
              type: string
              format: date-time
            revoked:
              type: boolean
              description: '`true` when the request set `revoke_previous=true` and the previous key was deleted.'
    DlqQueue:
      type: string
      description: The source queue of a dead-letter item.
      enum: [pm-inbound, pm-outbound, pm-delivery-events, pm-webhooks, pm-index]
    DlqItem:
      type: object
      description: >-
        A dead-lettered queue message. The stored body is not returned: it is a pointer, and inbound
        pointers carry envelope addresses. Kept for 14 days.
      required: [id, queue, kind, tenant_id, first_seen_at, redriven_at, redrive_count]
      properties:
        id: {$ref: '#/components/schemas/DlqItemId'}
        queue: {$ref: '#/components/schemas/DlqQueue'}
        kind:
          type: [string, 'null']
          description: The body's `kind`, if any.
          examples: [message]
        tenant_id:
          description: The tenant the body names, if any.
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        first_seen_at:
          type: string
          format: date-time
        redriven_at:
          type: [string, 'null']
          format: date-time
        redrive_count:
          type: integer
          minimum: 0
    DlqItemPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/DlqItem'}
        next_cursor:
          type: [string, 'null']
    JobKind:
      type: string
      description: >-
        `reparse` (re-parse messages from raw MIME and re-emit their events with `reprocessed: true`),
        `reembed` (re-chunk and re-embed into Vectorize) or `reindex` (rebuild the keyword index).
      enum: [reparse, reembed, reindex]
    JobStatus:
      type: string
      enum: [queued, running, completed, failed, canceled]
    JobCreateRequest:
      type: object
      required: [kind, tenant_id]
      properties:
        kind: {$ref: '#/components/schemas/JobKind'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        identity_ids:
          type: [array, 'null']
          description: Only these identities. `null` or omitted means every identity of the tenant.
          uniqueItems: true
          items: {$ref: '#/components/schemas/IdentityId'}
        after:
          type: [string, 'null']
          format: date-time
          description: Only messages at or after this time.
        before:
          type: [string, 'null']
          format: date-time
          description: Only messages before this time.
    Job:
      type: object
      required: [id, kind, tenant_id, status, created_at, completed_at, result]
      properties:
        id: {$ref: '#/components/schemas/JobId'}
        kind: {$ref: '#/components/schemas/JobKind'}
        tenant_id: {$ref: '#/components/schemas/TenantId'}
        status: {$ref: '#/components/schemas/JobStatus'}
        created_at:
          type: string
          format: date-time
        completed_at:
          type: [string, 'null']
          format: date-time
        result:
          type: [object, 'null']
          description: Counts, once the job ends (for `reparse`, including the messages skipped past `raw_days`).
          additionalProperties:
            type: integer
            minimum: 0
        created_by_key_id:
          type: [string, 'null']
          description: The API key that started the job, or `null` for a job the system started itself (for example the `reembed` job that the cron creates during an embedding-model change).
    WaitlistInviteRequest:
      type: object
      required: [count]
      properties:
        count:
          type: integer
          minimum: 1
          maximum: 500
          description: How many entries to invite, oldest confirmed first.
          examples: [50]
        plan:
          type: [string, 'null']
          description: Invite only entries whose plan of interest is this plan. `null` or omitted means any plan.
          examples: [team]
    WaitlistInviteResult:
      type: object
      required: [invited, waiting]
      properties:
        invited:
          type: integer
          minimum: 0
          description: Entries invited by this request.
        waiting:
          type: integer
          minimum: 0
          description: Confirmed entries still not invited.

    # ── Provider hooks ───────────────────────────────────────────────────────────────────────────

    SnsMessage:
      type: object
      description: >-
        An Amazon SNS HTTP(S) message, as SNS sends it. Only `SubscriptionConfirmation` and `Notification`
        messages for the endpoint's own topic are acted on: `PM_SES_SNS_TOPIC_ARN` for `/hooks/ses`,
        `PM_SES_INBOUND_TOPIC_ARN` for `/hooks/ses/inbound`.
      required: [Type, MessageId, TopicArn, Message, Timestamp, SignatureVersion, Signature, SigningCertURL]
      properties:
        Type:
          type: string
          examples: [Notification, SubscriptionConfirmation]
        MessageId:
          type: string
        TopicArn:
          type: string
        Subject:
          type: string
        Message:
          type: string
          description: >-
            For a notification, the SES event (`/hooks/ses`) or the SES receipt notification
            (`/hooks/ses/inbound`) as a JSON string.
        Timestamp:
          type: string
          format: date-time
        SignatureVersion:
          type: string
          description: Must be `2` (SHA256withRSA). Version `1` (SHA1withRSA) is refused with `403 invalid_signature`.
          enum: ['2']
        Signature:
          type: string
        SigningCertURL:
          type: string
          format: uri
          description: Must be `https` on the host `sns.{PM_SES_REGION}.amazonaws.com`.
        SubscribeURL:
          type: string
          format: uri
          description: '`SubscriptionConfirmation` only.'
        Token:
          type: string
          description: '`SubscriptionConfirmation` only.'
        UnsubscribeURL:
          type: string
          format: uri
      additionalProperties: true

    # ── Well-known ───────────────────────────────────────────────────────────────────────────────

    Jwks:
      type: object
      description: A JSON Web Key Set (RFC 7517).
      required: [keys]
      properties:
        keys:
          type: array
          items: {$ref: '#/components/schemas/Jwk'}
    Jwk:
      type: object
      description: An Ed25519 public key (RFC 8037).
      required: [kty, crv, x, kid, alg]
      properties:
        kty:
          type: string
          const: OKP
        crv:
          type: string
          const: Ed25519
        x:
          type: string
          description: The public key, base64url-encoded.
        kid:
          type: string
        alg:
          type: string
          const: EdDSA
        use:
          type: string
          const: sig

    # ── Identity keys and signatures ─────────────────────────────────────────────────────────────

    IdentityKeyKid:
      type: string
      description: >-
        An identity key's ID: the base64url RFC 7638 thumbprint of its public JWK (RFC 8037, appendix A.3).
        A deleted identity's key IDs are tombstoned and never published again.
      pattern: '^[A-Za-z0-9_-]{43}$'
      examples: [kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k]
    IdentityKeyStatus:
      type: string
      description: >-
        `active` (signs and is published; at most one per identity), `retiring` (published, does not sign,
        until `verify_until`) or `retired` (not published; kept until the identity is deleted, so its
        thumbprint is never reused).
      enum: [active, retiring, retired]
    IdentityKey:
      type: object
      description: >-
        One Ed25519 signing key of an identity. The private key is sealed under `PM_MASTER_KEY`, never
        leaves the Worker and is never returned.
      required: [kid, identity_id, status, alg, public_jwk, created_at, verify_until, retired_at]
      properties:
        kid: {$ref: '#/components/schemas/IdentityKeyKid'}
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        status: {$ref: '#/components/schemas/IdentityKeyStatus'}
        alg:
          type: string
          const: EdDSA
        public_jwk:
          description: The public key as published in the identity's JWK Set.
          $ref: '#/components/schemas/Jwk'
        created_at:
          type: string
          format: date-time
        verify_until:
          type: [string, 'null']
          format: date-time
          description: >-
            Set when the key becomes `retiring`: the rotation time plus `PM_IDENTITY_KEY_OVERLAP_DAYS`
            (default 7 days). The key stays in the JWK Set until then, unless it is revoked. `null` while the
            key is `active`.
        retired_at:
          type: [string, 'null']
          format: date-time
          description: When the key became `retired`; `null` before that.
      examples:
        - kid: kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
          identity_id: idn_01J9Z3K8V4QW7X2M5N6P8R0T1Y
          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
    IdentityKeyPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/IdentityKey'}
        next_cursor:
          type: [string, 'null']
    IdentityKeyRotation:
      type: object
      description: The result of rotating an identity key.
      required: [key, previous]
      properties:
        key:
          description: The new key, `active`.
          $ref: '#/components/schemas/IdentityKey'
        previous:
          description: >-
            The key that was active, now `retiring` with `verify_until` set; `null` when the identity had
            no active key and the rotation created the first one.
          oneOf:
            - $ref: '#/components/schemas/IdentityKey'
            - type: 'null'
    AssertionRequest:
      type: object
      description: A request for an agent assertion.
      required: [audience]
      properties:
        audience:
          type: string
          minLength: 1
          maxLength: 256
          pattern: '^[\x20-\x7E]{1,256}$'
          description: >-
            The verifier's expected audience, a URL or an identifier: 1–256 characters of printable ASCII.
            Becomes the `aud` claim ([O4]).
          examples: ['https://portal.supplier.example']
        expires_in:
          type: integer
          minimum: 60
          maximum: 600
          default: 300
          description: Seconds until the token expires ([O5]).
        nonce:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[\x20-\x7E]{1,128}$'
          description: Copied into the `nonce` claim, for the verifier's own challenge. 1–128 characters of printable ASCII.
        ext:
          type: object
          description: >-
            Extra claims placed under the `ext` claim: at most 2 KB as JSON, and no member may use a
            registered or Pylota claim name, so a claim the service sets is never overwritten ([O6]).
          propertyNames:
            not:
              enum: [iss, sub, aud, iat, nbf, exp, jti, email, email_verified, name, org, accountable_human, ai_agent, nonce, ext]
          examples:
            - booking_ref: BK-2291
    AssertionResponse:
      type: object
      description: A minted agent assertion. Neither the token nor its claims are stored.
      required: [assertion, kid, expires_at, jwks_uri]
      properties:
        assertion:
          type: string
          description: >-
            The token, a compact JWS (RFC 7519) with header `{"alg":"EdDSA","typ":"agent-assertion+jwt","kid":…}`
            and the claims `iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `email`, `email_verified`, `name`,
            `org`, `accountable_human`, `ai_agent`, and `nonce` and `ext` when given.
          pattern: '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$'
        kid: {$ref: '#/components/schemas/IdentityKeyKid'}
        expires_at:
          type: string
          format: date-time
          description: The `exp` claim.
        jwks_uri:
          type: string
          format: uri
          description: The identity's JWK Set, `https://{PM_API_HOST}/.well-known/jwks/{identity_id}.json`.
    HttpSignatureRequest:
      type: object
      description: A request for Web Bot Auth signature headers.
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          pattern: '^https://'
          description: >-
            The URL the agent will request: `https` only, at most 2,048 characters. An internationalised host
            is converted to its A-label for `@authority` ([O10]).
          examples: ['https://www.brightwell.example/fleet/availability?from=2026-10-12']
        method:
          type: string
          pattern: '^[!#$%&''*+.^_`|~0-9A-Z-]+$'
          description: >-
            The request method, an upper-case token. Signed only if `@method` is in `components`, and then
            required (`400 invalid_request` without it).
          examples: [GET]
        expires_in:
          type: integer
          minimum: 30
          maximum: 300
          default: 60
          description: Seconds until the signature expires. Too short an expiry fails in transit ([O11]).
        components:
          type: array
          uniqueItems: true
          description: >-
            The components to sign. `@authority`, `signature-agent` and `from` are always signed; `@method`,
            `@path` and `@query` may be added. Any other component, or one whose value is not ASCII, is
            refused with `400 invalid_request` ([O10]).
          items:
            type: string
            enum: ['@authority', signature-agent, from, '@method', '@path', '@query']
          default: ['@authority', signature-agent, from]
    HttpSignatureResponse:
      type: object
      description: The headers to attach to the request. Nothing is stored.
      required: [headers, expires_at]
      properties:
        headers:
          type: object
          required: [Signature-Agent, From, Signature-Input, Signature]
          properties:
            Signature-Agent:
              type: string
              description: The deployment's origin as a structured-field string, in double quotes.
              examples: ['"https://mail.example.com"']
            From:
              description: The identity's primary address.
              $ref: '#/components/schemas/EmailAddress'
            Signature-Input:
              type: string
              description: >-
                RFC 9421 signature parameters: the components, `created`, `expires`, `keyid` (the deployment
                key's JWK thumbprint), `alg="ed25519"`, a base64 `nonce` of 64 random bytes and
                `tag="web-bot-auth"`.
            Signature:
              type: string
              description: The RFC 9421 signature, made with the deployment's active `web_bot_auth` key.
        expires_at:
          type: string
          format: date-time
          description: When the signature expires (`expires` in `Signature-Input`).
    HttpMessageSignaturesDirectory:
      type: object
      description: >-
        The Web Bot Auth key directory: a JWK Set (RFC 7517) of the deployment's `active` and `retiring`
        Ed25519 keys, at most three. A verifier identifies a key by its RFC 8037 JWK thumbprint, which is the
        `keyid` of the signatures made with it.
      required: [keys]
      properties:
        keys:
          type: array
          minItems: 1
          maxItems: 3
          items: {$ref: '#/components/schemas/DirectoryJwk'}
    DirectoryJwk:
      type: object
      description: An Ed25519 public key in the Web Bot Auth key directory.
      required: [kty, crv, x]
      properties:
        kty:
          type: string
          const: OKP
        crv:
          type: string
          const: Ed25519
        x:
          type: string
          description: The public key, base64url-encoded without padding.

    # ── Pages ────────────────────────────────────────────────────────────────────────────────────

    TenantPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Tenant'}
        next_cursor:
          type: [string, 'null']
    PartnerPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Partner'}
        next_cursor:
          type: [string, 'null']
    IdentityPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Identity'}
        next_cursor:
          type: [string, 'null']
    AddressPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Address'}
        next_cursor:
          type: [string, 'null']
    DomainPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Domain'}
        next_cursor:
          type: [string, 'null']
    ThreadPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/ThreadSummary'}
        next_cursor:
          type: [string, 'null']
    MessagePage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Message'}
        next_cursor:
          type: [string, 'null']
    ContactPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Contact'}
        next_cursor:
          type: [string, 'null']
    WebhookPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Webhook'}
        next_cursor:
          type: [string, 'null']
    WebhookDeliveryPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/WebhookDelivery'}
        next_cursor:
          type: [string, 'null']
    SuppressionPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Suppression'}
        next_cursor:
          type: [string, 'null']
    ListEntryPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/ListEntry'}
        next_cursor:
          type: [string, 'null']
    ApiKeyPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/ApiKey'}
        next_cursor:
          type: [string, 'null']
    ErasureRequestPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/ErasureRequest'}
        next_cursor:
          type: [string, 'null']
    AuditEventPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/AuditEvent'}
        next_cursor:
          type: [string, 'null']

    # ── Event envelopes, one per event type (generated by gen_events.py) ──

    EventType:
      type: string
      description: Every event type. New types can be added within `api_version`; handle unknown ones.
      enum:
        - message.received
        - message.quarantined
        - message.released
        - message.triaged
        - message.sent
        - message.delivered
        - message.deferred
        - message.bounced
        - message.complained
        - message.rejected
        - message.failed
        - message.uncertain
        - message.reconciled
        - message.suppressed
        - message.canceled
        - verification.received
        - identity.created
        - identity.updated
        - identity.paused
        - identity.resumed
        - identity.deleted
        - identity.address_added
        - identity.address_activated
        - identity.address_promoted
        - identity.address_retired
        - identity.key_created
        - identity.key_rotated
        - identity.key_revoked
        - domain.created
        - domain.verified
        - domain.degraded
        - domain.failing
        - domain.suspended
        - domain.recovered
        - domain.reminder
        - domain.removed
        - erasure.completed
        - erasure.failed
        - export.completed
        - suppression.created
        - quota.warning
        - webhook.disabled
        - webhook.test
        - member.invited
        - member.joined
        - member.role_changed
        - member.removed
        - billing.plan_changed
        - billing.payment_failed
        - billing.limit_reached

    Event:
      description: Any webhook event. `type` selects the payload schema.
      oneOf:
        - $ref: '#/components/schemas/MessageReceivedEvent'
        - $ref: '#/components/schemas/MessageQuarantinedEvent'
        - $ref: '#/components/schemas/MessageReleasedEvent'
        - $ref: '#/components/schemas/MessageTriagedEvent'
        - $ref: '#/components/schemas/MessageSentEvent'
        - $ref: '#/components/schemas/MessageDeliveredEvent'
        - $ref: '#/components/schemas/MessageDeferredEvent'
        - $ref: '#/components/schemas/MessageBouncedEvent'
        - $ref: '#/components/schemas/MessageComplainedEvent'
        - $ref: '#/components/schemas/MessageRejectedEvent'
        - $ref: '#/components/schemas/MessageFailedEvent'
        - $ref: '#/components/schemas/MessageUncertainEvent'
        - $ref: '#/components/schemas/MessageReconciledEvent'
        - $ref: '#/components/schemas/MessageSuppressedEvent'
        - $ref: '#/components/schemas/MessageCanceledEvent'
        - $ref: '#/components/schemas/VerificationReceivedEvent'
        - $ref: '#/components/schemas/IdentityCreatedEvent'
        - $ref: '#/components/schemas/IdentityUpdatedEvent'
        - $ref: '#/components/schemas/IdentityPausedEvent'
        - $ref: '#/components/schemas/IdentityResumedEvent'
        - $ref: '#/components/schemas/IdentityDeletedEvent'
        - $ref: '#/components/schemas/IdentityAddressAddedEvent'
        - $ref: '#/components/schemas/IdentityAddressActivatedEvent'
        - $ref: '#/components/schemas/IdentityAddressPromotedEvent'
        - $ref: '#/components/schemas/IdentityAddressRetiredEvent'
        - $ref: '#/components/schemas/IdentityKeyCreatedEvent'
        - $ref: '#/components/schemas/IdentityKeyRotatedEvent'
        - $ref: '#/components/schemas/IdentityKeyRevokedEvent'
        - $ref: '#/components/schemas/DomainCreatedEvent'
        - $ref: '#/components/schemas/DomainVerifiedEvent'
        - $ref: '#/components/schemas/DomainDegradedEvent'
        - $ref: '#/components/schemas/DomainFailingEvent'
        - $ref: '#/components/schemas/DomainSuspendedEvent'
        - $ref: '#/components/schemas/DomainRecoveredEvent'
        - $ref: '#/components/schemas/DomainReminderEvent'
        - $ref: '#/components/schemas/DomainRemovedEvent'
        - $ref: '#/components/schemas/ErasureCompletedEvent'
        - $ref: '#/components/schemas/ErasureFailedEvent'
        - $ref: '#/components/schemas/ExportCompletedEvent'
        - $ref: '#/components/schemas/SuppressionCreatedEvent'
        - $ref: '#/components/schemas/QuotaWarningEvent'
        - $ref: '#/components/schemas/WebhookDisabledEvent'
        - $ref: '#/components/schemas/WebhookTestEvent'
        - $ref: '#/components/schemas/MemberInvitedEvent'
        - $ref: '#/components/schemas/MemberJoinedEvent'
        - $ref: '#/components/schemas/MemberRoleChangedEvent'
        - $ref: '#/components/schemas/MemberRemovedEvent'
        - $ref: '#/components/schemas/BillingPlanChangedEvent'
        - $ref: '#/components/schemas/BillingPaymentFailedEvent'
        - $ref: '#/components/schemas/BillingLimitReachedEvent'
      discriminator:
        propertyName: type
        mapping:
          message.received: '#/components/schemas/MessageReceivedEvent'
          message.quarantined: '#/components/schemas/MessageQuarantinedEvent'
          message.released: '#/components/schemas/MessageReleasedEvent'
          message.triaged: '#/components/schemas/MessageTriagedEvent'
          message.sent: '#/components/schemas/MessageSentEvent'
          message.delivered: '#/components/schemas/MessageDeliveredEvent'
          message.deferred: '#/components/schemas/MessageDeferredEvent'
          message.bounced: '#/components/schemas/MessageBouncedEvent'
          message.complained: '#/components/schemas/MessageComplainedEvent'
          message.rejected: '#/components/schemas/MessageRejectedEvent'
          message.failed: '#/components/schemas/MessageFailedEvent'
          message.uncertain: '#/components/schemas/MessageUncertainEvent'
          message.reconciled: '#/components/schemas/MessageReconciledEvent'
          message.suppressed: '#/components/schemas/MessageSuppressedEvent'
          message.canceled: '#/components/schemas/MessageCanceledEvent'
          verification.received: '#/components/schemas/VerificationReceivedEvent'
          identity.created: '#/components/schemas/IdentityCreatedEvent'
          identity.updated: '#/components/schemas/IdentityUpdatedEvent'
          identity.paused: '#/components/schemas/IdentityPausedEvent'
          identity.resumed: '#/components/schemas/IdentityResumedEvent'
          identity.deleted: '#/components/schemas/IdentityDeletedEvent'
          identity.address_added: '#/components/schemas/IdentityAddressAddedEvent'
          identity.address_activated: '#/components/schemas/IdentityAddressActivatedEvent'
          identity.address_promoted: '#/components/schemas/IdentityAddressPromotedEvent'
          identity.address_retired: '#/components/schemas/IdentityAddressRetiredEvent'
          identity.key_created: '#/components/schemas/IdentityKeyCreatedEvent'
          identity.key_rotated: '#/components/schemas/IdentityKeyRotatedEvent'
          identity.key_revoked: '#/components/schemas/IdentityKeyRevokedEvent'
          domain.created: '#/components/schemas/DomainCreatedEvent'
          domain.verified: '#/components/schemas/DomainVerifiedEvent'
          domain.degraded: '#/components/schemas/DomainDegradedEvent'
          domain.failing: '#/components/schemas/DomainFailingEvent'
          domain.suspended: '#/components/schemas/DomainSuspendedEvent'
          domain.recovered: '#/components/schemas/DomainRecoveredEvent'
          domain.reminder: '#/components/schemas/DomainReminderEvent'
          domain.removed: '#/components/schemas/DomainRemovedEvent'
          erasure.completed: '#/components/schemas/ErasureCompletedEvent'
          erasure.failed: '#/components/schemas/ErasureFailedEvent'
          export.completed: '#/components/schemas/ExportCompletedEvent'
          suppression.created: '#/components/schemas/SuppressionCreatedEvent'
          quota.warning: '#/components/schemas/QuotaWarningEvent'
          webhook.disabled: '#/components/schemas/WebhookDisabledEvent'
          webhook.test: '#/components/schemas/WebhookTestEvent'
          member.invited: '#/components/schemas/MemberInvitedEvent'
          member.joined: '#/components/schemas/MemberJoinedEvent'
          member.role_changed: '#/components/schemas/MemberRoleChangedEvent'
          member.removed: '#/components/schemas/MemberRemovedEvent'
          billing.plan_changed: '#/components/schemas/BillingPlanChangedEvent'
          billing.payment_failed: '#/components/schemas/BillingPaymentFailedEvent'
          billing.limit_reached: '#/components/schemas/BillingLimitReachedEvent'

    MessageReceivedEvent:
      description: '`message.received`: An inbound message is stored and visible.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.received
            data: {$ref: '#/components/schemas/MessageReceivedData'}

    MessageQuarantinedEvent:
      description: '`message.quarantined`: An inbound message is stored but quarantined.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.quarantined
            data: {$ref: '#/components/schemas/MessageQuarantinedData'}

    MessageReleasedEvent:
      description: '`message.released`: A quarantined message was released.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.released
            data: {$ref: '#/components/schemas/MessageReleasedData'}

    MessageTriagedEvent:
      description: '`message.triaged`: Triage finished (or failed).'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.triaged
            data: {$ref: '#/components/schemas/MessageTriagedData'}

    MessageSentEvent:
      description: '`message.sent`: The transport accepted an outbound message.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.sent
            data: {$ref: '#/components/schemas/MessageSentData'}

    MessageDeliveredEvent:
      description: '`message.delivered`: A recipient''s server accepted the message.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.delivered
            data: {$ref: '#/components/schemas/MessageDeliveredData'}

    MessageDeferredEvent:
      description: '`message.deferred`: A temporary failure; the provider is retrying.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.deferred
            data: {$ref: '#/components/schemas/MessageDeferredData'}

    MessageBouncedEvent:
      description: '`message.bounced`: A permanent failure, or retries exhausted.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.bounced
            data: {$ref: '#/components/schemas/MessageBouncedData'}

    MessageComplainedEvent:
      description: '`message.complained`: A recipient reported spam.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.complained
            data: {$ref: '#/components/schemas/MessageComplainedData'}

    MessageRejectedEvent:
      description: '`message.rejected`: The transport refused the message before sending.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.rejected
            data: {$ref: '#/components/schemas/MessageRejectedData'}

    MessageFailedEvent:
      description: '`message.failed`: The message could not be sent.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.failed
            data: {$ref: '#/components/schemas/MessageFailedData'}

    MessageUncertainEvent:
      description: '`message.uncertain`: The outcome is unknown; the message is never resent automatically.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.uncertain
            data: {$ref: '#/components/schemas/MessageUncertainData'}

    MessageReconciledEvent:
      description: '`message.reconciled`: An uncertain send was matched to a provider event.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.reconciled
            data: {$ref: '#/components/schemas/MessageReconciledData'}

    MessageSuppressedEvent:
      description: '`message.suppressed`: Every recipient is suppressed; nothing was sent.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.suppressed
            data: {$ref: '#/components/schemas/MessageSuppressedData'}

    MessageCanceledEvent:
      description: '`message.canceled`: The message was cancelled while queued.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: message.canceled
            data: {$ref: '#/components/schemas/MessageCanceledData'}

    VerificationReceivedEvent:
      description: '`verification.received`: A verification code or link was found in authenticated mail. The value itself is only available through `wait`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: verification.received
            data: {$ref: '#/components/schemas/VerificationReceivedData'}

    IdentityCreatedEvent:
      description: '`identity.created`: An identity was created.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.created
            data: {$ref: '#/components/schemas/IdentityCreatedData'}

    IdentityUpdatedEvent:
      description: '`identity.updated`: An identity was updated.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.updated
            data: {$ref: '#/components/schemas/IdentityUpdatedData'}

    IdentityPausedEvent:
      description: '`identity.paused`: An identity was paused (manually, for abuse, or because its tenant was suspended).'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.paused
            data: {$ref: '#/components/schemas/IdentityPausedData'}

    IdentityResumedEvent:
      description: '`identity.resumed`: A paused identity was resumed.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.resumed
            data: {$ref: '#/components/schemas/IdentityResumedData'}

    IdentityDeletedEvent:
      description: '`identity.deleted`: An identity was deleted: emitted once, when the identity-scope erasure that deletes it completes and the identity becomes `deleted` (after held threads are released, if any). It comes from the erasure job with `identity_id` set.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.deleted
            data: {$ref: '#/components/schemas/IdentityDeletedData'}

    IdentityAddressAddedEvent:
      description: '`identity.address_added`: An address was added to an identity.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.address_added
            data: {$ref: '#/components/schemas/IdentityAddressData'}

    IdentityAddressActivatedEvent:
      description: '`identity.address_activated`: A pending address became active once its domain was healthy.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.address_activated
            data: {$ref: '#/components/schemas/IdentityAddressData'}

    IdentityAddressPromotedEvent:
      description: '`identity.address_promoted`: An address became the identity''s primary.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.address_promoted
            data: {$ref: '#/components/schemas/IdentityAddressPromotedData'}

    IdentityAddressRetiredEvent:
      description: '`identity.address_retired`: An address became retired.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.address_retired
            data: {$ref: '#/components/schemas/IdentityAddressData'}

    IdentityKeyCreatedEvent:
      description: '`identity.key_created`: A signing key was created for an identity that had no active key, lazily by a signing request or by `POST …/keys`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.key_created
            data: {$ref: '#/components/schemas/IdentityKeyEventData'}

    IdentityKeyRotatedEvent:
      description: '`identity.key_rotated`: An identity signing key was rotated: `kid` is the new active key, `previous_kid` the key now `retiring`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.key_rotated
            data: {$ref: '#/components/schemas/IdentityKeyRotatedData'}

    IdentityKeyRevokedEvent:
      description: '`identity.key_revoked`: An identity signing key was revoked and is `retired`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: identity.key_revoked
            data: {$ref: '#/components/schemas/IdentityKeyEventData'}

    DomainCreatedEvent:
      description: '`domain.created`: A domain was added.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.created
            data: {$ref: '#/components/schemas/DomainEventData'}

    DomainVerifiedEvent:
      description: '`domain.verified`: A domain became healthy for the first time.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.verified
            data: {$ref: '#/components/schemas/DomainEventData'}

    DomainDegradedEvent:
      description: '`domain.degraded`: A domain became degraded.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.degraded
            data: {$ref: '#/components/schemas/DomainDegradedData'}

    DomainFailingEvent:
      description: '`domain.failing`: A domain started failing; sending falls back to platform addresses unless disabled by policy.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.failing
            data: {$ref: '#/components/schemas/DomainFailingData'}

    DomainSuspendedEvent:
      description: '`domain.suspended`: A domain was suspended; ownership must be re-proved.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.suspended
            data: {$ref: '#/components/schemas/DomainSuspendedData'}

    DomainRecoveredEvent:
      description: '`domain.recovered`: A domain returned to healthy.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.recovered
            data: {$ref: '#/components/schemas/DomainRecoveredData'}

    DomainReminderEvent:
      description: '`domain.reminder`: A domain is still unhealthy (sent at 24 h, 72 h and 7 days in the state).'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.reminder
            data: {$ref: '#/components/schemas/DomainReminderData'}

    DomainRemovedEvent:
      description: '`domain.removed`: A domain was removed (`reason`: `requested` or `zone_expired`).'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: domain.removed
            data: {$ref: '#/components/schemas/DomainRemovedData'}

    ErasureCompletedEvent:
      description: '`erasure.completed`: An erasure request completed, with its receipt.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: erasure.completed
            data: {$ref: '#/components/schemas/ErasureCompletedData'}

    ErasureFailedEvent:
      description: '`erasure.failed`: An erasure step failed after the job runner''s own retries.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: erasure.failed
            data: {$ref: '#/components/schemas/ErasureFailedData'}

    ExportCompletedEvent:
      description: '`export.completed`: A subject-access export is ready. Fetch the download link from the API.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: export.completed
            data: {$ref: '#/components/schemas/ExportCompletedData'}

    SuppressionCreatedEvent:
      description: '`suppression.created`: An address was suppressed.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: suppression.created
            data: {$ref: '#/components/schemas/SuppressionCreatedData'}

    QuotaWarningEvent:
      description: '`quota.warning`: A quota reached 80% or 100% of its limit.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: quota.warning
            data: {$ref: '#/components/schemas/QuotaWarningData'}

    WebhookDisabledEvent:
      description: '`webhook.disabled`: A webhook endpoint was disabled. Sent to platform endpoints and, when the disabled endpoint belongs to a partner (a partner endpoint, or an endpoint of a tenant a partner''s key created), to that partner''s other endpoints; never to tenant endpoints or to the endpoint itself.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: webhook.disabled
            data: {$ref: '#/components/schemas/WebhookDisabledData'}

    WebhookTestEvent:
      description: '`webhook.test`: A test event sent by `POST /v1/webhooks/{webhook_id}/test`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: webhook.test
            data: {$ref: '#/components/schemas/WebhookTestData'}

    MemberInvitedEvent:
      description: '`member.invited`: An invitation to the workspace was created or re-sent.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: member.invited
            data: {$ref: '#/components/schemas/MemberInvitedData'}

    MemberJoinedEvent:
      description: '`member.joined`: An invitation was accepted; the user is now a member.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: member.joined
            data: {$ref: '#/components/schemas/MemberJoinedData'}

    MemberRoleChangedEvent:
      description: '`member.role_changed`: A member''s role changed (an ownership transfer sends two).'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: member.role_changed
            data: {$ref: '#/components/schemas/MemberRoleChangedData'}

    MemberRemovedEvent:
      description: '`member.removed`: A member was removed, or left.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: member.removed
            data: {$ref: '#/components/schemas/MemberRemovedData'}

    BillingPlanChangedEvent:
      description: '`billing.plan_changed`: The workspace''s plan changed.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: billing.plan_changed
            data: {$ref: '#/components/schemas/BillingPlanChangedData'}

    BillingPaymentFailedEvent:
      description: '`billing.payment_failed`: A payment failed; the workspace keeps its plan until `grace_until`.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: billing.payment_failed
            data: {$ref: '#/components/schemas/BillingPaymentFailedData'}

    BillingLimitReachedEvent:
      description: '`billing.limit_reached`: A plan allowance is spent. Sent once per feature per period, when the first `402 billing_limit` is returned.'
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [type, data]
          properties:
            type:
              const: billing.limit_reached
            data: {$ref: '#/components/schemas/BillingLimitReachedData'}

    # ── Event envelope and payloads ──────────────────────────────────────────────────────────────

    EventBase:
      type: object
      description: >-
        The envelope of every webhook event. Payloads are thin: IDs, a summary, verdicts and at most
        `policy.webhook_text_bytes` of `extracted_text` (FR-WH-4). Fetch anything else through the API.
        Events can arrive out of order: use `occurred_at` and `sequence` to discard stale updates.
      required: [id, type, api_version, occurred_at, tenant_id, identity_id, sequence, data]
      properties:
        id: {$ref: '#/components/schemas/EventId'}
        type: {$ref: '#/components/schemas/EventType'}
        api_version:
          type: string
          description: The payload schema date. A breaking payload change gets a new date; endpoints can pin the old one for 12 months.
          pattern: '^\d{4}-\d{2}-\d{2}$'
          examples: ['2026-10-01']
        occurred_at:
          type: string
          format: date-time
        tenant_id:
          description: '`null` only for deployment-level platform events.'
          oneOf:
            - $ref: '#/components/schemas/TenantId'
            - type: 'null'
        identity_id:
          description: '`null` for events that do not belong to one identity (domain, job and platform events).'
          oneOf:
            - $ref: '#/components/schemas/IdentityId'
            - type: 'null'
        sequence:
          type: [integer, 'null']
          minimum: 1
          description: >-
            Increases strictly per owner: per identity for mailbox events (`message.*`, `identity.*`,
            `verification.received`, `suppression.created`, `quota.warning`), per domain for `domain.*`
            events, and per job for `erasure.*` and `export.*` events. `null` for platform events
            (`webhook.disabled`, `webhook.test`, `member.*` and `billing.*`).
        data:
          type: object
          description: The payload; its schema depends on `type`.
    SendFailureReason:
      type: string
      description: |
        Why an accepted send did not go out normally (`data.reason` of `message.rejected`, `message.failed`
        and `message.uncertain`).

        | Reason | Status | Meaning |
        |---|---|---|
        | `provider_validation` | `rejected` | The transport refused the content (header, size, format) |
        | `sender_domain_unavailable` | `rejected` | The domain is not onboarded with the transport |
        | `recipient_suppressed_by_provider` | per recipient `suppressed` | The provider's own suppression list; synced into ours |
        | `quota_exhausted` | `failed` | The provider's daily limit, still exhausted after 24 hours of backoff, or an SMTP relay still unavailable after 24 hours of retries |
        | `transport_timeout` | `uncertain` | No answer from the transport. It may have been sent |
        | `transport_connection_lost` | `uncertain` | The connection dropped after the request was written |
        | `resolved_not_sent` | `failed` | A human resolved an uncertain send as not sent |
        | `domain_failing_no_fallback` | `failed` | The domain failed and fallback was disabled by policy |
        | `marketing_needs_ses` | `rejected` | A `kind: marketing` message whose sending domain uses the `cloudflare` transport when it reached the transport (the domain's transport changed, or fallback moved it to the platform domain, after it was accepted). Cloudflare Email Service is for transactional mail only |
      enum: [provider_validation, sender_domain_unavailable, recipient_suppressed_by_provider, quota_exhausted, transport_timeout, transport_connection_lost, resolved_not_sent, domain_failing_no_fallback, marketing_needs_ses]
    EventAttachment:
      type: object
      required: [id, filename, content_type, size]
      properties:
        id: {$ref: '#/components/schemas/AttachmentId'}
        filename:
          type: [string, 'null']
        content_type:
          type: string
        size:
          type: integer
          minimum: 0

    MessageReceivedData:
      type: object
      required: [message, thread_id, trust, extracted_text, extracted_text_truncated, attachments]
      properties:
        message: {$ref: '#/components/schemas/MessageSummary'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        trust: {$ref: '#/components/schemas/Trust'}
        extracted_text:
          type: [string, 'null']
          description: Up to `policy.webhook_text_bytes` (default 16 KB, maximum 64 KB) of `extracted_text`. Untrusted content.
        extracted_text_truncated:
          type: boolean
        attachments:
          type: array
          items: {$ref: '#/components/schemas/EventAttachment'}
    MessageQuarantinedData:
      type: object
      description: As `message.received`, plus `quarantine_reason`, and without `extracted_text`.
      required: [message, thread_id, trust, attachments, quarantine_reason]
      properties:
        message: {$ref: '#/components/schemas/MessageSummary'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        trust: {$ref: '#/components/schemas/Trust'}
        attachments:
          type: array
          items: {$ref: '#/components/schemas/EventAttachment'}
        quarantine_reason: {$ref: '#/components/schemas/QuarantineReason'}
    MessageReleasedData:
      type: object
      required: [message, released_by_key_id, released_by_user_id, reason]
      properties:
        message: {$ref: '#/components/schemas/MessageSummary'}
        released_by_key_id:
          description: The API key that released the message, or `null` for a release by a person in the console.
          anyOf:
            - $ref: '#/components/schemas/KeyId'
            - type: 'null'
        released_by_user_id:
          description: The console user who released the message, or `null` for a release by API key.
          anyOf:
            - $ref: '#/components/schemas/UserId'
            - type: 'null'
        reason:
          type: string
    MessageTriagedData:
      type: object
      required: [message_id, thread_id, triage]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        thread_id: {$ref: '#/components/schemas/ThreadId'}
        triage: {$ref: '#/components/schemas/Triage'}
    MessageSentData:
      type: object
      required: [message, provider, provider_message_id, sent_via_fallback]
      properties:
        message: {$ref: '#/components/schemas/MessageSummary'}
        provider:
          type: string
          description: '`smtp` for a domain that sends through its own SMTP relay (`provider_message_id` is then `smtp:{host}:{Message-ID}`).'
          enum: [cloudflare, ses, smtp, simulator, loopback]
        provider_message_id:
          type: [string, 'null']
          description: '`null` when a person resolved an uncertain send as sent (`resolveMessage` with `outcome: "sent"`).'
        sent_via_fallback:
          type: boolean
          description: Sent from the identity's platform address because the domain was failing.
    MessageDeliveredData:
      type: object
      required: [message_id, recipient, smtp_code]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        recipient: {$ref: '#/components/schemas/EmailAddress'}
        smtp_code:
          type: [string, 'null']
    MessageDeferredData:
      type: object
      required: [message_id, recipient, smtp_code, smtp_response]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        recipient: {$ref: '#/components/schemas/EmailAddress'}
        smtp_code:
          type: [string, 'null']
        smtp_response:
          type: [string, 'null']
          maxLength: 512
    MessageBouncedData:
      type: object
      required: [message_id, recipient, bounce_type, smtp_code, smtp_response, suppressed]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        recipient: {$ref: '#/components/schemas/EmailAddress'}
        bounce_type:
          type: string
          enum: [hard, soft]
        smtp_code:
          type: [string, 'null']
        smtp_response:
          type: [string, 'null']
          maxLength: 512
        suppressed:
          type: boolean
          description: The recipient was added to the suppression list (hard bounces).
    MessageComplainedData:
      type: object
      required: [message_id, recipient, suppressed]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        recipient: {$ref: '#/components/schemas/EmailAddress'}
        suppressed:
          type: boolean
          const: true
          description: A complaint always creates a permanent suppression.
    MessageRejectedData:
      type: object
      required: [message_id, reason, detail]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        reason: {$ref: '#/components/schemas/SendFailureReason'}
        detail:
          type: [string, 'null']
          description: The transport's explanation, for example its error code.
    MessageFailedData:
      type: object
      required: [message_id, reason]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        reason: {$ref: '#/components/schemas/SendFailureReason'}
    MessageUncertainData:
      type: object
      required: [message_id, reason, fix]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        reason: {$ref: '#/components/schemas/SendFailureReason'}
        fix:
          type: string
          description: What to do, for example to check the recipient's mailbox and then call `resolve`.
    MessageReconciledData:
      type: object
      required: [message_id, status]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        status:
          description: The real status the uncertain send moved to.
          $ref: '#/components/schemas/OutboundStatus'
    MessageSuppressedData:
      type: object
      required: [message_id, recipients]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        recipients:
          type: array
          items: {$ref: '#/components/schemas/EmailAddress'}
    MessageCanceledData:
      type: object
      required: [message_id]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
    VerificationReceivedData:
      type: object
      description: The code or link itself is only available through `wait`.
      required: [message_id, sender_domain, kind]
      properties:
        message_id: {$ref: '#/components/schemas/MessageId'}
        sender_domain:
          type: string
        kind:
          type: string
          enum: [code, link]

    IdentityCreatedData:
      type: object
      required: [identity]
      properties:
        identity: {$ref: '#/components/schemas/Identity'}
    IdentityUpdatedData:
      type: object
      required: [identity, changed]
      properties:
        identity: {$ref: '#/components/schemas/Identity'}
        changed:
          type: array
          description: The names of the fields that changed.
          items:
            type: string
    IdentityPausedData:
      type: object
      required: [identity_id, reason, metrics]
      properties:
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        reason: {$ref: '#/components/schemas/PauseReason'}
        metrics:
          type: [object, 'null']
          description: For `abuse_threshold`, the complaint and bounce figures that triggered the pause; otherwise `null`.
    IdentityResumedData:
      type: object
      required: [identity_id]
      properties:
        identity_id: {$ref: '#/components/schemas/IdentityId'}
    IdentityDeletedData:
      type: object
      required: [identity_id, erasure_request_id]
      properties:
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        erasure_request_id: {$ref: '#/components/schemas/ErasureRequestId'}
    IdentityAddressData:
      type: object
      description: Payload of `identity.address_added`, `identity.address_activated` and `identity.address_retired`.
      required: [address]
      properties:
        address: {$ref: '#/components/schemas/Address'}
    IdentityAddressPromotedData:
      type: object
      required: [address, previous_primary]
      properties:
        address: {$ref: '#/components/schemas/Address'}
        previous_primary:
          description: The previous primary, now an alias with status `retiring` and its `retire_at`.
          $ref: '#/components/schemas/Address'

    IdentityKeyEventData:
      type: object
      description: Payload of `identity.key_created` and `identity.key_revoked`.
      required: [identity_id, kid]
      properties:
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        kid: {$ref: '#/components/schemas/IdentityKeyKid'}
    IdentityKeyRotatedData:
      type: object
      required: [identity_id, kid, previous_kid]
      properties:
        identity_id: {$ref: '#/components/schemas/IdentityId'}
        kid:
          description: The new active key.
          $ref: '#/components/schemas/IdentityKeyKid'
        previous_kid:
          description: The key now `retiring`, or `null` when the rotation created the identity's first key.
          oneOf:
            - $ref: '#/components/schemas/IdentityKeyKid'
            - type: 'null'

    DomainEventData:
      type: object
      description: Payload of `domain.created` and `domain.verified`.
      required: [domain]
      properties:
        domain: {$ref: '#/components/schemas/Domain'}
    DomainDegradedData:
      type: object
      required: [domain_id, issues]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        issues:
          type: array
          items: {$ref: '#/components/schemas/DomainIssue'}
    DomainFailingData:
      type: object
      required: [domain_id, issues, fallback_active]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        issues:
          type: array
          items: {$ref: '#/components/schemas/DomainIssue'}
        fallback_active:
          type: boolean
    DomainSuspendedData:
      type: object
      required: [domain_id, reason]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        reason:
          type: string
          enum: [failing_14_days, nameservers_changed, ownership_record_missing, registration_changed]
    DomainRecoveredData:
      type: object
      required: [domain_id, from_state]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        from_state: {$ref: '#/components/schemas/DomainState'}
    DomainReminderData:
      type: object
      required: [domain_id, state, hours_in_state]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        state: {$ref: '#/components/schemas/DomainState'}
        hours_in_state:
          type: integer
          description: 24, 72 and 168 (7 days); `504` (21 days) is the final reminder for a `nameservers` domain still `pending` before Cloudflare deletes its zone at 28 days.
          enum: [24, 72, 168, 504]
    DomainRemovedData:
      type: object
      required: [domain_id, reason]
      properties:
        domain_id: {$ref: '#/components/schemas/DomainId'}
        reason:
          type: string
          description: >-
            `requested`: removed through `DELETE /v1/domains/{domain_id}`. `zone_expired`: a `nameservers`
            zone was never activated and Cloudflare deleted it; the domain can be added again.
          enum: [requested, zone_expired]

    ErasureCompletedData:
      type: object
      required: [erasure_request]
      properties:
        erasure_request: {$ref: '#/components/schemas/ErasureRequest'}
    ErasureFailedData:
      type: object
      required: [erasure_request_id, step, error]
      properties:
        erasure_request_id: {$ref: '#/components/schemas/ErasureRequestId'}
        step:
          type: string
          description: The job step that failed, for example `delete_vectors`.
        error:
          type: string
    ExportCompletedData:
      type: object
      description: Fetch the download link with `getExport`.
      required: [export_id, expires_at]
      properties:
        export_id: {$ref: '#/components/schemas/ExportId'}
        expires_at:
          type: string
          format: date-time
    SuppressionCreatedData:
      type: object
      required: [address_hint, reason, source_message_id]
      properties:
        address_hint:
          type: string
          examples: [j***@example.net]
        reason: {$ref: '#/components/schemas/SuppressionReason'}
        source_message_id:
          oneOf:
            - $ref: '#/components/schemas/MessageId'
            - type: 'null'
    QuotaWarningData:
      type: object
      required: [metric, used, limit, scope]
      properties:
        metric:
          type: string
          description: The daily send cap that was reached. v1 emits this event only for `sends` (the tenant cap and each identity's cap); the agentic budget answers `429 agentic_budget_exhausted` without a warning event.
          enum: [sends]
        used:
          type: integer
          minimum: 0
        limit:
          type: integer
          minimum: 0
        scope:
          type: string
          enum: [tenant, identity]
    WebhookDisabledData:
      type: object
      required: [webhook_id, reason]
      properties:
        webhook_id: {$ref: '#/components/schemas/WebhookId'}
        reason:
          type: string
          description: Why the endpoint was disabled, for example `failing`.
    WebhookTestData:
      type: object
      required: [message]
      properties:
        message:
          type: string
          const: hello

    MemberInvitedData:
      type: object
      required: [invitation_id, email_hint, role]
      properties:
        invitation_id: {$ref: '#/components/schemas/InvitationId'}
        email_hint:
          type: string
          description: The invited address, masked.
          examples: [k***@acmecarhire.example]
        role: {$ref: '#/components/schemas/InvitationRole'}
    MemberJoinedData:
      type: object
      required: [user_id, role]
      properties:
        user_id: {$ref: '#/components/schemas/UserId'}
        role: {$ref: '#/components/schemas/MemberRole'}
    MemberRoleChangedData:
      type: object
      required: [user_id, from, to]
      properties:
        user_id: {$ref: '#/components/schemas/UserId'}
        from: {$ref: '#/components/schemas/MemberRole'}
        to: {$ref: '#/components/schemas/MemberRole'}
    MemberRemovedData:
      type: object
      required: [user_id]
      properties:
        user_id: {$ref: '#/components/schemas/UserId'}
    BillingPlanChangedData:
      type: object
      required: [from_plan, to_plan, reason]
      properties:
        from_plan:
          type: string
          examples: [free]
        to_plan:
          type: string
          examples: [developer]
        reason:
          type: string
          description: '`payment_recovered`: the plan was restored after a late payment.'
          enum: [checkout, portal, payment_failed_grace_ended, payment_recovered, canceled, operator]
    BillingPaymentFailedData:
      type: object
      required: [grace_until]
      properties:
        grace_until:
          type: string
          format: date-time
          description: The workspace keeps its plan until then.
    BillingLimitReachedData:
      type: object
      required: [feature, granted, resets_at]
      properties:
        feature: {$ref: '#/components/schemas/UsageFeatureName'}
        granted:
          type: [integer, 'null']
          minimum: 0
        resets_at:
          type: [string, 'null']
          format: date-time
