openapi: 3.1.0
info:
  title: AutoRelay API
  version: "2.0.0"
  description: |
    The AutoRelay public API. Send transactional + marketing email at
    developer-friendly latency, with first-class campaigns, contacts, lists,
    templates, domains, webhooks, and OTP delivery.

    ## Base URL

    All endpoints are served under `https://autorelay.net/api/v1`.

    ## Authentication

    Every request (except the public unsubscribe endpoints) requires an API key
    passed as an HTTP Bearer token:

    ```
    Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxx
    ```

    Generate a key in the AutoRelay dashboard under **Project settings → API
    keys**. Keys use the `ak_live_...` format. Missing or invalid keys return
    `401 unauthorized`.

    ### Scopes

    Each key is granted a set of scopes. Every operation documents the scope it
    requires via the `x-required-scope` extension and in its description. If the
    key lacks the scope, the API returns `403 insufficient_scope` with a
    `required_scope` field naming the missing scope. The full scope vocabulary
    is: `emails.send`, `emails.read`, `contacts.read`, `contacts.write`,
    `campaigns.read`, `campaigns.write`, `domains.read`, `domains.manage`,
    `webhooks.read`, `webhooks.manage`, `lists.read`, `stats.read`,
    `billing.read`. (Listing templates uses `campaigns.read`.)

    ## Request bodies

    All request bodies are JSON and use **snake_case** field names
    (e.g. `first_name`, `last_name`, `list_id`, `from_email`, `reply_to`,
    `template_id`, `scheduled_at`, `unsubscribe_url`, `product_name`).

    ## Responses

    Responses are Stripe-style and use snake_case fields:

    - **Single resource:** `{ "object": "<type>", ...fields }`.
    - **List:** `{ "object": "list", "data": [ ... ], "has_more": bool,
      "next_cursor": string|null }`.
    - **Delete:** `{ "object": "<type>", "id": string, "deleted": true }`.
    - **Error:** `{ "error": "<code>", "message": string }` plus optional
      `required_scope`, `account_status`, `retry_after_ms`, `attempts_left`.

    ## Pagination

    List endpoints accept `limit` and `cursor` query params. `limit` defaults to
    50; the maximum is 200 for contacts, 100 for emails, and 100 for other
    resources. Responses include `has_more` and a `next_cursor` token; pass it
    back as `cursor` to fetch the next page.

    ## Idempotency

    All mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) accept an optional
    `Idempotency-Key` request header. Replaying a request with the same key
    returns the original response and sets the `Idempotent-Replayed: true`
    response header.

    ## Rate limits

    Rate-limited operations include `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
    and `X-RateLimit-Reset` response headers. When a limit is exceeded the API
    returns `429 rate_limited` with a `Retry-After` header.

    ## Request tracing

    Every response includes an `X-Request-Id` header for support and debugging.

    ## Errors

    Errors share a single envelope (see the `Error` schema) with a stable
    machine-readable `error` code and a human `message`.
  contact:
    name: AutoRelay Support
    email: autorelay@hlptechnologies.com
  license:
    name: Apache-2.0
servers:
  - url: https://autorelay.net/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local dev

security:
  - BearerAuth: []

tags:
  - name: Emails
    description: Send single or batched transactional emails
  - name: Contacts
    description: Manage subscribers
  - name: Lists
    description: Group contacts for campaigns
  - name: Campaigns
    description: Send marketing campaigns to a list
  - name: Templates
    description: Reusable HTML templates
  - name: Domains
    description: Verify sending domains for SPF / DKIM
  - name: Webhooks
    description: Subscribe to delivery + engagement events
  - name: Stats
    description: Account-level aggregate metrics
  - name: OTP
    description: One-time passcode delivery and verification
  - name: Unsubscribe
    description: Public, token-based unsubscribe endpoints

paths:
  /emails:
    post:
      tags: [Emails]
      summary: Send an email
      description: |
        Send a single transactional email. Requires the `emails.send` scope.
        Provide either `html` or `template_id`. `html` is **not required** when
        `template_id` is present; if both are supplied, `template_id` wins and
        `html` is ignored. Merge variables for templates are passed via `data`.
      operationId: sendEmail
      x-required-scope: emails.send
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendEmailRequest" }
      responses:
        "200":
          description: Email queued
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Email" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      tags: [Emails]
      summary: List emails
      description: List sent emails, newest first. Requires the `emails.read` scope.
      operationId: listEmails
      x-required-scope: emails.read
      parameters:
        - $ref: "#/components/parameters/LimitEmails"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { type: string }
          description: Filter by email status.
      responses:
        "200":
          description: A page of emails
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Email" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /emails/batch:
    post:
      tags: [Emails]
      summary: Send a batch of emails
      description: |
        Send up to 100 emails in a single request. Requires the `emails.send`
        scope. Each item provides either `html` or `template_id`.
      operationId: sendEmailBatch
      x-required-scope: emails.send
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendEmailBatchRequest" }
      responses:
        "200":
          description: Batch processed
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Batch" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /emails/{emailId}:
    get:
      tags: [Emails]
      summary: Retrieve an email
      description: |
        Retrieve a single email with its delivery + engagement `events`.
        Requires the `emails.read` scope.
      operationId: getEmail
      x-required-scope: emails.read
      parameters:
        - name: emailId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The email with events
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Email"
                  - type: object
                    properties:
                      events:
                        type: array
                        items: { $ref: "#/components/schemas/EmailEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      description: List contacts. Requires the `contacts.read` scope.
      operationId: listContacts
      x-required-scope: contacts.read
      parameters:
        - $ref: "#/components/parameters/LimitContacts"
        - $ref: "#/components/parameters/Cursor"
        - name: listId
          in: query
          schema: { type: string }
          description: Filter to contacts in this list.
        - name: search
          in: query
          schema: { type: string }
          description: Free-text search over email / name.
        - name: status
          in: query
          schema: { type: string }
          description: Filter by contact status.
      responses:
        "200":
          description: A page of contacts
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Contacts]
      summary: Create a contact
      description: Create a contact. Requires the `contacts.write` scope.
      operationId: createContact
      x-required-scope: contacts.write
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateContactRequest" }
      responses:
        "201":
          description: Contact created
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /contacts/{contactId}:
    get:
      tags: [Contacts]
      summary: Retrieve a contact
      description: Retrieve a contact. Requires the `contacts.read` scope.
      operationId: getContact
      x-required-scope: contacts.read
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The contact
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      tags: [Contacts]
      summary: Update a contact
      description: Update a contact. Requires the `contacts.write` scope.
      operationId: updateContact
      x-required-scope: contacts.write
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateContactRequest" }
      responses:
        "200":
          description: The updated contact
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Contacts]
      summary: Delete a contact
      description: Delete a contact. Requires the `contacts.write` scope.
      operationId: deleteContact
      x-required-scope: contacts.write
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Contact deleted
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DeletedResource"
                  - type: object
                    properties:
                      object: { type: string, enum: [contact] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /contacts/import:
    post:
      tags: [Contacts]
      summary: Import contacts
      description: |
        Bulk-import contacts into a list. Requires the `contacts.write` scope.
        Returns a parse/validation summary and per-category reports.
      operationId: importContacts
      x-required-scope: contacts.write
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImportContactsRequest" }
      responses:
        "201":
          description: Import completed
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /campaigns:
    get:
      tags: [Campaigns]
      summary: List campaigns
      description: List campaigns, newest first. Requires the `campaigns.read` scope.
      operationId: listCampaigns
      x-required-scope: campaigns.read
      parameters:
        - $ref: "#/components/parameters/LimitDefault"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { type: string }
          description: Filter by campaign status.
      responses:
        "200":
          description: A page of campaigns
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Campaigns]
      summary: Create a campaign
      description: Create a draft campaign. Requires the `campaigns.write` scope.
      operationId: createCampaign
      x-required-scope: campaigns.write
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCampaignRequest" }
      responses:
        "201":
          description: Campaign created
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /campaigns/{campaignId}:
    get:
      tags: [Campaigns]
      summary: Retrieve a campaign
      description: Retrieve a campaign. Requires the `campaigns.read` scope.
      operationId: getCampaign
      x-required-scope: campaigns.read
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The campaign
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      tags: [Campaigns]
      summary: Update a campaign
      description: Update a campaign. Requires the `campaigns.write` scope.
      operationId: updateCampaign
      x-required-scope: campaigns.write
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateCampaignRequest" }
      responses:
        "200":
          description: The updated campaign
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Campaigns]
      summary: Delete a campaign
      description: |
        Delete a campaign. Only draft campaigns can be deleted; attempting to
        delete a launched campaign returns `409 conflict`. Requires the
        `campaigns.write` scope.
      operationId: deleteCampaign
      x-required-scope: campaigns.write
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Campaign deleted
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DeletedResource"
                  - type: object
                    properties:
                      object: { type: string, enum: [campaign] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /campaigns/{campaignId}/launch:
    post:
      tags: [Campaigns]
      summary: Launch a campaign
      description: |
        Launch a campaign. Requires the `campaigns.write` scope and a verified
        sending domain (the free shared sender is transactional-only;
        otherwise `403 forbidden`). Returns the campaign id with status
        `launching` and the background `job_id`.
      operationId: launchCampaign
      x-required-scope: campaigns.write
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Campaign launching
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignLaunch" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /lists:
    get:
      tags: [Lists]
      summary: List lists
      description: List contact lists, newest first. Requires the `lists.read` scope.
      operationId: listLists
      x-required-scope: lists.read
      parameters:
        - $ref: "#/components/parameters/LimitDefault"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of lists
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/List" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Lists]
      summary: Create a list
      description: Create a contact list. Requires the `lists.write` scope.
      operationId: createList
      x-required-scope: lists.write
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateListRequest" }
      responses:
        "201":
          description: List created
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/List" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /templates:
    get:
      tags: [Templates]
      summary: List templates
      description: |
        List templates, newest first. Requires the `campaigns.read` scope.
      operationId: listTemplates
      x-required-scope: campaigns.read
      parameters:
        - $ref: "#/components/parameters/LimitDefault"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of templates
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /stats:
    get:
      tags: [Stats]
      summary: Retrieve account stats
      description: |
        Retrieve account-level aggregate metrics. Requires the `stats.read`
        scope.
      operationId: getStats
      x-required-scope: stats.read
      responses:
        "200":
          description: Aggregate stats
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /domains:
    get:
      tags: [Domains]
      summary: List domains
      description: List sending domains, newest first. Requires the `domains.read` scope.
      operationId: listDomains
      x-required-scope: domains.read
      parameters:
        - $ref: "#/components/parameters/LimitDefault"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of domains
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Domain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Domains]
      summary: Add a domain
      description: |
        Register a sending domain. Requires the `domains.manage` scope. Returns
        the domain with `dkim_tokens` and the `dns_records` to publish.
      operationId: createDomain
      x-required-scope: domains.manage
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDomainRequest" }
      responses:
        "201":
          description: Domain created
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanLimitExceeded" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /domains/{domainId}:
    get:
      tags: [Domains]
      summary: Retrieve a domain
      description: Retrieve a domain. Requires the `domains.read` scope.
      operationId: getDomain
      x-required-scope: domains.read
      parameters:
        - name: domainId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The domain
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Domains]
      summary: Delete a domain
      description: Delete a domain. Requires the `domains.manage` scope.
      operationId: deleteDomain
      x-required-scope: domains.manage
      parameters:
        - name: domainId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Domain deleted
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DeletedResource"
                  - type: object
                    properties:
                      object: { type: string, enum: [domain] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /domains/{domainId}/verify:
    post:
      tags: [Domains]
      summary: Verify a domain
      description: |
        Poll DKIM verification status for a domain and update its status.
        Requires the `domains.manage` scope.
      operationId: verifyDomain
      x-required-scope: domains.manage
      parameters:
        - name: domainId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Verification result
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Domain"
                  - type: object
                    properties:
                      dkim_status:
                        type: [string, "null"]
                        description: Raw SES DKIM status (e.g. SUCCESS, PENDING, FAILED).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhooks
      description: List webhook endpoints. Requires the `webhooks.read` scope.
      operationId: listWebhooks
      x-required-scope: webhooks.read
      responses:
        "200":
          description: A page of webhooks
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListResponse"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Webhooks]
      summary: Create a webhook
      description: |
        Create a webhook endpoint. Requires the `webhooks.manage` scope. The
        signing `secret` is returned **only** in this create response and cannot
        be retrieved again.
      operationId: createWebhook
      x-required-scope: webhooks.manage
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
      responses:
        "201":
          description: Webhook created
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: One-time signing secret (`whsec_...`). Shown only on create.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}:
    get:
      tags: [Webhooks]
      summary: Retrieve a webhook
      description: Retrieve a webhook. Requires the `webhooks.read` scope.
      operationId: getWebhook
      x-required-scope: webhooks.read
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The webhook
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook
      description: Update a webhook. Requires the `webhooks.manage` scope.
      operationId: updateWebhook
      x-required-scope: webhooks.manage
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWebhookRequest" }
      responses:
        "200":
          description: The updated webhook
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: Delete a webhook. Requires the `webhooks.manage` scope.
      operationId: deleteWebhook
      x-required-scope: webhooks.manage
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Webhook deleted
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DeletedResource"
                  - type: object
                    properties:
                      object: { type: string, enum: [webhook] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook failed deliveries
      description: |
        List recent **failed** deliveries for a webhook. Requires the
        `webhooks.read` scope.
      operationId: listWebhookDeliveries
      x-required-scope: webhooks.read
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 99, minimum: 1 }
          description: Page size (default 50, max 99).
      responses:
        "200":
          description: A page of failed deliveries
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDeliveryList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /otp/send:
    post:
      tags: [OTP]
      summary: Send an OTP
      description: |
        Issue and email a one-time passcode. Requires the `emails.send` scope.
      operationId: sendOtp
      x-required-scope: emails.send
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendOtpRequest" }
      responses:
        "200":
          description: OTP sent
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OtpSent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /otp/verify:
    post:
      tags: [OTP]
      summary: Verify an OTP
      description: |
        Verify a one-time passcode. Requires the `emails.send` scope. Failure
        cases return OTP-specific error codes: `otp_not_found` (404),
        `otp_expired` (410), `otp_locked` (423), `otp_invalid` (401, with an
        optional `attempts_left`).
      operationId: verifyOtp
      x-required-scope: emails.send
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyOtpRequest" }
      responses:
        "200":
          description: OTP verified
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OtpVerified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Invalid code (`otp_invalid`) or missing/invalid API key
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: OTP not found (`otp_not_found`)
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: OTP expired (`otp_expired`)
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "423":
          description: Too many attempts (`otp_locked`)
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }

  /unsubscribe:
    get:
      tags: [Unsubscribe]
      summary: One-click unsubscribe (page)
      description: |
        Public, token-based unsubscribe. **No authentication.** Renders an HTML
        confirmation page and records the unsubscribe for the token's recipient.
      operationId: unsubscribeGet
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: Signed unsubscribe token.
      responses:
        "200":
          description: HTML confirmation page
          content:
            text/html:
              schema: { type: string }
        "429":
          description: Too many requests
          content:
            text/plain:
              schema: { type: string }
    post:
      tags: [Unsubscribe]
      summary: One-click unsubscribe (RFC 8058)
      description: |
        Public, token-based unsubscribe. **No authentication.** The token may be
        supplied as a `token` query param, a JSON body field, or a form field.
      operationId: unsubscribePost
      security: []
      parameters:
        - name: token
          in: query
          required: false
          schema: { type: string }
          description: Signed unsubscribe token (may instead be sent in the body).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token: { type: string }
      responses:
        "200":
          description: Unsubscribed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
        "400":
          description: Missing or invalid unsubscribe token
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        "429":
          description: Too many requests
          content:
            text/plain:
              schema: { type: string }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: AutoRelay-API-Key

  parameters:
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: Opaque pagination cursor from a previous `next_cursor`.
    LimitDefault:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
      description: Page size (default 50, max 100).
    LimitContacts:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      description: Page size (default 50, max 200).
    LimitEmails:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
      description: Page size (default 50, max 100).
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: |
        Optional idempotency key. Replaying a request with the same key returns
        the original response and the `Idempotent-Replayed: true` header.

  headers:
    X-Request-Id:
      description: Unique request identifier for tracing/support.
      schema: { type: string }
    X-RateLimit-Limit:
      description: Request quota for the rate-limit window.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix timestamp (or epoch ms) when the window resets.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying after a 429.
      schema: { type: integer }
    Idempotent-Replayed:
      description: Present and `true` when a response was replayed from an idempotency key.
      schema: { type: boolean }

  responses:
    BadRequest:
      description: Invalid request (`invalid_request`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid API key (`unauthorized`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: |
        Insufficient scope (`insufficient_scope`), restricted account
        (`account_restricted`), or other forbidden condition (`forbidden`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource does not exist (`not_found`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Conflicting state (`conflict`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Payload too large (`payload_too_large`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unprocessable:
      description: Unprocessable entity (`unprocessable`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PlanLimitExceeded:
      description: Plan limit exceeded (`plan_limit_exceeded`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Per-key rate limit exceeded (`rate_limited`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        Retry-After: { $ref: "#/components/headers/Retry-After" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalError:
      description: Internal server error (`internal_error`) or service unavailable (`service_unavailable`)
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Stable, machine-readable error code.
          enum:
            - invalid_request
            - unauthorized
            - insufficient_scope
            - forbidden
            - not_found
            - conflict
            - payload_too_large
            - unprocessable
            - plan_limit_exceeded
            - rate_limited
            - account_restricted
            - internal_error
            - service_unavailable
            - otp_not_found
            - otp_expired
            - otp_locked
            - otp_invalid
        message:
          type: string
          description: Human-readable error description.
        required_scope:
          type: string
          description: The scope that was missing (on `insufficient_scope`).
        account_status:
          type: string
          description: Account status (on `account_restricted` / `forbidden`).
        retry_after_ms:
          type: integer
          description: Milliseconds to wait before retrying (e.g. OTP resend cooldown).
        attempts_left:
          type: integer
          description: Remaining verification attempts (on `otp_invalid`).

    ListResponse:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, enum: [list] }
        data:
          type: array
          items: { type: object }
        has_more: { type: boolean }
        next_cursor:
          type: [string, "null"]

    DeletedResource:
      type: object
      required: [object, id, deleted]
      properties:
        object: { type: string }
        id: { type: string }
        deleted: { type: boolean, enum: [true] }

    Contact:
      type: object
      properties:
        object: { type: string, enum: [contact] }
        id: { type: string }
        email: { type: [string, "null"] }
        first_name: { type: string }
        last_name: { type: string }
        attributes:
          type: object
          additionalProperties: true
        list_id: { type: [string, "null"] }
        status: { type: [string, "null"] }
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    Campaign:
      type: object
      properties:
        object: { type: string, enum: [campaign] }
        id: { type: string }
        name: { type: [string, "null"] }
        subject: { type: [string, "null"] }
        status: { type: [string, "null"] }
        content: { type: [string, "null"] }
        from_email: { type: [string, "null"] }
        reply_to: { type: [string, "null"] }
        list_id: { type: [string, "null"] }
        segment_id: { type: [string, "null"] }
        template_id: { type: [string, "null"] }
        unsubscribe_url: { type: [string, "null"] }
        scheduled_at: { type: [string, "null"] }
        total_contacts: { type: integer }
        sent_count: { type: integer }
        failed_count: { type: integer }
        open_count: { type: integer }
        click_count: { type: integer }
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    CampaignLaunch:
      type: object
      properties:
        object: { type: string, enum: [campaign] }
        id: { type: string }
        status: { type: string, enum: [launching] }
        job_id: { type: string }

    List:
      type: object
      properties:
        object: { type: string, enum: [list] }
        id: { type: string }
        name: { type: [string, "null"] }
        contact_count: { type: integer }
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    Template:
      type: object
      properties:
        object: { type: string, enum: [template] }
        id: { type: string }
        name: { type: [string, "null"] }
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    Email:
      type: object
      properties:
        object: { type: string, enum: [email] }
        id: { type: string }
        to: { type: [string, "null"] }
        from: { type: [string, "null"] }
        subject: { type: [string, "null"] }
        status: { type: [string, "null"] }
        template_id: { type: [string, "null"] }
        reply_to: { type: [string, "null"] }
        message_id: { type: [string, "null"] }
        created_at: { type: [string, "null"] }
        updated_at:
          type: [string, "null"]
          description: Present only when the underlying record has an updated timestamp.

    EmailEvent:
      type: object
      properties:
        id: { type: string }
        type: { type: string }
        data:
          type: object
          additionalProperties: true
        created_at: { type: [string, "null"] }

    Batch:
      type: object
      properties:
        object: { type: string, enum: [batch] }
        batch_id: { type: string }
        total: { type: integer }
        sent: { type: integer }
        failed: { type: integer }
        data:
          type: array
          items:
            type: object
            properties:
              id: { type: [string, "null"] }
              to: { type: string }
              status: { type: string, enum: [sent, failed] }
              template_id: { type: [string, "null"] }
              error: { type: [string, "null"] }

    Domain:
      type: object
      properties:
        object: { type: string, enum: [domain] }
        id: { type: string }
        domain: { type: [string, "null"] }
        status: { type: [string, "null"] }
        dkim_tokens:
          type: array
          items: { type: string }
        dns_records:
          type: array
          items:
            type: object
            properties:
              type: { type: string, enum: [CNAME] }
              name: { type: string }
              value: { type: string }
        verified_at: { type: [string, "null"] }
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    Webhook:
      type: object
      properties:
        object: { type: string, enum: [webhook] }
        id: { type: string }
        url: { type: [string, "null"] }
        events:
          type: array
          items: { type: string }
        status: { type: [string, "null"] }
        last_delivery_at:
          type: [string, "null"]
          description: Present only when the webhook has a recorded last delivery.
        created_at: { type: [string, "null"] }
        updated_at: { type: [string, "null"] }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [webhook_delivery] }
        event_id: { type: string }
        event_type: { type: string }
        status: { type: string }
        error: { type: [string, "null"] }
        attempts: { type: integer }
        replay_count: { type: integer }
        last_attempt_at: { type: [string, "null"] }
        created_at: { type: [string, "null"] }
        resolved_at: { type: [string, "null"] }

    WebhookDeliveryList:
      type: object
      properties:
        object: { type: string, enum: [list] }
        webhook_id: { type: string }
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookDelivery" }
        has_more: { type: boolean }
        next_cursor:
          type: [string, "null"]

    Stats:
      type: object
      properties:
        object: { type: string, enum: [stats] }
        campaigns:
          type: object
          properties:
            avg_open_rate: { type: number }
            total_campaigns: { type: integer }
            total_sent: { type: integer }
            total_clicks: { type: integer }
            monthly_emails_sent: { type: integer }
        total_contacts: { type: integer }
        total_lists: { type: integer }
        total_templates: { type: integer }

    ImportResult:
      type: object
      properties:
        object: { type: string, enum: [import] }
        imported: { type: integer }
        summary:
          type: object
          additionalProperties: true
          description: Counts (e.g. imported, invalidEmails, duplicates).
        reports:
          type: object
          additionalProperties: true
          description: Per-category detail lists (e.g. invalidEmails).

    OtpSent:
      type: object
      properties:
        object: { type: string, enum: [otp] }
        status: { type: string, enum: [sent] }
        expires_at: { type: string }

    OtpVerified:
      type: object
      properties:
        object: { type: string, enum: [otp] }
        status: { type: string, enum: [verified] }
        verified: { type: boolean, enum: [true] }

    SendEmailRequest:
      type: object
      required: [to, from, subject]
      properties:
        to: { type: string, format: email }
        from:
          type: string
          description: Sender, e.g. `"Acme <hi@acme.com>"` or a bare address (max 500 chars).
        subject: { type: string, maxLength: 998 }
        html:
          type: string
          description: HTML body. Not required when `template_id` is provided (max 1 MiB).
        reply_to:
          type: string
          format: email
          description: Optional Reply-To address (max 320 chars).
        template_id:
          type: string
          description: Render this saved template instead of `html`. Wins if both are present.
        data:
          type: object
          additionalProperties: true
          description: Merge variables for the template (flat key→value object).
        tags:
          type: object
          additionalProperties: { type: string }
          description: Optional flat string→string tag map.

    SendEmailBatchRequest:
      type: object
      required: [emails]
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [to, from, subject]
            properties:
              to: { type: string, format: email }
              from: { type: string }
              subject: { type: string }
              html:
                type: string
                description: Not required when `template_id` is provided.
              reply_to: { type: string, format: email }
              template_id: { type: string }
              data:
                type: object
                additionalProperties: true
              tags:
                type: object
                additionalProperties: { type: string }

    CreateContactRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        first_name: { type: string }
        last_name: { type: string }
        name:
          type: string
          description: Alias for `first_name` when `first_name` is omitted.
        status:
          type: string
          enum: [subscribed, unsubscribed, bounced]
        list_id: { type: string }
        company: { type: string }
        attributes:
          type: object
          additionalProperties: true

    UpdateContactRequest:
      type: object
      properties:
        first_name: { type: string, maxLength: 200 }
        last_name: { type: string, maxLength: 200 }
        name: { type: string, maxLength: 200 }
        email: { type: string, format: email, maxLength: 320 }
        status:
          type: string
          enum: [subscribed, unsubscribed]
        company: { type: string, maxLength: 200 }
        list_id: { type: string, maxLength: 64 }
        attributes:
          type: object
          additionalProperties: true

    ImportContactsRequest:
      type: object
      required: [list_id, contacts]
      properties:
        list_id: { type: string }
        contacts:
          type: array
          minItems: 1
          description: Array of contact objects (each typically with `email` and attributes).
          items:
            type: object
            additionalProperties: true

    CreateCampaignRequest:
      type: object
      required: [name, subject, list_id]
      properties:
        name: { type: string, maxLength: 200 }
        subject: { type: string, maxLength: 300 }
        list_id: { type: string, maxLength: 128 }
        content: { type: [string, "null"] }
        from_email: { type: [string, "null"], format: email, maxLength: 320 }
        template_id: { type: [string, "null"], maxLength: 128 }
        scheduled_at:
          type: [string, "null"]
          format: date-time
          description: ISO 8601 datetime to schedule the send.

    UpdateCampaignRequest:
      type: object
      description: At least one field must be provided. Only these fields are accepted.
      minProperties: 1
      properties:
        name: { type: string, maxLength: 200 }
        subject: { type: string, maxLength: 300 }
        list_id: { type: string, maxLength: 128 }
        content: { type: [string, "null"] }
        from_email: { type: [string, "null"], format: email, maxLength: 320 }
        template_id: { type: [string, "null"], maxLength: 128 }
        scheduled_at: { type: [string, "null"], format: date-time }

    CreateListRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 100 }

    CreateDomainRequest:
      type: object
      required: [domain]
      properties:
        domain:
          type: string
          description: Fully-qualified domain to verify (e.g. `mail.acme.com`).

    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS endpoint to receive events. Private/internal hosts are rejected.
        events:
          type: array
          minItems: 1
          items: { type: string }
          description: Event types to subscribe to.

    UpdateWebhookRequest:
      type: object
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        events:
          type: array
          minItems: 1
          items: { type: string }
        active: { type: boolean }

    SendOtpRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        product_name:
          type: string
          description: Optional product name shown in the OTP email.

    VerifyOtpRequest:
      type: object
      required: [email, code]
      properties:
        email: { type: string, format: email }
        code: { type: string }
