openapi: 3.1.0
info:
  title: emitd API
  version: 0.3.0
  description: |
    Developer-first transactional + marketing email API built on Cloudflare's
    edge (Workers + D1 + R2 + Queues), backed by AWS SES for delivery.

    This document is generated directly from the `edge-api` Worker's route
    table and handler code (`services/edge-api/src/*.rs`) and the shared
    `email-core` crate (`packages/core/src/*.rs`) — every schema and error
    code below reflects what the code actually does, not an aspirational
    design. It is the source of truth client SDKs are generated from.

    ## Authentication
    Every route except `GET /health` requires a Bearer API key:
    ```
    Authorization: Bearer esk_live_...
    ```
    A key carries one of two permission levels (`email_core::KeyPermission`):
    - `full_access` — read + send + manage every resource (contacts,
      audiences, segments, broadcasts, automations, templates, webhooks,
      suppressions, messages, inbound).
    - `sending_access` — send email only (`POST /v1/email`,
      `POST /v1/email/batch`). Every other route responds `403
      insufficient_scope`.

    A `sending_access` key MAY additionally be scoped to a single verified
    sending domain; sends from any other domain fail with
    `from_domain_not_verified`.

    ## Rate limits and quotas
    Enforced per-tenant by a Durable Object (`DO_RATELIMIT`), consumed only
    by `POST /v1/email` and `POST /v1/email/batch` (one unit per email, so a
    batch of N consumes N):
    - **Per-minute burst**: a flat limit shared by all tenants/plans
      (`RATE_LIMIT_PER_MIN`, currently 100 requests/minute) — this is
      *not* tiered by plan today.
    - **Daily and monthly send quotas**: tiered by plan (`email_core::Plan`),
      tracked as rolling windows (not fixed-window resets):

      | Plan     | Daily quota | Monthly quota | Overage / 1,000 |
      |----------|------------:|---------------:|-----------------:|
      | Free     | 500         | 10,000         | none (hard stop)|
      | Starter  | 5,000       | 50,000         | $0.40 |
      | Growth   | 50,000      | 500,000        | $0.40 |
      | Business | 100,000     | 1,000,000      | $0.40 |
      | Scale    | 250,000     | 2,500,000      | $0.40 |

      A tenant that opted into overage lifts the monthly cap; an operator
      override on the tenant record takes precedence over both.

    Every response from a rate/quota-checked route includes:
    - `X-RateLimit-Limit` — the per-minute limit
    - `X-RateLimit-Remaining` — remaining requests in the current minute
    - `X-RateLimit-Reset` — seconds until the current minute window resets

    A `429` additionally includes `Retry-After` (seconds). A send rejected
    *after* a quota unit was consumed (validation failure, unverified
    domain, suppression) has that unit refunded — a rejected send never
    burns paid quota.

    ## Idempotency
    `POST /v1/email` and `POST /v1/email/batch` accept an `Idempotency-Key`
    header. A retried request with the same key returns the original result
    (marked `idempotent_replay: true` for single sends) without sending or
    billing again. Keys are tenant-scoped and do not expire on their own.

    ## Pagination
    Every list endpoint takes `?limit=` (default 25, max 100) and `?cursor=`.
    Two cursor styles are in use depending on the resource (see each
    endpoint): a bare epoch-millisecond value (`messages`, `suppressions`,
    `inbound`, `email/test-events`), or a composite `"{created_at}:{id}"`
    keyset cursor (`contacts`, `audiences`, `segments`, `broadcasts`,
    `automations`). `templates` and `webhooks` are not paginated — both
    routes return the tenant's full set.

    ## Errors
    Error bodies share a common envelope: `{"error": "<stable_code>",
    "detail"?: "<human-readable>", ...}` — additional fields (e.g. `field`,
    `max`, `limit`, `status`) appear on some codes; see each operation's
    responses. HTTP status carries the category: `400` malformed JSON,
    `401` missing/invalid key, `403` insufficient permission/scope, `404`
    not found (tenant-scoped — never distinguishes "doesn't exist" from
    "not yours"), `409` conflicting state transition, `402` plan-limit
    reached (contacts, webhooks), `422` validation failure, `429`
    rate-limited or over quota.

    ## Attachments
    Attachments can be uploaded via `POST /v1/attachments` (multipart form)
    and referenced by `r2_key` in a send's `attachments` array, or included
    inline as base64 in `content`. Maximum 10 MB per attachment/upload.

    ## Not covered by this document
    `POST /mcp` (a Model Context Protocol JSON-RPC endpoint for AI agents,
    not a REST resource) is intentionally out of scope for this OpenAPI
    document.
  contact:
    name: emitd Support
    url: https://emitd.dev/support
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.emitd.com
    description: >-
      Production API origin (the `edge-api` Worker, name "relay-edge";
      custom domain declared in services/edge-api/wrangler.toml).

tags:
  - name: Email
    description: Send transactional/marketing email, manage scheduled sends.
  - name: Messages
    description: Read-only message activity log.
  - name: Contacts
    description: Marketing contact store (CRUD, batch, CSV import/export).
  - name: Audiences
    description: Static contact lists and their membership.
  - name: Segments
    description: Saved (and ad-hoc) contact filters.
  - name: Suppressions
    description: Per-tenant suppression (do-not-send) list.
  - name: Templates
    description: Server-side rendered templates with immutable versions.
  - name: Webhooks
    description: Event webhook endpoint registration and replay.
  - name: Inbound
    description: Received email (Cloudflare Email Routing) activity.
  - name: Attachments
    description: Upload and retrieve email attachments via R2.
  - name: Broadcasts
    description: One-off marketing sends to an audience or segment.
  - name: Automations
    description: Event-triggered, multi-step marketing workflows.
  - name: Health
    description: Unauthenticated service health check.

security:
  - bearerAuth: []

