openapi: 3.1.0
info:
  title: TheAlterMeAI Email API
  version: 1.0.0
  description: |
    REST API for email marketing operations scoped to one Organization (brand profile).

    **For AI agents and integrators:** fetch this OpenAPI file, authenticate with `Authorization: Bearer emk_…`
    (keys are created in the signed-in app under Workspace → API keys). Standard contact fields are
    `email` (required), `firstName`, `lastName`, optional `tags` and `attributes`. Use `POST /api/v1/email/contacts`
    with `audienceId` to add list members, or `POST /api/v1/email/contacts/batch` for up to 500 rows per request.

    **One key.** Creating a campaign saves a draft and does not send. `POST /api/v1/email/campaigns/{id}/send` mails
    the audience. `POST /api/v1/email/messages` sends transactional mail to the addresses in the body (password
    resets, receipts). Guide: https://thealtermeai.com/developers/transactional

    **Templates:** `GET/POST /api/v1/email/templates` and `GET /api/v1/email/templates/{templateId}` (`templates:read` /
    `templates:write`). **Agents should prefer `html`** (inline CSS, table-based email HTML with `{{merge_tags}}`);
    `mjmlSource` and legacy `blocks` are alternatives. Campaigns need `templateId` or `templateVersionId`.
    `GET/POST /api/v1/email/sender-identities` for From addresses (`campaigns:read` / `campaigns:write`). Keys need
    `templates:write` to create templates — use the Agents & API scope preset or Edit your key. Guide:
    https://thealtermeai.com/developers/email/templates
    `GET /api/v1/email/organization` returns the organization and the key's scopes.
    The key is bound to one organization. Do not send a profile header to switch brands.

    **Contact identity:** one row per `(organization, email)`; addresses are case-insensitive. Audiences are memberships
    on that contact. Upserts (single, batch, and CSV import in the app) merge on email — existing list members do not
    fail a whole import. Batch and CSV skip or update row-by-row; unsubscribed members are not silently resubscribed.
    Details: https://thealtermeai.com/developers/email/contacts#reimports

    Outbound webhooks: `GET/POST /api/v1/email/webhooks` and manage by id (`webhooks:read` / `webhooks:write`).
    Guide: https://thealtermeai.com/developers/email/webhooks

    **Dashboard-only (session cookie, not in v1):** segment builder (`POST /api/email/segments`,
    `POST /api/email/segments/preview`) and campaign engagement segments
    (`POST /api/email/campaigns/{id}/engagement-segment`, optional `url` for per-link click segments).
    Public segment CRUD may be added to v1 later.

    Usage is billed from Work Funds at the same per-recipient rates as the web app. The first sends each
    calendar month are free: the allowance belongs to the **registrable domain** in the From address
    (`mail.acme.com` and `news.acme.com` share `acme.com`'s allowance), and a send past it is billed
    rather than refused. Each sending domain also climbs a warm-up ladder, which caps a new domain below
    the brand's allowance. A refused send returns a `code` naming the limit that refused it — see
    `x-mcos-codes` below and https://thealtermeai.com/developers/email/limits. Branch on the code, not
    on the HTTP status: several different 429s need different handling.
    Human-readable docs: https://thealtermeai.com/developers

    **OpenAPI document URL (this file):** https://thealtermeai.com/openapi/email-v1.yaml
    (served by the web app — not on the API host; `https://api.thealtermeai.com/openapi/...` returns 404).

    **Transactional webhooks:** subscribe to `message.sent`, `message.delivered`, `message.bounced`, and
    `message.complained` (configured in the dashboard). Guide: https://thealtermeai.com/developers/email/webhooks
  # The complete set of refusal codes. Every entry is emitted by the API, and this list is checked against
  # `packages/shared/src/email/public-codes.ts` by `apps/web/lib/email/docs-contract.spec.ts` — a code
  # cannot ship here undocumented, or be documented without existing.
  x-mcos-codes:
    - code: RATE_LIMITED
      status: 429
      retryable: true
      meaning: This API key exceeded its request rate.
      resolution: Wait for the window to reset (`Retry-After` / `X-RateLimit-Reset`).
    - code: FREE_ALLOWANCE_EXHAUSTED
      status: 429
      retryable: true
      meaning: The brand's free sends for this calendar month are used; the allowance is shared by every workspace on the same registrable domain.
      resolution: Wait until the 1st of next month (UTC), or send past it with a funded wallet (billed per email).
    - code: DAILY_QUOTA_EXCEEDED
      status: 429
      retryable: true
      meaning: The workspace's sends for this UTC day are used.
      resolution: Wait until UTC midnight.
    - code: MONTHLY_QUOTA_EXCEEDED
      status: 429
      retryable: true
      meaning: The workspace's monthly sending limit, granted by the platform, is used.
      resolution: Wait until the 1st of next month (UTC), or request a limit increase.
    - code: DOMAIN_WARMING_UP
      status: 429
      retryable: true
      meaning: The sending domain is in a warm-up stage and has used its cap for today.
      resolution: Wait until UTC midnight; the stage advances after several days of sustained clean volume.
    - code: DOMAIN_WARMUP_FROZEN
      status: 429
      retryable: false
      meaning: A deliverability review froze this domain after repeated elevated bounce or complaint rates.
      resolution: Contact support. Waiting does not clear a freeze.
    - code: DOMAIN_DAILY_QUOTA_EXCEEDED
      status: 429
      retryable: true
      meaning: A platform-set daily cap for this sending domain is used.
      resolution: Wait until UTC midnight, or contact support if the cap is wrong.
    - code: DOMAIN_MONTHLY_QUOTA_EXCEEDED
      status: 429
      retryable: true
      meaning: A platform-set monthly cap for this sending domain is used.
      resolution: Wait until the 1st of next month (UTC), or contact support.
    - code: DOMAIN_PAUSED
      status: 429
      retryable: false
      meaning: The sending domain was paused by the account owner or by the platform.
      resolution: Resume the domain in the app, or contact support if the platform paused it.
    - code: ORGANIZATION_PAUSED
      status: 429
      retryable: false
      meaning: Sending is paused for the whole workspace.
      resolution: Resume sending in the app, or contact support.
    - code: ORGANIZATION_INACTIVE
      status: 429
      retryable: false
      meaning: Email is not set up, or the workspace is not active.
      resolution: Complete email setup, or contact support about the workspace status.
    - code: CAMPAIGN_TOO_LARGE
      status: 400
      retryable: false
      meaning: The campaign exceeds the largest single campaign the platform will queue.
      resolution: Split the campaign into smaller sends.
    - code: INSUFFICIENT_FUNDS
      status: 402
      retryable: false
      meaning: Sends beyond the free allowance are billed per email, and the wallet is short.
      resolution: Top up Work Funds, or wait for the next month's free allowance.
servers:
  - url: https://api.thealtermeai.com
    description: Production API (Bearer emk_ requests)
  - url: http://localhost:3001
    description: Local API (typical dev API port; OpenAPI file stays at http://localhost:3000/openapi/email-v1.yaml)
security:
  - BearerAuth: []
tags:
  - name: Organization
  - name: Audiences
  - name: Contacts
  - name: Campaigns
  - name: Templates
  - name: Sending
  - name: Messages
  - name: Webhooks
  - name: Conversions
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key prefixed with `emk_`. Pass as `Authorization: Bearer emk_…`.
        Keys are Live (production sends) or Test (sync + transactional to your account email only).
        The organization is taken from the key. A profile header does not select a different brand.
  parameters:
    Limit:
      name: limit
      in: query
      description: Page size. Campaigns default to 25. Other list routes default to 50. Maximum 200.
      schema:
        type: integer
        minimum: 1
        maximum: 200
    Offset:
      name: offset
      in: query
      schema:
        type: integer
        minimum: 0
        default: 0
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Makes retries safe. Same key + same JSON body returns the original response. Same key + different body → 400
        `IDEMPOTENCY_CONFLICT`.
      schema:
        type: string
        maxLength: 256
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
        code:
          type: string
    TransactionalAttachment:
      type: object
      required: [filename, content]
      properties:
        filename:
          type: string
          description: Display name; no path separators.
        content:
          type: string
          description: Base64-encoded file bytes (max 25 MB total across all attachments).
        contentType:
          type: string
          example: application/pdf
    SendTransactionalMessageRequest:
      type: object
      required: [to]
      properties:
        to:
          oneOf:
            - type: string
              description: Single address or `Name <email@example.com>`.
            - type: array
              items:
                type: string
              maxItems: 500
        cc:
          type: array
          items:
            type: string
          maxItems: 1000
          description: Copied on the first `to` recipient only; each address bills separately.
        bcc:
          type: array
          items:
            type: string
          maxItems: 1000
        from:
          type: string
          description: `Name <addr>` on a verified sender identity for this organization.
        fromIdentityId:
          type: string
          format: uuid
        replyTo:
          type: string
        subject:
          type: string
          description: Required unless `template` / `templateId` is set.
        html:
          type: string
        text:
          type: string
        template:
          type: string
          description: Template name (alias) in your library.
        templateId:
          type: string
          format: uuid
        variables:
          type: object
          additionalProperties:
            type: string
          description: Values for `{{placeholders}}` in template HTML and subject.
        attachments:
          type: array
          maxItems: 10
          items:
            $ref: "#/components/schemas/TransactionalAttachment"
        headers:
          type: object
          additionalProperties:
            type: string
          description: Custom SMTP headers (List-Unsubscribe cannot be overridden).
        scheduledAt:
          type: string
          format: date-time
          description: ISO 8601 future time (up to 1 year). Response status `scheduled`; cancel with DELETE.
        tracking:
          type: boolean
          default: true
          description: Wrap links and add an open pixel. On by default; set false to skip engagement tracking.
        unsubscribe:
          type: boolean
          default: false
          description: When true, append marketing compliance footer (newsletter-style).
        externalId:
          type: string
          maxLength: 128
          description: Idempotency for this message body (distinct from `Idempotency-Key` header).
        metadata:
          type: object
          additionalProperties:
            oneOf:
              - type: string
              - type: number
              - type: boolean
    TransactionalMessage:
      type: object
      properties:
        id:
          type: string
          format: uuid
        to:
          type: string
        subject:
          type: string
        status:
          type: string
          enum: [QUEUED, SCHEDULED, SENT, DELIVERED, BOUNCED, COMPLAINED, FAILED, SUPPRESSED]
        externalId:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        error:
          type: string
          nullable: true
        simulated:
          type: boolean
        batchId:
          type: string
          format: uuid
          nullable: true
        createdAt:
          type: string
          format: date-time
        sentAt:
          type: string
          format: date-time
          nullable: true
        scheduledAt:
          type: string
          format: date-time
          nullable: true
    SendTransactionalMessageResponse:
      type: object
      properties:
        messages:
          type: array
          items:
            $ref: "#/components/schemas/TransactionalMessage"
        message:
          $ref: "#/components/schemas/TransactionalMessage"
          description: First message (convenience for single-recipient sends).
        accepted:
          type: integer
        skipped:
          type: integer
          description: Suppressed recipients (not an error).
        copied:
          type: integer
          description: cc + bcc addresses accepted on the first recipient.
        batchId:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued, scheduled, completed]
        scheduledFor:
          type: string
          format: date-time
          nullable: true
        idempotentReplay:
          type: boolean
    EmailTemplateSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
          nullable: true
        isArchived:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        latestVersionId:
          type: string
          format: uuid
          nullable: true
        latestVersion:
          type: integer
          nullable: true
    CreateEmailTemplateRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
        description:
          type: string
        html:
          type: string
          description: >
            Preferred for API and AI agents. Complete email HTML: inline style attributes, tables for layout,
            {{merge_tags}} for personalization. Tailwind utility classes (e.g. class="flex gap-4") are not compiled
            and do not render in Gmail/Outlook; the dashboard visual editor does not interpret Tailwind on import.
            Mutually exclusive with mjmlSource and blocks.
        mjmlSource:
          type: string
          description: MJML source compiled to HTML on save. Use when you already author in MJML instead of html.
        blocks:
          type: object
          description: Legacy block JSON schema. Prefer html for agents; dashboard visual editor is not exposed as blocks.
        previewText:
          type: string
        thumbnailUrl:
          type: string
    SenderIdentity:
      type: object
      properties:
        id:
          type: string
          format: uuid
        domainId:
          type: string
          format: uuid
          description: Sending domain this From address belongs to (dashboard groups by domain).
        fromName:
          type: string
        fromEmail:
          type: string
        replyTo:
          type: string
          nullable: true
        isDefault:
          type: boolean
        domain:
          type: string
        domainStatus:
          type: string
        sendable:
          type: boolean
          description: True when domainStatus is VERIFIED.
        createdAt:
          type: string
          format: date-time
    CreateCampaignRequest:
      type: object
      required: [name, subject, audienceId, fromName, fromEmail]
      properties:
        name:
          type: string
        subject:
          type: string
        audienceId:
          type: string
          format: uuid
        templateVersionId:
          type: string
          format: uuid
          description: Pins content at creation time.
        templateId:
          type: string
          format: uuid
          description: Uses the template's latest version when templateVersionId is omitted.
        fromName:
          type: string
        fromEmail:
          type: string
          description: Must be on a verified sending domain (see GET /api/v1/email/sender-identities).
        previewText:
          type: string
        replyTo:
          type: string
    PatchEmailTemplateRequest:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
        html:
          type: string
          description: Preferred redesign payload for agents — same rules as create (inline CSS, merge tags).
        mjmlSource:
          type: string
        blocks:
          type: object
          description: Legacy block JSON; prefer html for agents.
        previewText:
          type: string
        thumbnailUrl:
          type: string
        isArchived:
          type: boolean
        force:
          type: boolean
          description: Archive even when campaigns reference this template.
    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: Public HTTPS URL (SSRF-safe validation applies).
        description:
          type: string
        events:
          type: array
          minItems: 1
          items:
            type: string
    PatchWebhookRequest:
      type: object
      properties:
        url:
          type: string
        description:
          type: string
        events:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [ACTIVE, DISABLED]
    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        description:
          type: string
          nullable: true
        events:
          type: array
          items:
            type: string
        status:
          type: string
        apiVersion:
          type: string
        lastSuccessAt:
          type: string
          format: date-time
          nullable: true
        lastFailureAt:
          type: string
          format: date-time
          nullable: true
        consecutiveFailures:
          type: integer
        lastError:
          type: string
          nullable: true
        disabledAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
    # ── Allowance and warm-up (the send gates) ─────────────────────────────
    Organization:
      type: object
      description: |
        The key's organization, its environment, and the numbers that decide whether a send is accepted.
        `allowance` is the brand's free bin; `domains` is per sending domain, because warm-up and caps are
        scored on the exact domain rather than on the brand.
      properties:
        organization:
          type: object
          properties:
            profileId:
              type: string
              format: uuid
            name:
              type: string
        key:
          type: object
          properties:
            name:
              type: string
            environment:
              type: string
              enum: [live, test]
            scopes:
              type: array
              items:
                type: string
        allowance:
          $ref: "#/components/schemas/FreeAllowance"
        domains:
          type: array
          items:
            $ref: "#/components/schemas/SendingDomainState"
    FreeAllowance:
      type: object
      description: |
        Free sends per calendar month, keyed on the registrable domain of the From address. Shared by every
        workspace on that brand, which is why `sharedByWorkspaces` is worth surfacing: the number can move
        without this integration sending anything.
      properties:
        pool:
          type: string
          nullable: true
          description: The registrable domain the bin belongs to (`acme.com`), or null for an unregistrable host.
        monthly:
          type: object
          properties:
            limit:
              type: integer
            used:
              type: integer
            remaining:
              type: integer
            sharedByWorkspaces:
              type: integer
        daily:
          type: integer
          nullable: true
          description: Derived from the monthly allowance; there is one default, not two.
        billing:
          type: string
          enum: [per_email_beyond_allowance]
    SendingDomainState:
      type: object
      description: One sending domain's state. The exact domain, not the apex — Gmail scores the exact domain.
      properties:
        domain:
          type: string
        registrableDomain:
          type: string
          nullable: true
        status:
          type: string
          enum: [PENDING, VERIFIED, FAILED, PAUSED]
        warmup:
          type: object
          properties:
            stage:
              type: integer
              minimum: 1
            capToday:
              type: integer
              nullable: true
              description: Null once the domain is warmed; the account's own ceiling then applies.
            sentToday:
              type: integer
            daysQualified:
              type: integer
            daysRequired:
              type: integer
            frozen:
              type: boolean
        caps:
          type: object
          properties:
            daily:
              type: integer
              nullable: true
            monthly:
              type: integer
              nullable: true
            source:
              type: string
              enum: [warmup, domain, organization, none]
  responses:
    SendRefused:
      description: |
        The send was refused before anything was queued. The body carries `code`, which names the limit
        that refused it — see the top-level `x-mcos-codes` list for every value, its status, and whether a
        retry can succeed. Retryable codes also send `Retry-After` (seconds).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
paths:
  /api/v1/email/organization:
    get:
      tags: [Organization]
      summary: Organization identity, key scopes, free allowance, and sending domains
      operationId: getOrganization
      description: |
        The endpoint to read **before** a send fails. `allowance.monthly.remaining` is the brand's free
        sends left this month; each `domains[].warmup.capToday` minus `sentToday` is the domain's remaining
        room today, which is usually the tighter of the two.
      responses:
        "200":
          description: OK (includes key environment in JSON and `X-MCOS-Key-Environment` header)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "401":
          description: Invalid or missing API key
  /api/v1/email/audiences:
    get:
      tags: [Audiences]
      summary: List audiences
      operationId: listAudiences
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      security:
        - BearerAuth: []
      x-required-scopes:
        - audiences:read
      responses:
        "200":
          description: OK
        "403":
          description: Missing scope
    post:
      tags: [Audiences]
      summary: Create audience
      operationId: createAudience
      x-required-scopes:
        - audiences:write
      responses:
        "201":
          description: Created
  /api/v1/email/audiences/{audienceId}:
    get:
      tags: [Audiences]
      summary: Get audience
      operationId: getAudience
      parameters:
        - name: audienceId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - audiences:read
      responses:
        "200":
          description: OK
    patch:
      tags: [Audiences]
      summary: Update audience
      operationId: updateAudience
      parameters:
        - name: audienceId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - audiences:write
      responses:
        "200":
          description: OK
    delete:
      tags: [Audiences]
      summary: Delete audience
      operationId: deleteAudience
      parameters:
        - name: audienceId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - audiences:write
      responses:
        "204":
          description: Deleted
  /api/v1/email/contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      x-required-scopes:
        - contacts:read
      responses:
        "200":
          description: OK
    post:
      tags: [Contacts]
      summary: Create or upsert contact
      operationId: createContact
      x-required-scopes:
        - contacts:write
      responses:
        "201":
          description: Created
  /api/v1/email/contacts/batch:
    post:
      tags: [Contacts]
      summary: Batch upsert contacts into one audience (max 500)
      operationId: batchUpsertContacts
      description: |
        Each row is processed independently. Response includes per-row success and error details.
        Partial success returns HTTP 200; all rows failing validation returns HTTP 400.
      x-required-scopes:
        - contacts:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [audienceId, contacts]
              properties:
                audienceId:
                  type: string
                  format: uuid
                defaultStatus:
                  type: string
                  enum: [subscribed, pending, unsubscribed]
                  default: subscribed
                contacts:
                  type: array
                  maxItems: 500
                  items:
                    type: object
                    required: [email]
                    properties:
                      email:
                        type: string
                      firstName:
                        type: string
                      lastName:
                        type: string
                      status:
                        type: string
                        enum: [subscribed, pending, unsubscribed]
                      tags:
                        type: array
                        items:
                          type: string
                      attributes:
                        type: object
                        additionalProperties: true
      responses:
        "200":
          description: At least one row succeeded
        "400":
          description: Validation error or every row failed
  /api/v1/email/contacts/{contactId}:
    get:
      tags: [Contacts]
      summary: Get contact
      operationId: getContact
      parameters:
        - name: contactId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - contacts:read
      responses:
        "200":
          description: OK
    patch:
      tags: [Contacts]
      summary: Update contact
      operationId: updateContact
      x-required-scopes:
        - contacts:write
      responses:
        "200":
          description: OK
    delete:
      tags: [Contacts]
      summary: Delete contact
      operationId: deleteContact
      x-required-scopes:
        - contacts:write
      responses:
        "204":
          description: Deleted
  /api/v1/email/campaigns:
    get:
      tags: [Campaigns]
      summary: List campaigns
      operationId: listCampaigns
      parameters:
        - name: status
          in: query
          schema:
            type: string
      x-required-scopes:
        - campaigns:read
      responses:
        "200":
          description: OK
    post:
      tags: [Campaigns]
      summary: Create draft campaign
      operationId: createCampaign
      x-required-scopes:
        - campaigns:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCampaignRequest"
      responses:
        "201":
          description: Created
        "400":
          description: Missing templateVersionId or templateId
        "409":
          description: From address not sendable (NOT_SENDABLE)
  /api/v1/email/campaigns/{campaignId}:
    get:
      tags: [Campaigns]
      summary: Get campaign
      operationId: getCampaign
      parameters:
        - name: campaignId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - campaigns:read
      responses:
        "200":
          description: OK
    patch:
      tags: [Campaigns]
      summary: Update draft campaign
      operationId: updateCampaign
      x-required-scopes:
        - campaigns:write
      responses:
        "200":
          description: OK
  /api/v1/email/campaigns/{campaignId}/send:
    post:
      tags: [Campaigns]
      summary: Send campaign (debits Work Funds per recipient)
      operationId: sendCampaign
      parameters:
        - name: campaignId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - campaigns:write
      responses:
        "202":
          description: Send accepted
        "403":
          description: Missing scope or feature disabled
        "400":
          description: Campaign is not sendable (no template, A/B test too small) or exceeds `maxRecipientsPerCampaign`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          $ref: "#/components/responses/SendRefused"
        "429":
          $ref: "#/components/responses/SendRefused"
  /api/v1/email/messages:
    get:
      tags: [Messages]
      summary: List transactional messages
      operationId: listTransactionalMessages
      x-required-scopes:
        - messages:read
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: externalId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      $ref: "#/components/schemas/TransactionalMessage"
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
    post:
      tags: [Messages]
      summary: Send transactional message(s)
      operationId: sendTransactionalMessage
      x-required-scopes:
        - messages:write
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendTransactionalMessageRequest"
      responses:
        "201":
          description: At least one message accepted for delivery
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendTransactionalMessageResponse"
        "202":
          description: Scheduled for future delivery
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendTransactionalMessageResponse"
        "200":
          description: Idempotent replay (`externalId` or `Idempotency-Key`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendTransactionalMessageResponse"
        "400":
          description: Validation or `IDEMPOTENCY_CONFLICT`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Missing scope or feature disabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          $ref: "#/components/responses/SendRefused"
        "429":
          $ref: "#/components/responses/SendRefused"
  /api/v1/email/messages/{messageId}:
    get:
      tags: [Messages]
      summary: Get transactional message
      operationId: getTransactionalMessage
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - messages:read
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    $ref: "#/components/schemas/TransactionalMessage"
        "404":
          description: Not found
    delete:
      tags: [Messages]
      summary: Cancel a scheduled message
      operationId: cancelTransactionalMessage
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - messages:write
      responses:
        "200":
          description: Cancelled (status FAILED with cancellation error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    $ref: "#/components/schemas/TransactionalMessage"
        "404":
          description: Not found
        "409":
          description: Not scheduled (`INVALID_STATE`)
  /api/v1/email/templates:
    get:
      tags: [Templates]
      summary: List email templates
      operationId: listEmailTemplates
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: includeArchived
          in: query
          schema:
            type: boolean
            default: false
      x-required-scopes:
        - templates:read
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailTemplateSummary"
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
    post:
      tags: [Templates]
      summary: Create email template
      operationId: createEmailTemplate
      x-required-scopes:
        - templates:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateEmailTemplateRequest"
      responses:
        "201":
          description: Created (includes latestVersionId for campaigns)
        "400":
          description: TEMPLATE_INVALID
  /api/v1/email/templates/{templateId}:
    get:
      tags: [Templates]
      summary: Get email template
      operationId: getEmailTemplate
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: includeHtml
          in: query
          schema:
            type: boolean
            default: false
      x-required-scopes:
        - templates:read
      responses:
        "200":
          description: OK
        "404":
          description: Not found
    patch:
      tags: [Templates]
      summary: Update or archive email template
      operationId: patchEmailTemplate
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - templates:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchEmailTemplateRequest"
      responses:
        "200":
          description: Updated (new version when html/mjml/blocks change)
        "400":
          description: TEMPLATE_INVALID or VALIDATION
        "409":
          description: IN_USE when archiving without force
  /api/v1/email/sender-identities:
    get:
      tags: [Sending]
      summary: List sender identities
      operationId: listSenderIdentities
      parameters:
        - name: sendableOnly
          in: query
          schema:
            type: boolean
            default: false
          description: When true, only identities on verified domains.
      x-required-scopes:
        - campaigns:read
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  senderIdentities:
                    type: array
                    items:
                      $ref: "#/components/schemas/SenderIdentity"
    post:
      tags: [Sending]
      summary: Create sender identity
      operationId: createSenderIdentity
      x-required-scopes:
        - campaigns:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fromEmail, fromName]
              properties:
                fromEmail:
                  type: string
                fromName:
                  type: string
                replyTo:
                  type: string
                isDefault:
                  type: boolean
      responses:
        "201":
          description: Created
        "409":
          description: Domain not verified or duplicate address
  /api/v1/email/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      x-required-scopes:
        - webhooks:read
      responses:
        "200":
          description: OK (includes availableEvents catalogue)
    post:
      tags: [Webhooks]
      summary: Create webhook endpoint
      operationId: createWebhookEndpoint
      x-required-scopes:
        - webhooks:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookRequest"
      responses:
        "201":
          description: Created (includes secret once)
  /api/v1/email/webhooks/{endpointId}:
    get:
      tags: [Webhooks]
      summary: Get webhook endpoint
      operationId: getWebhookEndpoint
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - webhooks:read
      responses:
        "200":
          description: OK
        "404":
          description: Not found
    patch:
      tags: [Webhooks]
      summary: Update webhook endpoint
      operationId: patchWebhookEndpoint
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - webhooks:write
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchWebhookRequest"
      responses:
        "200":
          description: OK
    delete:
      tags: [Webhooks]
      summary: Delete webhook endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - webhooks:write
      responses:
        "200":
          description: Deleted
  /api/v1/email/webhooks/{endpointId}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate webhook signing secret
      operationId: rotateWebhookSecret
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      x-required-scopes:
        - webhooks:write
      responses:
        "200":
          description: New secret returned once
  /api/v1/email/conversions:
    get:
      tags: [Conversions]
      summary: List conversion events
      operationId: listConversions
      x-required-scopes:
        - conversions:read
      responses:
        "200":
          description: OK
    post:
      tags: [Conversions]
      summary: Record conversion event
      operationId: createConversion
      x-required-scopes:
        - conversions:write
      responses:
        "201":
          description: Created