paths:
  /v1/email:
    post:
      tags: [Email]
      summary: Send an email
      operationId: sendEmail
      description: |
        Pipeline: (optional) template render → validate → suppression
        check (global + per-tenant + erasure) → persist → enqueue to
        `sender`. `edge-api` never calls SES directly.

        Test mode (`test_mode: true` or `X-Relay-Test: true`) simulates
        delivery: no SES call, no queue write. Recipient local-parts at
        `relay.dev` drive the simulated outcome: `delivered@relay.dev` →
        sent + delivery event, `bounced@relay.dev` → failed + bounce event
        + auto-suppression, `complained@relay.dev` → sent + complaint event
        + auto-suppression. Any other address defaults to delivered.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: >-
            Unique key for safe retries, scoped to the tenant. A replay
            (same key) returns the original result without sending or
            consuming quota again.
        - name: X-Relay-Test
          in: header
          required: false
          schema:
            type: string
            enum: ["true", "false"]
          description: If "true", simulate delivery without sending (see description).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendRequest'
            examples:
              basic:
                summary: Basic email
                value:
                  from: hello@example.com
                  to: [user@example.com]
                  subject: Welcome to emitd!
                  html_body: <p>Hello!</p>
              template:
                summary: Server-rendered template
                value:
                  from: hello@example.com
                  to: [user@example.com]
                  template_alias: welcome-email
                  template_model: { first_name: Ada }
              with_attachments:
                summary: Email with an inline attachment
                value:
                  from: hello@example.com
                  to: [user@example.com]
                  subject: Invoice attached
                  html_body: "<p>Please find your invoice attached.</p>"
                  attachments:
                    - filename: invoice.pdf
                      content_type: application/pdf
                      content: JVBERi0xLjQK...
      responses:
        '200':
          description: >-
            Suppressed, scheduled, test-mode, or idempotent-replay result
            (not queued for immediate delivery). See `SendResult` for the
            possible shapes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResult'
        '202':
          description: Accepted and enqueued for immediate delivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: >-
            Validation failure, unverified sender domain, or unknown
            template alias.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                validation_failed:
                  value: { error: validation_failed, detail: "at least one recipient is required" }
                unverified_domain:
                  value: { error: from_domain_not_verified, from: hello@unverified.example }
                unknown_template:
                  value: { error: template_not_found, alias: welcome-email }
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/email/batch:
    post:
      tags: [Email]
      summary: Send a batch of emails
      operationId: sendBatch
      description: |
        Send up to 100 emails in one call. Each is validated and processed
        independently through the same pipeline as `POST /v1/email`; a
        per-email result is returned in input order. Consumes one
        rate-limit/quota unit per email in the batch (refunded per-email on
        rejection).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: >-
            Scopes the whole batch: a retried request with the same key
            returns the stored batch response verbatim, with no re-sends.
        - name: X-Relay-Test
          in: header
          required: false
          schema:
            type: string
            enum: ["true", "false"]
          description: If "true", simulate delivery for every email in the batch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchSendRequest'
      responses:
        '200':
          description: Batch processed (per-item results carry each item's own status/error).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SendResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: The batch itself is empty or exceeds 100 emails.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty:
                  value: { error: empty_batch }
                too_large:
                  value: { error: batch_too_large, max: 100 }
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/email/{id}:
    patch:
      tags: [Email]
      summary: Reschedule a queued or scheduled send
      operationId: rescheduleEmail
      description: Requires `Action::Manage` (a `full_access` key) — not available to a `sending_access` key.
      parameters:
        - $ref: '#/components/parameters/MessageIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheduled_at]
              properties:
                scheduled_at:
                  type: integer
                  format: int64
                  description: Epoch milliseconds; must be in the future.
      responses:
        '200':
          description: Rescheduled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
                  status: { type: string, enum: [scheduled] }
                  scheduled_at: { type: integer, format: int64 }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Missing `scheduled_at`, a past timestamp, or the message is no longer queued/scheduled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing_field:
                  value: { error: missing_field, field: scheduled_at }
                in_the_past:
                  value: { error: scheduled_at_must_be_in_the_future }
                wrong_state:
                  value: { error: message_not_reschedulable, status: sent }
    delete:
      tags: [Email]
      summary: Cancel a queued or scheduled send
      operationId: cancelScheduledEmail
      description: Requires `Action::Manage` (a `full_access` key).
      parameters:
        - $ref: '#/components/parameters/MessageIdParam'
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
                  status: { type: string, enum: [cancelled] }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: The message is no longer queued/scheduled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: message_not_cancellable
                status: sent

  /v1/email/test-events:
    get:
      tags: [Email]
      summary: List simulated events for test-mode sends
      operationId: listTestEvents
      description: >-
        Events generated by `test_mode`/`X-Relay-Test` sends only (joined
        against `messages.test_mode = 1`). Cursor is the bare epoch-ms
        `created_at` of the last row returned.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: integer
            format: int64
          description: Return rows strictly older than this epoch-ms timestamp.
      responses:
        '200':
          description: Test event list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestEvent'
                  next_cursor:
                    type: [integer, "null"]
                    format: int64
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/messages:
    get:
      tags: [Messages]
      summary: List message activity
      operationId: listMessages
      description: >-
        Newest-first, cursor is the bare epoch-ms `created_at` of the last
        row returned (`?cursor=` returns rows strictly older). Tag
        filtering uses D1 `json_extract()` against the `tags` JSON column;
        there is no `status` filter on this endpoint.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: integer
            format: int64
          description: Return rows strictly older than this epoch-ms timestamp.
        - name: tag_key
          in: query
          schema:
            type: string
          description: 'Filter to messages whose `tags` JSON has this key (e.g. `campaign`).'
        - name: tag_value
          in: query
          schema:
            type: string
          description: Combined with `tag_key` to filter on an exact value; alone it is ignored.
      responses:
        '200':
          description: Message list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MessageSummary'
                  next_cursor:
                    type: [integer, "null"]
                    format: int64
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/messages/{id}:
    get:
      tags: [Messages]
      summary: Get message detail
      operationId: getMessage
      parameters:
        - $ref: '#/components/parameters/MessageIdParam'
      responses:
        '200':
          description: Message detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/contacts:
    post:
      tags: [Contacts]
      summary: Create or upsert a contact
      operationId: createContact
      description: >-
        Upserts by normalized (lowercased/trimmed) email. Enforces the
        plan's `max_contacts` cap, but only when the email does not already
        exist — re-identifying a known contact never counts against the
        cap. Emits a `contact.created`/`contact.updated` webhook and, on
        genuine creation, enrolls the contact into any active
        `contact_created`-triggered automation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                attributes:
                  $ref: '#/components/schemas/ContactAttributes'
                status:
                  type: string
                  enum: [subscribed, unsubscribed, cleaned]
                  default: subscribed
                  description: Unrecognized values fall back to `subscribed`.
      responses:
        '200':
          description: An existing contact was updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactMutationResult'
        '201':
          description: A new contact was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactMutationResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '402':
          $ref: '#/components/responses/PlanLimitReached'
        '422':
          description: Invalid email, or `attributes` failed validation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_email: { value: { error: invalid_email } }
                too_many_keys: { value: { error: too_many_attribute_keys } }
                too_large: { value: { error: attributes_too_large } }
                bad_value: { value: { error: invalid_attribute_value } }
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      description: Newest-first keyset page over `(created_at, id)`.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
          description: '`"{created_at}:{id}"` from a previous page''s `next_cursor`.'
        - name: status
          in: query
          schema:
            type: string
            enum: [subscribed, unsubscribed, cleaned]
          description: An unrecognized value is silently dropped (no filter applied), not rejected.
      responses:
        '200':
          description: Contact list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/contacts/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact
      operationId: getContact
      parameters:
        - $ref: '#/components/parameters/ContactIdParam'
      responses:
        '200':
          description: Contact detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Contacts]
      summary: Update a contact
      operationId: patchContact
      description: >-
        `attributes` is a merge (incoming keys overlay existing; keys not
        present are kept) — not a replace. `status` changes only affect
        marketing eligibility.
      parameters:
        - $ref: '#/components/parameters/ContactIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                attributes:
                  $ref: '#/components/schemas/ContactAttributes'
                status:
                  type: string
                  enum: [subscribed, unsubscribed, cleaned]
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactMutationResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: '`attributes` failed validation, or `status` is not one of the known values.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [Contacts]
      summary: Delete a contact
      operationId: deleteContact
      description: Hard delete. Also removes the contact's audience memberships.
      parameters:
        - $ref: '#/components/parameters/ContactIdParam'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
                  email: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/contacts/batch:
    post:
      tags: [Contacts]
      summary: Upsert up to 100 contacts
      operationId: batchUpsertContacts
      description: >-
        Each item is validated/upserted independently — a bad item is a
        per-item failure, not a whole-batch rejection. The plan cap is
        checked once up front against the batch size, not per item.
        Deliberately emits no webhooks and does not trigger automation
        enrollment (to avoid a fan-out storm from a large batch).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string, format: email }
                      attributes:
                        $ref: '#/components/schemas/ContactAttributes'
                      status:
                        type: string
                        enum: [subscribed, unsubscribed, cleaned]
      responses:
        '200':
          description: Batch processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        email: { type: string }
                        id: { type: string }
                        status: { type: string }
                        created: { type: boolean }
                        error: { type: string }
                  summary:
                    type: object
                    properties:
                      created: { type: integer }
                      updated: { type: integer }
                      failed: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: The batch is empty or exceeds 100 items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty: { value: { error: empty_batch } }
                too_large: { value: { error: batch_too_large, max: 100 } }

  /v1/contacts/import:
    post:
      tags: [Contacts]
      summary: Start an async CSV contact import
      operationId: createContactImport
      description: >-
        Uploads the CSV to R2 and inserts a `pending` job; a per-minute
        cron parses and upserts it in bounded batches (`IMPORT_BATCH` = 500
        data rows/tick) so an import of any size never blows one Worker
        invocation's budget. Poll `GET /v1/contacts/imports/{id}` for
        progress. The CSV's header row must contain an `email` column;
        every other column becomes a contact attribute. Emits
        `contact.import.completed` when done.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV file, max 10 MB.
      responses:
        '202':
          description: Import job created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  import_id: { type: string }
                  status: { type: string, enum: [pending] }
        '400':
          description: Missing or empty file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing: { value: { error: missing_file } }
                empty: { value: { error: empty_file } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: File exceeds 10 MB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: file_too_large, max_bytes: 10485760 }

  /v1/contacts/imports/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact import job's status
      operationId: getContactImport
      parameters:
        - $ref: '#/components/parameters/JobIdParam'
      responses:
        '200':
          description: Import job status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactImportStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/contacts/export:
    post:
      tags: [Contacts]
      summary: Start an async GDPR contact export
      operationId: createContactExport
      description: >-
        Exports the caller's own tenant's contacts as CSV. `Action::Read`
        (not `Manage`) — exporting your own data is a read. A per-minute
        cron builds the CSV (keyset-paginated, `EXPORT_BATCH` = 1000
        rows/page within one tick) and writes it to R2; poll
        `GET /v1/contacts/exports/{id}` for the `download_url`.
      responses:
        '202':
          description: Export job created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  export_id: { type: string }
                  status: { type: string, enum: [pending] }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/contacts/exports/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact export job's status
      operationId: getContactExport
      parameters:
        - $ref: '#/components/parameters/JobIdParam'
      responses:
        '200':
          description: >-
            Export job status. `download_url` is present once `status`
            is `done`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactExportStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/contacts/exports/{id}/download:
    get:
      tags: [Contacts]
      summary: Download a finished contact export
      operationId: downloadContactExport
      description: >-
        Streams the CSV from R2. Returns 404 (never 403) if the job
        doesn't exist, isn't owned by the caller's tenant, or isn't `done`
        yet — an export id never confirms another tenant's job.
      parameters:
        - $ref: '#/components/parameters/JobIdParam'
      responses:
        '200':
          description: The CSV file.
          content:
            text/csv:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/audiences:
    post:
      tags: [Audiences]
      summary: Create an audience
      operationId: createAudience
      description: Creates an empty static contact list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '201':
          description: Created. Note the response omits `created_at`/`updated_at` (unlike the GET representation).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Empty/blank name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: invalid_name }
    get:
      tags: [Audiences]
      summary: List audiences
      operationId: listAudiences
      description: Newest-first keyset page over `(created_at, id)`.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
          description: '`"{created_at}:{id}"` from a previous page''s `next_cursor`.'
      responses:
        '200':
          description: Audience list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Audience'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/audiences/{id}:
    get:
      tags: [Audiences]
      summary: Get an audience
      operationId: getAudience
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
      responses:
        '200':
          description: Audience detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Audience'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Audiences]
      summary: Rename or redescribe an audience
      operationId: patchAudience
      description: Fields omitted from the body keep their current value.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceMutationResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Empty/blank name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [Audiences]
      summary: Delete an audience
      operationId: deleteAudience
      description: Hard delete, including all membership rows.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/audiences/{id}/members:
    post:
      tags: [Audiences]
      summary: Add one contact to an audience
      operationId: addAudienceMember
      description: >-
        Idempotent — re-adding an existing member is a no-op (200), not an
        error. On a genuine add, enrolls the contact into any active
        `audience_added`-triggered automation configured for this
        audience.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id: { type: string }
      responses:
        '200':
          description: Already a member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceMemberResult'
        '201':
          description: Added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceMemberResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The audience itself does not exist / is not owned by the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: '`contact_id` does not exist / is not owned by the caller.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: contact_not_found }
    get:
      tags: [Audiences]
      summary: List an audience's members
      operationId: listAudienceMembers
      description: Oldest-first keyset page over `(created_at, id)` of the member contacts.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Member list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/audiences/{id}/members/bulk:
    post:
      tags: [Audiences]
      summary: Add up to 100 contacts to an audience
      operationId: addAudienceMembersBulk
      description: >-
        Same idempotent-add + automation-enrollment behavior as the
        single-member endpoint, tallied into a summary. A `contact_id` not
        owned by the tenant is a per-item failure, not a batch abort.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_ids]
              properties:
                contact_ids:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string }
      responses:
        '200':
          description: Batch processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        contact_id: { type: string }
                        added: { type: boolean }
                        error: { type: string }
                  summary:
                    type: object
                    properties:
                      added: { type: integer }
                      skipped: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Empty batch or more than 100 ids.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty: { value: { error: empty_batch } }
                too_large: { value: { error: batch_too_large, max: 100 } }

  /v1/audiences/{id}/members/{contact_id}:
    delete:
      tags: [Audiences]
      summary: Remove a contact from an audience
      operationId: removeAudienceMember
      description: >-
        Idempotent — removing a non-member (or an already-removed one)
        still returns 200 `deleted:true`.
      parameters:
        - $ref: '#/components/parameters/AudienceIdParam'
        - name: contact_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Removed (or was already not a member).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  audience_id: { type: string }
                  contact_id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The audience itself does not exist / is not owned by the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/segments:
    post:
      tags: [Segments]
      summary: Create a saved segment
      operationId: createSegment
      description: >-
        `filter` is validated by `email_core::parse_filter` and stored as
        submitted; it is re-validated and re-compiled to SQL on every
        `preview`/`count`/broadcast-fanout use, never trusted as
        pre-compiled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, filter]
              properties:
                name:
                  type: string
                filter:
                  $ref: '#/components/schemas/SegmentFilter'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Segment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Empty/blank name, or `filter` failed validation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_name: { value: { error: invalid_name } }
                unknown_field: { value: { error: unknown_field } }
                unknown_op: { value: { error: unknown_op } }
                bad_value: { value: { error: bad_value } }
                bad_attribute_key: { value: { error: bad_attribute_key } }
                too_many_conditions: { value: { error: too_many_conditions } }
    get:
      tags: [Segments]
      summary: List segments
      operationId: listSegments
      description: Newest-first keyset page over `(created_at, id)`.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Segment list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Segment'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/segments/preview:
    post:
      tags: [Segments]
      summary: Preview an ad-hoc filter without saving it
      operationId: previewAdhocSegment
      description: Compiles `filter` and returns its live count plus a first page of matching contacts — powers a live segment builder while the user is still editing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filter]
              properties:
                filter:
                  $ref: '#/components/schemas/SegmentFilter'
                limit:
                  type: integer
                  minimum: 1
                  maximum: 100
                  default: 25
      responses:
        '200':
          description: Live count + page of matches.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /v1/segments/{id}:
    get:
      tags: [Segments]
      summary: Get a segment
      operationId: getSegment
      parameters:
        - $ref: '#/components/parameters/SegmentIdParam'
      responses:
        '200':
          description: Segment detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Segment'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Segments]
      summary: Rename or replace a segment's filter
      operationId: patchSegment
      description: Fields omitted from the body keep their current value; a replacement `filter` is re-validated.
      parameters:
        - $ref: '#/components/parameters/SegmentIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                filter:
                  $ref: '#/components/schemas/SegmentFilter'
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Segment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [Segments]
      summary: Delete a segment
      operationId: deleteSegment
      parameters:
        - $ref: '#/components/parameters/SegmentIdParam'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/segments/{id}/preview:
    get:
      tags: [Segments]
      summary: Preview a saved segment's matching contacts
      operationId: previewSegment
      description: Oldest-first keyset page over `(created_at, id)` of contacts matching the segment's compiled filter.
      parameters:
        - $ref: '#/components/parameters/SegmentIdParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Matching contacts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/segments/{id}/count:
    get:
      tags: [Segments]
      summary: Get a saved segment's live match count
      operationId: countSegment
      parameters:
        - $ref: '#/components/parameters/SegmentIdParam'
      responses:
        '200':
          description: Live count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/suppressions:
    get:
      tags: [Suppressions]
      summary: List suppressions
      operationId: listSuppressions
      description: Newest-first; cursor is the bare epoch-ms `created_at` of the last row returned.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Suppression list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Suppression'
                  next_cursor:
                    type: [integer, "null"]
                    format: int64
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Suppressions]
      summary: Add a suppression
      operationId: addSuppression
      description: >-
        API-added suppressions are always stored as `reason: "manual"` —
        any `reason` supplied in the body is ignored (`hard_bounce`/
        `complaint`/`unsubscribe` are set only by the verified events
        pipeline). Upserts on `(tenant_id, email)`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                reason:
                  type: string
                  description: Accepted but ignored; always stored as `manual`.
      responses:
        '201':
          description: Added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { type: string }
                  reason: { type: string, enum: [manual] }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid email.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: invalid_email }

  /v1/suppressions/bulk:
    post:
      tags: [Suppressions]
      summary: Add up to 100 suppressions
      operationId: bulkAddSuppressions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [email]
                    properties:
                      email:
                        type: string
                        format: email
      responses:
        '200':
          description: Per-email results (an invalid email is a per-item failure, not a batch abort).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        email: { type: string }
                        status: { type: string, enum: [suppressed] }
                        error: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Empty batch or more than 100 emails.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty: { value: { error: empty_batch } }
                too_large: { value: { error: batch_too_large, max: 100 } }
    delete:
      tags: [Suppressions]
      summary: Remove up to 100 suppressions
      operationId: bulkRemoveSuppressions
      description: Uses the JSON-body-with-DELETE pattern.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: string
                    format: email
      responses:
        '200':
          description: Per-email removal results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        email: { type: string }
                        deleted: { type: boolean }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Empty batch or more than 100 emails.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/suppressions/{email}:
    delete:
      tags: [Suppressions]
      summary: Remove a suppression
      operationId: deleteSuppression
      description: >-
        Always returns 200 `deleted:true` regardless of whether the email
        was actually suppressed — this endpoint does not check existence
        first.
      parameters:
        - name: email
          in: path
          required: true
          schema:
            type: string
            format: email
      responses:
        '200':
          description: Removed (or was never present).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  email: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/templates:
    post:
      tags: [Templates]
      summary: Create a template
      operationId: createTemplate
      description: >-
        Creates the template shell only — call
        `POST /v1/templates/{alias}/versions` to give it renderable
        content. `alias` must be unique per tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alias, name]
              properties:
                alias:
                  type: string
                name:
                  type: string
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  alias: { type: string }
                  name: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: '`alias` already exists for this tenant.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: alias_exists }
        '422':
          description: Empty/blank `alias` or `name`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: alias_and_name_required }
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      description: Returns the tenant's full set of templates, newest first. Not paginated.
      responses:
        '200':
          description: Template list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Template'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/templates/{alias}:
    get:
      tags: [Templates]
      summary: Get a template and its versions
      operationId: getTemplate
      parameters:
        - name: alias
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Template plus every version (newest first).
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    $ref: '#/components/schemas/Template'
                  versions:
                    type: array
                    items:
                      $ref: '#/components/schemas/TemplateVersion'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/templates/{alias}/versions:
    post:
      tags: [Templates]
      summary: Create a new template version
      operationId: createTemplateVersion
      description: >-
        Each call creates a new immutable version. When `activate` (default
        `true`), every other version is deactivated first — `POST
        /v1/email`'s `template_alias` rendering always uses the single
        active version.
      parameters:
        - name: alias
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                subject:
                  type: string
                html_body:
                  type: string
                text_body:
                  type: string
                activate:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Version created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: integer
                    description: 1-based, monotonically increasing per template.
                  active:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Empty `subject`, or neither `html_body` nor `text_body` supplied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                subject_required: { value: { error: subject_required } }
                body_required: { value: { error: body_required } }

  /v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      operationId: createWebhook
      description: >-
        `secret` (an HMAC-SHA256 signing key, `whsec_...`) is generated
        server-side and returned **only in this response** — it is never
        shown again (subsequent `GET /v1/webhooks` calls return an empty
        `secret`). Rejected once the tenant is at its plan's
        `max_webhooks` cap (a defence-in-depth limit — the events pipeline
        fans out one subrequest per active webhook per event).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Must start with `https://`.
                events:
                  type: array
                  description: Event types to subscribe to. Omit (or send `[]`) to receive all of them.
                  items:
                    $ref: '#/components/schemas/EventType'
      responses:
        '201':
          description: Created — `secret` is shown exactly once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: The tenant's plan `max_webhooks` cap has been reached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: webhook_limit_reached
                limit: 5
                message: You've reached your plan's webhook-endpoint limit. Upgrade to add more.
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: '`url` is not HTTPS, or `events` contains an unrecognized event type.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                not_https: { value: { error: url_must_be_https, detail: "Webhook URLs must use HTTPS for security." } }
                bad_event: { value: { error: invalid_event, event: "not.a.real.event" } }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhooks
      description: >-
        Returns the tenant's full set (not paginated). Note: unlike the
        creation response, each item's `secret` is always an empty string
        here (the list query never selects the secret column) and
        `events` is the raw comma-separated string, not an array.
      responses:
        '200':
          description: Webhook list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookListItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      operationId: deleteWebhook
      description: Always returns 200 `deleted:true`; does not check existence first.
      parameters:
        - $ref: '#/components/parameters/WebhookIdParam'
      responses:
        '200':
          description: Deleted (or was never present).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/webhooks/{id}/replay:
    post:
      tags: [Webhooks]
      summary: Replay webhook deliveries
      operationId: replayWebhook
      description: >-
        Re-executes up to the 50 most recent matching deliveries,
        recording each as a new `webhook_deliveries` row (never mutates
        the original).
      parameters:
        - $ref: '#/components/parameters/WebhookIdParam'
        - name: status
          in: query
          schema:
            type: string
            enum: [failed, all]
            default: failed
          description: Which stored deliveries to replay.
      responses:
        '200':
          description: Replay results (or an empty list with an informational `message` if nothing matched).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReplayResult'
                  message:
                    type: string
                    description: '`"no_failed_deliveries"` when `data` is empty.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Webhook not found / not owned by the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: webhook_not_found }

  /v1/inbound:
    get:
      tags: [Inbound]
      summary: List received email
      operationId: listInbound
      description: >-
        Messages received via Cloudflare Email Routing. Newest-first;
        cursor is the bare epoch-ms `created_at` of the last row returned.
        Note the `from_addr`/`to_addr` field names here — the single-item
        `GET` below uses `from`/`to` instead (see `InboundMessageDetail`).
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Inbound message list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InboundMessageSummary'
                  next_cursor:
                    type: [integer, "null"]
                    format: int64
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/inbound/{id}:
    get:
      tags: [Inbound]
      summary: Get a received message, including raw MIME
      operationId: getInboundMessage
      description: >-
        The raw message is returned byte-for-byte from R2 exactly as
        ingested (never parsed) — `raw` is `null` if the R2 object is
        missing (metadata is still returned rather than a 500).
      parameters:
        - $ref: '#/components/parameters/MessageIdParam'
      responses:
        '200':
          description: Inbound message detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundMessageDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/attachments:
    post:
      tags: [Attachments]
      summary: Upload an attachment
      operationId: uploadAttachment
      description: >-
        Uploads a file to R2 under `{tenant_id}/{message_id}/{filename}`.
        Returns an `r2_key` to reference in a `SendRequest`'s
        `attachments[].r2_key`. `message_id` is an arbitrary caller-chosen
        grouping value (defaults to a random uuid if omitted) — it does
        not have to be a real message id ahead of time. Max 10 MB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                message_id:
                  type: string
                  description: Optional grouping key for the R2 path. A random uuid is used if omitted.
      responses:
        '200':
          description: Uploaded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AttachmentUploadResult'
        '400':
          description: 'No `file` field in the multipart body.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "missing file"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          description: File exceeds 10 MB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: file_too_large, max_bytes: 10485760 }

  /attachments/{key}:
    get:
      tags: [Attachments]
      summary: Download an attachment
      operationId: serveAttachment
      description: >-
        Not under `/v1` — a tenant-scoped convenience endpoint, not a
        public CDN. Requires an API key whose tenant matches the object
        key's `{tenant_id}/...` prefix; a mismatched or missing key
        returns 404 (never 403), so a leaked/guessed key can't be used to
        even confirm another tenant's attachment exists. The response is
        forced to download (`Content-Disposition: attachment`,
        `X-Content-Type-Options: nosniff`) and privately cached
        (5 minutes).
      parameters:
        - name: key
          in: path
          required: true
          schema:
            type: string
          description: 'The full R2 object key, `{tenant_id}/{message_id}/{filename}` (as returned by `POST /v1/attachments`).'
      responses:
        '200':
          description: The attachment file.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Not found, or the key does not belong to the caller's tenant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /health:
    get:
      tags: [Health]
      summary: Health check
      operationId: healthCheck
      description: Unauthenticated. Always returns 200 from a running Worker — there is no dependency check (D1/R2/queue reachability is not probed).
      security: []
      responses:
        '200':
          description: Healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                  service:
                    type: string
                    enum: [edge-api]
                  api_version:
                    type: string
                    description: The API version this deployment speaks (echoed for clients that send `Relay-Version`; only one version exists today).
                    example: "2024-01-01"

  /v1/broadcasts:
    post:
      tags: [Broadcasts]
      summary: Create a draft broadcast
      operationId: createBroadcast
      description: >-
        `name` is the only required field — everything else can be filled
        in later via `PATCH` while still a `draft`. `target_kind`/
        `target_id` must be supplied together (or not at all), and if
        supplied must resolve to a tenant-owned audience/segment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                subject: { type: string }
                from_domain_id: { type: string, description: A verified sending domain id owned by the tenant. }
                body_html: { type: string }
                body_text: { type: string }
                target_kind: { type: string, enum: [audience, segment] }
                target_id: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Broadcast'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Empty/blank name, or an invalid/unresolvable target.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_name: { value: { error: invalid_name } }
                invalid_target: { value: { error: invalid_target } }
                invalid_target_kind: { value: { error: invalid_target_kind } }
                target_not_found: { value: { error: target_not_found } }
    get:
      tags: [Broadcasts]
      summary: List broadcasts
      operationId: listBroadcasts
      description: Newest-first keyset page over `(created_at, id)`.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Broadcast list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Broadcast'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/broadcasts/{id}:
    get:
      tags: [Broadcasts]
      summary: Get a broadcast
      operationId: getBroadcast
      parameters:
        - $ref: '#/components/parameters/BroadcastIdParam'
      responses:
        '200':
          description: Broadcast detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Broadcast'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Broadcasts]
      summary: Edit a draft broadcast
      operationId: patchBroadcast
      description: >-
        Only allowed while `status = draft` (else 409 `not_editable`) —
        editing a scheduled/sending/finished broadcast out from under the
        fan-out cron would be unsafe. Fields omitted from the body keep
        their current value; `target_kind`/`target_id` are re-validated
        only when this request touches either of them.
      parameters:
        - $ref: '#/components/parameters/BroadcastIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                subject: { type: string }
                from_domain_id: { type: string }
                body_html: { type: string }
                body_text: { type: string }
                target_kind: { type: string, enum: [audience, segment] }
                target_id: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Broadcast'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The broadcast is not a `draft`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: not_editable }
        '422':
          description: Empty/blank name, or an invalid/unresolvable target.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [Broadcasts]
      summary: Delete a broadcast
      operationId: deleteBroadcast
      description: Hard delete, tenant-scoped. No status restriction — this can delete a broadcast in any state.
      parameters:
        - $ref: '#/components/parameters/BroadcastIdParam'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/broadcasts/{id}/send:
    post:
      tags: [Broadcasts]
      summary: Validate and send (or schedule) a draft broadcast
      operationId: sendBroadcast
      description: >-
        Only callable from `draft` (else 409 `not_draft`). Requires a
        non-empty subject, a non-empty `body_html` or `body_text`, a
        target that still resolves, and a verified `from_domain_id`.
        Computes a best-effort `total_recipients` snapshot, then sets
        `status = scheduled` (if `scheduled_at` is in the future) or
        `status = sending` (the fan-out cron picks it up on its next
        per-minute tick — this call does not enqueue anything itself).
      parameters:
        - $ref: '#/components/parameters/BroadcastIdParam'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at:
                  type: integer
                  format: int64
                  description: Epoch milliseconds. Omitted or in the past → send now.
      responses:
        '200':
          description: 'Transitioned to `scheduled` or `sending`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Broadcast'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not a draft, or the draft is incomplete/invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                not_draft: { value: { error: not_draft } }
                invalid_subject: { value: { error: invalid_subject } }
                invalid_body: { value: { error: invalid_body } }
                invalid_target: { value: { error: invalid_target } }
                domain_not_verified: { value: { error: domain_not_verified } }

  /v1/broadcasts/{id}/cancel:
    post:
      tags: [Broadcasts]
      summary: Cancel a draft or scheduled broadcast
      operationId: cancelBroadcast
      description: >-
        `draft`/`scheduled` → `cancelled`. Any other status (already
        `sending`/`paused`/`sent`/`cancelled`) is a 409 `not_cancelable` —
        including the race where the fan-out cron claimed it between your
        read and this call.
      parameters:
        - $ref: '#/components/parameters/BroadcastIdParam'
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Broadcast'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not cancelable in the broadcast's current status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: not_cancelable }

  /v1/automations:
    post:
      tags: [Automations]
      summary: Create a draft automation
      operationId: createAutomation
      description: >-
        `name` + a valid `trigger_kind` are required; everything else can
        be filled in via `PATCH` before activation. `steps` (default `[]`)
        is validated by `email_core::validate_steps` when supplied — see
        `AutomationStep` for the per-type shape and forward-only jump
        rule (loops are structurally impossible).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, trigger_kind]
              properties:
                name:
                  type: string
                trigger_kind:
                  type: string
                  enum: [contact_created, audience_added, manual]
                trigger_config:
                  type: object
                  description: '`{"audience_id": "<owned audience id>"}` when `trigger_kind` is `audience_added`; ignored/`{}` otherwise.'
                  default: {}
                from_domain_id:
                  type: string
                  description: A verified sending domain id — required before `activate` will succeed.
                steps:
                  type: array
                  default: []
                  maxItems: 50
                  items:
                    $ref: '#/components/schemas/AutomationStep'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid name/trigger/steps.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_name: { value: { error: invalid_name } }
                invalid_trigger_kind: { value: { error: invalid_trigger_kind } }
                missing_audience_id: { value: { error: missing_audience_id } }
                audience_not_found: { value: { error: audience_not_found } }
                steps_not_array: { value: { error: steps_not_array } }
                too_many_steps: { value: { error: too_many_steps } }
                step_not_object: { value: { error: step_not_object } }
                missing_field: { value: { error: missing_field } }
                unknown_step_type: { value: { error: unknown_step_type } }
                empty_subject: { value: { error: empty_subject } }
                missing_body: { value: { error: missing_body } }
                wait_out_of_range: { value: { error: wait_out_of_range } }
                bad_jump_target: { value: { error: bad_jump_target } }
                bad_branch_filter: { value: { error: bad_branch_filter } }
    get:
      tags: [Automations]
      summary: List automations
      operationId: listAutomations
      description: Newest-first keyset page over `(created_at, id)`.
      parameters:
        - $ref: '#/components/parameters/LimitParam'
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Automation list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Automation'
                  next_cursor:
                    type: [string, "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /v1/automations/{id}:
    get:
      tags: [Automations]
      summary: Get an automation
      operationId: getAutomation
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      responses:
        '200':
          description: Automation detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Automations]
      summary: Edit a draft automation
      operationId: patchAutomation
      description: >-
        Only allowed while `status = draft` (else 409 `not_editable`) —
        editing a live program out from under in-flight enrollments would
        be unsafe. Fields omitted from the body keep their current value;
        the trigger is re-validated whenever `trigger_kind`/
        `trigger_config` is touched.
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                trigger_kind:
                  type: string
                  enum: [contact_created, audience_added, manual]
                trigger_config:
                  type: object
                from_domain_id:
                  type: string
                steps:
                  type: array
                  maxItems: 50
                  items:
                    $ref: '#/components/schemas/AutomationStep'
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          description: Invalid trigger or steps (same codes as `POST /v1/automations`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags: [Automations]
      summary: Delete an automation
      operationId: deleteAutomation
      description: Hard delete the automation and its enrollments.
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, enum: [true] }
                  id: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/automations/{id}/activate:
    post:
      tags: [Automations]
      summary: Activate an automation
      operationId: activateAutomation
      description: >-
        `draft`/`paused` → `active`. Requires a non-empty, valid step
        program, a valid trigger, and a verified `from_domain_id` (send
        steps need a verified marketing sender).
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      responses:
        '200':
          description: Activated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not activatable from the current status, or the program/trigger/domain is not ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                not_activatable: { value: { error: not_activatable } }
                no_steps: { value: { error: no_steps } }
                invalid_steps: { value: { error: invalid_steps } }
                invalid_trigger: { value: { error: invalid_trigger } }
                domain_not_verified: { value: { error: domain_not_verified } }

  /v1/automations/{id}/pause:
    post:
      tags: [Automations]
      summary: Pause an active automation
      operationId: pauseAutomation
      description: '`active` → `paused`. In-flight enrollments are held (not cancelled) — reactivating resumes them where they parked.'
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      responses:
        '200':
          description: Paused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not currently `active`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: not_pausable }

  /v1/automations/{id}/archive:
    post:
      tags: [Automations]
      summary: Archive an automation
      operationId: archiveAutomation
      description: >-
        Terminal. Any non-archived status → `archived`, and every
        still-active enrollment is cancelled in the same call.
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already archived.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: already_archived }

  /v1/automations/{id}/enroll:
    post:
      tags: [Automations]
      summary: Manually enroll a contact
      operationId: enrollInAutomation
      description: >-
        The automation must be `active`. Idempotent —
        `UNIQUE(automation_id, contact_id)` makes re-enrolling a no-op
        (200), a genuine enrollment is 201.
      parameters:
        - $ref: '#/components/parameters/AutomationIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id:
                  type: string
      responses:
        '200':
          description: Already enrolled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollmentResult'
        '201':
          description: Enrolled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollmentResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The automation is not `active`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: not_active }
        '422':
          description: '`contact_id` does not exist / is not owned by the caller.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example: { error: contact_not_found }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key sent as `Authorization: Bearer <key>`. Looked up by SHA-256
        hash (`email_core::sha256_hex`) against `api_keys.hash`; revoked
        keys and keys belonging to a suspended tenant are rejected. See the
        top-level Authentication section for permission levels.

  parameters:
    LimitParam:
      name: limit
      in: query
      required: false
      description: Max rows to return.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    MessageIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: The message id returned by `POST /v1/email`(`/batch`).
    ContactIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
    JobIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: The `import_id`/`export_id` returned when the job was created.
    AudienceIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
    SegmentIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
    WebhookIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
    BroadcastIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
    AutomationIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string

  responses:
    BadRequest:
      description: Malformed JSON body.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: invalid_json
    Unauthorized:
      description: Missing, invalid, or revoked API key; or the owning tenant is suspended.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
    Forbidden:
      description: The key's permission level does not allow this action (e.g. a `sending_access` key calling a read/manage route).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: insufficient_scope
    NotFound:
      description: >-
        No such resource, or it does not belong to the authenticated
        tenant. Tenant-scoped lookups deliberately return the same 404 for
        both cases rather than a 403, so a key can never be used to probe
        for another tenant's resource ids.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_found
    PlanLimitReached:
      description: >-
        A plan cap (contacts, webhook endpoints) has been reached. Not a
        billing/quota gate — an operator or self-serve plan upgrade raises
        the limit.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: contact_limit_reached
            limit: 1000
    ValidationError:
      description: The request body failed validation. `error` carries a stable machine-readable code.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: The resource's current state does not allow this operation (e.g. editing a non-draft broadcast).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: >-
        Per-minute rate limit or daily/monthly send quota exceeded. Only
        emitted by `POST /v1/email` and `POST /v1/email/batch`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
        X-RateLimit-Reset:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rate_limit:
              summary: Per-minute burst limit
              value: { error: rate_limit_exceeded }
            quota:
              summary: Daily/monthly send quota
              value: { error: quota_exceeded }

  schemas:
    Error:
      type: object
      description: >-
        The common error envelope. `error` is a stable, machine-readable
        code (e.g. `invalid_email`, `not_found`, `alias_exists`); `detail`
        is an optional human-readable elaboration. Some codes carry
        additional context fields (e.g. `field`, `max`, `limit`, `status`,
        `alias`) documented on the operation that returns them.
      required:
        - error
      properties:
        error:
          type: string
        detail:
          type: string
      additionalProperties: true

    # ── Email ──────────────────────────────────────────────────────────────
    SendRequest:
      type: object
      description: >-
        Mirrors `email_core::SendRequest`. Only `from` is required for the
        JSON body to deserialize; `validate_send_request` additionally
        requires at least one recipient (across to/cc/bcc), a non-empty
        subject, and at least one of `html_body`/`text_body` — UNLESS
        `template_alias` is set, in which case the template supplies
        subject/body before validation runs.
      required:
        - from
      properties:
        from:
          type: string
          format: email
          description: Sender address; its domain must be a verified sending domain for the tenant (and match the key's domain scope, if scoped).
          example: hello@example.com
        to:
          type: array
          items: { type: string, format: email }
          default: []
        cc:
          type: array
          items: { type: string, format: email }
          default: []
        bcc:
          type: array
          items: { type: string, format: email }
          default: []
        reply_to:
          type: [string, "null"]
          format: email
        subject:
          type: string
          maxLength: 1000
          description: Required by validation unless a template supplies it.
        html_body:
          type: [string, "null"]
        text_body:
          type: [string, "null"]
        message_stream:
          type: string
          enum: [outbound, broadcast]
          default: outbound
          description: Postmark-style stream separation.
        tag:
          type: [string, "null"]
          description: Legacy single tag; prefer `tags`.
        tags:
          type: [object, "null"]
          additionalProperties: { type: string }
          example: { campaign: summer, user_id: "123" }
        template_alias:
          type: [string, "null"]
          description: Server-side template alias to render (see Templates). Renders subject/html_body/text_body before validation.
        template_model:
          type: [object, "null"]
          description: Variables for `template_alias` rendering.
        scheduled_at:
          type: [integer, "null"]
          format: int64
          description: Epoch milliseconds. When in the future, the message is persisted as `scheduled` and enqueued by a per-minute cron once due.
        attachments:
          type: [array, "null"]
          maxItems: 10
          items:
            $ref: '#/components/schemas/Attachment'
        test_mode:
          type: [boolean, "null"]
          description: Simulate delivery without sending (same effect as the `X-Relay-Test` header).

    Attachment:
      type: object
      required:
        - filename
        - content_type
      properties:
        filename:
          type: string
          example: invoice.pdf
        content_type:
          type: string
          example: application/pdf
        content:
          type: [string, "null"]
          format: base64
          description: Base64-encoded inline content. Mutually exclusive with `r2_key` in practice (inline content is used as-is if present).
        r2_key:
          type: [string, "null"]
          description: R2 object key returned by `POST /v1/attachments`.
        size:
          type: [integer, "null"]
          format: int64

    SendResult:
      type: object
      description: >-
        The heterogeneous per-email result shape returned by
        `POST /v1/email` (200/202) and as each item of
        `POST /v1/email/batch`'s `data` array. Exactly which fields are
        present depends on `status`/`error`.
      properties:
        message_id:
          type: string
        status:
          type: string
          enum: [queued, suppressed, scheduled, sent, failed]
        ses_message_id:
          type: string
          description: Present only for test-mode sends (a fake placeholder id).
        test_mode:
          type: boolean
        scheduled_at:
          type: integer
          format: int64
          description: Present when `status` is `scheduled`.
        reason:
          type: string
          description: 'Present when suppressed via the cross-tenant blocklist: `"global_suppression"`.'
        idempotent_replay:
          type: boolean
          description: True when this response is a replay of a previous idempotent request.
        error:
          type: string
          description: Stable error code when this item was rejected (e.g. `validation_failed`, `from_domain_not_verified`, `template_not_found`).
        detail:
          type: string
        alias:
          type: string
          description: Present on `template_not_found`.
        from:
          type: string
          description: Present on `from_domain_not_verified`.
      additionalProperties: true

    BatchSendRequest:
      type: object
      required:
        - emails
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/SendRequest'

    TestEvent:
      type: object
      description: A simulated delivery event recorded for a test-mode send.
      properties:
        id:
          type: string
        message_id:
          type: [string, "null"]
        type:
          type: string
          description: Event type (e.g. `delivery`, `bounce`, `complaint`).
        payload:
          type: [string, "null"]
        created_at:
          type: integer
          format: int64

    # ── Messages ───────────────────────────────────────────────────────────
    MessageSummary:
      type: object
      description: >-
        The exact row shape returned by `GET /v1/messages`(`/{id}`) — note
        the `from_addr`/`to_addr` field names (this endpoint's own local
        row type, distinct from the richer `email_core::Message` used
        internally; it does not include `tenant_id`, `message_stream`,
        `tags`, or `scheduled_at`).
      properties:
        id:
          type: string
        from_addr:
          type: string
        to_addr:
          type: string
          description: The primary (first `to`) recipient only — the full recipient set is not stored.
        subject:
          type: string
        status:
          type: string
          enum: [queued, sent, failed, suppressed, scheduled, cancelled]
        ses_message_id:
          type: [string, "null"]
        error:
          type: [string, "null"]
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    # ── Contacts ───────────────────────────────────────────────────────────
    ContactAttributes:
      type: object
      description: >-
        Flat schemaless attributes (`email_core::validate_attributes`):
        object only, at most 50 keys, ≤8 KiB serialized, and every value
        must be a string/number/bool/null — no nested objects or arrays.
      additionalProperties:
        type: [string, number, boolean, "null"]
      default: {}

    Contact:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        status:
          type: string
          enum: [subscribed, unsubscribed, cleaned]
        attributes:
          $ref: '#/components/schemas/ContactAttributes'
        source:
          type: string
          description: '`"api"`, `"csv"` (import), or another ingestion source.'
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    ContactMutationResult:
      type: object
      description: The intentionally minimal response from create/patch (no attributes/source/timestamps).
      properties:
        id:
          type: string
        email:
          type: string
        status:
          type: string
          enum: [subscribed, unsubscribed, cleaned]

    ContactImportStatus:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
          enum: [pending, processing, done, failed]
        total:
          type: integer
          description: Data rows seen so far (0 until the first tick reads the header).
        imported:
          type: integer
        failed:
          type: integer
        error_sample:
          type: array
          description: Up to 100 row-level failures. Never the raw CSV line (may carry PII) — only the row number and a stable error code.
          items:
            type: object
            properties:
              row:
                type: integer
                description: 1-based data-row number (header excluded).
              error:
                type: string
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    ContactExportStatus:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
          enum: [pending, processing, done, failed]
        format:
          type: string
          enum: [csv]
        download_url:
          type: string
          description: 'Present only once `status` is `done`: `/v1/contacts/exports/{id}/download`.'
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    # ── Audiences ──────────────────────────────────────────────────────────
    Audience:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: [string, "null"]
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    AudienceMutationResult:
      type: object
      description: The response shape from create/patch — omits timestamps (unlike the GET representation).
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: [string, "null"]

    AudienceMemberResult:
      type: object
      properties:
        audience_id:
          type: string
        contact_id:
          type: string
        added:
          type: boolean

    # ── Segments ───────────────────────────────────────────────────────────
    SegmentCondition:
      type: object
      description: One condition of a segment filter (`email_core::segment::Condition`).
      required: [field, op]
      properties:
        field:
          type: string
          description: >-
            `status`, `email`, `created_at`, or `attributes.<key>` (key is
            ≤64 chars, `[A-Za-z0-9_.-]`).
          example: attributes.plan
        op:
          type: string
          enum: [eq, neq, contains, gt, lt, gte, lte, exists, in]
        value:
          description: >-
            Shape depends on `op`: number for gt/lt/gte/lte, string for
            contains, non-empty array of string|number for `in`, omitted
            for `exists`, string|number|bool for eq/neq.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: array
              items:
                oneOf: [{ type: string }, { type: number }]

    SegmentFilter:
      type: object
      description: >-
        `email_core::segment::SegmentFilter`, validated by `parse_filter`
        and compiled to a parameterized SQL predicate by `compile_filter`.
        At most 25 conditions; all conditions are ANDed (there is no OR).
      required: [conditions]
      properties:
        match:
          type: string
          enum: [all]
          description: 'Optional; if present must be exactly `"all"` (the only supported combinator today).'
        conditions:
          type: array
          maxItems: 25
          items:
            $ref: '#/components/schemas/SegmentCondition'

    Segment:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        filter:
          $ref: '#/components/schemas/SegmentFilter'
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    # ── Suppressions ───────────────────────────────────────────────────────
    Suppression:
      type: object
      properties:
        email:
          type: string
        reason:
          type: string
          enum: [hard_bounce, complaint, unsubscribe, manual]
        created_at:
          type: integer
          format: int64

    # ── Templates ──────────────────────────────────────────────────────────
    Template:
      type: object
      properties:
        id:
          type: string
        alias:
          type: string
        name:
          type: string
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    TemplateVersion:
      type: object
      properties:
        version:
          type: integer
        subject:
          type: string
        html_body:
          type: [string, "null"]
        text_body:
          type: [string, "null"]
        active:
          type: integer
          enum: [0, 1]
          description: Serialized as an integer (0/1), not a JSON boolean — the raw D1 column value.
        created_at:
          type: integer
          format: int64

    # ── Webhooks ───────────────────────────────────────────────────────────
    EventType:
      type: string
      description: >-
        Every event type a webhook endpoint can subscribe to
        (`webhooks::VALID_EVENTS`) — the message-lifecycle events plus the
        contact-lifecycle events emitted by `contact_webhooks::emit`.
      enum:
        - delivery
        - bounce
        - complaint
        - open
        - click
        - sent
        - failed
        - delivery_delayed
        - suppressed
        - scheduled
        - cancelled
        - contact.created
        - contact.updated
        - contact.deleted
        - contact.import.completed

    Webhook:
      type: object
      description: The creation response — the only place `secret` is ever shown, and the only place `events` is a JSON array.
      properties:
        id:
          type: string
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        secret:
          type: string
          description: HMAC-SHA256 signing secret (`whsec_...`). Shown only here.

    WebhookListItem:
      type: object
      description: >-
        The shape returned by `GET /v1/webhooks` — distinct from `Webhook`:
        `events` is a raw comma-separated string and `secret` is always
        an empty string (the list query does not select it).
      properties:
        id:
          type: string
        url:
          type: string
          format: uri
        events:
          type: string
          description: Comma-separated event type list, e.g. `"delivery,bounce,complaint"`.
        secret:
          type: string
          enum: [""]
        active:
          type: integer
          enum: [0, 1]
        created_at:
          type: integer
          format: int64

    ReplayResult:
      type: object
      properties:
        delivery_id:
          type: string
        status:
          type: string
          enum: [success, failed]
        status_code:
          type: integer
          description: HTTP status the endpoint responded with. Present when `status` is `success`.
        error:
          type: string
          description: Present when `status` is `failed`.

    # ── Inbound ────────────────────────────────────────────────────────────
    InboundMessageSummary:
      type: object
      description: The list-item shape (`GET /v1/inbound`) — `from_addr`/`to_addr`, no raw body.
      properties:
        id:
          type: string
        from_addr:
          type: string
        to_addr:
          type: string
        subject:
          type: [string, "null"]
        message_id:
          type: [string, "null"]
          description: The Message-ID header, if present.
        in_reply_to:
          type: [string, "null"]
        size_bytes:
          type: integer
          format: int64
        created_at:
          type: integer
          format: int64

    InboundMessageDetail:
      type: object
      description: The single-item shape (`GET /v1/inbound/{id}`) — `from`/`to` (not `from_addr`/`to_addr`), plus `raw`.
      properties:
        id:
          type: string
        from:
          type: string
        to:
          type: string
        subject:
          type: [string, "null"]
        message_id:
          type: [string, "null"]
        in_reply_to:
          type: [string, "null"]
        size_bytes:
          type: integer
          format: int64
        created_at:
          type: integer
          format: int64
        raw:
          type: [string, "null"]
          description: The full raw MIME message, or `null` if the R2 object could not be read.

    # ── Attachments ────────────────────────────────────────────────────────
    AttachmentUploadResult:
      type: object
      properties:
        r2_key:
          type: string
        filename:
          type: string
        content_type:
          type: string
        size:
          type: integer
          format: int64

    # ── Broadcasts ─────────────────────────────────────────────────────────
    Broadcast:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        subject:
          type: string
        from_domain_id:
          type: [string, "null"]
        body_html:
          type: [string, "null"]
        body_text:
          type: [string, "null"]
        target_kind:
          type: [string, "null"]
          enum: [audience, segment, null]
        target_id:
          type: [string, "null"]
        status:
          type: string
          enum: [draft, scheduled, sending, paused, sent, cancelled]
        scheduled_at:
          type: [integer, "null"]
          format: int64
        total_recipients:
          type: integer
          description: A best-effort snapshot computed at `send` time (audience member count, or a segment's live match count).
        sent_count:
          type: integer
          description: Advanced by the fan-out cron as recipients are enqueued.
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64
        sent_at:
          type: [integer, "null"]
          format: int64

    # ── Automations ────────────────────────────────────────────────────────
    AutomationStep:
      description: >-
        One step of an automation's program (`email_core::automation::Step`).
        `type` discriminates the shape. Jump targets (`branch.else_to`,
        `goto.to`) must be strictly forward (`> ` the step's own index)
        and `<=` the program length — this is what makes loops
        structurally impossible; the length-sentinel value means "end of
        program".
      oneOf:
        - type: object
          required: [type, subject]
          properties:
            type: { type: string, enum: [send] }
            subject: { type: string, description: "Required, non-empty." }
            body_html: { type: string }
            body_text: { type: string }
          description: Enqueues one marketing send to the enrolled contact, then advances. At least one of body_html/body_text is required.
        - type: object
          required: [type, seconds]
          properties:
            type: { type: string, enum: [wait] }
            seconds:
              type: integer
              minimum: 0
              maximum: 7776000
              description: Max 90 days (7,776,000 seconds).
          description: Parks the enrollment for `seconds` before advancing.
        - type: object
          required: [type, filter, else_to]
          properties:
            type: { type: string, enum: [branch] }
            filter:
              $ref: '#/components/schemas/SegmentFilter'
            else_to:
              type: integer
              description: Step index to jump to when the contact does NOT match `filter`. On a match, advances to the next step.
          description: Evaluates `filter` against the enrolled contact.
        - type: object
          required: [type, to]
          properties:
            type: { type: string, enum: [goto] }
            to:
              type: integer
              description: Step index to jump to unconditionally.

    Automation:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        trigger_kind:
          type: string
          enum: [contact_created, audience_added, manual]
        trigger_config:
          type: object
        from_domain_id:
          type: [string, "null"]
        steps:
          type: array
          items:
            $ref: '#/components/schemas/AutomationStep'
        status:
          type: string
          enum: [draft, active, paused, archived]
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    EnrollmentResult:
      type: object
      properties:
        automation_id:
          type: string
        contact_id:
          type: string
        enrolled:
          type: boolean
