openapi: 3.1.0
info:
  title: AhaSend API v2
  description: |
    The AhaSend API v2 allows you to send transactional emails, manage domains, webhooks, routes, API keys, and view statistics.

    ## Authentication
    All API requests must be authenticated using a Bearer token in the Authorization header:
    ```
    Authorization: Bearer aha-sk-64-CHARACTER-RANDOM-STRING
    ```

    ## Scopes
    API keys have specific scopes that control access to different resources and actions:

    ### Message Scopes
    - `messages:send:all` - Send messages from any domain in the account
    - `messages:send:{domain}` - Send messages from a specific domain
    - `messages:cancel:all` - Cancel messages from any domain
    - `messages:cancel:{domain}` - Cancel messages from a specific domain
    - `messages:read:all` - Read messages from any domain
    - `messages:read:{domain}` - Read messages from a specific domain

    ### Domain Scopes
    - `domains:read` - Read all domains
    - `domains:write` - Create and update domains
    - `domains:delete:all` - Delete any domain
    - `domains:delete:{domain}` - Delete a specific domain

    ### Account Scopes
    - `accounts:read` - Read account information
    - `accounts:write` - Update account settings
    - `accounts:billing` - Access billing information
    - `accounts:members:read` - Read account members
    - `accounts:members:add` - Add account members
    - `accounts:members:update` - Update account members
    - `accounts:members:remove` - Remove account members

    ### Webhook Scopes
    - `webhooks:read:all` - Read all webhooks
    - `webhooks:read:{domain}` - Read webhooks for a specific domain
    - `webhooks:write:all` - Create and update webhooks
    - `webhooks:write:{domain}` - Create and update webhooks for a specific domain
    - `webhooks:delete:all` - Delete any webhook
    - `webhooks:delete:{domain}` - Delete webhooks for a specific domain

    ### Route Scopes
    - `routes:read:all` - Read all routes
    - `routes:read:{domain}` - Read routes for a specific domain
    - `routes:write:all` - Create and update routes
    - `routes:write:{domain}` - Create and update routes for a specific domain
    - `routes:delete:all` - Delete any route
    - `routes:delete:{domain}` - Delete routes for a specific domain

    ### Suppression Scopes
    - `suppressions:read` - Read suppressions
    - `suppressions:write` - Create suppressions
    - `suppressions:delete` - Delete suppressions
    - `suppressions:wipe` - Delete the account's suppressions except protected ones (dangerous)

    ### Template Scopes
    - `templates:read` - Read transactional templates, their drafts and their published versions, and campaign templates
    - `templates:write` - Create and update transactional templates, and publish, discard and restore their drafts; create and update campaign templates
    - `templates:delete` - Delete transactional templates and campaign templates

    ### Campaign Scopes
    - `campaigns:read` - Read campaigns, their stats and their recipients
    - `campaigns:write` - Create, update and duplicate campaigns
    - `campaigns:delete` - Delete draft campaigns
    - `campaigns:send` - Send, unschedule and test-send campaigns

    ### Contact Scopes
    - `contacts:read` - Read contacts
    - `contacts:write` - Create and update contacts
    - `contacts:delete` - Delete contacts

    ### Contact lifecycle and attributes
    Contacts are account-global. A contact created through this API joins no
    list on its own; campaigns target lists, so add it to one with the Lists
    endpoints before a campaign can reach it.
    Contact subscription state and suppression records are independent:
    creating, updating, unsubscribing, resubscribing, or deleting a contact
    never creates, changes, or removes complaint, permanent, or protected
    suppressions. Prefer unsubscribe over delete when the goal is only to stop
    marketing mail, because unsubscribe is reversible and preserves contact
    history.

    Writable attribute keys and types come from the account's contact-attribute
    definitions managed in the dashboard; this API does not expose property
    definition CRUD. Writes accept only canonical values for those definitions,
    while reads remain legacy-safe and can return any recursively nested JSON
    value previously stored.

    An email used as `{id_or_email}` is a raw SDK argument and must be
    percent-encoded exactly once as a URL path segment by an HTTP client. For
    example, do not pre-encode `user+tag@example.com` before passing it to an
    SDK that encodes path parameters.

    ### List Scopes
    - `lists:read` - Read lists and list membership
    - `lists:write` - Create and update lists, and add or remove their contacts
    - `lists:delete` - Delete lists

    ### Lists and membership
    A list is a static, private, single opt-in container of contacts, and it is
    what a campaign targets. A membership carries its own `subscription_status`
    (`unconfirmed`, `confirmed`, `unsubscribed`, or `complained`), which is
    per-list consent and is independent of the contact's account-wide
    `unsubscribed` flag. A membership created without a status named is
    `confirmed`, because that is what a campaign reaches. `complained` is the
    mailbox provider's verdict, never writable, and a membership holding it is
    frozen.

    Deleting a list keeps its memberships, so an unsubscribe survives it.
    Removing a contact from a list deletes the membership row and discards that
    record with it, which is why unsubscribing is the reversible way to stop
    marketing mail on one list. Expanding a list's members with
    `include_contacts=true` requires `contacts:read` in addition to
    `lists:read`.

    ### SMTP Credentials Scopes
    - `smtp-credentials:read:all` - Read all SMTP credentials
    - `smtp-credentials:read:{domain}` - Read SMTP credentials for a specific domain
    - `smtp-credentials:write:all` - Create SMTP credentials
    - `smtp-credentials:write:{domain}` - Create SMTP credentials for a specific domain
    - `smtp-credentials:delete:all` - Delete any SMTP credentials
    - `smtp-credentials:delete:{domain}` - Delete SMTP credentials for a specific domain

    ### Statistics Scopes
    - `statistics-transactional:read:all` - Read all transactional statistics
    - `statistics-transactional:read:{domain}` - Read transactional statistics for a specific domain

    ### API Key Scopes
    - `api-keys:read` - Read API keys
    - `api-keys:write` - Create and update API keys
    - `api-keys:delete` - Delete API keys

    ### Sub-Account Scopes
    - `sub-accounts:read` - List and read sub accounts under the parent
    - `sub-accounts:write` - Create and update sub accounts
    - `sub-accounts:delete` - Soft-delete sub accounts
    - `sub-accounts:suspend` - Pause and resume sub accounts, and unpause their domains
    - `sub-accounts:usage` - Read per-sub-account usage and allocated cost
    - `sub-account-api-keys:read` - List and read API keys owned by sub accounts
    - `sub-account-api-keys:write` - Create and update API keys owned by sub accounts
    - `sub-account-api-keys:delete` - Delete API keys owned by sub accounts

    ## Rate Limiting
    - General API endpoints: 100 requests per second, 200 burst
    - Statistics endpoints: 1 request per second, 1 burst

    ## Pagination
    List endpoints use cursor-based pagination with the following parameters:
    - `limit`: Maximum number of items to return (default: 100, max: 100)
    - `after`: Pass `pagination.next_cursor` to fetch the next page
    - `before`: Pass `pagination.previous_cursor` to fetch the previous page

    `after` and `before` are mutually exclusive. A legacy `cursor` query may be
    accepted by parts of the server for backwards compatibility, but it is not
    part of the v2 public contract.

    ## Time Formats
    All timestamps must be in RFC3339 format, e.g., `2023-12-25T10:30:00Z`

    ## Idempotency
    The operations that explicitly declare the optional `Idempotency-Key`
    header support idempotency. Other POST operations do not gain idempotency
    implicitly. When the header is provided:
    - A fresh execution does not return `Idempotent-Replayed`.
    - A completed stored response is replayed with its original status and body
      and `Idempotent-Replayed: true`.
    - An unexpired in-progress execution returns HTTP 409 with
      `Idempotent-Replayed: false` and a positive integer `Retry-After` header.
    - Reusing a key with a different HTTP method, resolved request path, or
      request body returns HTTP 422. That response has neither
      `Idempotent-Replayed` nor `Retry-After`.
    - For ordinary idempotent operations, deterministic 2xx and 4xx outcomes
      produced after the idempotency middleware are stored and replayed.
      Authentication and IP-allow-list failures happen before idempotency and
      are therefore not stored.
    - The two secret-bearing API-key create operations use manual encrypted
      completion. They store and replay a successful 201, including the same
      one-time `secret_key`; a controller-produced 4xx is not stored and leaves
      the key in progress until its execution lease expires.
    - Server errors (5xx), handler failures, and panics are not stored. The key
      is released so retrying the same request with the same key re-executes it.
      The v2 API does not use an HTTP 412 failed-key tombstone.
    - Idempotency keys for non-secret responses expire after 24 hours.
    - Encrypted API-key creation replay responses expire after 5 minutes.

    ## IP Allow Lists
    Each API key can carry an `ip_allow_list`: a set of source IPs that restrict where the key may authenticate from.
    - Entries are CIDR blocks (e.g. `203.0.113.0/24`) or bare IPv4/IPv6 addresses (stored as a `/32` or `/128`). They are canonicalized and de-duplicated; the allow-all prefixes `0.0.0.0/0` and `::/0` are rejected, and at most 100 entries are allowed after de-duplication (a longer list is rejected with HTTP 400).
    - An empty list (the default) places no restriction.
    - When the list is non-empty, any authenticated request whose client IP is not covered by an entry is rejected with HTTP 403 on every v2 endpoint, regardless of the key's scopes.
    - Manage the list with the `ip_allow_list` field on the API-key create and update endpoints (including the sub-account API-key endpoints).
    - When a key updates its own `ip_allow_list` to a value that excludes the caller's current IP, the update is rejected with HTTP 409 to prevent self-lockout. Updating a sub-account key from a parent key has no such guard.

  version: "2.0.0"
  contact:
    email: support@ahasend.com
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.ahasend.com
    description: Production server

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "aha-sk-64-CHARACTER-RANDOM-STRING"
      description: |
        API key for authentication. Non-empty Security Requirement values are
        AhaSend API-key roles. Roles listed within one requirement object are
        jointly required; separate requirement objects are alternatives.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional idempotency key for safe request retries. Must be a unique string for each logical request.
        An identical request with a completed stored outcome returns the original status and body. An in-progress
        execution returns 409, a changed method/path/body returns 422, and a released 5xx execution may run again.
        Keys for non-secret responses expire after 24 hours. API-key create responses include a one-time `secret_key`,
        so successful encrypted replay responses for those operations expire after 5 minutes.
      schema:
        type: string
        maxLength: 255
      example: "user-12345-create-domain-20240101"

  headers:
    IdempotentReplayed:
      description: |
        Indicates that this response is a stored replay of a previous identical request.
        When this header is absent, the response is not a stored replay. Ordinary fresh
        responses do not send `false`; that value is reserved for an in-progress 409.
      schema:
        type: string
        enum: ['true']

    IdempotencyInProgress:
      description: |
        Identifies an idempotency-key execution that is still in progress. This
        header is emitted only on the specialized HTTP 409 response.
      schema:
        type: string
        enum: ['false']

    IdempotencyRetryAfter:
      description: |
        Positive whole number of seconds after which the in-progress execution
        lease may be retried with the same key and unchanged request.
      schema:
        type: integer
        minimum: 1

  responses:
    IdempotencyConflict:
      description: |
        Request in progress. This response always includes
        `Idempotent-Replayed: false` and a positive integer `Retry-After`.
        Clients must use that header tuple, not the human-readable message, to
        distinguish this retryable idempotency state from other HTTP 409s.
      headers:
        Idempotent-Replayed:
          $ref: '#/components/headers/IdempotencyInProgress'
        Retry-After:
          $ref: '#/components/headers/IdempotencyRetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: "A request with this idempotency key is already in progress"

    IdempotencyPayloadMismatch:
      description: |
        Idempotency key was already used with a different HTTP method, resolved
        request path, or request body. This response includes neither
        `Idempotent-Replayed` nor `Retry-After`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: "idempotency key was already used with a different request payload"

    CampaignConflict:
      description: |
        Either the campaign's status does not allow the request (for most
        requests, the campaign is not a draft; for an unschedule, it is not
        validating or scheduled, or it has queued mail), or an execution
        with this idempotency key is still in progress. The in-progress case
        is identified only by `Idempotent-Replayed: false` plus a positive
        integer `Retry-After`; retry it after that many seconds with the same
        key. A new status conflict has neither header; a stored replay of
        one has `Idempotent-Replayed: true` (a test send's status conflict is
        not stored, so it is never replayed). The message is not a stable
        discriminator.
      headers:
        Idempotent-Replayed:
          description: |
            `false` with a positive `Retry-After` means an execution is in
            progress. `true` means a stored status 409 was replayed. Absent
            means a fresh status conflict.
          schema:
            type: string
            enum: ['true', 'false']
        Retry-After:
          $ref: '#/components/headers/IdempotencyRetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: "campaign is not a draft"

    TemplateBeingChanged:
      description: |
        The template changed in the dashboard or through another request
        while this one was writing it, more often than the request retries
        by itself. Retry after `Retry-After` seconds; on an operation that
        takes an `Idempotency-Key`, retry with the same key.
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: "template is being changed by another request, try again"

  schemas:
    ErrorResponse:
      type: object
      description: |
        Human-readable API error. The server currently sends no stable machine
        error code. In particular, clients must not parse `message` to
        distinguish IP-allow-list, scope, ownership, plan, self-lockout,
        suppression-duplicate, or other errors that share an HTTP status.
      additionalProperties: false
      required:
        - message
      properties:
        message:
          type: string
          description: Error description
      example:
        message: "Error message"

    SuccessResponse:
      type: object
      required:
        - message
      properties:
        message:
          type: string
          description: Success message
      example:
        message: "Operation completed successfully"

    PaginationInfo:
      type: object
      required:
        - has_more
      properties:
        has_more:
          type: boolean
          description: Whether more items exist after the last item of this page
        next_cursor:
          type: string
          description: Pass as `after` to fetch the next page
        previous_cursor:
          type: string
          description: Pass as `before` to fetch the previous page; absent on a `before` page when the start of the list has been reached
      example:
        has_more: true
        next_cursor: "eyJpZCI6MTIzNH0="

    # API Key Schemas
    APIKeyScope:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier for the scope
        created_at:
          type: string
          format: date-time
          description: When the scope was created
        updated_at:
          type: string
          format: date-time
          description: When the scope was last updated
        api_key_id:
          type: string
          format: uuid
          description: ID of the API key this scope belongs to
        scope:
          type: string
          description: The scope string
        domain_id:
          type: [string, 'null']
          format: uuid
          description: Domain ID for domain-specific scopes; always present and null for non-domain scopes
      required:
        - id
        - created_at
        - updated_at
        - api_key_id
        - scope
        - domain_id

    APIKey:
      type: object
      properties:
        object:
          type: string
          enum: [api_key]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the API key
        created_at:
          type: string
          format: date-time
          description: When the API key was created
        updated_at:
          type: string
          format: date-time
          description: When the API key was last updated
        last_used_at:
          type: [string, 'null']
          format: date-time
          description: When the API key was last used (updates every 5-10 minutes)
        account_id:
          type: string
          format: uuid
          description: Account ID this API key belongs to
        label:
          type: string
          description: Human-readable label for the API key
        public_key:
          type: string
          description: Public portion of the API key
        scopes:
          type: array
          items:
            $ref: '#/components/schemas/APIKeyScope'
          description: Scopes granted to this API key
        ip_allow_list:
          type: array
          items:
            type: string
          description: >-
            Source IPs allowed to authenticate with this API key, as canonical
            CIDR blocks (a bare address is stored as a `/32` for IPv4 or `/128`
            for IPv6). Always present; an empty array means the key may be used
            from any source IP. When non-empty, an authenticated request whose
            client IP is not covered by an entry is rejected with HTTP 403 on
            every v2 endpoint, regardless of the key's scopes.
      required:
        - object
        - id
        - created_at
        - updated_at
        - last_used_at
        - account_id
        - label
        - public_key
        - scopes
        - ip_allow_list

    CreateAPIKeyRequest:
      type: object
      required:
        - label
        - scopes
      properties:
        label:
          type: string
          minLength: 1
          maxLength: 255
          description: Human-readable label for the API key; must not be empty
        scopes:
          type: array
          items:
            type: string
          minItems: 1
          description: Array of scope strings to grant to this API key
        ip_allow_list:
          type: array
          items:
            type: string
          description: >-
            Optional list of source IPs allowed to authenticate with this key.
            Each entry is a CIDR block (e.g. `203.0.113.0/24`) or a bare
            IPv4/IPv6 address (stored as a `/32` or `/128`). Entries are
            canonicalized (host bits are masked) and de-duplicated. The
            allow-all prefixes `0.0.0.0/0` and `::/0` are rejected, and at most
            100 entries are allowed after de-duplication. Omit the field or
            pass an empty array to leave the key usable from any IP.
      example:
        label: "Production API Key"
        scopes: ["messages:send:all", "domains:read"]
        ip_allow_list: ["203.0.113.0/24", "198.51.100.7"]

    UpdateAPIKeyRequest:
      type: object
      description: At least one non-null field must be provided, and at least one provided value must differ from the current value. Omitted or null fields are left unchanged.
      anyOf:
        - required: [label]
          properties:
            label:
              type: string
        - required: [scopes]
          properties:
            scopes:
              type: array
        - required: [ip_allow_list]
          properties:
            ip_allow_list:
              type: array
      properties:
        label:
          type: [string, 'null']
          minLength: 1
          maxLength: 255
          description: Human-readable label for the API key. Omit to keep the existing label.
        scopes:
          type: [array, 'null']
          items:
            type: string
          minItems: 1
          description: Array of scope strings to grant to this API key
        ip_allow_list:
          type: [array, 'null']
          items:
            type: string
          description: >-
            Replacement list of allowed source IPs (CIDR blocks or bare
            IPv4/IPv6 addresses; canonicalized and de-duplicated, at most 100
            entries, allow-all prefixes `0.0.0.0/0` and `::/0` rejected). Omit
            the field or send null to leave the existing list unchanged; send an
            empty array to clear it (key usable from any IP); send a non-empty
            array to replace it.
      example:
        label: "Updated API Key"
        scopes: ["messages:send:{example.com}", "domains:read"]
        ip_allow_list: ["203.0.113.0/24"]

    PaginatedAPIKeysResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/APIKey'
          description: Array of API keys
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Domain Schemas
    DNSRecord:
      type: object
      properties:
        type:
          type: string
          description: DNS record type (e.g., CNAME, TXT, MX)
        label:
          type: string
          description: Human-readable DNS record label
        host:
          type: string
          description: DNS record host/name
        content:
          type: string
          description: DNS record content/value
        required:
          type: boolean
          description: Whether this DNS record is required for domain verification
        propagated:
          type: boolean
          description: Whether this DNS record has been propagated and verified
      required:
        - type
        - host
        - content
        - required
        - propagated

    Domain:
      type: object
      properties:
        object:
          type: string
          enum: [domain]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the domain
        created_at:
          type: string
          format: date-time
          description: When the domain was created
        updated_at:
          type: string
          format: date-time
          description: When the domain was last updated
        domain:
          type: string
          description: The domain name
        account_id:
          type: string
          format: uuid
          description: Account ID this domain belongs to
        dns_records:
          type: array
          items:
            $ref: '#/components/schemas/DNSRecord'
          description: DNS records required for domain verification
        last_dns_check_at:
          type: [string, 'null']
          format: date-time
          description: When DNS records were last checked
        dns_valid:
          type: boolean
          description: Whether all required DNS records are properly configured
        tracking_subdomain:
          type: [string, 'null']
          description: Custom tracking subdomain. Null means the account or product default is used. Applies to both manual-DNS and managed-DNS domains.
        return_path_subdomain:
          type: [string, 'null']
          description: Custom return-path subdomain. Null means the account or product default is used. Applies to both manual-DNS and managed-DNS domains.
        subscription_subdomain:
          type: [string, 'null']
          description: Custom subscription management subdomain. Null means the account or product default is used. Applies to both manual-DNS and managed-DNS domains.
        media_subdomain:
          type: [string, 'null']
          description: Custom media subdomain. Null means the account or product default is used. Applies to both manual-DNS and managed-DNS domains.
        dkim_rotation_interval_days:
          type: [integer, 'null']
          description: Custom DKIM rotation interval in days. Null means the account default is used. Only supported for managed DNS domains.
        dkim_selector:
          type: [string, 'null']
          description: |
            Per-domain DKIM selector override. Null means no per-domain override is set.

            This reports the configured per-domain override only, not the effective selector used for DKIM signing. The effective selector is resolved separately and can differ — for example, a selector locked after DNS verification takes precedence over this override.
        rotation_ready:
          type: boolean
          description: Whether the standby DKIM slot is ready for rotation.
        dsn_recipient:
          type: [string, 'null']
          description: Optional recipient address for delivery status notifications.
        sending_type:
          type: string
          enum: [transactional, marketing]
          description: The type of email that you send from the domain. The sending type affects your deliverability. Marketing email from a transactional domain can get your account paused.
        paused:
          type: boolean
          description: Whether sending from the domain is paused. While the domain is paused, new email from it is refused, and you cannot delete or rename it. Your other domains continue to send.
        paused_at:
          type: [string, 'null']
          format: date-time
          description: When sending from the domain was paused. Null when the domain is not paused.
        pause_reason:
          type: [string, 'null']
          description: |
            The reason of the pause. Null when the domain is not paused.

            - `bounce_rate`: too many recent emails from the domain bounced.
      required:
        - object
        - id
        - created_at
        - updated_at
        - domain
        - account_id
        - dns_records
        - last_dns_check_at
        - dns_valid
        - tracking_subdomain
        - return_path_subdomain
        - subscription_subdomain
        - media_subdomain
        - dkim_rotation_interval_days
        - dkim_selector
        - rotation_ready
        - dsn_recipient
        - sending_type
        - paused
        - paused_at
        - pause_reason

    CreateDomainRequest:
      type: object
      required:
        - domain
      properties:
        domain:
          type: string
          format: hostname
          description: Fully qualified domain name
        dkim_private_key:
          type: string
          description: |
            DKIM Private Key for the domain. Only RSA keys with a minimum key size of 1024 bits are supported.

            **Note:** This parameter is only supported on [Platform Partner](https://ahasend.com/partners) accounts.

            <Note>Key size of at least 1024 bits is supported; 2048 bit keys are strongly recommended for improved security.</Note>
        tracking_subdomain:
          type: string
          description: Optional custom tracking subdomain. Omit to use the default on create. Applies to both manual-DNS and managed-DNS domains.
        return_path_subdomain:
          type: string
          description: Optional custom return-path subdomain. Omit to use the default on create. Applies to both manual-DNS and managed-DNS domains.
        subscription_subdomain:
          type: string
          description: Optional custom subscription management subdomain. Omit to use the default on create. Applies to both manual-DNS and managed-DNS domains.
        media_subdomain:
          type: string
          description: Optional custom media subdomain. Omit to use the default on create. Applies to both manual-DNS and managed-DNS domains.
        dkim_rotation_interval_days:
          type: integer
          description: Optional custom DKIM rotation interval in days. Only supported for managed DNS domains on eligible plans.
        dkim_selector:
          type: [string, 'null']
          description: |
            Optional custom DKIM selector for the domain. Omit, send null, or send an empty or whitespace-only string to use the default selector (no per-domain override); the value is trimmed before it is interpreted, so whitespace-only input is treated as empty.

            When provided, it is normalized to lowercase and must be a single DNS label: 1-62 characters using lowercase letters, numbers, and hyphens only, without a leading or trailing hyphen.

            **Note:** This parameter is only supported on [Platform Partner](https://ahasend.com/partners) accounts.
        sending_type:
          type: string
          enum: [transactional, marketing]
          description: The type of email that you send from the domain. Omit to create a transactional domain. Null and empty values are refused.
      example:
        domain: "example.com"
        tracking_subdomain: "click"
        return_path_subdomain: "mail"
        subscription_subdomain: "preferences"
        media_subdomain: "media"
        dkim_rotation_interval_days: 45

    UpdateDomainRequest:
      type: object
      properties:
        tracking_subdomain:
          type: string
          description: Optional custom tracking subdomain. Omit to leave the current value unchanged. Applies to both manual-DNS and managed-DNS domains.
        return_path_subdomain:
          type: string
          description: Optional custom return-path subdomain. Omit to leave the current value unchanged. Applies to both manual-DNS and managed-DNS domains.
        subscription_subdomain:
          type: string
          description: Optional custom subscription management subdomain. Omit to leave the current value unchanged. Applies to both manual-DNS and managed-DNS domains.
        media_subdomain:
          type: string
          description: Optional custom media subdomain. Omit to leave the current value unchanged. Applies to both manual-DNS and managed-DNS domains.
        dkim_rotation_interval_days:
          type: integer
          description: Optional custom DKIM rotation interval in days. Omit to leave the current value unchanged. Only supported for managed DNS domains on eligible plans.
        dkim_selector:
          type: [string, 'null']
          description: |
            Optional custom DKIM selector for the domain. Omit or send null to leave the current value unchanged; send an empty or whitespace-only string to clear the override and revert to the default selector. The value is trimmed before it is interpreted, so whitespace-only input clears the override just like an empty string.

            When provided, it is normalized to lowercase and must be a single DNS label: 1-62 characters using lowercase letters, numbers, and hyphens only, without a leading or trailing hyphen. It cannot be changed once the selector locks after DNS verification or while a DKIM key rotation is in progress.

            **Note:** This parameter is only supported on [Platform Partner](https://ahasend.com/partners) accounts.
        sending_type:
          type: string
          enum: [transactional, marketing]
          description: The type of email that you send from the domain. Omit to leave the current value unchanged. Null and empty values are refused. A change applies to new messages in a maximum of five minutes.
      example:
        tracking_subdomain: "click"
        return_path_subdomain: "mail"
        subscription_subdomain: "preferences"
        media_subdomain: "media"
        dkim_rotation_interval_days: 45

    PaginatedDomainsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Domain'
          description: Array of domains
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Message Schemas
    Address:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: Valid email address from a domain defined in your account with valid DNS records
        name:
          type: string
          description: Display name for the sender
      example:
        email: "noreply@example.com"
        name: "Example Corp"

    Recipient:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: Recipient email address
        name:
          type: string
          description: Display name for the recipient
        substitutions:
          type: object
          additionalProperties: true
          description: Substitution data for the recipient. Used with jinja2 templating language for dynamic content. In the HTML and AMP bodies, values are HTML-escaped; write `{{ name|safe }}` to insert a value as HTML. The subject, the headers and the text body get values as they are.
      example:
        email: "user@example.com"
        name: "John Doe"
        substitutions:
          first_name: "John"
          order_id: "12345"

    Attachment:
      type: object
      required:
        - data
        - content_type
        - file_name
      properties:
        base64:
          type: boolean
          description: If true, data must be encoded using base64. Otherwise, data will be interpreted as UTF-8
          default: false
        data:
          type: string
          description: Either plaintext or base64 encoded attachment data (depending on base64 field)
        content_type:
          type: string
          description: The MIME type of the attachment
        content_disposition:
          type: string
          description: The disposition of the attachment
        content_id:
          type: string
          description: |
            Content-ID that lets you reference this attachment as an inline image from your HTML.

            The value **must be wrapped in angle brackets**, matching the MIME `Content-ID` header format — for example `"<image1@example.com>"`. Reference it from `html_content` **without** the brackets, e.g. `<img src="cid:image1@example.com">`.

            If the angle brackets are omitted, the file is delivered as a regular (downloadable) attachment instead of rendering inline.
        file_name:
          type: string
          description: The filename of the attachment
      example:
        base64: true
        data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
        content_type: "image/png"
        file_name: "pixel.png"

    Tracking:
      type: [object, 'null']
      properties:
        open:
          type: [boolean, 'null']
          description: Whether to track opens
        click:
          type: [boolean, 'null']
          description: Whether to track clicks
      example:
        open: true
        click: true

    Retention:
      type: [object, 'null']
      properties:
        metadata:
          type: [integer, 'null']
          description: Number of days to retain metadata
          minimum: 1
          maximum: 30
        data:
          type: [integer, 'null']
          description: Number of days to retain data
          minimum: 0
          maximum: 30
      example:
        metadata: 1
        data: 0
    MessageSchedule:
      type: object
      properties:
        first_attempt:
          type: string
          format: date-time
          description: The time to make the first attempt for delivering the message. Must be RFC3339 format, in the future, and within 7 days of the request.
        expires:
          type: string
          format: date-time
          description: Expire and drop the message if not delivered by this time. Must be RFC3339 format, in the future, and within 8 days of the request.

    CreateMessageRequest:
      type: object
      required:
        - from
        - recipients
        - subject
      properties:
        from:
          $ref: '#/components/schemas/Address'
        recipients:
          type: array
          items:
            $ref: '#/components/schemas/Recipient'
          minItems: 1
          maxItems: 100
          description: This does not set the To header to multiple addresses, it sends a separate message for each recipient
        reply_to:
          $ref: '#/components/schemas/Address'
          description: If provided, the reply-to header in headers array must not be provided
        subject:
          type: string
          description: |
            Email subject line.

            A `{{ variable }}` in a subject resolves, whatever else the subject contains -- an emoji or an accented character included -- for every recipient whose message carries substitutions, the request's own `substitutions` or that recipient's. A recipient with neither is sent nothing to render, so their subject arrives exactly as written, braces and all.

            Pad the braces with ordinary spaces: a variable written with non-ASCII whitespace inside them is not reliably recognised.

            On a subject that is being resolved and carries an emoji or an accented character, template syntax other than a variable -- `{% ... %}`, `{# ... #}` or an unclosed brace -- is rejected. A substitution value is rejected for any of `{{`, `{%` or `{#`, a `{{ ... }}` included, and so is a subject whose values combine into one of them.
        text_content:
          type: string
          description: Plain text content. Required if html_content is empty
        html_content:
          type: string
          description: HTML content. Required if text_content is empty
        amp_content:
          type: string
          description: AMP HTML content
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          description: File attachments. For inline images, set the attachment's `content_id` to a value wrapped in angle brackets (e.g. `<image1@example.com>`) and reference it from `html_content` with `cid:` (e.g. `<img src="cid:image1@example.com">`).
        headers:
          type: object
          additionalProperties:
            type: string
          description: Custom email headers. Each name must be visible ASCII with no space and no colon. reply-to header cannot be provided if reply_to is provided, message-id will be ignored and automatically generated
        substitutions:
          type: object
          additionalProperties: true
          description: Global substitutions, recipient substitutions override global. In the HTML and AMP bodies, values are HTML-escaped; write `{{ name|safe }}` to insert a value as HTML. The subject, the headers and the text body get values as they are.
        tags:
          type: array
          items:
            type: string
          description: Tags for categorizing messages
        sandbox:
          type: boolean
          description: If true, the message will be sent to the sandbox environment
          default: false
        sandbox_result:
          type: string
          enum: [deliver, bounce, defer, fail, suppress]
          description: The result of the sandbox send
        tracking:
          $ref: '#/components/schemas/Tracking'
          description: Tracking settings for the message, overrides default account settings
        retention:
          $ref: '#/components/schemas/Retention'
          description: Retention settings for the message, overrides default account settings
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Schedule for message delivery
      example:
        from:
          email: "noreply@example.com"
          name: "Example Corp"
        recipients:
          - email: "user@example.com"
            name: "John Doe"
        subject: "Welcome to Example Corp"
        html_content: "<h1>Welcome {{first_name}}!</h1>"
        text_content: "Welcome {{first_name}}!"
        substitutions:
          first_name: "John"

    CreateTemplateMessageRequest:
      type: object
      required:
        - template_id
        - recipients
      properties:
        template_id:
          type: string
          format: uuid
          description: The ID of one of this account's transactional templates. List Templates returns the IDs.
        from:
          $ref: '#/components/schemas/Address'
          description: The sender. Leave it out to send from the template's default sender. A `from` given here is used even when the template has a default sender, and it must have an `email`.
        recipients:
          type: array
          items:
            $ref: '#/components/schemas/Recipient'
          minItems: 1
          maxItems: 100
          description: |
            Each recipient gets a separate message. This does not set the To header to more than one address.

            A recipient's `substitutions` hold its values for the template's variables. They are the only place for these values.
        reply_to:
          $ref: '#/components/schemas/Address'
          description: If provided, the reply-to header in headers must not be provided. When the request has neither, the template's reply-to is used.
        subject:
          type: string
          description: |
            Email subject line. When it is left out, empty or only spaces, the template's published subject is used.

            A `{{ variable }}` in the subject, yours or the template's, resolves for every recipient from that recipient's `substitutions` and the reserved variables, whatever else the subject contains -- an emoji or an accented character included. A variable with no value becomes empty.

            Pad the braces with ordinary spaces: a variable written with non-ASCII whitespace inside them is not reliably recognised.

            On a subject that carries a `{{ variable }}` and an emoji or an accented character, template syntax other than a variable -- `{% ... %}`, `{# ... #}` or an unclosed brace -- is rejected. A substitution value is rejected for any of `{{`, `{%` or `{#`, a `{{ ... }}` included, and so is a subject whose values combine into one of them.
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          description: File attachments. For an inline image, set the attachment's `content_id` to a value wrapped in angle brackets (e.g. `<image1@example.com>`). The template's HTML can then show it with `cid:` (e.g. `<img src="cid:image1@example.com">`).
        headers:
          type: object
          additionalProperties:
            type: string
          description: |
            Custom email headers. Each name must be visible ASCII with no space and no colon. reply-to header cannot be provided if reply_to is provided, message-id will be ignored and automatically generated.

            A `{{ variable }}` in a value resolves from the recipient's `substitutions` and the reserved variables. On a template whose design uses `unsubscribe_url`, a `List-Unsubscribe` or `List-Unsubscribe-Post` header given here is dropped.
        tags:
          type: array
          items:
            type: string
          description: Tags for categorizing messages
        sandbox:
          type: boolean
          description: If true, the message will be sent to the sandbox environment
          default: false
        sandbox_result:
          type: string
          enum: [deliver, bounce, defer, fail, suppress]
          description: The result of the sandbox send
        tracking:
          $ref: '#/components/schemas/Tracking'
          description: Tracking settings for the message, overrides default account settings
        retention:
          $ref: '#/components/schemas/Retention'
          description: Retention settings for the message, overrides default account settings
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Schedule for message delivery
      example:
        template_id: "0195c2a4-7d3e-7b6a-9f1c-2e4d5a6b7c8d"
        recipients:
          - email: "user@example.com"
            name: "John Doe"
            substitutions:
              first_name: "John"
              order_id: "12345"

    CreateConversationMessageRequest:
      type: object
      required:
        - from
        - to
        - subject
      properties:
        from:
          $ref: '#/components/schemas/Address'
        to:
          type: array
          items:
            $ref: '#/components/schemas/Address'
          minItems: 1
          maxItems: 50
          description: This parameter can set the `To` header to multiple addresses. The combined to, cc, and bcc recipient count must not exceed 50.
        cc:
          type: array
          items:
            $ref: '#/components/schemas/Address'
          minItems: 1
          maxItems: 50
          description: This parameter can set the `CC` header to multiple addresses. Do not include `cc` in the headers array. The combined to, cc, and bcc recipient count must not exceed 50.
        bcc:
          type: array
          items:
            $ref: '#/components/schemas/Address'
          minItems: 1
          maxItems: 50
          description: This parameter can set the `To` header to multiple addresses. The combined to, cc, and bcc recipient count must not exceed 50.
        reply_to:
          $ref: '#/components/schemas/Address'
          description: If provided, the reply-to header in headers array must not be provided
        subject:
          type: string
          description: Email subject line
        text_content:
          type: string
          description: Plain text content. Required if html_content is empty
        html_content:
          type: string
          description: HTML content. Required if text_content is empty
        amp_content:
          type: string
          description: AMP HTML content
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          description: File attachments. For inline images, set the attachment's `content_id` to a value wrapped in angle brackets (e.g. `<image1@example.com>`) and reference it from `html_content` with `cid:` (e.g. `<img src="cid:image1@example.com">`).
        headers:
          type: object
          additionalProperties:
            type: string
          description: Custom email headers. Each name must be visible ASCII with no space and no colon. `cc` and `reply-to` headers cannot be provided if `reply_to` or `cc` parameters are provided, message-id will be ignored and automatically generated
        tags:
          type: array
          items:
            type: string
          description: Tags for categorizing messages
        sandbox:
          type: boolean
          description: If true, the message will be sent to the sandbox environment
          default: false
        sandbox_result:
          type: string
          enum: [deliver, bounce, defer, fail, suppress]
          description: The result of the sandbox send
        tracking:
          $ref: '#/components/schemas/Tracking'
          description: Tracking settings for the message, overrides default account settings
        retention:
          $ref: '#/components/schemas/Retention'
          description: Retention settings for the message, overrides default account settings
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Schedule for message delivery
      example:
        from:
          email: "noreply@example.com"
          name: "Example Corp"
        to:
          - email: "user@example.com"
            name: "John Doe"
          - email: "someone@example.com"
            name: "Jane Doe"
        cc:
          - email: "hr@example.com"
            name: "Example Corp HR"
        bcc:
          - email: "bcc@example.com"
            name: "BCC Recipient"
        subject: "Welcome to Example Corp"
        html_content: "<h1>Dear John and Jane, welcome to Example Corp!</h1>"
        text_content: "Dear John and Jane, welcome to Example Corp!"

    CreateSingleMessageResponse:
      type: object
      required:
        - object
        - id
        - recipient
        - status
        - error
      properties:
        object:
          type: string
          enum: [message]
          description: Object type identifier
        id:
          type: [string, 'null']
          description: Message ID (null if the message was not sent)
          example: <uuid@example.com>
        recipient:
          allOf:
            - $ref: '#/components/schemas/Recipient'
            - type: object
              required:
                - name
        status:
          type: string
          enum: [queued, scheduled, error]
          description: Status of the message
        error:
          type: [string, 'null']
          description: Error message if the message was not sent
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Provided if the request contained a schedule

    CreateMessageResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/CreateSingleMessageResponse'
          description: List of messages and their statuses

    DeliveryEvent:
      type: object
      properties:
        time:
          type: string
          format: date-time
          description: Timestamp of the delivery event
        log:
          type: string
          description: Log message for the delivery event
        status:
          type: string
          description: Status of the delivery event
      required:
        - time
        - log
        - status

    MessageContentPart:
      type: object
      properties:
        content_type:
          type: string
          description: MIME content type (e.g., "text/plain", "text/html")
          example: "text/html"
        content:
          type: string
          description: The actual content for this part
      required:
        - content_type
        - content

    MessageAttachment:
      type: object
      properties:
        filename:
          type: string
          description: Original filename of the attachment
          example: "document.pdf"
        content:
          type: string
          description: Base64 encoded attachment content
        content_type:
          type: string
          description: MIME content type of the attachment
          example: "application/pdf"
        content_id:
          type: string
          description: Content-ID for inline attachments
          example: "image001@example.com"
      required:
        - filename
        - content
        - content_type
        - content_id

    MessageContentParsed:
      type: object
      properties:
        parts:
          type: array
          items:
            $ref: '#/components/schemas/MessageContentPart'
          description: Array of message content parts (text, HTML, etc.)
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/MessageAttachment'
          description: Array of message attachments
        headers:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          description: Email headers as key-value pairs (values are arrays to handle multiple headers with same name)
          example:
            "Content-Type": ["text/html; charset=utf-8"]
            "X-Custom-Header": ["value1", "value2"]
      required:
        - parts
        - attachments
        - headers

    # Base message schema without heavy content fields
    MessageSummary:
      type: object
      properties:
        object:
          type: string
          enum: [message]
          description: Object type identifier
        created_at:
          type: string
          format: date-time
          description: When the message was created
        updated_at:
          type: string
          format: date-time
          description: When the message was last updated
        sent_at:
          type: [string, 'null']
          format: date-time
          description: When the message was sent
        delivered_at:
          type: [string, 'null']
          format: date-time
          description: When the message was delivered
        retain_until:
          type: string
          format: date-time
          description: When the message data will be purged
        direction:
          type: string
          enum: [inbound, outbound]
          description: Message direction
        is_bounce_notification:
          type: boolean
          description: Whether this is a bounce notification
        bounce_classification:
          type: string
          description: Classification of bounce if applicable
        delivery_attempts:
          type: array
          items:
            $ref: '#/components/schemas/DeliveryEvent'
          description: List of delivery attempts for this message
        message_id:
          type: string
          description: Message-ID header value
        id:
          type: [string, 'null']
          format: uuid
          description: API-generated message ID
        subject:
          type: string
          description: Message subject
        tags:
          type: array
          items:
            type: string
          description: Tags associated with the message
        sender:
          type: string
          format: email
          description: Sender email address
        recipient:
          type: string
          format: email
          description: Recipient email address
        status:
          type: string
          description: Current message status
        num_attempts:
          type: integer
          description: Number of delivery attempts
        click_count:
          type: integer
          description: Number of clicks tracked for this message
        open_count:
          type: integer
          description: Number of opens tracked for this message
        reference_message_id:
          type: [integer, 'null']
          format: int64
          description: ID of the original message (for bounce messages)
        domain_id:
          type: string
          format: uuid
          description: Domain ID this message was sent from
        account_id:
          type: string
          format: uuid
          description: Account ID this message belongs to
        template_id:
          type: [string, 'null']
          format: uuid
          description: The transactional template the message was sent from with [Create Template Message](https://ahasend.com/docs/api-reference/messages/create-template-message). Null for any other message, including SMTP mail, dashboard test sends and messages sent before this field was added. The template may have been deleted since.
      required:
        - object
        - created_at
        - updated_at
        - sent_at
        - delivered_at
        - retain_until
        - direction
        - is_bounce_notification
        - bounce_classification
        - delivery_attempts
        - message_id
        - id
        - subject
        - tags
        - sender
        - recipient
        - status
        - num_attempts
        - click_count
        - open_count
        - reference_message_id
        - domain_id
        - account_id
        - template_id

    # Full message schema with content fields
    Message:
      allOf:
        - $ref: '#/components/schemas/MessageSummary'
        - type: object
          properties:
            content:
              type: string
              description: Original raw email content in RFC822 format. Omitted when message content is unavailable.
            content_parsed:
              $ref: '#/components/schemas/MessageContentParsed'
              description: Parsed message content. Omitted when message content is unavailable.

    # Update the paginated response to use the lighter schema
    PaginatedMessagesResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/MessageSummary'
          description: Array of message summaries (content and content_parsed fields omitted for performance)
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Template Schemas
    TemplateVariable:
      type: object
      required:
        - name
        - required
      properties:
        name:
          type: string
          description: Variable name as the design writes it, for example `first_name`
        required:
          type: boolean
          description: |
            Whether each recipient of a send has to supply a value for this
            variable. A variable the design wraps in a fallback is not required.

            `email` and `unsubscribe_url` are never required: the send
            supplies them for each recipient and replaces a value the
            recipient gives. `email` is the recipient's address.
            `unsubscribe_url` is a link made for the recipient, and the
            message then carries `List-Unsubscribe` and
            `List-Unsubscribe-Post` headers for it; a `List-Unsubscribe` or
            `List-Unsubscribe-Post` in the request's `headers` is dropped.

    Template:
      type: object
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - subject
        - preheader
        - variables
        - from
        - reply_to
        - editor
        - has_draft
      properties:
        object:
          type: string
          enum: [template]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Pass as `template_id` to [Create Template Message](https://ahasend.com/docs/api-reference/messages/create-template-message) to send this template
        created_at:
          type: string
          format: date-time
          description: When the template was created
        updated_at:
          type: string
          format: date-time
          description: When the template's name or published content last changed
        name:
          type: string
          description: Template name, as it appears in the dashboard
        subject:
          type: string
          description: The published subject, set on the template page. Empty until one is published, and overridden by a send that names its own `subject`.
        preheader:
          type: string
          description: The published preview text, injected into the delivered HTML when it is set
        variables:
          type: array
          items:
            $ref: '#/components/schemas/TemplateVariable'
          description: |
            Every variable the design uses. A send of this template takes the
            values only from each recipient's `substitutions`. Every recipient
            must give a value for each required variable; if one does not, the
            request is rejected and no message is sent.
        from:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: The default sender, used by a send that names no `from`. Null when the template has none, and then every send must name one.
        reply_to:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: The default reply-to address, used by a send that sets no reply-to, also one that names its own `from`. Null when the template has none. It has the shape of a send request's `reply_to`; its `name` is always empty.
        editor:
          $ref: '#/components/schemas/TemplateEditor'
        has_draft:
          type: boolean
          description: Whether the template has a draft, changes saved in the dashboard or through the API that are not published yet. Get Template Draft returns it.
        content:
          oneOf:
            - $ref: '#/components/schemas/TemplateContent'
            - type: 'null'
          description: The published content. Null when the published copy has no body, as for a template that was never published. List Templates leaves this field out; every other operation that returns a Template sets it.

    TemplateEditor:
      type: string
      enum: [advanced, simple, html]
      description: |
        The editor the template is designed with. It never changes after the
        template is created.

        - `advanced`: designed in MJML, with the dashboard's advanced editor.
        - `simple`: designed with the dashboard's simple editor. Its HTML is
          made from the design and can be read but not changed through the API.
        - `html`: an HTML template, uploaded or written by hand.

    TemplateContent:
      type: object
      required:
        - text_is_custom
      description: |
        A template's content. A key that does not apply to the template's
        editor is left out, and so are `mjml`, `html` and `text` when they are
        empty.
      properties:
        mjml:
          type: string
          description: The MJML source. Only for `advanced` templates.
        html:
          type: string
          description: The HTML of an `html` template, or the HTML made from the design of a `simple` template.
        text:
          type: string
          description: The plain text version of the email.
        text_is_custom:
          type: boolean
          description: Whether the text was written rather than made from the HTML. A text made from the HTML is made again whenever the design changes.

    TemplateDraft:
      type: object
      required:
        - object
        - template_id
        - updated_at
        - subject
        - preheader
        - variables
        - from
        - reply_to
        - content
      description: |
        A template's draft: the changes saved in the dashboard or through the
        API that are not published yet. Sends use the published copy until the
        draft is published.
      properties:
        object:
          type: string
          enum: [template_draft]
          description: Object type identifier
        template_id:
          type: string
          format: uuid
          description: The template the draft belongs to
        updated_at:
          type: string
          format: date-time
          description: When the draft last changed
        subject:
          type: string
          description: The draft's subject
        preheader:
          type: string
          description: The draft's preview text
        variables:
          type: array
          items:
            $ref: '#/components/schemas/TemplateVariable'
          description: Every variable the draft uses, as a send would require them once the draft is published
        from:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: The draft's default sender. Null when it has none.
        reply_to:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: The draft's default reply-to address. Null when it has none. Its `name` is always empty.
        content:
          oneOf:
            - $ref: '#/components/schemas/TemplateContent'
            - type: 'null'
          description: The draft's content. Null when the draft has no body.

    TemplatePublisher:
      type: object
      required:
        - type
        - id
      description: Who published a version.
      properties:
        type:
          type: string
          enum: [user, api_key]
          description: '`user` for a version published in the dashboard, `api_key` for one published through the API'
        id:
          type: string
          format: uuid
          description: The user's ID, as Get Account Members shows it, also for a user removed from the account since; or the API key's ID.

    TemplateVersion:
      type: object
      required:
        - object
        - id
        - version
        - published_at
        - published_by
      description: One published version of a template, without its content.
      properties:
        object:
          type: string
          enum: [template_version]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Version ID
        version:
          type: integer
          description: The version number, counted from 1 for each template
        published_at:
          type: string
          format: date-time
          description: When the version was published
        published_by:
          oneOf:
            - $ref: '#/components/schemas/TemplatePublisher'
            - type: 'null'
          description: Who published the version. Null when this is not known, as for versions made before versions recorded it, or when the user or API key was deleted for good.

    TemplateVersionDetail:
      allOf:
        - $ref: '#/components/schemas/TemplateVersion'
        - type: object
          required:
            - subject
            - preheader
            - variables
            - from
            - reply_to
            - content
          properties:
            subject:
              type: string
              description: The version's subject
            preheader:
              type: string
              description: The version's preview text
            variables:
              type: array
              items:
                $ref: '#/components/schemas/TemplateVariable'
              description: Every variable the version uses
            from:
              oneOf:
                - $ref: '#/components/schemas/Address'
                - type: 'null'
              description: The version's default sender. Null when it has none.
            reply_to:
              oneOf:
                - $ref: '#/components/schemas/Address'
                - type: 'null'
              description: The version's default reply-to address. Null when it has none. Its `name` is always empty.
            content:
              oneOf:
                - $ref: '#/components/schemas/TemplateContent'
                - type: 'null'
              description: The version's content. Null when the version has no body.
    TemplateContentInput:
      type: object
      additionalProperties: false
      description: |
        The content of a template write. It holds at most one of `mjml` and
        `html`, and at least one of `mjml`, `html` and `text`. The `content`
        of an `advanced` or `html` draft, or of the Template when it has no
        draft, can be sent back as it is: an `mjml` or `html` equal to the
        current one is held to the size limit but not compiled again or held
        to the HTML checks, and keeps the stored HTML. While a draft exists,
        the Template's `content` is the published copy's, and sending it back
        replaces the draft's design. Leave `content` out of a write that does
        not change it. For a `simple` template, send back only `text`: its
        `html` can be changed only in the dashboard.

        The text follows these rules:
        - Beside a new `mjml` or `html`, a missing `text` keeps the draft's
          text when it is custom, and that text must still render, as a sent
          text must; otherwise, and on a create, it makes the text from the
          new HTML. A null `text` makes the text from the new
          HTML, and so does a `text` equal to a text made from an earlier
          HTML: the draft's made text, or the published copy's made text when
          the draft does not hold that text as custom. A read `content` can
          so be sent back with a changed design. Any other `text` is stored
          as made when it equals the text made from the new HTML, and as
          custom otherwise. To keep a made text beside a new design, send the
          design first and the text in a second request.
        - Otherwise a `text` equal to the draft's custom text keeps it; one
          equal to the text made from the draft's HTML is stored as made; any
          other is stored as custom. A null `text` makes the text again from
          the draft's HTML, and is refused when there is no HTML.

        Line ends written as CRLF are stored as LF, and a blank text is
        refused. `mjml`, `html` and a custom `text` each have a size limit.
      properties:
        mjml:
          type: string
          minLength: 1
          description: |
            The MJML source. Only for `advanced` templates. It is compiled in
            strict mode, and every problem strict validation finds is refused
            with its line. Images must use full `https://` URLs: the API cannot
            upload files.
        html:
          type: string
          minLength: 1
          description: |
            The HTML. Only for `html` templates; the HTML of a `simple`
            template can be changed only in the dashboard. Images and
            stylesheets must use full `https://` URLs: the API cannot upload
            files.
        text:
          type:
            - string
            - 'null'
          description: The plain text version. Null makes it from the HTML.
        text_is_custom:
          type: boolean
          description: Accepted so a read `content` can be sent back, and ignored. The text rules decide it.

    TemplateAddressInput:
      type: object
      additionalProperties: false
      required:
        - email
      description: An address in a template write, in the shape a Template answers it.
      properties:
        email:
          type: string
          format: email
          description: The email address.
        name:
          type: string
          description: The display name.

    CreateTemplateRequest:
      type: object
      additionalProperties: false
      required:
        - name
      description: |
        A new transactional template. Its fields go to its draft, as a
        dashboard save does; `publish: true` publishes the draft in the same
        request, so sends use it.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Template name. Spaces around it are dropped.
        editor:
          $ref: '#/components/schemas/TemplateEditor'
          description: Required when `content` holds neither `mjml` nor `html`. `content.mjml` means `advanced` and `content.html` means `html`; an `editor` that disagrees with the content is refused.
        subject:
          type:
            - string
            - 'null'
          maxLength: 998
          description: The subject. Line breaks become spaces and spaces around it are dropped.
        preheader:
          type:
            - string
            - 'null'
          maxLength: 500
          description: The preview text. Line breaks become spaces and spaces around it are dropped.
        from:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The default sender. `email` must be on a domain of this account with valid DNS records that is not paused.
        reply_to:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The default reply-to address. It needs a `from`, and its `name` must be empty.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
        publish:
          type: boolean
          default: false
          description: Publish the draft once it is written.

    UpdateTemplateRequest:
      type: object
      additionalProperties: false
      description: |
        Changes to a template. A field left out is not changed or checked,
        apart from a custom text kept beside a new design.
        Every field but `name` goes to the draft, as a dashboard save does,
        and a draft equal to the published copy is not kept. The draft is
        shared with the dashboard: `publish: true`, like Publish Template,
        publishes the whole draft, including changes made in the dashboard.
        `name` changes at once, as the last step. The editor never changes.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Template name. Spaces around it are dropped.
        subject:
          type:
            - string
            - 'null'
          maxLength: 998
          description: The subject. Null or an empty string clears it. Line breaks become spaces and spaces around it are dropped.
        preheader:
          type:
            - string
            - 'null'
          maxLength: 500
          description: The preview text. Null or an empty string clears it. Line breaks become spaces and spaces around it are dropped.
        from:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The default sender. It replaces the address and the name together, so a missing `name` clears the name; the reply-to is kept. Null clears the sender and the reply-to. `email` must be on a domain of this account with valid DNS records that is not paused.
        reply_to:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The default reply-to address. Null clears it. It needs a sender, and its `name` must be empty.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
        publish:
          type: boolean
          default: false
          description: 'Publish the draft once it is written. With only `publish: true`, the request publishes the draft.'

    RestoreTemplateVersionRequest:
      type: object
      additionalProperties: false
      description: Optional. An empty body restores without publishing.
      properties:
        publish:
          type: boolean
          default: false
          description: Publish the draft once the version is restored into it.

    TemplateVersionsResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/TemplateVersion'
          description: The template's versions, newest first. A template keeps its 50 most recent versions, so the list is not paginated.

    PaginatedTemplatesResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Template'
          description: Array of templates, each without its `content`
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Campaign Template Schemas
    CampaignTemplate:
      type: object
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - editor
      description: A design a campaign copies when it is made from it. A campaign template has no draft, no versions, no subject and no sender; the campaign owns its subject and sender.
      properties:
        object:
          type: string
          enum: [campaign_template]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Campaign template ID
        created_at:
          type: string
          format: date-time
          description: When the campaign template was created
        updated_at:
          type: string
          format: date-time
          description: When the campaign template last changed
        name:
          type: string
          description: Campaign template name, as it appears in the dashboard
        editor:
          $ref: '#/components/schemas/TemplateEditor'
        content:
          oneOf:
            - $ref: '#/components/schemas/TemplateContent'
            - type: 'null'
          description: The content. Null when the campaign template has neither HTML nor text, as for one created without content. A campaign can be made from the campaign template only once it has HTML. List Campaign Templates leaves this field out; every other operation that returns a CampaignTemplate sets it.

    CreateCampaignTemplateRequest:
      type: object
      additionalProperties: false
      required:
        - name
      description: A new campaign template.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Campaign template name. Spaces around it are dropped.
        editor:
          $ref: '#/components/schemas/TemplateEditor'
          description: Required when `content` holds neither `mjml` nor `html`. `content.mjml` means `advanced` and `content.html` means `html`; an `editor` that disagrees with the content is refused.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
          description: The content, by the rules of `TemplateContentInput`. A campaign template has no draft, so where those rules name the draft, they mean the campaign template itself.

    UpdateCampaignTemplateRequest:
      type: object
      additionalProperties: false
      description: Changes to a campaign template. A field left out is not changed or checked. The editor never changes.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Campaign template name. Spaces around it are dropped.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
          description: The content, by the rules of `TemplateContentInput`. A campaign template has no draft, so where those rules name the draft, they mean the campaign template itself. For a `simple` campaign template, send only `text`.

    PaginatedCampaignTemplatesResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/CampaignTemplate'
          description: Array of campaign templates, each without its `content`
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Campaign Schemas
    CampaignStatus:
      type: string
      enum: [draft, validating, waiting_for_review, scheduled, sending, paused, sent, canceled, failed]
      description: |
        Where the campaign is in its life. Only a `draft` can be changed or
        deleted.

        - `draft`: being prepared.
        - `validating`: scheduled, and its lists are being checked.
        - `waiting_for_review`: held for a review before it sends.
        - `scheduled`: accepted, waiting for its send time.
        - `sending`: sending now.
        - `paused`: sending stopped for now; `status_reason` says why.
        - `sent`: every message was sent.
        - `canceled`: stopped before it finished; `status_reason` says why.
        - `failed`: could not be sent; `status_reason` says why.

    CampaignIssue:
      type: object
      required:
        - code
        - message
      description: A reason the campaign cannot be sent yet.
      properties:
        code:
          type: string
          enum:
            - sender_missing
            - lists_missing
            - subject_missing
            - preheader_missing
            - design_missing
            - text_broken
            - unsubscribe_link_missing
          description: |
            - `sender_missing`: the campaign has no complete sender, or its domain was deleted.
            - `lists_missing`: the campaign has no list that still exists.
            - `subject_missing`: the campaign has no subject.
            - `preheader_missing`: the campaign has no preview text.
            - `design_missing`: the campaign has no design, or its design has no HTML.
            - `text_broken`: the subject, the preview text or the design holds template syntax that cannot be rendered, so every message would fail.
            - `unsubscribe_link_missing`: the HTML does not use `{{ unsubscribe_url }}`, with or without filters.
        message:
          type: string
          description: What to do, in the words the dashboard uses.

    CampaignListItem:
      type: object
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - subject
        - preheader
        - from
        - reply_to
        - status
        - status_reason
        - send_at
        - send_resumes_at
        - finished_at
      description: A campaign as List Campaigns returns it, without its lists, content and issues.
      properties:
        object:
          type: string
          enum: [campaign]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Campaign ID
        created_at:
          type: string
          format: date-time
          description: When the campaign was created
        updated_at:
          type: string
          format: date-time
          description: When the campaign's own fields last changed
        name:
          type: string
          description: Campaign name, as it appears in the dashboard. Recipients do not see it.
        subject:
          type:
            - string
            - 'null'
          description: The subject line. Null until one is set.
        preheader:
          type:
            - string
            - 'null'
          description: The preview text, which most inboxes show after the subject. Null until one is set.
        from:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: The sender. Null until a complete sender is set, and when its domain was deleted. `name` is an empty string when the campaign has no sender name, so the object can be sent back as it is.
        reply_to:
          type:
            - string
            - 'null'
          format: email
          description: The reply-to address. Null when none is set.
        status:
          $ref: '#/components/schemas/CampaignStatus'
        status_reason:
          type:
            - string
            - 'null'
          description: Why the campaign is in its status, when there is a reason to give. Null otherwise.
        send_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When the campaign sends or sent, in UTC. Null until it is scheduled.
        send_resumes_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When a send that reached the account's daily marketing limit continues, in UTC. Null otherwise.
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When the send ended, in UTC. Null until then.

    Campaign:
      allOf:
        - $ref: '#/components/schemas/CampaignListItem'
        - type: object
          required:
            - list_ids
            - editor
            - content
            - ready
            - issues
          properties:
            list_ids:
              type: array
              items:
                type: string
                format: uuid
              description: The lists the campaign sends to, in the order they were saved. A list that was deleted is left out.
            editor:
              oneOf:
                - $ref: '#/components/schemas/TemplateEditor'
                - type: 'null'
              description: The editor of the campaign's design. Null while the campaign has no design.
            content:
              oneOf:
                - $ref: '#/components/schemas/TemplateContent'
                - type: 'null'
              description: The content of the campaign's design. Null while it has no design, or its design has neither HTML nor text.
            ready:
              type:
                - boolean
                - 'null'
              description: Whether the draft has no issue, so it can be sent. Null unless the campaign is a draft.
            issues:
              oneOf:
                - type: array
                  items:
                    $ref: '#/components/schemas/CampaignIssue'
                - type: 'null'
              description: The reasons the draft cannot be sent yet, in a fixed order; empty when it is ready. Null unless the campaign is a draft.

    CreateCampaignRequest:
      type: object
      additionalProperties: false
      required:
        - name
      description: |
        A new draft campaign. Every field but `name` can be set later with
        Update Campaign. The design comes from at most one source: an
        `editor`, `content`, both, or a `template_id`. With none of them the
        campaign has no design yet.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Campaign name. Spaces around it are dropped.
        subject:
          type:
            - string
            - 'null'
          description: The subject line. Line breaks become spaces and spaces around it are dropped; an empty subject is no subject.
        preheader:
          type:
            - string
            - 'null'
          description: The preview text, cleaned as `subject` is.
        from:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The sender. `email` must be on a domain of this account with valid DNS. A paused domain is accepted here and refused when the campaign is sent.
        reply_to:
          type:
            - string
            - 'null'
          format: email
          description: The reply-to address, an address only, without a display name.
        list_ids:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: string
            format: uuid
          description: The lists to send to, in order. A list named twice counts once. Each must be a list of this account.
        editor:
          $ref: '#/components/schemas/TemplateEditor'
          description: The editor of the design. Without `content` the campaign gets an empty design of this editor, which the dashboard's editor opens. With `content`, it is required when the content holds neither `mjml` nor `html`, and must agree with it otherwise. Not accepted with `template_id`, whose editor the design keeps.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
          description: The content of the design, by the rules of `TemplateContentInput`. A campaign has no draft, so where those rules name the draft, they mean the campaign's design. Not accepted with `template_id`.
        template_id:
          type: string
          format: uuid
          description: A campaign template whose design the campaign copies. The copy is the campaign's own; later changes to the template do not reach it. A campaign template with no HTML is refused.

    UpdateCampaignRequest:
      type: object
      additionalProperties: false
      description: |
        Changes to a draft campaign. A field left out is neither changed nor
        checked. The design's editor never changes.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Campaign name. Spaces around it are dropped.
        subject:
          type:
            - string
            - 'null'
          description: The subject line, cleaned as on create. Null clears it.
        preheader:
          type:
            - string
            - 'null'
          description: The preview text, cleaned as on create. Null clears it.
        from:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: The sender, as on create. Null clears the sender; `reply_to` stays as it is.
        reply_to:
          type:
            - string
            - 'null'
          format: email
          description: The reply-to address, as on create. Null clears it.
        list_ids:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: string
            format: uuid
          description: The lists to send to, which replace the campaign's lists, as on create.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
          description: The content of the design, by the rules of `TemplateContentInput`, in the design's editor. For a `simple` design, send only `text`. A campaign with no design yet needs `mjml` or `html`, which sets its editor.

    DuplicateCampaignRequest:
      type: object
      additionalProperties: false
      description: The copy's name. Without one the copy is named `Copy of` and the source's name, cut to 255 characters.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Name of the copy. Spaces around it are dropped.

    PaginatedCampaignsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/CampaignListItem'
          description: Array of campaigns
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    SendCampaignRequest:
      type: object
      additionalProperties: false
      description: When to send. Without `send_at` the campaign is sent now.
      properties:
        send_at:
          type: string
          format: date-time
          description: The time to send, in RFC 3339, at least 1 minute ahead. `null` is refused.

    CampaignSend:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          required:
            - warnings
          properties:
            warnings:
              type: array
              items:
                type: string
              description: Things the send accepted but you should know, such as an account that cannot send right now while the campaign is sent later, or a design its view-in-browser page cannot show. Empty when there is none.

    CampaignNotReady:
      type: object
      required:
        - message
      description: A refusal. A campaign with issues is refused with `campaign is not ready to send` and the issues that block the request; every other refusal has only `message`.
      properties:
        message:
          type: string
          description: Error message
        issues:
          type: array
          items:
            $ref: '#/components/schemas/CampaignIssue'
          description: The issues that block the request, in the order of `CampaignIssue`. For a test, every issue but `unsubscribe_link_missing`.
      example:
        message: "campaign is not ready to send"
        issues:
          - code: subject_missing
            message: Add a subject before you send the campaign.

    TestCampaignRequest:
      type: object
      additionalProperties: false
      required:
        - to
      description: The addresses to send a test to.
      properties:
        to:
          type: array
          minItems: 1
          items:
            type: string
          description: 1 to 5 addresses, counted after addresses that differ only in case are dropped. An entry may carry a display name, such as `Ann <ann@example.com>`; the test goes to the address alone.

    CampaignTestFailure:
      type: object
      required:
        - email
        - message
      description: An address the mail server refused.
      properties:
        email:
          type: string
          description: The address as the mail server reported it, which can be one the request did not hold.
        message:
          type: string
          description: Why the address was refused

    CampaignTest:
      type: object
      required:
        - object
        - sent_to
        - failed
        - result_unknown
      description: What a test send did.
      properties:
        object:
          type: string
          enum: [campaign_test]
          description: Object type identifier
        sent_to:
          type: array
          items:
            type: string
          description: The addresses the mail server accepted
        failed:
          type: array
          items:
            $ref: '#/components/schemas/CampaignTestFailure'
          description: The addresses the mail server refused
        result_unknown:
          type: boolean
          description: True when the mail server took the request but its answer could not be read. The test may have gone out; `sent_to` and `failed` are empty. Check the inboxes before you send another.

    CampaignStatsReport:
      type: object
      required:
        - computed_at
        - recipients
        - openers
        - clickers
        - bounces
        - deferrals
        - by_status
        - by_bounce_classification
      description: The campaign's stored report, which is recounted from its recipients from time to time.
      properties:
        computed_at:
          type: string
          format: date-time
          description: When the report was counted
        recipients:
          type: integer
          description: The recipients the report counted, the base of `bounce_rate` and `deferral_rate`
        openers:
          type: integer
          description: Contacts who opened, each once, without bots
        clickers:
          type: integer
          description: Contacts who clicked, each once, without bots
        bounces:
          type: integer
          description: Recipients that bounced or failed
        deferrals:
          type: integer
          description: Recipients whose delivery was deferred
        by_status:
          type: object
          additionalProperties:
            type: integer
          description: Recipients by status, under the names the dashboard's report shows, such as `Queued`, `Delivered`, `Bounced` or `Administratively Bounced`. Each name is a `status` of List Campaign Recipients.
        by_bounce_classification:
          type: object
          additionalProperties:
            type: integer
          description: Bounces by bounce classification. `Unknown` counts the bounces with no classification and has no recipient page; every other key is a `bounce_classification` of List Campaign Recipients.

    CampaignStatsRates:
      type: object
      required:
        - open_rate
        - click_rate
        - bounce_rate
        - deferral_rate
      description: Percentages from 0 to 100 with two decimals. Each is null while `report` is null or its base is zero.
      properties:
        open_rate:
          type:
            - number
            - 'null'
          description: Openers out of `delivered`, at most 100
        click_rate:
          type:
            - number
            - 'null'
          description: Clickers out of `delivered`, at most 100
        bounce_rate:
          type:
            - number
            - 'null'
          description: Bounces out of `report.recipients`
        deferral_rate:
          type:
            - number
            - 'null'
          description: Deferrals out of `report.recipients`

    CampaignStats:
      type: object
      required:
        - object
        - campaign_id
        - recipients
        - delivered
        - opens
        - clicks
        - unsubscribes
        - complaints
        - report
        - rates
        - recount_failed
      description: |
        The numbers of a campaign. The counters are live; `report` is the
        stored report, which is recounted from time to time. A request
        never counts: when the report is missing or old, it asks for a
        recount and answers what is stored, so read again later for the
        new numbers.
      properties:
        object:
          type: string
          enum: [campaign_stats]
          description: Object type identifier
        campaign_id:
          type: string
          format: uuid
          description: Campaign ID
        recipients:
          type: integer
          description: Recipients the campaign sends to
        delivered:
          type: integer
          description: Messages delivered
        opens:
          type: integer
          description: Opens, without bots
        clicks:
          type: integer
          description: Clicks, without bots
        unsubscribes:
          type: integer
          description: Recipients who unsubscribed
        complaints:
          type: integer
          description: Recipients who marked the message as spam
        report:
          oneOf:
            - $ref: '#/components/schemas/CampaignStatsReport'
            - type: 'null'
          description: The stored report. Null until the campaign's first recount.
        rates:
          $ref: '#/components/schemas/CampaignStatsRates'
        recount_failed:
          type: boolean
          description: True while the last recount of the report failed, so `report` may be old.

    CampaignRecipient:
      type: object
      required:
        - object
        - contact_id
        - email
        - list_name
        - status
        - bounce_classification
        - bounce_reason
        - bounce_message
        - attempts
        - created_at
        - updated_at
      description: A recipient of a campaign.
      properties:
        object:
          type: string
          enum: [campaign_recipient]
          description: Object type identifier
        contact_id:
          type: string
          format: uuid
          description: Contact ID
        email:
          type: string
          description: The address the campaign sent to
        list_name:
          type: string
          description: Name of the list the contact was sent to from
        status:
          type: string
          description: The status, under the name the dashboard's report shows, as a key of `by_status`
        bounce_classification:
          type:
            - string
            - 'null'
          description: The bounce classification. Null when it has none.
        bounce_reason:
          type:
            - string
            - 'null'
          description: The bounce reason. Null when it has none.
        bounce_message:
          type:
            - string
            - 'null'
          description: The receiving server's bounce message. Null when it has none.
        attempts:
          type: integer
          description: Delivery attempts
        created_at:
          type: string
          format: date-time
          description: When the recipient was added to the send
        updated_at:
          type: string
          format: date-time
          description: When the recipient last changed

    PaginatedCampaignRecipientsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/CampaignRecipient'
          description: Array of recipients, by contact ID, the highest first
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Account Schemas
    Account:
      type: object
      properties:
        object:
          type: string
          enum: [account]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the account
        parent_account_id:
          type: [string, 'null']
          format: uuid
          description: Parent account ID when this account is a sub account
        created_at:
          type: string
          format: date-time
          description: When the account was created
        updated_at:
          type: string
          format: date-time
          description: When the account was last updated
        name:
          type: string
          description: Account name
        website:
          type: string
          maxLength: 255
          description: Account website, stored as a bare domain such as `acme.com`
        about:
          type: string
          description: Account description
        track_opens:
          type: boolean
          description: Default open tracking setting
        track_clicks:
          type: boolean
          description: Default click tracking setting
        reject_bad_recipients:
          type: boolean
          description: Whether to reject bad recipients
        reject_mistyped_recipients:
          type: boolean
          description: Whether to reject mistyped recipients
        message_metadata_retention:
          type: integer
          description: Default message metadata retention in days
        message_data_retention:
          type: integer
          description: Default message data retention in days
        owner_id:
          type: string
          format: uuid
          description: Account owner user ID
      required:
        - object
        - id
        - parent_account_id
        - created_at
        - updated_at
        - name
        - website
        - about
        - track_opens
        - track_clicks
        - reject_bad_recipients
        - reject_mistyped_recipients
        - message_metadata_retention
        - message_data_retention
        - owner_id

    SubAccount:
      type: object
      required:
        - object
        - id
        - parent_account_id
        - name
        - website
        - status
        - monthly_credit
        - created_at
        - domain_count
        - member_count
        - last_activity_at
      properties:
        object:
          type: string
          enum: [sub_account]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the sub account
        parent_account_id:
          type: string
          format: uuid
          description: Parent account ID
        created_at:
          type: string
          format: date-time
          description: When the sub account was created
        name:
          type: string
          description: Sub account name
        website:
          type: string
          maxLength: 255
          description: Account website, stored as a bare domain such as `acme.com`
        status:
          type: string
          enum: [active, suspended, parent-suspended, deleted]
          description: Current sub-account status
        monthly_credit:
          type: integer
          format: int64
          minimum: 0
          description: Optional monthly cap; 0 means no cap
        domain_count:
          type: integer
          format: int64
          minimum: 0
          description: Number of domains owned by the sub account
        member_count:
          type: integer
          format: int64
          minimum: 0
          description: Number of direct members on the sub account
        last_activity_at:
          type: [string, 'null']
          format: date-time
          description: Last recorded sub-account email activity
      example:
        object: sub_account
        id: "2f3c5d2a-9ef8-4c91-a5f4-79990c8c1d3a"
        parent_account_id: "9d0cf9d0-4f5e-4674-bcf1-8ec39968b6e1"
        name: "Acme Subsidiary"
        website: "acme.example.com"
        status: active
        monthly_credit: 0
        created_at: "2024-01-01T00:00:00Z"
        domain_count: 2
        member_count: 3
        last_activity_at: "2024-01-15T12:00:00Z"

    CreateSubAccountRequest:
      type: object
      required:
        - name
        - website
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Human-readable name for the sub account; leading and trailing whitespace is trimmed and the result must not be blank
        website:
          type: string
          maxLength: 255
          description: Account website. A domain such as `acme.com`, or an http(s) URL such as `https://acme.com`, which is reduced to its host before it is stored
        monthly_credit:
          type: integer
          format: int64
          minimum: 0
          maximum: 1000000000
          default: 0
          description: Optional monthly cap; 0 means no cap
      example:
        name: "Acme Subsidiary"
        website: "acme.example.com"
        monthly_credit: 0

    UpdateSubAccountRequest:
      type: object
      description: At least one non-null field must be provided, and at least one provided value must differ from the current value. Omitted or null fields are left unchanged.
      anyOf:
        - required: [name]
          properties:
            name:
              type: string
        - required: [website]
          properties:
            website:
              type: string
        - required: [monthly_credit]
          properties:
            monthly_credit:
              type: integer
      properties:
        name:
          type: [string, 'null']
          minLength: 1
          maxLength: 255
          description: Human-readable name for the sub account; leading and trailing whitespace is trimmed and the result must not be blank
        website:
          type: [string, 'null']
          maxLength: 255
          description: Account website. A domain such as `acme.com`, or an http(s) URL such as `https://acme.com`, which is reduced to its host before it is stored
        monthly_credit:
          type: [integer, 'null']
          format: int64
          minimum: 0
          maximum: 1000000000
          description: Optional monthly cap; 0 means no cap
      example:
        name: "Acme Subsidiary"
        website: "acme.example.com"
        monthly_credit: 50000

    SuspendSubAccountRequest:
      type: object
      required:
        - reason
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 500
          description: Reason for the pause. When this call starts the pause, the dashboard of the sub account shows it as a note.
      example:
        reason: "Customer requested temporary pause"

    SubAccountUsageBreakdown:
      type: object
      required:
        - reception_count
        - allocated_cost
      properties:
        account_id:
          type: string
          format: uuid
          description: Account ID for this usage bucket
        name:
          type: string
          description: Sub account name when this bucket is for a sub account
        reception_count:
          type: integer
          format: int64
          minimum: 0
          description: Number of received messages in the billing period
        allocated_cost:
          type: number
          format: double
          minimum: 0
          description: Proportional allocated cost for this usage bucket
      example:
        account_id: "2f3c5d2a-9ef8-4c91-a5f4-79990c8c1d3a"
        name: "Acme Subsidiary"
        reception_count: 3000000
        allocated_cost: 60.0

    SubAccountUsageResponse:
      type: object
      required:
        - billing_period
        - currency
        - allocation_method
        - allocation_note
        - parent
        - sub_accounts
        - removed_sub_accounts
        - total
      properties:
        billing_period:
          type: object
          required:
            - start
            - end
          properties:
            start:
              type: string
              format: date-time
              description: Billing period start
            end:
              type: string
              format: date-time
              description: Billing period end
        currency:
          type: string
          description: Currency code used for cost allocation
        allocation_method:
          type: string
          enum: [proportional]
          description: Method used to allocate the pooled parent invoice cost
        allocation_note:
          type: string
          description: Disclaimer explaining that allocated cost is not standalone sub-account pricing
        parent:
          allOf:
            - $ref: '#/components/schemas/SubAccountUsageBreakdown'
            - type: object
              required:
                - account_id
        sub_accounts:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/SubAccountUsageBreakdown'
              - type: object
                required:
                  - account_id
                  - name
          description: Per-sub-account usage and allocated cost
        removed_sub_accounts:
          $ref: '#/components/schemas/SubAccountUsageBreakdown'
          description: Aggregated usage from sub accounts deleted during the period; still billed to the parent and not identified individually
        total:
          $ref: '#/components/schemas/SubAccountUsageBreakdown'
      example:
        billing_period:
          start: "2024-01-01T00:00:00Z"
          end: "2024-02-01T00:00:00Z"
        currency: usd
        allocation_method: proportional
        allocation_note: "allocated_cost is a proportional share of the parent's pooled invoice for the period, not what the sub would pay on its own plan."
        parent:
          account_id: "9d0cf9d0-4f5e-4674-bcf1-8ec39968b6e1"
          reception_count: 1000000
          allocated_cost: 20.0
        sub_accounts:
          - account_id: "2f3c5d2a-9ef8-4c91-a5f4-79990c8c1d3a"
            name: "Acme Subsidiary"
            reception_count: 3000000
            allocated_cost: 60.0
        removed_sub_accounts:
          reception_count: 0
          allocated_cost: 0.0
        total:
          reception_count: 4000000
          allocated_cost: 80.0

    PaginatedSubAccountsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/SubAccount'
          description: Array of sub accounts
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    UpdateAccountRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 255
          description: Account name
        website:
          type: string
          maxLength: 255
          description: Account website. A domain such as `acme.com`, or an http(s) URL such as `https://acme.com`, which is reduced to its host before it is stored
        about:
          type: string
          description: Account description (used for account verification)
        track_opens:
          type: boolean
          description: Default open tracking setting
        track_clicks:
          type: boolean
          description: Default click tracking setting
        reject_bad_recipients:
          type: boolean
          description: Whether to reject bad recipients
        reject_mistyped_recipients:
          type: boolean
          description: Whether to reject mistyped recipients
        message_metadata_retention:
          type: integer
          minimum: 1
          maximum: 30
          description: Default message metadata retention in days
        message_data_retention:
          type: integer
          minimum: 0
          maximum: 30
          description: Default message data retention in days
      example:
        name: "Updated Company Name"
        website: "https://example.com"
        track_opens: true

    UserAccount:
      type: object
      properties:
        created_at:
          type: string
          format: date-time
          description: When the relationship was created
        updated_at:
          type: string
          format: date-time
          description: When the relationship was last updated
        user_id:
          type: string
          format: uuid
          description: User ID
        account_id:
          type: string
          format: uuid
          description: Account ID
        role:
          type: string
          enum: [Administrator, Developer, Analyst, "Billing Manager"]
          description: User role in the account
      required:
        - created_at
        - updated_at
        - user_id
        - account_id
        - role

    AccountMembersResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/UserAccount'
          description: Array of account members

    AddMemberRequest:
      type: object
      required:
        - email
        - role
      properties:
        email:
          type: string
          format: email
          description: Email address of the user to add
        name:
          type: string
          description: Display name for the user
        role:
          type: string
          enum: [Administrator, Developer, Analyst, "Billing Manager"]
          description: Role to assign to the user
      example:
        email: "user@example.com"
        name: "John Doe"
        role: "Developer"

    # Contact Schemas
    JSONValue:
      description: Any recursively nested JSON value stored in a contact attribute
      oneOf:
        - type: 'null'
        - type: boolean
        - type: number
        - type: string
        - type: array
          items:
            $ref: '#/components/schemas/JSONValue'
        - type: object
          additionalProperties:
            $ref: '#/components/schemas/JSONValue'

    CanonicalContactAttributeValue:
      description: |
        A non-null contact attribute value. The account's declared attribute
        definition selects the accepted member: `string` accepts a JSON string,
        `number` a finite JSON number, `boolean` a JSON boolean, `date` a strict
        ISO `YYYY-MM-DD` string, and `timestamp` an RFC3339 string. The server
        rejects values whose JSON type or format does not match that definition.
      anyOf:
        - type: string
          description: Value for a declared string attribute
        - type: number
          description: Finite value for a declared number attribute
        - type: boolean
          description: Value for a declared boolean attribute
        - type: string
          format: date
          description: Strict `YYYY-MM-DD` value for a declared date attribute
        - type: string
          format: date-time
          description: RFC3339 value for a declared timestamp attribute

    CreateContactRequest:
      type: object
      additionalProperties: false
      description: |
        Creates an account-global contact without list membership. Writable
        attribute keys and types are derived from definitions managed in the
        dashboard. Validation metadata, membership, and activity are not
        writable.
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: Contact email; trimmed and lowercased before persistence
        first_name:
          type: [string, 'null']
        last_name:
          type: [string, 'null']
        status:
          type: [string, 'null']
          enum: [enabled, disabled, blocked, null]
          description: Defaults to `enabled` when omitted or null
        status_reason:
          type: [string, 'null']
        unsubscribed:
          type: [boolean, 'null']
          description: Only `true` creates an initial UTC unsubscription timestamp
        attributes:
          type: [object, 'null']
          description: |
            Values are validated against dashboard-managed account attribute
            definitions. Attribute members cannot be null.
          additionalProperties:
            $ref: '#/components/schemas/CanonicalContactAttributeValue'
      example:
        email: "person@example.com"
        first_name: "Pat"
        last_name: "Example"
        status: enabled
        status_reason: null
        unsubscribed: false
        attributes:
          customer: true
          score: 12.5
          birthday: "2024-02-29"
          joined_at: "2026-09-09T10:04:56Z"

    BatchUpsertContactInput:
      type: object
      additionalProperties: false
      description: |
        One create-or-update input. On update, omitted or null scalar fields
        leave stored values unchanged. On create, omitted or null fields use
        the same defaults as `CreateContactRequest`. A present attributes
        object is merged and null attribute members remove existing keys.
      required: [email]
      properties:
        email:
          type: string
          format: email
          description: Contact email; trimmed and lowercased before duplicate classification
        first_name:
          type: [string, 'null']
        last_name:
          type: [string, 'null']
        status:
          type: [string, 'null']
          enum: [enabled, disabled, blocked, null]
        status_reason:
          type: [string, 'null']
        unsubscribed:
          type: [boolean, 'null']
        attributes:
          type: [object, 'null']
          description: A present object is merged; null members remove existing keys.
          additionalProperties:
            oneOf:
              - $ref: '#/components/schemas/CanonicalContactAttributeValue'
              - type: 'null'

    BatchUpsertContactsRequest:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            $ref: '#/components/schemas/BatchUpsertContactInput'

    BatchContactResult:
      type: object
      additionalProperties: false
      required: [position, email, outcome]
      properties:
        position:
          type: integer
          minimum: 0
        email:
          type: string
          description: Normalized input email, or empty when the input email was not a string
        outcome:
          type: string
          enum: [created, updated, failed]
        contact:
          $ref: '#/components/schemas/Contact'
        reason:
          type: string
          description: Caller-safe reason present only for failed records

    BatchUpsertContactsResponse:
      type: object
      additionalProperties: false
      required: [object, created, updated, failed, data]
      properties:
        object:
          type: string
          enum: [list]
        created:
          type: integer
          minimum: 0
        updated:
          type: integer
          minimum: 0
        failed:
          type: integer
          minimum: 0
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchContactResult'

    Contact:
      type: object
      additionalProperties: false
      description: |
        Public contact projection. Attribute reads are legacy-safe: values can
        contain null, booleans, numbers, strings, arrays, or nested objects even
        though new writes accept only canonical dashboard-defined values.
      required:
        - object
        - id
        - created_at
        - updated_at
        - email
        - first_name
        - last_name
        - status
        - status_reason
        - unsubscribed
        - unsubscribed_at
        - attributes
        - validation_status
        - last_validated_at
      properties:
        object:
          type: string
          enum: [contact]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the contact
        created_at:
          type: string
          format: date-time
          description: When the contact was created
        updated_at:
          type: string
          format: date-time
          description: When the contact was last updated
        email:
          type: string
          format: email
          description: Normalized contact email address
        first_name:
          type: string
        last_name:
          type: string
        status:
          type: string
          enum: [enabled, disabled, blocked]
        status_reason:
          type: string
        unsubscribed:
          type: boolean
        unsubscribed_at:
          type: [string, 'null']
          format: date-time
        attributes:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/JSONValue'
        validation_status:
          type: string
          enum: [unvalidated, valid, invalid, risky, unknown]
        last_validated_at:
          type: [string, 'null']
          format: date-time

    UpdateContactRequest:
      type: object
      additionalProperties: false
      description: |
        A partial contact update. Omitted or null scalar fields and a null
        attributes object leave stored values unchanged. A present attributes
        object is merged; a null member removes that attribute key.
      properties:
        email:
          type: [string, 'null']
          format: email
        first_name:
          type: [string, 'null']
        last_name:
          type: [string, 'null']
        status:
          type: [string, 'null']
          enum: [enabled, disabled, blocked, null]
        status_reason:
          type: [string, 'null']
        unsubscribed:
          type: [boolean, 'null']
        attributes:
          type: [object, 'null']
          additionalProperties:
            oneOf:
              - $ref: '#/components/schemas/CanonicalContactAttributeValue'
              - type: 'null'

    ContactPagination:
      type: object
      additionalProperties: false
      required:
        - has_more
        - next_cursor
        - previous_cursor
      properties:
        has_more:
          type: boolean
          description: Whether another page exists in the requested direction
        next_cursor:
          type: [string, 'null']
          description: Pass as `after` to fetch the next page
        previous_cursor:
          type: [string, 'null']
          description: Pass as `before` to fetch the previous page

    PaginatedContactsResponse:
      type: object
      additionalProperties: false
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/Contact'
        pagination:
          $ref: '#/components/schemas/ContactPagination'

    # List Schemas
    ContactList:
      type: object
      additionalProperties: false
      description: |
        A static, private, single opt-in container of contacts, and the unit a
        campaign targets.
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - description
        - tags
        - contact_count
      properties:
        object:
          type: string
          enum: [contact_list]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the list
        created_at:
          type: string
          format: date-time
          description: When the list was created
        updated_at:
          type: string
          format: date-time
          description: When the list was last updated
        name:
          type: string
          description: List name; names are not unique within an account
        description:
          type: string
          description: Free-text description; empty when none was given
        tags:
          type: array
          description: Always present; an empty array when the list carries no tags
          items:
            type: string
        contact_count:
          type: integer
          minimum: 0
          description: |
            The list's subscribers: the members a campaign to this list would
            reach. A member counts when its membership is `confirmed` and the
            contact is enabled, not unsubscribed from everything, and not found
            invalid by validation. It is the same number the dashboard shows.

    EmbeddedContactList:
      type: object
      additionalProperties: false
      description: |
        The list as a membership carries it: a `contact_list` without
        `contact_count`, which no membership read pays for.
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - description
        - tags
      properties:
        object:
          type: string
          enum: [contact_list]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the list
        created_at:
          type: string
          format: date-time
          description: When the list was created
        updated_at:
          type: string
          format: date-time
          description: When the list was last updated
        name:
          type: string
          description: List name; names are not unique within an account
        description:
          type: string
          description: Free-text description; empty when none was given
        tags:
          type: array
          description: Always present; an empty array when the list carries no tags
          items:
            type: string

    CreateContactListRequest:
      type: object
      additionalProperties: false
      description: |
        The writable list projection. A property that is not writable is
        rejected with 400 naming it.
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: |
            Trimmed before it is measured and stored. The bound counts
            characters rather than bytes, and a name that is empty after
            trimming is rejected.
        description:
          type: [string, 'null']
          maxLength: 1000
          description: |
            Trimmed before it is measured and stored. The bound counts
            characters rather than bytes. Null and omission both store an empty
            description.
        tags:
          type: [array, 'null']
          items:
            type: string
          description: |
            Each tag is trimmed, empty tags are dropped, and duplicates are
            collapsed; at most 50 tags may survive that normalization. A null
            element is rejected rather than read as an empty string. Null and
            omission both store an empty array.

    UpdateContactListRequest:
      type: object
      additionalProperties: false
      description: |
        A partial list update. An omitted or null property leaves the stored
        value unchanged; a present `tags` array replaces the stored tags, and an
        empty one clears them. A property that is not writable is rejected with
        400 naming it.
      properties:
        name:
          type: [string, 'null']
          minLength: 1
          maxLength: 255
          description: |
            Trimmed before it is measured and stored, exactly as on create. The
            bound counts characters rather than bytes, and a name that is empty
            after trimming is rejected.
        description:
          type: [string, 'null']
          maxLength: 1000
          description: Trimmed and bounded exactly as on create.
        tags:
          type: [array, 'null']
          items:
            type: string
          description: Normalized and bounded exactly as on create.

    ListContact:
      type: object
      additionalProperties: false
      description: |
        One contact's membership of one list. It carries no id of its own: a
        membership is addressed by the `(list_id, contact_id)` pair.
      required:
        - object
        - list_id
        - contact_id
        - email
        - subscription_status
        - subscribed_at
        - unsubscribed_at
        - created_at
        - updated_at
      properties:
        object:
          type: string
          enum: [list_contact]
          description: Object type identifier
        list_id:
          type: string
          format: uuid
          description: The list this membership is on
        contact_id:
          type: string
          format: uuid
          description: The contact this membership belongs to
        email:
          type: string
          format: email
          description: The contact's normalized email address
        subscription_status:
          type: string
          enum: [unconfirmed, confirmed, unsubscribed, complained]
          description: |
            Per-list consent, independent of the contact's account-wide
            `unsubscribed` flag. `complained` is the mailbox provider's verdict
            and is never writable.
        subscribed_at:
          type: [string, 'null']
          format: date-time
          description: |
            When consent was given. Set when the membership becomes
            `confirmed` and left alone afterwards, so it records when consent
            was given rather than when it was last re-asserted.
        unsubscribed_at:
          type: [string, 'null']
          format: date-time
          description: |
            When the membership became `unsubscribed`, or when the complaint
            landed for a `complained` membership. Cleared when the membership
            becomes `confirmed` again.
        created_at:
          type: string
          format: date-time
          description: When the membership was created
        updated_at:
          type: string
          format: date-time
          description: When the membership was last updated
        contact:
          $ref: '#/components/schemas/Contact'
          description: |
            The whole contact. Present only on `GET
            /v2/accounts/{account_id}/lists/{list_id}/contacts` with
            `include_contacts=true`.
        list:
          $ref: '#/components/schemas/EmbeddedContactList'
          description: |
            The list this membership is on. Present only on `GET
            /v2/accounts/{account_id}/contacts/{id_or_email}/lists`.

    UpsertListContactRequest:
      type: object
      additionalProperties: false
      description: |
        Names at most `subscription_status`. An empty object is legal and names
        no status: on a new membership that means `confirmed`, and on one that
        already exists it leaves the stored status exactly as it is. A property
        that is not writable is rejected with 400 naming it.
      properties:
        subscription_status:
          type: [string, 'null']
          enum: [confirmed, unconfirmed, unsubscribed, null]
          description: |
            Null and omission are one case and name no status. `complained` is
            the mailbox provider's verdict rather than the caller's and is
            rejected with 400.

    BatchAddListContactInput:
      type: object
      description: |
        Names exactly one of `email` or `id`; a null names neither. No contact
        is created here.

        None of that is a request-level rejection, which is why it is stated
        here rather than constrained above: an entry that names neither
        identifier, names both, or carries any other property is reported
        `invalid` at its own position inside a 200, and one naming nobody in
        this account is reported `not_found`.
      properties:
        email:
          type: [string, 'null']
          format: email
          description: Contact email; trimmed and lowercased before duplicates are counted
        id:
          type: [string, 'null']
          format: uuid
          description: Contact ID

    BatchAddListContactsRequest:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            $ref: '#/components/schemas/BatchAddListContactInput'

    BatchListContactResult:
      type: object
      additionalProperties: false
      description: |
        One entry's outcome. `position` is the entry's index in the request
        array and the only handle an entry that named no single identifier
        carries.
      required: [position, outcome]
      properties:
        position:
          type: integer
          minimum: 0
        email:
          type: string
          description: |
            The normalized input email, echoed only when the entry named one
            identifier and it decoded to a non-empty string. An entry whose
            `email` was not a string or was empty once trimmed, and one naming
            both identifiers, carry `position` alone.
        id:
          type: string
          format: uuid
          description: |
            The input contact ID, echoed only when the entry named one that
            parsed as a UUID.
        outcome:
          type: string
          enum: [added, already_member, not_found, invalid]
          description: |
            `added` created the membership, `already_member` found one and left
            it untouched whatever its status, `not_found` matched no contact in
            this account, and `invalid` was decidable from the payload alone.
        membership:
          $ref: '#/components/schemas/ListContact'
          description: |
            The membership, present on `added` and on `already_member`, where
            it is the row as it stands.
        reason:
          type: string
          description: Caller-safe reason present only on `not_found` and `invalid`

    BatchAddListContactsResponse:
      type: object
      additionalProperties: false
      required: [object, added, skipped, failed, data]
      properties:
        object:
          type: string
          enum: [list]
        added:
          type: integer
          minimum: 0
          description: Entries whose membership this request created
        skipped:
          type: integer
          minimum: 0
          description: Entries reported `already_member`
        failed:
          type: integer
          minimum: 0
          description: Entries reported `not_found` or `invalid`
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchListContactResult'

    PaginatedContactListsResponse:
      type: object
      additionalProperties: false
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/ContactList'
        pagination:
          $ref: '#/components/schemas/ContactPagination'

    PaginatedListContactsResponse:
      type: object
      additionalProperties: false
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
        data:
          type: array
          items:
            $ref: '#/components/schemas/ListContact'
        pagination:
          $ref: '#/components/schemas/ContactPagination'

    # Suppression Schemas
    Suppression:
      type: object
      properties:
        object:
          type: string
          enum: [suppression]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the suppression
        created_at:
          type: string
          format: date-time
          description: When the suppression was created
        email:
          type: string
          format: email
          description: Suppressed email address
        domain:
          type: string
          description: Domain for which the email is suppressed
        reason:
          type: string
          description: Reason for suppression
        protected:
          type: boolean
          description: |
            Whether a bulk wipe must keep this suppression. Read-only. True when
            AhaSend recorded the recipient's own decision -- they unsubscribed, or they
            reported a message as spam. It is not derived from the `reason` you send:
            suppressions you create yourself are never protected, whatever reason they
            carry, so your own wipe can always clear them. Suppressions predating this
            field are also protected when they record a complaint under the earlier
            `FBL Abuse Report` spelling, which nothing writes any more. It says nothing
            about whether the address can be mailed, which `expires_at` alone answers,
            and it does not exempt the row from expiring.
        expires_at:
          type: string
          format: date-time
          description: When the suppression expires
      required:
        - object
        - id
        - created_at
        - email
        - domain
        - reason
        - protected
        - expires_at

    CreateSuppressionResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Suppression'
          description: Array of suppressions

    CreateSuppressionRequest:
      type: object
      required:
        - email
        - expires_at
      properties:
        email:
          type: string
          format: email
          description: Email address to suppress
        domain:
          type: string
          format: hostname
          description: Domain for which to suppress the email
        reason:
          type: string
          maxLength: 255
          description: Reason for suppression
        expires_at:
          type: string
          format: date-time
          description: When the suppression expires (RFC3339 format)
      example:
        email: "user@example.com"
        domain: "example.com"
        reason: "User requested removal"
        expires_at: "2024-12-25T10:30:00Z"

    PaginatedSuppressionsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Suppression'
          description: Array of suppressions
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Route Schemas
    Route:
      type: object
      properties:
        object:
          type: string
          enum: [route]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the route
        created_at:
          type: string
          format: date-time
          description: When the route was created
        updated_at:
          type: string
          format: date-time
          description: When the route was last updated
        name:
          type: string
          description: Route name
        url:
          type: string
          format: uri
          description: Webhook URL for the route
        recipient:
          type: string
          description: Recipient filter; an empty string means no recipient filter
        attachments:
          type: boolean
          description: Whether to include attachments in route payload
        headers:
          type: boolean
          description: Whether to include headers in route payload
        group_by_message_id:
          type: boolean
          description: Whether to group by message ID
        strip_replies:
          type: boolean
          description: Whether to strip reply content
        enabled:
          type: boolean
          description: Whether the route is enabled
        success_count:
          type: integer
          minimum: 0
          description: Number of successful calls
        error_count:
          type: integer
          minimum: 0
          description: Number of unsuccessful calls
        errors_since_last_success:
          type: integer
          minimum: 0
          description: |
            Number of consecutive failed calls. Reset to zero by the next
            successful call.
        last_request_at:
          type: [string, 'null']
          format: date-time
          description: When the route was last called
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - url
        - recipient
        - attachments
        - headers
        - group_by_message_id
        - strip_replies
        - enabled
        - success_count
        - error_count
        - errors_since_last_success
        - last_request_at

    CreatedRoute:
      allOf:
        - $ref: '#/components/schemas/Route'
        - type: object
          properties:
            secret:
              type: string
              description: Route signing secret. Returned only when the route is created.
          required:
            - secret

    CreateRouteRequest:
      type: object
      required:
        - name
        - url
        - recipient
      properties:
        name:
          type: string
          maxLength: 255
          description: Route name
        url:
          type: string
          format: uri
          description: Webhook URL for the route
        recipient:
          type: string
          maxLength: 255
          description: Recipient filter
        attachments:
          type: boolean
          description: Whether to include attachments in webhooks
          default: false
        headers:
          type: boolean
          description: Whether to include headers in webhooks
          default: false
        group_by_message_id:
          type: boolean
          description: Whether to group by message ID
          default: false
        strip_replies:
          type: boolean
          description: Whether to strip reply content
          default: false
        enabled:
          type: boolean
          description: Whether the route is enabled
          default: true
      example:
        name: "Support Route"
        url: "https://example.com/webhook"
        recipient: "support@example.com"
        enabled: true

    UpdateRouteRequest:
      type: object
      properties:
        name:
          type: [string, 'null']
          maxLength: 255
          description: Route name
        url:
          type: [string, 'null']
          format: uri
          description: Webhook URL for the route
        recipient:
          type: [string, 'null']
          maxLength: 255
          description: Recipient filter
        attachments:
          type: [boolean, 'null']
          description: Whether to include attachments in webhooks
        headers:
          type: [boolean, 'null']
          description: Whether to include headers in webhooks
        group_by_message_id:
          type: [boolean, 'null']
          description: Whether to group by message ID
        strip_replies:
          type: [boolean, 'null']
          description: Whether to strip reply content
        enabled:
          type: [boolean, 'null']
          description: Whether the route is enabled
      example:
        name: "Updated Support Route"
        enabled: false

    PaginatedRoutesResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Route'
          description: Array of routes
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Webhook Schemas
    Webhook:
      type: object
      properties:
        object:
          type: string
          enum: [webhook]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the webhook
        created_at:
          type: string
          format: date-time
          description: When the webhook was created
        updated_at:
          type: string
          format: date-time
          description: When the webhook was last updated
        name:
          type: string
          description: Webhook name
        url:
          type: string
          format: uri
          description: Webhook URL
        enabled:
          type: boolean
          description: Whether the webhook is enabled
        on_reception:
          type: boolean
          description: Trigger on message reception
        on_delivered:
          type: boolean
          description: Trigger on message delivery
        on_transient_error:
          type: boolean
          description: Trigger on transient errors
        on_failed:
          type: boolean
          description: Trigger on permanent failures
        on_bounced:
          type: boolean
          description: Trigger on bounces
        on_suppressed:
          type: boolean
          description: Trigger on suppressions
        on_opened:
          type: boolean
          description: Trigger on opens
        on_clicked:
          type: boolean
          description: Trigger on clicks
        on_suppression_created:
          type: boolean
          description: Trigger on suppression creation
        on_dns_error:
          type: boolean
          description: Trigger on DNS errors
        scope:
          type: string
          enum: [global, scoped]
          description: Webhook scope
        domains:
          type: array
          items:
            type: string
          description: Domains this webhook applies to. Always present; global webhooks return an empty array.
        success_count:
          type: integer
          minimum: 0
          description: Number of successful calls
        error_count:
          type: integer
          minimum: 0
          description: Number of unsuccessful calls
        errors_since_last_success:
          type: integer
          minimum: 0
          description: |
            Number of consecutive failed calls. Reset to zero by the next
            successful call.
        last_request_at:
          type: [string, 'null']
          format: date-time
          description: When the webhook was last called
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - url
        - enabled
        - on_reception
        - on_delivered
        - on_transient_error
        - on_failed
        - on_bounced
        - on_suppressed
        - on_opened
        - on_clicked
        - on_suppression_created
        - on_dns_error
        - scope
        - domains
        - success_count
        - error_count
        - errors_since_last_success
        - last_request_at

    CreatedWebhook:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            secret:
              type: string
              description: Webhook signing secret. Returned only when the webhook is created.
          required:
            - secret

    CreateWebhookRequest:
      type: object
      required:
        - name
        - url
        - scope
      properties:
        name:
          type: string
          maxLength: 255
          description: Webhook name
        url:
          type: string
          format: uri
          description: Webhook URL
        enabled:
          type: boolean
          description: Whether the webhook is enabled
          default: true
        on_reception:
          type: boolean
          description: Trigger on message reception
          default: false
        on_delivered:
          type: boolean
          description: Trigger on message delivery
          default: false
        on_transient_error:
          type: boolean
          description: Trigger on transient errors
          default: false
        on_failed:
          type: boolean
          description: Trigger on permanent failures
          default: false
        on_bounced:
          type: boolean
          description: Trigger on bounces
          default: false
        on_suppressed:
          type: boolean
          description: Trigger on suppressions
          default: false
        on_opened:
          type: boolean
          description: Trigger on opens
          default: false
        on_clicked:
          type: boolean
          description: Trigger on clicks
          default: false
        on_suppression_created:
          type: boolean
          description: Trigger on suppression creation
          default: false
        on_dns_error:
          type: boolean
          description: Trigger on DNS errors
          default: false
        scope:
          type: string
          enum: [global, scoped]
          description: Webhook scope
        domains:
          type: [array, 'null']
          items:
            type: string
          description: |
            For `scoped`, a non-empty array is required. For `global`, this may
            be omitted, null, empty, or non-empty; supplied values are accepted
            but ignored.
      allOf:
        - if:
            properties:
              scope:
                const: scoped
            required: [scope]
          then:
            required: [domains]
            properties:
              domains:
                type: array
                minItems: 1
                items:
                  type: string
      example:
        name: "Delivery Webhook"
        url: "https://example.com/webhook"
        scope: "global"
        enabled: true
        on_delivered: true

    UpdateWebhookRequest:
      type: object
      description: |
        Partial update. Omitted and null scalar fields leave their stored values
        unchanged. Domain transition rules depend on the webhook's existing
        scope and are described on `domains`.
      properties:
        name:
          type: [string, 'null']
          maxLength: 255
          description: Webhook name
        url:
          type: [string, 'null']
          format: uri
          description: Webhook URL
        enabled:
          type: [boolean, 'null']
          description: Whether the webhook is enabled
        on_reception:
          type: [boolean, 'null']
          description: Trigger on message reception
        on_delivered:
          type: [boolean, 'null']
          description: Trigger on message delivery
        on_transient_error:
          type: [boolean, 'null']
          description: Trigger on transient errors
        on_failed:
          type: [boolean, 'null']
          description: Trigger on permanent failures
        on_bounced:
          type: [boolean, 'null']
          description: Trigger on bounces
        on_suppressed:
          type: [boolean, 'null']
          description: Trigger on suppressions
        on_opened:
          type: [boolean, 'null']
          description: Trigger on opens
        on_clicked:
          type: [boolean, 'null']
          description: Trigger on clicks
        on_suppression_created:
          type: [boolean, 'null']
          description: Trigger on suppression creation
        on_dns_error:
          type: [boolean, 'null']
          description: Trigger on DNS errors
        scope:
          type: [string, 'null']
          enum: [global, scoped, null]
          description: Webhook scope. Omit or send null to keep the current scope.
        domains:
          type: [array, 'null']
          items:
            type: string
          description: |
            Omit or send null to leave domain associations unchanged. An array
            replaces associations when the resulting scope is `scoped`; `[]`
            explicitly clears them. Changing a global webhook to `scoped`
            requires a non-empty array in the same request and otherwise
            returns HTTP 400. Changing to `global` clears all associations;
            supplied domains are ignored when the resulting scope is global.

    PaginatedWebhooksResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/Webhook'
          description: Array of webhooks
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # SMTP Credentials Schemas
    SMTPCredential:
      type: object
      properties:
        object:
          type: string
          enum: [credential_smtp]
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the SMTP credential
        created_at:
          type: string
          format: date-time
          description: When the credential was created
        updated_at:
          type: string
          format: date-time
          description: When the credential was last updated
        name:
          type: string
          description: Credential name
        username:
          type: string
          description: SMTP username
        sandbox:
          type: boolean
          description: Whether this is a sandbox credential
        scope:
          type: string
          enum: [global, scoped]
          description: Credential scope
        domains:
          type: array
          items:
            type: string
          description: Domains this credential can send from. Always present; global credentials return an empty array.
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - username
        - sandbox
        - scope
        - domains

    CreatedSMTPCredential:
      allOf:
        - $ref: '#/components/schemas/SMTPCredential'
        - type: object
          properties:
            password:
              type: string
              description: SMTP password. Returned only when the credential is created.
          required:
            - password

    CreateSMTPCredentialRequest:
      type: object
      required:
        - name
        - scope
      properties:
        name:
          type: string
          maxLength: 255
          description: Credential name
        sandbox:
          type: boolean
          description: Whether this is a sandbox credential
          default: false
        scope:
          type: string
          enum: [global, scoped]
          description: Credential scope - "global" or "scoped"
        domains:
          type: [array, 'null']
          items:
            type: string
          description: |
            For `scoped`, a non-empty array is required. For `global`, this may
            be omitted, null, empty, or non-empty; supplied values are accepted
            but ignored.
      allOf:
        - if:
            properties:
              scope:
                const: scoped
            required: [scope]
          then:
            required: [domains]
            properties:
              domains:
                type: array
                minItems: 1
                items:
                  type: string
      example:
        name: "Production SMTP"
        scope: "global"

    PaginatedSMTPCredentialsResponse:
      type: object
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/SMTPCredential'
          description: Array of SMTP credentials
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    # Statistics Schemas
    DeliverabilityStatistics:
      type: object
      properties:
        from_timestamp:
          type: string
          format: date-time
          description: Start time of the statistics bucket
        to_timestamp:
          type: string
          format: date-time
          description: End time of the statistics bucket
        reception_count:
          type: integer
          description: Number of messages accepted for delivery
        delivered_count:
          type: integer
          description: Number of messages delivered
        deferred_count:
          type: integer
          description: Number of messages deferred
        bounced_count:
          type: integer
          description: Number of messages bounced
        failed_count:
          type: integer
          description: Number of messages failed
        suppressed_count:
          type: integer
          description: Number of messages suppressed
        opened_count:
          type: integer
          description: Number of messages opened at least once
        clicked_count:
          type: integer
          description: Number of messages that have at least one link in them clicked.
      required:
        - from_timestamp
        - to_timestamp
        - reception_count
        - delivered_count
        - deferred_count
        - bounced_count
        - failed_count
        - suppressed_count
        - opened_count
        - clicked_count

    DeliverabilityStatisticsResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/DeliverabilityStatistics'
          description: Array of deliverability statistics

    Bounce:
      type: object
      properties:
        classification:
          type: string
          description: Bounce classification
        count:
          type: integer
          description: Number of bounces
      required:
        - classification
        - count
    BounceStatistics:
      type: object
      properties:
        from_timestamp:
          type: string
          format: date-time
          description: Start time of the statistics bucket
        to_timestamp:
          type: string
          format: date-time
          description: End time of the statistics bucket
        bounces:
          type: array
          items:
            $ref: '#/components/schemas/Bounce'
          description: Bounce count per bounce classification
      required:
        - from_timestamp
        - to_timestamp
        - bounces

    BounceStatisticsResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/BounceStatistics'
          description: Array of bounce statistics

    DeliveryTimeStatistics:
      type: object
      properties:
        from_timestamp:
          type: string
          format: date-time
          description: Start time of the statistics bucket
        to_timestamp:
          type: string
          format: date-time
          description: End time of the statistics bucket
        avg_delivery_time:
          type: number
          format: double
          description: Average delivery time in seconds
        delivered_count:
          type: integer
          description: Number of messages
        delivery_times:
          type: array
          items:
            $ref: '#/components/schemas/DeliveryTime'
            description: Delivery times per recipient domain
      required:
        - from_timestamp
        - to_timestamp
        - avg_delivery_time
        - delivered_count
        - delivery_times

    DeliveryTime:
      type: object
      properties:
        recipient_domain:
          type: string
          format: fqdn
          description: The recipient domain
        delivery_time:
          type: number
          format: double
          description: The average time from reception to delivery in seconds
        count:
          type: integer
          format: int64
          description: Number of delivered messages included in the average
      required:
        - recipient_domain
        - delivery_time
        - count


    DeliveryTimeStatisticsResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum: [list]
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/DeliveryTimeStatistics'
          description: Array of delivery time statistics

paths:
  # Utility endpoints
  /v2/ping:
    get:
      summary: Ping
      description: Health check endpoint that returns a simple pong response
      operationId: ping
      tags:
        - Utility
      security:
        - BearerAuth: []
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))

              response, _, err := client.UtilityAPI.Ping(context.Background())
              if err != nil {
                log.Fatalf("Error pinging API: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const health = await client.ping();
            console.log("AhaSend API is reachable.", { message: health.message });

      responses:
        '200':
          description: Pong response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                message: "pong"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: "missing or malformed bearer token"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: invalid API key
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: insufficient permissions for the required scope
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: internal server error

  # API Keys endpoints
  /v2/accounts/{account_id}/api-keys:
    get:
      summary: Get API Keys
      description: Returns a list of API keys for the account
      operationId: getAPIKeys
      tags:
        - API Keys
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["api-keys:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.APIKeysAPI.GetAPIKeys(ctx, accountID, &common.PaginationParams{
                Limit: ahasend.Int32(20),
              })
              if err != nil {
                log.Fatalf("Error getting API keys: %v", err)
              }
              for _, key := range response.Data {
                fmt.Printf("%s %s\n", key.ID, key.Label)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.apiKeys.list({ limit: 20 });
            console.log("API keys listed.", { count: page.data.length });

      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedAPIKeysResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create API Key
      description: Creates a new API key with the specified scopes
      operationId: createAPIKey
      tags:
        - API Keys
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["api-keys:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.APIKeysAPI.CreateAPIKey(ctx, accountID, requests.CreateAPIKeyRequest{
                Label: "My API Key",
                Scopes: []string{
                  "messages:read:all",
                  "domains:read",
                },
                // Optional: restrict this key to specific source IPs (CIDR blocks or
                // bare IPv4/IPv6 addresses). Omit it to allow any IP.
                IPAllowList: []string{"203.0.113.0/24", "198.51.100.7"},
              })
              if err != nil {
                log.Fatalf("Error creating API key: %v", err)
              }
              // response.SecretKey is returned only on create: put it in your secret
              // store now, and never log it.
              fmt.Printf("Created API key %s (%s)\n", response.ID, response.Label)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const apiKey = await client.apiKeys.create({
              label: "Production API key",
              scopes: ["messages:send:all"],
            });
            console.log("API key created.", { id: apiKey.id, label: apiKey.label });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPIKeyRequest'
      responses:
        '201':
          description: API key created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/APIKey'
                  - type: object
                    required:
                      - secret_key
                    properties:
                      secret_key:
                        type: string
                        readOnly: true
                        description: |
                          One-time secret key. Store it immediately. Exact
                          successful idempotent replays return the same secret
                          during the 5-minute encrypted replay window.
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/api-keys/{key_id}:
    get:
      summary: Get API Key
      description: Returns a specific API key by ID
      operationId: getAPIKey
      tags:
        - API Keys
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API Key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["api-keys:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.APIKeysAPI.GetAPIKey(ctx, accountID, keyID)
              if err != nil {
                log.Fatalf("Error getting API key: %v", err)
              }
              fmt.Printf("%s %s (%d scopes)\n", response.ID, response.Label, len(response.Scopes))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const keyId = "00000000-0000-4000-8000-000000000001";
            const apiKey = await client.apiKeys.get(keyId);
            console.log("API key found.", { id: apiKey.id, label: apiKey.label });

      responses:
        '200':
          description: API key details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKey'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update API Key
      description: Updates an existing API key's label and scopes
      operationId: updateAPIKey
      tags:
        - API Keys
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API Key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["api-keys:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.APIKeysAPI.UpdateAPIKey(ctx, accountID, keyID, requests.UpdateAPIKeyRequest{
                Label: ahasend.String("My API Key"),
                Scopes: &[]string{
                  "messages:read:all",
                  "domains:read",
                },
                // Replaces the allowed source IPs. &[]string{} clears the list (any IP);
                // leaving the field nil keeps the current list.
                IPAllowList: &[]string{"203.0.113.0/24"},
              })
              if err != nil {
                log.Fatalf("Error updating API key: %v", err)
              }
              fmt.Printf("Updated API key %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const keyId = "00000000-0000-4000-8000-000000000001";
            const apiKey = await client.apiKeys.update(keyId, { label: "Renamed API key" });
            console.log("API key updated.", { id: apiKey.id, label: apiKey.label });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAPIKeyRequest'
      responses:
        '200':
          description: API key updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKey'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            Conflict — the request would lock the calling API key out of its own
            account. Returned only when a key updates itself with an
            `ip_allow_list` that does not cover the caller's current source IP.
            No change is persisted. This is a terminal message-only conflict;
            it has no server error code or idempotency headers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete API Key
      description: Deletes an API key
      operationId: deleteAPIKey
      tags:
        - API Keys
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API Key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["api-keys:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.APIKeysAPI.DeleteAPIKey(ctx, accountID, keyID)
              if err != nil {
                log.Fatalf("Error deleting API key: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const keyId = "00000000-0000-4000-8000-000000000001";
            const result = await client.apiKeys.delete(keyId);
            console.log("API key deleted.", { message: result.message });

      responses:
        '200':
          description: API key deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                message: "api key {id} ({label}) deleted successfully"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Domains endpoints
  /v2/accounts/{account_id}/domains:
    get:
      summary: Get Domains
      description: Returns a list of domains for the account
      operationId: getDomains
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: dns_valid
          in: query
          description: Filter results by DNS validation status
          schema:
            type: [boolean, 'null']
        - name: sending_type
          in: query
          description: Filter results by sending type
          schema:
            type: string
            enum: [transactional, marketing]
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["domains:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Pass a *bool instead of nil to filter by DNS validity.
              response, _, err := client.DomainsAPI.GetDomains(ctx, accountID, nil, nil)
              if err != nil {
                log.Fatalf("Error getting domains: %v", err)
              }
              for _, domain := range response.Data {
                fmt.Printf("%s dns_valid=%t\n", domain.Domain, domain.DNSValid)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.domains.list({ limit: 20 });
            console.log("Domains listed.", { count: page.data.length });

      responses:
        '200':
          description: List of domains
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedDomainsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Domain
      description: Creates a new domain
      operationId: createDomain
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["domains:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.DomainsAPI.CreateDomain(ctx, accountID, requests.CreateDomainRequest{
                Domain: "example.com",
              })
              if err != nil {
                log.Fatalf("Error creating domain: %v", err)
              }
              // Publish these records at your DNS provider.
              for _, record := range response.DNSRecords {
                fmt.Printf("%s %s %s\n", record.Type, record.Host, record.Content)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const domain = await client.domains.create({ domain: "example.com" });
            console.log("Domain created.", { id: domain.id, domain: domain.domain });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDomainRequest'
      responses:
        '201':
          description: Domain created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Either the domain already exists on the account or the idempotency-key
            execution is in progress. The in-progress case is identified only
            by `Idempotent-Replayed: false` plus a positive integer
            `Retry-After`. A new duplicate-domain response has neither
            header; a stored replay of that response has
            `Idempotent-Replayed: true`. The message is not a stable
            discriminator.
          headers:
            Idempotent-Replayed:
              description: |
                `false` with a positive `Retry-After` means an execution is in
                progress. `true` means a stored duplicate-domain 409 was
                replayed. Absent means a fresh duplicate-domain conflict.
              schema:
                type: string
                enum: ['true', 'false']
            Retry-After:
              $ref: '#/components/headers/IdempotencyRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/domains/{domain}:
    get:
      summary: Get Domain
      description: Returns a specific domain by name
      operationId: getDomain
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: path
          required: true
          description: Domain name
          schema:
            type: string
            format: hostname
      security:
        - BearerAuth: ["domains:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.DomainsAPI.GetDomain(ctx, accountID, "example.com")
              if err != nil {
                log.Fatalf("Error getting domain: %v", err)
              }
              fmt.Printf("%s dns_valid=%t\n", response.Domain, response.DNSValid)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const domainName = "example.com";
            const domain = await client.domains.get(domainName);
            console.log("Domain found.", { domain: domain.domain, dnsValid: domain.dns_valid });

      responses:
        '200':
          description: Domain details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Domain
      description: Updates DNS domain settings such as custom subdomains and DKIM rotation interval.
      operationId: updateDomain
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: path
          required: true
          description: Domain name
          schema:
            type: string
            format: hostname
      security:
        - BearerAuth: ["domains:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.DomainsAPI.UpdateDomain(ctx, accountID, "example.com", requests.UpdateDomainRequest{
                TrackingSubdomain: ahasend.String("click"),
              })
              if err != nil {
                log.Fatalf("Error updating domain: %v", err)
              }
              fmt.Printf("Updated domain %s\n", response.Domain)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const domainName = "example.com";
            const domain = await client.domains.update(domainName, { tracking_subdomain: "click" });
            console.log("Domain updated.", { domain: domain.domain });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateDomainRequest'
      responses:
        '200':
          description: Domain updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete Domain
      description: Deletes a domain
      operationId: deleteDomain
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: path
          required: true
          description: Domain name
          schema:
            type: string
            format: hostname
      security:
        - BearerAuth: ["domains:delete:{domain}"]
        - BearerAuth: ["domains:delete:all"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.DomainsAPI.DeleteDomain(ctx, accountID, "example.com")
              if err != nil {
                log.Fatalf("Error deleting domain: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const domainName = "example.com";
            const result = await client.domains.delete(domainName);
            console.log("Domain deleted.", { message: result.message });

      responses:
        '200':
          description: Domain deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Forbidden. For example, the API key cannot delete the domain, or sending from the domain is paused. You cannot delete a domain while sending from it is paused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                domain_paused:
                  summary: Sending from the domain is paused
                  value:
                    message: Sending is paused on the domain 'example.com'. You cannot remove or rename it while sending is paused. Contact support.
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/domains/{domain}/check-dns:
    post:
      summary: Check Domain DNS
      description: Triggers a DNS validation check for the domain. If the domain was checked within the last 60 seconds, the cached validation result is returned instead of performing a fresh lookup.
      operationId: checkDomainDNS
      tags:
        - Domains
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: path
          required: true
          description: Domain name
          schema:
            type: string
            format: hostname
      security:
        - BearerAuth: ["domains:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Trigger a DNS validation check
              response, _, err := client.DomainsAPI.CheckDomainDNS(ctx, accountID, "example.com")
              if err != nil {
                log.Fatalf("Error checking domain DNS: %v", err)
              }
              fmt.Printf("DNS valid: %t\n", response.DNSValid)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const domainName = "example.com";
            const domain = await client.domains.checkDns(domainName);
            console.log("DNS check completed.", { domain: domain.domain, dnsValid: domain.dns_valid });

      responses:
        '200':
          description: Domain DNS status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Managed DNS is currently unavailable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Messages endpoints
  /v2/accounts/{account_id}/messages:
    get:
      summary: Get Messages
      description: |
        Returns a list of message summaries for the account. Can be filtered by various parameters.

        `messages:read:all` can read across the account. Domain-scoped keys see
        only messages for domains authorized by their matching read roles.

      operationId: getMessages
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: |
            Filter by comma-separated list of message statuses. Allowed values are:
            `Received`, `Delivered`, `Opened`, `Clicked`, `Deferred`, `Bounced`, `Failed`, `Suppressed`,
            `Sandbox Delivered`, `Sandbox Deferred`, `Sandbox Failed`, `Sandbox Bounced`,
            and `Sandbox Suppressed`.

            Delivery and engagement statuses are cumulative: `Delivered` also matches messages
            currently marked `Opened` or `Clicked`, and `Opened` also matches messages currently
            marked `Clicked`.
          schema:
            type: string
          example: "Bounced,Failed"
        - name: sender
          in: query
          description: Sender email address (must be from domain in API key scopes)
          schema:
            type: string
            format: email
        - name: recipient
          in: query
          description: Recipient email address
          schema:
            type: string
            format: email
        - name: subject
          in: query
          description: Filter by subject text
          schema:
            type: string
        - name: message_id_header
          in: query
          description: Filter by message ID header (same ID returned by CreateMessage API)
          schema:
            type: string
        - name: tags
          in: query
          description: Filter by comma-separated list of tags (max 20)
          schema:
            type: string
          example: "billing,urgent"
        - name: from_time
          in: query
          description: Filter messages created after this time (RFC3339 format)
          schema:
            type: string
            format: date-time
          example: "2023-12-25T10:30:00Z"
        - name: to_time
          in: query
          description: Filter messages created before this time (RFC3339 format)
          schema:
            type: string
            format: date-time
          example: "2023-12-26T10:30:00Z"
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["messages:read:all"]
        - BearerAuth: ["messages:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.MessagesAPI.GetMessages(ctx, accountID, requests.GetMessagesParams{
                Status: ahasend.String("Bounced,Failed"),
                Sender: ahasend.String("info@example.com"),
                Tags:   []string{"billing", "urgent"},
              })
              if err != nil {
                log.Fatalf("Error getting messages: %v", err)
              }
              for _, message := range response.Data {
                fmt.Printf("%s %s %s\n", message.MessageID, message.Recipient, message.Status)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.messages.list({ limit: 20 });
            console.log("Messages listed.", { count: page.data.length });

      responses:
        '200':
          description: List of messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedMessagesResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Message
      description: |
        Creates and sends a message to one or more recipients. This API cannot be used if you want to send a conversational/P2P message and set CC or BCC. Use [Create Conversation Message](https://ahasend.com/docs/api-reference/messages/create-conversation) instead.

        **Validation Requirements:**
        - Either `text_content` or `html_content` is required
        - `from.email` must be from a domain you own with valid DNS records
        - `retention.metadata` must be between 1 and 30 days
        - `retention.data` must be between 0 and 30 days
        - If `reply_to` is provided, do not include `reply-to` in headers
        - `message-id` header will be ignored and automatically generated
        - Schedule times must be in RFC3339 format
        - `schedule.first_attempt` must be in the future and within 7 days of the request
        - `schedule.expires` must be in the future and within 8 days of the request

        **Headers and subject:**
        - Header names must be visible ASCII with no space and no colon. An empty name, or one holding a space, a colon or a line break, is rejected.
        - A substitution value that a header uses must not contain a line break. Such a request is rejected, and the error names the recipient.
        - A `{{ name }}` in a subject that carries an emoji or an accented character is replaced before the message is built, for each recipient whose message carries substitutions. Such a subject is rejected when it holds template syntax other than `{{ name }}`, when a substitution value holds `{{`, `{%` or `{#`, or when the subject and the values combine into template syntax. See `subject`.

        Authorization uses the domain in `from.email`: either
        `messages:send:all` or the matching domain-specific send role is
        sufficient.
      operationId: createMessage
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["messages:send:all"]
        - BearerAuth: ["messages:send:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with every POST and reuses it on its
              // own retries. Deriving the key from your own ID instead also makes a rerun
              // of the same job (after a crash, say) return the first send, not a second.
              orderID := "1234"

              response, _, err := client.MessagesAPI.CreateMessage(ctx, accountID, requests.CreateMessageRequest{
                From: common.SenderAddress{
                  Email: "info@example.com",
                  Name:  ahasend.String("Example Corp."),
                },
                Recipients: []common.Recipient{
                  {
                    Email: "john@example.com",
                    Name:  ahasend.String("John Smith"),
                  },
                },
                Subject:     "Hello",
                TextContent: ahasend.String("Hello world!"),
                Sandbox:     ahasend.Bool(true),
              }, api.WithIdempotencyKey("order-"+orderID+"-receipt"))
              if err != nil {
                log.Fatalf("Error sending message: %v", err)
              }
              // Each recipient gets its own message, or an error saying why it was not sent.
              for _, message := range response.Data {
                if message.ID != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.ID)
                } else if message.Error != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.Error)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.messages.send({
              from: { email: "sender@example.com", name: "Example" },
              recipients: [{ email: "recipient@example.net" }],
              subject: "Hello from AhaSend",
              html_content: "<p>Hello!</p>",
              sandbox: true,
            });
            console.log("Sandbox message accepted.", { count: result.data.length });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMessageRequest'
      responses:
        '202':
          description: Message created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateMessageResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Forbidden. For example, the API key cannot send from the domain, the account cannot send, or sending from the domain is paused. A sandbox message is accepted from a paused domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                domain_paused:
                  summary: Sending from the domain is paused
                  value:
                    message: Sending from domain 'example.com' is paused. Check your dashboard for the reason.
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/messages/template:
    post:
      summary: Create Template Message
      description: |
        Sends a stored transactional template to one or more recipients. Each recipient gets a separate message. The template supplies the body. It also supplies the subject, the sender and the reply-to when the request does not give them. To send content that is not stored in a template, use [Create Message](https://ahasend.com/docs/api-reference/messages/create-message).

        **The message:**
        The message has the template's HTML body, with the template's preview text added, and the template's text body. It has no AMP part. The message records the template: the message's `template_id` (Get Message) is the ID of this template.

        **Variables:**
        Values for the template's variables come only from each recipient's `substitutions`. Every recipient must give a value for each required variable of the template (see `variables` on Get Template). If a recipient does not, the request is rejected with a 400 that names each recipient and the variables it does not give, and no message is sent. A value for a name that the template does not use is accepted.

        Your `subject` and `headers` resolve from the same values. A `{{ name }}` that has no value becomes empty.

        **Reserved variables:**
        The send supplies `email` for each recipient: the recipient's address. When the template's design uses `unsubscribe_url`, the send also supplies it: a link made for each recipient. The message then carries `List-Unsubscribe` with that link and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, so the unsubscribe button of a mail client works. That button suppresses the recipient for the sending domain at once. The link opens a confirmation page, and the suppression happens when the recipient confirms. A `List-Unsubscribe` or `List-Unsubscribe-Post` in your `headers` is dropped, in any letter case. When the design does not use `unsubscribe_url`, no link is made and your headers are sent as you give them.

        A recipient's own value for a reserved variable is replaced.

        **Fields that are not accepted:**
        These fields at the top of the request are rejected with a 400. Any value except `null` is rejected, `""` and `{}` included.
        - `substitutions`: `substitutions go on each recipient, not at the top of the request`
        - `text_content`, `html_content` and `amp_content`: `<field> is not accepted, the template supplies the body`, with the name of the field

        A request with more than one of them gets the error for the first in this list. These fields are checked as soon as the request body is read, before the rest of the request.

        **Sender:**
        - When the request has no `from`, the message is sent from the template's default sender. When the template has no default sender, the request is rejected with a 400 `from is required because the template has no default sender`.
        - A `from` given in the request is used even when the template has a default sender. It must have an `email`, and it is checked as on Create Message.
        - The sender, the request's or the template's, must be from a domain you own with valid DNS records.
        - When the request has no `reply_to` and no `reply-to` in `headers`, the template's reply-to is used.

        **Subject:**
        The request's `subject` is used when it holds more than spaces. If not, the template's published subject is used. When neither has a subject, the request is rejected with a 400 `the template has no subject, publish one on the template page or pass subject`.

        **Headers and subject:**
        - Header names must be visible ASCII with no space and no colon. An empty name, or one holding a space, a colon or a line break, is rejected.
        - A substitution value that a header uses must not contain a line break. Such a request is rejected, and the error names the recipient.
        - A `{{ name }}` in a subject that carries an emoji or an accented character is replaced before the message is built, on every send. Such a subject is rejected when it holds template syntax other than `{{ name }}`, when a substitution value holds `{{`, `{%` or `{#`, or when the subject and the values combine into template syntax. See `subject`.

        **Order of checks:**
        When the request body is read, the fields that are not accepted are checked first, then the request body against its schema. Then the checks run in this order, and a request that fails more than one gets the error of the first:
        1. The number of recipients, then the header names.
        2. A `from` in the request: its domain, as on Create Message.
        3. No `from`, and an API key that cannot send from any domain: 403 `this api key is not authorized to send messages`.
        4. A `template_id` that is not one of this account's transactional templates: 404 `template not found`.
        5. A template with neither an HTML nor a text body: 400 `template has no published content, design it and publish it`.
        6. An HTML or text body that holds a `{{`, `{%` or `{#` that cannot be rendered: 400 `template's published design holds a {{, {% or {# that cannot be rendered, fix it and publish it`.
        7. A default sender or reply-to of the template that the send would use and that is not valid: 400 `the template's default sender or reply-to is not valid, edit it on the template page and publish it`.
        8. No `from`: the template's default sender (see Sender) and its domain.
        9. The subject (see Subject), then the variables of each recipient (see Variables).
        10. The sandbox fields, then whether the account can send and whether sending from the domain is paused, as on Create Message.

        The other checks, such as the schedule and the substitution values that headers use, come after these.

        Authorization uses the domain of the sender, `from.email` or the
        template's default sender: either `messages:send:all` or the matching
        domain-specific send role is sufficient. Sending a template requires
        no additional scope.
      operationId: createTemplateMessage
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["messages:send:all"]
        - BearerAuth: ["messages:send:{domain}"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTemplateMessageRequest'
      responses:
        '202':
          description: Message created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateMessageResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_variables:
                  summary: A recipient does not give a required variable
                  value:
                    message: "missing required template variables: user@example.com needs first_name, order_id"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Forbidden. For example, the API key cannot send from the domain of the sender, the account cannot send, or sending from the domain is paused. A sandbox message is accepted from a paused domain. A request with no `from` is refused with `this api key is not authorized to send messages` when the API key cannot send from any domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                domain_paused:
                  summary: Sending from the domain is paused
                  value:
                    message: Sending from domain 'example.com' is paused. Check your dashboard for the reason.
        '404':
          description: |
            `template not found`: `template_id` is not the ID of one of this account's transactional templates. A deleted template, a campaign design and a template of another account get this answer too.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // The template supplies the body, and the subject and sender when the
              // request leaves them out. Each recipient gives its own variable values.
              // For a template with no default sender, set From to an address on one of
              // your domains.
              response, _, err := client.MessagesAPI.CreateTemplateMessage(ctx, accountID, requests.CreateTemplateMessageRequest{
                TemplateID: templateID,
                Recipients: []common.Recipient{
                  {
                    Email: "john@example.com",
                    Name:  ahasend.String("John Smith"),
                    Substitutions: map[string]interface{}{
                      "first_name": "John",
                      "order_id":   "12345",
                    },
                  },
                },
                Sandbox: ahasend.Bool(true),
              })
              if err != nil {
                log.Fatalf("Error sending message: %v", err)
              }
              // Each recipient gets its own message, or an error saying why it was not sent.
              for _, message := range response.Data {
                if message.ID != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.ID)
                } else if message.Error != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.Error)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.messages.sendTemplate({
              template_id: "00000000-0000-4000-8000-000000000009",
              recipients: [{ email: "recipient@example.net", substitutions: { first_name: "Ada" } }],
              sandbox: true,
            });
            console.log("Sandbox template message accepted.", { count: result.data.length });

  /v2/accounts/{account_id}/messages/conversation:
    post:
      summary: Create Conversational Message
      description: |
        Creates and sends a conversational message (with support for CC and BCC) to one or more recipients. This API does not support template substitutions, use [Create Message](https://ahasend.com/docs/api-reference/messages/create-message) instead if you need substitutions.

        **Validation Requirements:**
        - Either `text_content` or `html_content` is required
        - `from.email` must be from a domain you own with valid DNS records
        - `retention.metadata` must be between 1 and 30 days
        - `retention.data` must be between 0 and 30 days
        - If the `reply_to` parameter is provided, do not include `reply-to` in headers
        - If the `cc` parameter is provided, do not include `cc` in headers
        - `message-id` header will be ignored and automatically generated
        - header names must be visible ASCII with no space and no colon; an empty name, or one holding a space, a colon or a line break, is rejected
        - Schedule times must be in RFC3339 format
        - `schedule.first_attempt` must be in the future and within 7 days of the request
        - `schedule.expires` must be in the future and within 8 days of the request

        Authorization uses the domain in `from.email`: either
        `messages:send:all` or the matching domain-specific send role is
        sufficient.
      operationId: createConversationMessage
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["messages:send:all"]
        - BearerAuth: ["messages:send:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // A conversational message sets the To/CC/BCC headers directly, so
              // recipients can see each other (BCC excluded). The combined
              // To + CC + BCC count must not exceed 50. Unlike Create Message,
              // this endpoint does not support template substitutions.
              //
              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.MessagesAPI.CreateConversationMessage(ctx, accountID, requests.CreateConversationMessageRequest{
                From: common.SenderAddress{
                  Email: "info@example.com",
                  Name:  ahasend.String("Example Corp."),
                },
                To: []common.SenderAddress{
                  {Email: "john@example.com", Name: ahasend.String("John Smith")},
                  {Email: "jane@example.com", Name: ahasend.String("Jane Doe")},
                },
                CC: []common.SenderAddress{
                  {Email: "hr@example.com", Name: ahasend.String("Example Corp HR")},
                },
                BCC: []common.SenderAddress{
                  {Email: "archive@example.com"},
                },
                Subject:     "Welcome to Example Corp",
                HtmlContent: ahasend.String("<h1>Dear John and Jane, welcome to Example Corp!</h1>"),
                TextContent: ahasend.String("Dear John and Jane, welcome to Example Corp!"),
                Sandbox:     ahasend.Bool(true),
              })
              if err != nil {
                log.Fatalf("Error sending conversational message: %v", err)
              }
              for _, message := range response.Data {
                if message.ID != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.ID)
                } else if message.Error != nil {
                  fmt.Printf("%s: %s\n", message.Recipient.Email, *message.Error)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.messages.sendConversation({
              from: { email: "sender@example.com", name: "Example" },
              to: [{ email: "recipient@example.net" }],
              subject: "Hello from AhaSend",
              html_content: "<p>Hello!</p>",
              sandbox: true,
            });
            console.log("Sandbox conversation accepted.", { count: result.data.length });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateConversationMessageRequest'
      responses:
        '202':
          description: Message created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateMessageResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Forbidden. For example, the API key cannot send from the domain, the account cannot send, or sending from the domain is paused. A sandbox message is accepted from a paused domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                domain_paused:
                  summary: Sending from the domain is paused
                  value:
                    message: Sending from domain 'example.com' is paused. Check your dashboard for the reason.
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/messages/{message_id}:
    get:
      summary: Get Message
      description: |
        Returns the complete message by its ID, including raw content and parsed content structure.
        Access requires `messages:read:all` or a read role matching the
        message's sender domain.
      operationId: getMessage
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: message_id
          in: path
          required: true
          description: Message API ID. Accepts the generated Message-ID returned by [Create Message](https://ahasend.com/docs/api-reference/messages/create-message) or [Create Template Message](https://ahasend.com/docs/api-reference/messages/create-template-message), for example `<uuid@example.com>`, or the bare UUID portion.
          schema:
            type: string
      security:
        - BearerAuth: ["messages:read:all"]
        - BearerAuth: ["messages:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The Message-ID returned by Create Message, or its bare UUID.
              response, _, err := client.MessagesAPI.GetMessageByAPIID(ctx, accountID, "<8b1f2e8c-5d6a-4c3b-9e7f-1a2b3c4d5e6f@example.com>")
              if err != nil {
                log.Fatalf("Error getting message: %v", err)
              }
              fmt.Printf("Subject: %s\n", response.Subject)
              fmt.Printf("Status: %s\n", response.Status)
              fmt.Printf("Recipient: %s\n", response.Recipient)
              fmt.Printf("Opens: %d, clicks: %d\n", response.OpenCount, response.ClickCount)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const messageId = "00000000-0000-4000-8000-000000000002";
            const message = await client.messages.get(messageId);
            console.log("Message found.", { id: message.id, status: message.status });

      responses:
        '200':
          description: Message details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Message not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/messages/{message_id}/cancel:
    delete:
      summary: Cancel Message
      description: |
        Cancels a scheduled message. Access requires `messages:cancel:all` or a
        cancel role matching the message's sender domain.
      operationId: cancelMessage
      tags:
        - Messages
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: message_id
          in: path
          required: true
          description: Message API ID. Accepts the generated Message-ID returned by [Create Message](https://ahasend.com/docs/api-reference/messages/create-message) or [Create Template Message](https://ahasend.com/docs/api-reference/messages/create-template-message), for example `<uuid@example.com>`, or the bare UUID portion.
          schema:
            type: string
      security:
        - BearerAuth: ["messages:cancel:all"]
        - BearerAuth: ["messages:cancel:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The Message-ID returned by Create Message, or its bare UUID.
              response, _, err := client.MessagesAPI.CancelMessage(ctx, accountID, "<8b1f2e8c-5d6a-4c3b-9e7f-1a2b3c4d5e6f@example.com>")
              if err != nil {
                log.Fatalf("Error canceling message: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const messageId = "00000000-0000-4000-8000-000000000002";
            const result = await client.messages.cancel(messageId);
            console.log("Message cancellation requested.", { message: result.message });

      responses:
        '200':
          description: Scheduled message is cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Message not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Template endpoints
  /v2/accounts/{account_id}/templates:
    get:
      summary: List Templates
      description: |
        Returns a cursor-paginated list of the account's transactional
        templates, newest first. Campaign designs are a different resource and
        never appear here. Each template is listed without its `content`; Get
        Template returns it.

        A template with no design yet is listed like any other; whether it can
        be sent is the send's answer, not this one.
      operationId: listTemplates
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response.
          schema:
            type: string
      security:
        - BearerAuth: ["templates:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.TemplatesAPI.GetTemplates(ctx, accountID, requests.GetTemplatesParams{
                Limit: ahasend.Int32(100),
              })
              if err != nil {
                log.Fatalf("Error listing templates: %v", err)
              }
              for _, template := range response.Data {
                fmt.Printf("%s %s\n", template.ID, template.Name)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.templates.list({ limit: 20 });
            console.log("Templates listed.", { count: page.data.length });

      responses:
        '200':
          description: Paginated templates
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedTemplatesResponse'
        '400':
          description: Invalid limit or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      summary: Create Template
      description: |
        Creates a transactional template. Its fields go to its draft, as a
        dashboard save does, so sends keep failing until it is published: pass
        `publish: true` to publish it in the same request, or call Publish
        Template.

        `content.mjml` makes an `advanced` template and `content.html` an `html`
        template. A template with neither must name its `editor`. The editor never
        changes after create.

        MJML is compiled in strict mode, and HTML must use full `https://` URLs
        for its images and stylesheets: the API cannot upload files.

        A create with `publish: true` and nothing to publish is refused and
        creates nothing.
      operationId: createTemplate
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTemplateRequest'
      responses:
        '201':
          description: Template created. The Template object is the published copy; `has_draft` says whether the request left a draft.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: 'Invalid request: the body, a field, or a publish with nothing to publish. The message names the field and the problem.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // HTML content makes an html template; MJML would make an advanced one.
              // The text is made from the HTML when it is left out. Publish makes the
              // template ready to send at once; without it, the fields stay in a draft.
              response, _, err := client.TemplatesAPI.CreateTemplate(ctx, accountID, requests.CreateTemplateRequest{
                Name:    "Password reset",
                Subject: ahasend.String("Reset your password, {{ first_name }}"),
                From: &common.SenderAddress{
                  Email: "hello@yourdomain.com",
                  Name:  ahasend.String("Example"),
                },
                Content: &requests.TemplateContentInput{
                  HTML: ahasend.String(`<p>Hi {{ first_name }},</p><p><a href="{{ reset_url }}">Reset your password</a></p>`),
                },
                Publish: true,
              })
              if err != nil {
                log.Fatalf("Error creating template: %v", err)
              }
              fmt.Printf("Created %s template %s\n", response.Editor, response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const template = await client.templates.create({
              name: "Welcome email",
              subject: "Welcome, {{ first_name }}",
              content: { html: "<p>Hello {{ first_name }}, welcome aboard.</p>" },
              publish: true,
            });
            console.log("Template created.", { id: template.id, editor: template.editor });

  /v2/accounts/{account_id}/templates/{template_id}:
    get:
      summary: Get Template
      description: |
        Returns one of the account's transactional templates: its published
        copy, which is what a send uses, with its content and the variable list
        a send has to satisfy. `has_draft` says whether changes are waiting to
        be published; Get Template Draft returns them. A campaign design's ID
        is not addressable here and answers 404.
      operationId: getTemplate
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.TemplatesAPI.GetTemplate(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error getting template: %v", err)
              }
              // A send from this template must supply every required variable.
              for _, variable := range response.Variables {
                fmt.Printf("%s required=%t\n", variable.Name, variable.Required)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const template = await client.templates.get(templateId);
            console.log("Template found.", { id: template.id, name: template.name });

      responses:
        '200':
          description: Template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      summary: Update Template
      description: |
        Changes a transactional template. Every field but `name` goes to the
        template's draft, the same draft the dashboard edits, and sends use the
        published copy until the draft is published: pass `publish: true`, or call
        Publish Template. A publish publishes the whole draft, including changes
        made in the dashboard. A draft that ends up equal to the published copy is
        not kept, and a request that changes nothing changes nothing, so the same
        request can be repeated safely.

        `name` is not drafted: it changes when the request succeeds, as its last
        step. A field the request leaves out is neither changed nor checked,
        apart from a custom text kept beside a new design (see
        `TemplateContentInput`).

        When the template changes in the dashboard or through another request
        while this one writes it, the request applies its fields again over the
        new draft. It answers 503 when that keeps happening; retry it.
      operationId: updateTemplate
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTemplateRequest'
      responses:
        '200':
          description: Template updated. The Template object is the published copy; `has_draft` says whether a draft is left.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: Invalid template_id, an invalid body or field, a request with no changes, or a publish with nothing to publish or a draft that cannot be rendered. The message names the field and the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          $ref: '#/components/responses/TemplateBeingChanged'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Only the fields you set change. They go to the template's draft, the
              // same draft the dashboard edits; Publish publishes the whole draft. An
              // empty ReplyTo clears the reply-to address.
              response, _, err := client.TemplatesAPI.UpdateTemplate(ctx, accountID, templateID, requests.UpdateTemplateRequest{
                Subject: ahasend.String("Your password reset link"),
                ReplyTo: &common.SenderAddress{},
                Publish: true,
              })
              if err != nil {
                log.Fatalf("Error updating template: %v", err)
              }
              fmt.Printf("Updated template %s, has draft: %t\n", response.Name, response.HasDraft)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const templateId = "00000000-0000-4000-8000-000000000009";
            // Fields go to the template's draft; publish: true also publishes it.
            const template = await client.templates.update(templateId, {
              subject: "Welcome to the team, {{ first_name }}",
              preheader: null,
              publish: true,
            });
            console.log("Template updated.", { id: template.id, hasDraft: template.has_draft });

    delete:
      summary: Delete Template
      description: |
        Deletes a transactional template, as the dashboard does. Sends of it fail
        from then on, and a second delete answers 404.
      operationId: deleteTemplate
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:delete"]
      responses:
        '200':
          description: Template deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:delete` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.TemplatesAPI.DeleteTemplate(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error deleting template: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const result = await client.templates.delete(templateId);
            console.log("Template deleted.", { message: result.message });

  /v2/accounts/{account_id}/templates/{template_id}/draft:
    get:
      summary: Get Template Draft
      description: |
        Returns a template's draft: the changes saved in the dashboard or
        through the API that are not published yet. Sends use the published
        copy, which Get Template returns, until the draft is published.

        A template with no draft answers 404 `template has no draft`; its
        `has_draft` is false.
      operationId: getTemplateDraft
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:read"]
      responses:
        '200':
          description: Template draft
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateDraft'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found (`template not found`), or the template has no draft (`template has no draft`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // A template with no draft returns 404; its HasDraft is false.
              response, _, err := client.TemplatesAPI.GetTemplateDraft(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error getting template draft: %v", err)
              }
              fmt.Printf("Draft subject: %s, changed at %s\n", response.Subject, response.UpdatedAt)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const draft = await client.templates.getDraft(templateId);
            console.log("Template draft found.", { templateId: draft.template_id, subject: draft.subject });

    delete:
      summary: Discard Template Draft
      description: |
        Discards a template's draft, the changes saved in the dashboard or through
        the API that are not published yet. A template with no draft is answered
        as it is, so the request can be repeated safely.
      operationId: discardTemplateDraft
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:write"]
      responses:
        '200':
          description: The template, with `has_draft` false
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          $ref: '#/components/responses/TemplateBeingChanged'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.TemplatesAPI.DiscardTemplateDraft(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error discarding template draft: %v", err)
              }
              fmt.Printf("Template %s has draft: %t\n", response.Name, response.HasDraft)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const template = await client.templates.discardDraft(templateId);
            console.log("Template draft discarded.", { id: template.id, hasDraft: template.has_draft });

  /v2/accounts/{account_id}/templates/{template_id}/publish:
    post:
      summary: Publish Template
      description: |
        Publishes a template's draft, so sends use it, and keeps it as a new
        version. The whole draft is published, including changes made in the
        dashboard. A template with no draft has nothing to publish and is answered
        as it is when its published copy has a body, so the request can be
        repeated safely.

        The operation takes no request body; one sent is ignored.
      operationId: publishTemplate
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      responses:
        '200':
          description: Template published
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: Invalid template_id, a template with nothing to publish (`template has no content to publish`), or a draft whose design holds a `{{`, `{%` or `{#` that cannot be rendered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          $ref: '#/components/responses/TemplateBeingChanged'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // The whole draft is published, including changes made in the dashboard.
              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries.
              response, _, err := client.TemplatesAPI.PublishTemplate(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error publishing template: %v", err)
              }
              fmt.Printf("Published template %s\n", response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const templateId = "00000000-0000-4000-8000-000000000009";
            const template = await client.templates.publish(templateId);
            console.log("Template published.", { id: template.id, hasDraft: template.has_draft });

  /v2/accounts/{account_id}/templates/{template_id}/versions:
    get:
      summary: List Template Versions
      description: |
        Returns a template's published versions, newest first, without their
        content. Every publish of a draft makes a version. A template keeps its
        50 most recent versions, so the list is not paginated. Get Template
        Version returns one version with its content.
      operationId: listTemplateVersions
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:read"]
      responses:
        '200':
          description: Template versions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateVersionsResponse'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Versions come newest first, at most 50, with no pagination.
              response, _, err := client.TemplatesAPI.GetTemplateVersions(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error listing template versions: %v", err)
              }
              for _, version := range response.Data {
                publisher := "unknown"
                if version.PublishedBy != nil {
                  publisher = version.PublishedBy.Type + " " + version.PublishedBy.ID.String()
                }
                fmt.Printf("v%d %s published by %s\n", version.Version, version.PublishedAt, publisher)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const versions = await client.templates.listVersions(templateId);
            console.log("Template versions listed.", { count: versions.data.length });

  /v2/accounts/{account_id}/templates/{template_id}/versions/{version_id}:
    get:
      summary: Get Template Version
      description: |
        Returns one published version of a template with its content.
      operationId: getTemplateVersion
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
        - name: version_id
          in: path
          required: true
          description: Version ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:read"]
      responses:
        '200':
          description: Template version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateVersionDetail'
        '400':
          description: Invalid template_id or version_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found (`template not found`), or version not found (`template version not found`), also for a version of another template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
              versionID := uuid.MustParse("22222222-2222-4222-8222-222222222222")

              response, _, err := client.TemplatesAPI.GetTemplateVersion(ctx, accountID, templateID, versionID)
              if err != nil {
                log.Fatalf("Error getting template version: %v", err)
              }
              fmt.Printf("v%d subject: %s\n", response.Version, response.Subject)
              if response.Content != nil {
                fmt.Println(response.Content.Text)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000009";
            const versionId = "00000000-0000-4000-8000-000000000012";
            const version = await client.templates.getVersion(templateId, versionId);
            console.log("Template version found.", { id: version.id, version: version.version });

  /v2/accounts/{account_id}/templates/{template_id}/versions/{version_id}/restore:
    post:
      summary: Restore Template Version
      description: |
        Restores a published version into the template's draft. With
        `publish: true` the draft is then published, as Publish Template does;
        a version the publish checks refuse is refused before the restore, and
        the draft is left as it was.
      operationId: restoreTemplateVersion
      tags:
        - Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Template ID
          schema:
            type: string
            format: uuid
        - name: version_id
          in: path
          required: true
          description: Version ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RestoreTemplateVersionRequest'
      responses:
        '200':
          description: Version restored. The Template object is the published copy; `has_draft` says whether a draft is left.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        '400':
          description: Invalid template_id or version_id, an invalid body, or a publish the publish checks refuse
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found (`template not found`), or version not found (`template version not found`), also for a version of another template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          $ref: '#/components/responses/TemplateBeingChanged'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
              versionID := uuid.MustParse("22222222-2222-4222-8222-222222222222")

              // The version goes into the draft; Publish then publishes it, so sends use
              // it again.
              response, _, err := client.TemplatesAPI.RestoreTemplateVersion(ctx, accountID, templateID, versionID, requests.RestoreTemplateVersionRequest{
                Publish: true,
              })
              if err != nil {
                log.Fatalf("Error restoring template version: %v", err)
              }
              fmt.Printf("Restored template %s, has draft: %t\n", response.Name, response.HasDraft)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const templateId = "00000000-0000-4000-8000-000000000009";
            const versionId = "00000000-0000-4000-8000-000000000012";
            const template = await client.templates.restoreVersion(templateId, versionId, { publish: true });
            console.log("Template version restored.", { id: template.id, hasDraft: template.has_draft });

  # Campaign template endpoints
  /v2/accounts/{account_id}/campaign-templates:
    get:
      summary: List Campaign Templates
      description: |
        Returns a cursor-paginated list of the account's campaign templates,
        newest first. Transactional templates are a different resource and
        never appear here. Each campaign template is listed without its
        `content`; Get Campaign Template returns it.
      operationId: listCampaignTemplates
      tags:
        - Campaign Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response.
          schema:
            type: string
      security:
        - BearerAuth: ["templates:read"]
      responses:
        '200':
          description: Paginated campaign templates
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedCampaignTemplatesResponse'
        '400':
          description: Invalid limit or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Campaign templates are listed without their content; Get Campaign
              // Template returns it.
              response, _, err := client.CampaignTemplatesAPI.ListCampaignTemplates(ctx, accountID, requests.ListCampaignTemplatesParams{
                Limit: ahasend.Int32(100),
              })
              if err != nil {
                log.Fatalf("Error listing campaign templates: %v", err)
              }
              for _, template := range response.Data {
                fmt.Printf("%s %s (%s)\n", template.ID, template.Name, template.Editor)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.campaignTemplates.list({ limit: 20 });
            console.log("Campaign templates listed.", { count: page.data.length });

    post:
      summary: Create Campaign Template
      description: |
        Creates a campaign template. A campaign template has no draft: what
        the request sets is what a campaign made from the template copies.

        `content.mjml` makes an `advanced` campaign template and
        `content.html` an `html` one. A campaign template with neither must
        name its `editor`; without `content` it is created with an empty
        design, which the dashboard's editor opens. The editor never changes
        after create.

        MJML is compiled in strict mode, and HTML must use full `https://`
        URLs for its images and stylesheets: the API cannot upload files.
      operationId: createCampaignTemplate
      tags:
        - Campaign Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignTemplateRequest'
      responses:
        '201':
          description: Campaign template created
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignTemplate'
        '400':
          description: 'Invalid request: the body or a field. The message names the field and the problem.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // HTML content makes an html campaign template; MJML would make an
              // advanced one. The text is made from the HTML when it is left out.
              response, _, err := client.CampaignTemplatesAPI.CreateCampaignTemplate(ctx, accountID, requests.CreateCampaignTemplateRequest{
                Name: "Monthly newsletter",
                Content: &requests.TemplateContentInput{
                  HTML: ahasend.String(`<p>Hi {{ first_name }},</p><p>Here is our news.</p><p><a href="{{ unsubscribe_url }}">Unsubscribe</a></p>`),
                },
              })
              if err != nil {
                log.Fatalf("Error creating campaign template: %v", err)
              }
              fmt.Printf("Created %s campaign template %s\n", response.Editor, response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const template = await client.campaignTemplates.create({
              name: "Monthly newsletter",
              content: {
                html: '<p>Hello {{ first_name }}.</p><p><a href="{{ unsubscribe_url }}">Unsubscribe</a></p>',
              },
            });
            console.log("Campaign template created.", { id: template.id, editor: template.editor });

  /v2/accounts/{account_id}/campaign-templates/{template_id}:
    get:
      summary: Get Campaign Template
      description: |
        Returns one of the account's campaign templates with its content. A
        transactional template's ID is not addressable here and answers 404.
      operationId: getCampaignTemplate
      tags:
        - Campaign Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Campaign template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:read"]
      responses:
        '200':
          description: Campaign template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignTemplate'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.CampaignTemplatesAPI.GetCampaignTemplate(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error getting campaign template: %v", err)
              }
              // A campaign can be made from the template only once it has HTML.
              hasHTML := response.Content != nil && response.Content.HTML != ""
              fmt.Printf("%s (%s), has HTML: %t\n", response.Name, response.Editor, hasHTML)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000013";
            const template = await client.campaignTemplates.get(templateId);
            console.log("Campaign template found.", { id: template.id, name: template.name });

    put:
      summary: Update Campaign Template
      description: |
        Changes a campaign template's name, its content, or both. A field the
        request leaves out is neither changed nor checked. The change applies
        at once; campaigns already made from the template keep their own
        copies. A name or content equal to the stored one changes nothing, so
        the same request can be repeated safely. The editor never changes.

        The design of a `simple` campaign template can be changed only in the
        dashboard: send only `content.text` for one. A design saved later in
        the dashboard, also from an editor opened before this request,
        replaces the design this request wrote.
      operationId: updateCampaignTemplate
      tags:
        - Campaign Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Campaign template ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["templates:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCampaignTemplateRequest'
      responses:
        '200':
          description: Campaign template updated
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignTemplate'
        '400':
          description: Invalid template_id, an invalid body or field, or a request with no changes. The message names the field and the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Only the fields you set change, at once. Campaigns already made from
              // the template keep their own copies.
              response, _, err := client.CampaignTemplatesAPI.UpdateCampaignTemplate(ctx, accountID, templateID, requests.UpdateCampaignTemplateRequest{
                Name: ahasend.String("Monthly newsletter, 2026"),
              })
              if err != nil {
                log.Fatalf("Error updating campaign template: %v", err)
              }
              fmt.Printf("Updated campaign template %s\n", response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const templateId = "00000000-0000-4000-8000-000000000013";
            // Campaigns already made from the template keep their own copies.
            const template = await client.campaignTemplates.update(templateId, {
              name: "Monthly newsletter (2026)",
            });
            console.log("Campaign template updated.", { id: template.id, name: template.name });

    delete:
      summary: Delete Campaign Template
      description: |
        Deletes a campaign template, as the dashboard does. Campaigns made
        from it keep their own copies, and a second delete answers 404.
      operationId: deleteCampaignTemplate
      tags:
        - Campaign Templates
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: template_id
          in: path
          required: true
          description: Campaign template ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["templates:delete"]
      responses:
        '200':
          description: Campaign template deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Invalid template_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `templates:delete` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              templateID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Campaigns made from the template keep their own copies.
              response, _, err := client.CampaignTemplatesAPI.DeleteCampaignTemplate(ctx, accountID, templateID)
              if err != nil {
                log.Fatalf("Error deleting campaign template: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const templateId = "00000000-0000-4000-8000-000000000013";
            const result = await client.campaignTemplates.delete(templateId);
            console.log("Campaign template deleted.", { message: result.message });

  /v2/accounts/{account_id}/campaigns:
    get:
      summary: List Campaigns
      description: |
        Returns a cursor-paginated list of the account's marketing campaigns,
        newest first. Each campaign is listed without its lists, content and
        issues; Get Campaign returns them.
      operationId: listCampaigns
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: Only campaigns in this status.
          schema:
            $ref: '#/components/schemas/CampaignStatus'
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response.
          schema:
            type: string
      security:
        - BearerAuth: ["campaigns:read"]
      responses:
        '200':
          description: Paginated campaigns
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedCampaignsResponse'
        '400':
          description: Invalid status, limit or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Leave Status nil to list campaigns in every status.
              response, _, err := client.CampaignsAPI.ListCampaigns(ctx, accountID, requests.ListCampaignsParams{
                Status: ahasend.String(common.CampaignStatusScheduled),
                Limit:  ahasend.Int32(100),
              })
              if err != nil {
                log.Fatalf("Error listing campaigns: %v", err)
              }
              for _, campaign := range response.Data {
                if campaign.SendAt != nil {
                  fmt.Printf("%s %s sends at %s\n", campaign.ID, campaign.Name, campaign.SendAt)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.campaigns.list({ status: "draft", limit: 20 });
            console.log("Campaigns listed.", { count: page.data.length });

    post:
      summary: Create Campaign
      description: |
        Creates a draft marketing campaign. Every field but `name` is
        optional and can be set later with Update Campaign; the campaign
        answers its `issues` until it is ready to send.

        The design comes from `content` (`content.mjml` makes an `advanced`
        design and `content.html` an `html` one), from an `editor` alone (an
        empty design the dashboard's editor opens), or from a campaign
        template named by `template_id`, which the campaign copies. MJML is
        compiled in strict mode, and HTML must use full `https://` URLs for
        its images and stylesheets: the API cannot upload files.

        The subject, preview text, sender and reply-to are checked by the
        rules of the dashboard, and their refusals use the dashboard's
        sentences.
      operationId: createCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignRequest'
      responses:
        '201':
          description: Campaign created
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: 'Invalid request: the body or a field, a list or campaign template that does not exist. The message names the field and the problem.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
              templateID := uuid.MustParse("33333333-3333-4333-8333-333333333333")

              // The campaign copies the campaign template's design; later changes to
              // the template do not reach it. Every field but Name can be set later.
              response, _, err := client.CampaignsAPI.CreateCampaign(ctx, accountID, requests.CreateCampaignRequest{
                Name:      "October newsletter",
                Subject:   ahasend.String("Our news for October, {{ first_name }}"),
                Preheader: ahasend.String("Three things we shipped this month"),
                From: &common.SenderAddress{
                  Email: "news@yourdomain.com",
                  Name:  ahasend.String("Example"),
                },
                ListIDs:    []uuid.UUID{listID},
                TemplateID: &templateID,
              })
              if err != nil {
                log.Fatalf("Error creating campaign: %v", err)
              }
              fmt.Printf("Created campaign %s, ready: %t\n", response.ID, response.Ready != nil && *response.Ready)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaign = await client.campaigns.create({
              name: "October newsletter",
              subject: "What is new in October",
              preheader: "Three things we shipped this month",
              from: { email: "news@example.com", name: "Example" },
              list_ids: ["00000000-0000-4000-8000-000000000010"],
              template_id: "00000000-0000-4000-8000-000000000013",
            });
            console.log("Campaign created.", { id: campaign.id, ready: campaign.ready });

  /v2/accounts/{account_id}/campaigns/{campaign_id}:
    get:
      summary: Get Campaign
      description: |
        Returns one of the account's campaigns with its lists, its content
        and, for a draft, the issues that keep it from being sent.
      operationId: getCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["campaigns:read"]
      responses:
        '200':
          description: Campaign
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: Invalid campaign_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.CampaignsAPI.GetCampaign(ctx, accountID, campaignID)
              if err != nil {
                log.Fatalf("Error getting campaign: %v", err)
              }
              fmt.Printf("%s is %s, sending to %d lists\n", response.Name, response.Status, len(response.ListIDs))
              // A draft lists what keeps it from being sent.
              for _, issue := range response.Issues {
                fmt.Printf("%s: %s\n", issue.Code, issue.Message)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const campaign = await client.campaigns.get(campaignId);
            console.log("Campaign found.", { id: campaign.id, status: campaign.status, ready: campaign.ready });

    put:
      summary: Update Campaign
      description: |
        Changes a draft campaign. A field the request leaves out is neither
        changed nor checked, and `null` clears `subject`, `preheader`, `from`
        and `reply_to`. A campaign that is not a draft cannot be changed.

        The change waits for any other change of the same campaign, from the
        dashboard or the API, and a send that starts after it reads it. The
        design of a `simple` campaign can be changed only in the dashboard:
        send only `content.text` for one. Between this request and a
        dashboard editor open on the same campaign, the later design save
        wins.
      operationId: updateCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:write"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCampaignRequest'
      responses:
        '200':
          description: Campaign updated
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: Invalid campaign_id, an invalid body or field, a list that does not exist, or a request with no changes. The message names the field and the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/CampaignConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Only the fields you set change, and only while the campaign is a
              // draft. A ReplyTo that points at "" clears the reply-to address.
              response, _, err := client.CampaignsAPI.UpdateCampaign(ctx, accountID, campaignID, requests.UpdateCampaignRequest{
                Subject: ahasend.String("Our news for October"),
                ReplyTo: ahasend.String(""),
              })
              if err != nil {
                log.Fatalf("Error updating campaign: %v", err)
              }
              fmt.Printf("Updated campaign %s, %d issues left\n", response.Name, len(response.Issues))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaignId = "00000000-0000-4000-8000-000000000014";
            // Only a draft can change; null clears the reply-to address.
            const campaign = await client.campaigns.update(campaignId, {
              subject: "What we shipped in October",
              reply_to: null,
            });
            console.log("Campaign updated.", { id: campaign.id, ready: campaign.ready });

    delete:
      summary: Delete Campaign
      description: |
        Deletes a draft campaign with its design and its list choices. A
        campaign that is not a draft cannot be deleted, and a second delete
        answers 404.
      operationId: deleteCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["campaigns:delete"]
      responses:
        '200':
          description: Campaign deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Invalid campaign_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:delete` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: The campaign is not a draft
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Only a draft can be deleted.
              response, _, err := client.CampaignsAPI.DeleteCampaign(ctx, accountID, campaignID)
              if err != nil {
                log.Fatalf("Error deleting campaign: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const result = await client.campaigns.delete(campaignId);
            console.log("Campaign deleted.", { message: result.message });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/duplicate:
    post:
      summary: Duplicate Campaign
      description: |
        Makes a new draft from a campaign in any status: a copy of its
        design, its subject, preview text, sender and reply-to address, and
        its lists that still exist, in the same order. The copy's design is
        its own.
      operationId: duplicateCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:write"]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DuplicateCampaignRequest'
      responses:
        '201':
          description: Campaign duplicated
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: Invalid campaign_id, or an invalid body or name
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // The copy is a new draft. Leave Name nil to name it "Copy of" and the
              // source's name.
              response, _, err := client.CampaignsAPI.DuplicateCampaign(ctx, accountID, campaignID, requests.DuplicateCampaignRequest{
                Name: ahasend.String("November newsletter"),
              })
              if err != nil {
                log.Fatalf("Error duplicating campaign: %v", err)
              }
              fmt.Printf("Created draft %s %s\n", response.ID, response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const copy = await client.campaigns.duplicate(campaignId, { name: "November newsletter" });
            console.log("Campaign duplicated.", { id: copy.id, status: copy.status });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/send:
    post:
      summary: Send Campaign
      description: |
        Sends a draft campaign now, or at `send_at`. A campaign with any
        issue is refused with the issues. The send then checks what would
        stop the campaign at its send time: its sender domain must still be
        verified and not paused, the account must be able to send, and its
        lists must have subscribed contacts. For a send at a later time, a
        block that a payment or the next billing period can clear is a
        warning instead of a refusal.

        The campaign moves to `validating` and goes through the same list
        validation and review as a send from the dashboard, which move it on
        from there. Read `status` and `status_reason` with Get Campaign. To
        change the send time, unschedule the campaign and send it again.
      operationId: sendCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:send"]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendCampaignRequest'
      responses:
        '202':
          description: The send was accepted. The campaign is in `validating`, as the send left it.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignSend'
        '400':
          description: Invalid campaign_id, an invalid body or `send_at`, a campaign with issues (with `issues`), or a check of the send that failed. The message says what to fix, in the dashboard's words for the checks the dashboard shares.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignNotReady'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:send` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/CampaignConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Leave SendAt nil to send now.
              response, _, err := client.CampaignsAPI.SendCampaign(ctx, accountID, campaignID, requests.SendCampaignRequest{
                SendAt: ahasend.Time(time.Now().Add(24 * time.Hour)),
              })
              if notReady, ok := api.AsCampaignNotReady(err); ok {
                for _, issue := range notReady.Issues {
                  fmt.Printf("%s: %s\n", issue.Code, issue.Message)
                }
                os.Exit(1)
              }
              if err != nil {
                log.Fatalf("Error sending campaign: %v", err)
              }
              fmt.Printf("Campaign %s is %s\n", response.ID, response.Status)
              for _, warning := range response.Warnings {
                fmt.Println("Warning:", warning)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaignId = "00000000-0000-4000-8000-000000000014";
            // Leave send_at out to send now. The campaign is checked before it sends.
            const campaign = await client.campaigns.send(campaignId, { send_at: "2030-01-01T09:00:00Z" });
            console.log("Campaign send accepted.", { status: campaign.status, warnings: campaign.warnings });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/unschedule:
    post:
      summary: Unschedule Campaign
      description: |
        Returns a `validating` or `scheduled` campaign to `draft`: it cancels
        the list validation that runs, or the scheduled send. A campaign that
        has queued mail, or that is in any other status, cannot be
        unscheduled. The request takes no body.
      operationId: unscheduleCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:send"]
      responses:
        '200':
          description: The campaign, back in `draft`
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: Invalid campaign_id, or a body that is not an empty JSON object
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:send` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/CampaignConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // A validating or scheduled campaign goes back to draft. To change the
              // send time, unschedule it and send it again.
              response, _, err := client.CampaignsAPI.UnscheduleCampaign(ctx, accountID, campaignID)
              if err != nil {
                log.Fatalf("Error unscheduling campaign: %v", err)
              }
              fmt.Printf("Campaign %s is %s\n", response.ID, response.Status)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const campaign = await client.campaigns.unschedule(campaignId);
            console.log("Campaign unscheduled.", { id: campaign.id, status: campaign.status });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/test:
    post:
      summary: Test Campaign
      description: |
        Sends a test of a draft campaign to up to 5 addresses. Each address
        gets the design with placeholders for the contact's fields; its
        links lead to a contact that does not exist. A campaign with any
        issue but `unsubscribe_link_missing` is refused with the issues.

        An account can send 50 test emails per day (UTC), counted per
        address, from the API and the dashboard together. An address the
        mail server refused still counts; a test that sent nothing (502)
        does not.

        With an `Idempotency-Key`, a 2xx answer is stored and replayed, so a
        retry with the same key sends nothing again. Unlike other
        operations, a 4xx answer is not stored: it releases the key, so a
        retry with the same key, for example after the daily limit resets,
        is answered afresh.
      operationId: testCampaign
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["campaigns:send"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestCampaignRequest'
      responses:
        '200':
          description: What the test send did. With `result_unknown`, the test may have gone out.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignTest'
        '400':
          description: Invalid campaign_id, an invalid body or address, no address or more than 5, a campaign with issues (with `issues`), a sender domain that is paused or no longer verified, or an account that cannot send
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignNotReady'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:send` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/CampaignConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests, or the account's daily limit of 50 test emails is reached (`daily test send limit reached`). Neither is stored, so a retry may use the same `Idempotency-Key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: The mail server could not take the test, and nothing was sent. Retry, with the same `Idempotency-Key` if you sent one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.CampaignsAPI.TestCampaign(ctx, accountID, campaignID, requests.TestCampaignRequest{
                To: []string{"Ann <ann@yourdomain.com>", "bob@yourdomain.com"},
              })
              if err != nil {
                log.Fatalf("Error sending test: %v", err)
              }
              if response.ResultUnknown {
                fmt.Println("The test may have gone out; check the inboxes before you send another")
                return
              }
              fmt.Printf("Sent to %v\n", response.SentTo)
              for _, failure := range response.Failed {
                fmt.Printf("Refused %s: %s\n", failure.Email, failure.Message)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const result = await client.campaigns.test(campaignId, { to: ["reviewer@example.com"] });
            console.log("Campaign test sent.", {
              sentTo: result.sent_to,
              failed: result.failed.length,
              resultUnknown: result.result_unknown,
            });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/stats:
    get:
      summary: Get Campaign Stats
      description: |
        Returns the numbers of a campaign: its live counters, its stored
        report and the rates from both, the numbers the dashboard's report
        shows. The stored report is recounted from time to time; a request
        that finds it missing or old asks for a recount and answers what is
        stored.
      operationId: getCampaignStats
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["campaigns:read"]
      responses:
        '200':
          description: Campaign stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignStats'
        '400':
          description: Invalid campaign_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.CampaignsAPI.GetCampaignStats(ctx, accountID, campaignID)
              if err != nil {
                log.Fatalf("Error getting campaign stats: %v", err)
              }
              fmt.Printf("Delivered %d of %d\n", response.Delivered, response.Recipients)
              // A rate is nil until the report is counted and its base is not zero.
              if response.Rates.OpenRate != nil {
                fmt.Printf("Open rate: %.2f%%\n", *response.Rates.OpenRate)
              }
              if response.Report != nil {
                for status, count := range response.Report.ByStatus {
                  fmt.Printf("%s: %d\n", status, count)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const campaignId = "00000000-0000-4000-8000-000000000014";
            const stats = await client.campaigns.getStats(campaignId);
            console.log("Campaign stats loaded.", { delivered: stats.delivered, rates: stats.rates });

  /v2/accounts/{account_id}/campaigns/{campaign_id}/recipients:
    get:
      summary: List Campaign Recipients
      description: |
        Returns a cursor-paginated list of a campaign's recipients in one
        status or with one bounce classification, by contact ID, the highest
        first. Send exactly one of `status` and `bounce_classification`.
      operationId: listCampaignRecipients
      tags:
        - Campaigns
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: campaign_id
          in: path
          required: true
          description: Campaign ID
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: A status as a key of the stats' `by_status` names it, such as `Queued`, `Delivered` or `Administratively Bounced`.
          schema:
            type: string
            minLength: 1
            maxLength: 64
        - name: bounce_classification
          in: query
          description: A key of the stats' `by_bounce_classification`, except `Unknown`, which has no recipient page.
          schema:
            type: string
            minLength: 1
            maxLength: 255
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response. An empty page past the last recipient has no `previous_cursor`; read the first page with no cursor.
          schema:
            type: string
      security:
        - BearerAuth: ["campaigns:read"]
      responses:
        '200':
          description: Paginated campaign recipients
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedCampaignRecipientsResponse'
        '400':
          description: Invalid campaign_id, filter, limit or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Campaign not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              campaignID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Set exactly one of Status and BounceClassification, each a key of the
              // campaign's stats report.
              response, _, err := client.CampaignsAPI.ListCampaignRecipients(ctx, accountID, campaignID, requests.ListCampaignRecipientsParams{
                Status: ahasend.String("Bounced"),
                Limit:  ahasend.Int32(100),
              })
              if err != nil {
                log.Fatalf("Error listing campaign recipients: %v", err)
              }
              for _, recipient := range response.Data {
                reason := ""
                if recipient.BounceReason != nil {
                  reason = *recipient.BounceReason
                }
                fmt.Printf("%s (%s) %s\n", recipient.Email, recipient.ListName, reason)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const campaignId = "00000000-0000-4000-8000-000000000014";
            // Send exactly one of status and bounce_classification.
            const page = await client.campaigns.recipients.list(campaignId, { status: "Delivered", limit: 20 });
            console.log("Campaign recipients listed.", { count: page.data.length });

  # Account endpoints
  /v2/accounts/{account_id}:
    get:
      summary: Get Account
      description: Returns account information
      operationId: getAccount
      tags:
        - Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["accounts:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.AccountsAPI.GetAccount(ctx, accountID)
              if err != nil {
                log.Fatalf("Error getting account: %v", err)
              }
              fmt.Printf("%s %s\n", response.ID, response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const account = await client.accounts.get();
            console.log("Account found.", { id: account.id, name: account.name });

      responses:
        '200':
          description: Account details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Account
      description: |
        Updates account settings

        **Validation Requirements:**
        - `name` must be maximum 255 characters
        - `website` must be a domain such as `acme.com`, or an http(s) URL such as `https://acme.com`; it is stored as the host, so `https://acme.com/team` is read back as `acme.com`. Maximum 255 characters
        - `message_metadata_retention` must be between 1 and 30 days
        - `message_data_retention` must be between 0 and 30 days
      operationId: updateAccount
      tags:
        - Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["accounts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Update the account name and open/click tracking settings
              response, _, err := client.AccountsAPI.UpdateAccount(ctx, accountID, requests.UpdateAccountRequest{
                Name:        ahasend.String("Acme Corp"),
                TrackOpens:  ahasend.Bool(true),
                TrackClicks: ahasend.Bool(true),
              })
              if err != nil {
                log.Fatalf("Error updating account: %v", err)
              }
              fmt.Printf("Updated account %s\n", response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const account = await client.accounts.update({ name: "Example, Inc." });
            console.log("Account updated.", { id: account.id, name: account.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAccountRequest'
      responses:
        '200':
          description: Account updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/members:
    get:
      summary: Get Account Members
      description: Returns a list of account members
      operationId: getAccountMembers
      tags:
        - Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["accounts:members:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.AccountsAPI.GetAccountMembers(ctx, accountID)
              if err != nil {
                log.Fatalf("Error listing account members: %v", err)
              }
              for _, member := range response.Data {
                fmt.Printf("%s %s\n", member.UserID, member.Role)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const members = await client.accounts.listMembers();
            console.log("Account members listed.", { count: members.data.length });

      responses:
        '200':
          description: List of account members
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMembersResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Add Account Member
      description: |
        Adds a new member to the account

        **Validation Requirements:**
        - `email` must be a valid email address
        - `role` must be one of: Administrator, Developer, Analyst, Billing Manager
      operationId: addAccountMember
      tags:
        - Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["accounts:members:add"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.AccountsAPI.AddAccountMember(ctx, accountID, requests.AddMemberRequest{
                Email: "teammate@example.com",
                Name:  ahasend.String("Jordan Lee"),
                Role:  "Developer",
              })
              if err != nil {
                log.Fatalf("Error adding account member: %v", err)
              }
              fmt.Printf("Added user %s as %s\n", response.UserID, response.Role)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const member = await client.accounts.addMember({
              email: "developer@example.com",
              role: "Developer",
            });
            console.log("Account member added.", { userId: member.user_id, role: member.role });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddMemberRequest'
      responses:
        '201':
          description: Member added successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Either the user is already a member of the account or the idempotency-key
            execution is in progress. The in-progress case is identified only
            by `Idempotent-Replayed: false` plus a positive integer
            `Retry-After`. A new duplicate-member response has neither
            header; a stored replay of that response has
            `Idempotent-Replayed: true`. The message is not a stable
            discriminator.
          headers:
            Idempotent-Replayed:
              description: |
                `false` with a positive `Retry-After` means an execution is in
                progress. `true` means a stored duplicate-member 409 was
                replayed. Absent means a fresh duplicate-member conflict.
              schema:
                type: string
                enum: ['true', 'false']
            Retry-After:
              $ref: '#/components/headers/IdempotencyRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/members/{user_id}:
    delete:
      summary: Remove Account Member
      description: |
        Removes a member from the account

        **Restrictions:**
        - You cannot delete yourself
        - You cannot delete the account owner
      operationId: removeAccountMember
      tags:
        - Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: user_id
          in: path
          required: true
          description: User ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["accounts:members:remove"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              userID := uuid.MustParse("5b9a1c1e-8f7d-4a55-9d0e-2c1f3b7a6e42")

              response, _, err := client.AccountsAPI.RemoveAccountMember(ctx, accountID, userID)
              if err != nil {
                log.Fatalf("Error removing account member: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const userId = "00000000-0000-4000-8000-000000000003";
            const result = await client.accounts.removeMember(userId);
            console.log("Account member removed.", { message: result.message });

      responses:
        '200':
          description: Member removed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Sub-account endpoints
  /v2/accounts/{account_id}/sub-accounts:
    get:
      summary: List Sub Accounts
      description: Returns a cursor-paginated list of sub accounts under the parent account. Soft-deleted sub accounts are not included; usage billed for sub accounts deleted during the current period is reported by the usage endpoint's `removed_sub_accounts` aggregate.
      operationId: listSubAccounts
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response.
          schema:
            type: string
      security:
        - BearerAuth: ["sub-accounts:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.SubAccountsAPI.ListSubAccounts(ctx, accountID, &common.PaginationParams{
                Limit: ahasend.Int32(50),
              })
              if err != nil {
                log.Fatalf("Error listing sub accounts: %v", err)
              }
              for _, sub := range response.Data {
                fmt.Printf("%s %s %s\n", sub.ID, sub.Name, sub.Status)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.subAccounts.list({ limit: 20 });
            console.log("Sub-accounts listed.", { count: page.data.length });

      responses:
        '200':
          description: List of sub accounts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedSubAccountsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Parent account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Sub Account
      description: Creates a new sub account under the parent account
      operationId: createSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["sub-accounts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.SubAccountsAPI.CreateSubAccount(ctx, accountID, requests.CreateSubAccountRequest{
                Name:          "Acme Subsidiary",
                Website:       "acme.example.com",
                MonthlyCredit: ahasend.Int64(0),
              })
              if err != nil {
                log.Fatalf("Error creating sub account: %v", err)
              }
              fmt.Printf("Created sub account %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const subAccount = await client.subAccounts.create({
              name: "Example subsidiary",
              website: "subsidiary.example.com",
            });
            console.log("Sub-account created.", { id: subAccount.id, status: subAccount.status });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSubAccountRequest'
      responses:
        '201':
          description: Sub account created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Parent account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/usage:
    get:
      summary: Get Sub-Account Usage
      description: Returns current billing-period usage and proportional allocated cost for the parent and active sub accounts
      operationId: getSubAccountsUsage
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:usage"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.SubAccountsAPI.GetSubAccountsUsage(ctx, accountID)
              if err != nil {
                log.Fatalf("Error getting sub-accounts usage: %v", err)
              }
              fmt.Printf("Allocation method: %s\n", response.AllocationMethod)
              fmt.Printf("Total: %d messages, %.2f %s\n", response.Total.ReceptionCount, response.Total.AllocatedCost, response.Currency)
              for _, sub := range response.SubAccounts {
                name := "(unnamed)"
                if sub.Name != nil {
                  name = *sub.Name
                } else if sub.AccountID != nil {
                  name = sub.AccountID.String()
                }
                fmt.Printf("  %s: %d messages, %.2f %s\n", name, sub.ReceptionCount, sub.AllocatedCost, response.Currency)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const usage = await client.subAccounts.usage();
            console.log("Sub-account usage loaded.", {
              currency: usage.currency,
              subAccountCount: usage.sub_accounts.length,
            });

      responses:
        '200':
          description: Sub-account usage breakdown
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccountUsageResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Parent account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}:
    get:
      summary: Get Sub Account
      description: Returns a specific sub account under the parent account
      operationId: getSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.GetSubAccount(ctx, accountID, subAccountID)
              if err != nil {
                log.Fatalf("Error getting sub account: %v", err)
              }
              fmt.Printf("%s %s (%s)\n", response.Name, response.Website, response.Status)
              fmt.Printf("Domains: %d, members: %d\n", response.DomainCount, response.MemberCount)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const subAccount = await client.subAccounts.get(subAccountId);
            console.log("Sub-account found.", { id: subAccount.id, status: subAccount.status });

      responses:
        '200':
          description: Sub account details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Sub Account
      description: Updates a sub account's editable settings
      operationId: updateSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.UpdateSubAccount(ctx, accountID, subAccountID, requests.UpdateSubAccountRequest{
                Name:          ahasend.String("Acme Subsidiary"),
                MonthlyCredit: ahasend.Int64(50000),
              })
              if err != nil {
                log.Fatalf("Error updating sub account: %v", err)
              }
              fmt.Printf("%s monthly credit: %d\n", response.Name, response.MonthlyCredit)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const subAccount = await client.subAccounts.update(subAccountId, {
              name: "Renamed subsidiary",
            });
            console.log("Sub-account updated.", { id: subAccount.id, status: subAccount.status });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSubAccountRequest'
      responses:
        '200':
          description: Sub account updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete Sub Account
      description: Soft-deletes a sub account under the parent account
      operationId: deleteSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.DeleteSubAccount(ctx, accountID, subAccountID)
              if err != nil {
                log.Fatalf("Error deleting sub account: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const result = await client.subAccounts.delete(subAccountId);
            console.log("Sub-account deleted.", { message: result.message });

      responses:
        '200':
          description: Sub account deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                message: "sub account deleted"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/suspend:
    post:
      summary: Pause Sub Account
      description: Pauses sending on a sub account under the parent account. The status of the sub account becomes `suspended`.
      operationId: suspendSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:suspend"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.SuspendSubAccount(ctx, accountID, subAccountID, requests.SuspendSubAccountRequest{
                Reason: "Customer requested temporary pause",
              })
              if err != nil {
                log.Fatalf("Error suspending sub account: %v", err)
              }
              fmt.Printf("%s is %s\n", response.ID, response.Status)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const subAccount = await client.subAccounts.suspend(subAccountId, {
              reason: "Requested by account administrator",
            });
            console.log("Sub-account suspended.", { id: subAccount.id, status: subAccount.status });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuspendSubAccountRequest'
      responses:
        '200':
          description: Sub account paused successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/unsuspend:
    post:
      summary: Resume Sub Account
      description: Resumes sending on a paused sub account under the parent account
      operationId: unsuspendSubAccount
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-accounts:suspend"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.UnsuspendSubAccount(ctx, accountID, subAccountID)
              if err != nil {
                log.Fatalf("Error unsuspending sub account: %v", err)
              }
              fmt.Printf("%s is %s\n", response.ID, response.Status)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const subAccount = await client.subAccounts.unsuspend(subAccountId);
            console.log("Sub-account unsuspended.", { id: subAccount.id, status: subAccount.status });

      responses:
        '200':
          description: Sub account resumed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubAccount'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Forbidden, or AhaSend paused the sub account. Only AhaSend support
            can resume that pause.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/domains/{domain}/unpause:
    post:
      summary: Unpause Sub Account Domain
      description: |
        Lifts the pause on a domain of a sub account under the parent account, so that the domain can send again.
        The call is idempotent: if sending from the domain is not paused, it changes nothing and returns the domain.
        A change of the pause can take some minutes to apply to new email.
      operationId: unpauseSubAccountDomain
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: path
          required: true
          description: Domain name
          schema:
            type: string
            format: hostname
      security:
        - BearerAuth: ["sub-accounts:suspend"]
      responses:
        '200':
          description: The domain, with sending not paused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account, or domain not found in the sub account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              response, _, err := client.SubAccountsAPI.UnpauseSubAccountDomain(ctx, accountID, subAccountID, "example.com")
              if err != nil {
                log.Fatalf("Error unpausing sub account domain: %v", err)
              }
              fmt.Printf("%s paused: %t\n", response.Domain, response.Paused)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const domain = await client.subAccounts.unpauseDomain(subAccountId, "mail.example.com");
            console.log("Sub-account domain unpaused.", { name: domain.domain, paused: domain.paused });

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/api-keys:
    get:
      summary: List Sub-Account API Keys
      description: Returns a cursor-paginated list of API keys owned by a sub account. The `secret_key` field is omitted from list responses.
      operationId: listSubAccountAPIKeys
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value from `next_cursor` in the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value from `previous_cursor` in the response.
          schema:
            type: string
      security:
        - BearerAuth: ["sub-account-api-keys:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/common"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              // List responses never carry secret keys; only create returns one.
              response, _, err := client.SubAccountsAPI.ListSubAccountAPIKeys(ctx, accountID, subAccountID, &common.PaginationParams{
                Limit: ahasend.Int32(50),
              })
              if err != nil {
                log.Fatalf("Error listing sub-account API keys: %v", err)
              }
              for _, key := range response.Data {
                fmt.Printf("%s %s\n", key.ID, key.Label)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const page = await client.subAccounts.apiKeys.list(subAccountId, { limit: 20 });
            console.log("Sub-account API keys listed.", { count: page.data.length });

      responses:
        '200':
          description: List of sub-account API keys
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedAPIKeysResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Sub-Account API Key
      description: |
        Creates an API key owned by the sub account. Parent credentials must belong to the parent account and include `sub-account-api-keys:write`; sub-account credentials cannot manage nested API keys. The returned `secret_key` authenticates as the child account and should be stored immediately.

        Bootstrap example:
        1. Create the sub account with `POST /v2/accounts/{account_id}/sub-accounts` and a unique `Idempotency-Key`.
        2. Create the bootstrap child API key with this endpoint and a different unique `Idempotency-Key`.
        3. Store the one-time `secret_key` from the `201` response. Exact idempotent replays within 5 minutes return the same `secret_key`.
        4. Use that child secret with the child `sub_account_id` as the `account_id` on ordinary v2 routes.
      operationId: createSubAccountAPIKey
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["sub-account-api-keys:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")

              // Bootstrap an API key for the sub account.
              //
              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.SubAccountsAPI.CreateSubAccountAPIKey(ctx, accountID, subAccountID, requests.CreateAPIKeyRequest{
                Label:  "Bootstrap key",
                Scopes: []string{"messages:send:all", "domains:read"},
                // Optional: restrict the key to specific source IPs.
                IPAllowList: []string{"203.0.113.0/24"},
              })
              if err != nil {
                log.Fatalf("Error creating sub-account API key: %v", err)
              }
              // response.SecretKey is returned only on create (and on an idempotent
              // replay of it): put it in your secret store now, and never log it.
              fmt.Printf("Created API key %s (%s)\n", response.ID, response.Label)
            }

        - lang: shell
          label: Bootstrap child API key
          source: |
            PARENT_ACCOUNT_ID="9d0cf9d0-4f5e-4674-bcf1-8ec39968b6e1"
            PARENT_SECRET="aha-sk-parent-64-character-key"

            curl -sS -X POST "https://api.ahasend.com/v2/accounts/${PARENT_ACCOUNT_ID}/sub-accounts" \
              -H "Authorization: Bearer ${PARENT_SECRET}" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: subacct-20240101-acme" \
              -d '{"name":"Acme Subsidiary","website":"acme.example.com"}' \
              > sub-account.json

            CHILD_ACCOUNT_ID="$(jq -r '.id' sub-account.json)"

            curl -sS -X POST "https://api.ahasend.com/v2/accounts/${PARENT_ACCOUNT_ID}/sub-accounts/${CHILD_ACCOUNT_ID}/api-keys" \
              -H "Authorization: Bearer ${PARENT_SECRET}" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: child-bootstrap-key-20240101-acme" \
              -d '{"label":"Bootstrap key","scopes":["messages:send:all","domains:read"]}' \
              > child-api-key.json

            CHILD_SECRET="$(jq -r '.secret_key' child-api-key.json)"
            printf 'Store this one-time child secret securely: %s\n' "${CHILD_SECRET}"

            curl -sS "https://api.ahasend.com/v2/accounts/${CHILD_ACCOUNT_ID}/domains" \
              -H "Authorization: Bearer ${CHILD_SECRET}"
        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const apiKey = await client.subAccounts.apiKeys.create(subAccountId, {
              label: "Bootstrap key",
              scopes: ["messages:send:all"],
            });
            console.log("Sub-account API key created.", { id: apiKey.id, label: apiKey.label });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPIKeyRequest'
            example:
              label: "Bootstrap key"
              scopes: ["messages:send:all", "domains:read"]
      responses:
        '201':
          description: Sub-account API key created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/APIKey'
                  - type: object
                    required:
                      - secret_key
                    properties:
                      secret_key:
                        type: string
                        readOnly: true
                        description: |
                          One-time secret key. Store it immediately. Exact
                          successful idempotent replays return the same secret
                          during the 5-minute encrypted replay window.
              example:
                object: api_key
                id: "13b3aa8e-78d3-48a1-92d2-4b8b1228c2dd"
                created_at: "2024-01-01T00:05:00Z"
                updated_at: "2024-01-01T00:05:00Z"
                last_used_at: null
                account_id: "2f3c5d2a-9ef8-4c91-a5f4-79990c8c1d3a"
                label: "Bootstrap key"
                public_key: "aha-pk-child-public-key"
                secret_key: "aha-sk-child-secret-key"
                scopes:
                  - id: "c574470d-76ef-4f74-9b24-70a583a17e03"
                    created_at: "2024-01-01T00:05:00Z"
                    updated_at: "2024-01-01T00:05:00Z"
                    api_key_id: "13b3aa8e-78d3-48a1-92d2-4b8b1228c2dd"
                    scope: "messages:send:all"
                    domain_id: null
                ip_allow_list: []
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Sub account not found under the parent account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/api-keys/{key_id}:
    get:
      summary: Get Sub-Account API Key
      description: Returns a specific API key owned by a sub account. The `secret_key` field is omitted from read responses.
      operationId: getSubAccountAPIKey
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-account-api-keys:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")
              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.SubAccountsAPI.GetSubAccountAPIKey(ctx, accountID, subAccountID, keyID)
              if err != nil {
                log.Fatalf("Error getting sub-account API key: %v", err)
              }
              fmt.Printf("%s %s (%d scopes)\n", response.ID, response.Label, len(response.Scopes))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const keyId = "00000000-0000-4000-8000-000000000005";
            const apiKey = await client.subAccounts.apiKeys.get(subAccountId, keyId);
            console.log("Sub-account API key found.", { id: apiKey.id, label: apiKey.label });

      responses:
        '200':
          description: Sub-account API key details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKey'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key or sub account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Sub-Account API Key
      description: Updates the label and scopes for an API key owned by a sub account. Updated scopes are validated against the child account.
      operationId: updateSubAccountAPIKey
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-account-api-keys:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")
              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.SubAccountsAPI.UpdateSubAccountAPIKey(ctx, accountID, subAccountID, keyID, requests.UpdateAPIKeyRequest{
                Label:  ahasend.String("Updated bootstrap key"),
                Scopes: &[]string{"messages:send:all", "domains:read"},
                // Replaces the allowed source IPs. &[]string{} clears the list (any IP);
                // leaving the field nil keeps the current list.
                IPAllowList: &[]string{"203.0.113.0/24"},
              })
              if err != nil {
                log.Fatalf("Error updating sub-account API key: %v", err)
              }
              fmt.Printf("Updated API key %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const keyId = "00000000-0000-4000-8000-000000000005";
            const apiKey = await client.subAccounts.apiKeys.update(subAccountId, keyId, {
              label: "Renamed bootstrap key",
            });
            console.log("Sub-account API key updated.", { id: apiKey.id, label: apiKey.label });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAPIKeyRequest'
            example:
              label: "Updated bootstrap key"
              scopes: ["messages:send:all", "domains:read"]
      responses:
        '200':
          description: Sub-account API key updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKey'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key or sub account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete Sub-Account API Key
      description: Deletes an API key owned by a sub account.
      operationId: deleteSubAccountAPIKey
      tags:
        - Sub Accounts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Parent account ID
          schema:
            type: string
            format: uuid
        - name: sub_account_id
          in: path
          required: true
          description: Sub account ID
          schema:
            type: string
            format: uuid
        - name: key_id
          in: path
          required: true
          description: API key ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["sub-account-api-keys:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              subAccountID := uuid.MustParse("7d3c9f5e-2a41-4b8e-9c6d-0f1e2a3b4c5d")
              keyID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.SubAccountsAPI.DeleteSubAccountAPIKey(ctx, accountID, subAccountID, keyID)
              if err != nil {
                log.Fatalf("Error deleting sub-account API key: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const subAccountId = "00000000-0000-4000-8000-000000000004";
            const keyId = "00000000-0000-4000-8000-000000000005";
            const result = await client.subAccounts.apiKeys.delete(subAccountId, keyId);
            console.log("Sub-account API key deleted.", { message: result.message });

      responses:
        '200':
          description: Sub-account API key deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                message: "api key {id} ({label}) deleted successfully"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: API key or sub account not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Contact endpoints
  /v2/accounts/{account_id}/contacts:
    post:
      summary: Create Contact
      description: |
        Creates one account-global contact with no list membership. Email is
        trimmed and lowercased. Writable attributes must match the account's
        dashboard-managed definitions; validation, membership, and activity
        fields are response-only. The contact cannot be campaign-targeted until
        it is added to a list with the Lists endpoints. This operation does not
        read or change suppression records.

        The optional `Idempotency-Key` follows the ordinary 24-hour v2 replay contract.
        Successful 201, controller-produced deterministic 400, and duplicate
        contact 409 outcomes are stored for 24 hours. An identical request
        replays the same status and semantically equivalent JSON body with
        `Idempotent-Replayed: true`. Authentication, authorization, rate-limit,
        payload-mismatch, and server-error responses are not stored. Reusing
        the key with a different method, resolved path, or semantic JSON payload
        returns 422.
      operationId: createContact
      tags:
        - Contacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["contacts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.ContactsAPI.CreateContact(ctx, accountID, requests.CreateContactRequest{
                Email:      "person@example.com",
                FirstName:  ahasend.String("Pat"),
                Attributes: map[string]any{"customer": true},
              })
              if err != nil {
                log.Fatalf("Error creating contact: %v", err)
              }
              fmt.Printf("Created contact %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const contact = await client.contacts.create({
              email: "person@example.com",
              first_name: "Pat",
              attributes: { customer: true },
            });
            console.log("Contact created.", { id: contact.id, status: contact.status });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContactRequest'
      responses:
        '201':
          description: Contact created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Invalid request body, email, or attribute value
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Either the normalized email already exists on the account or the
            idempotency-key execution is in progress. The in-progress case is
            identified only by `Idempotent-Replayed: false` plus a positive
            integer `Retry-After`. A fresh duplicate-contact response has
            neither header; a stored replay of that response has
            `Idempotent-Replayed: true`.
          headers:
            Idempotent-Replayed:
              description: |
                `false` with a positive `Retry-After` means an execution is in
                progress. `true` means a stored duplicate-contact 409 was
                replayed. Absent means a fresh duplicate-contact conflict.
              schema:
                type: string
                enum: ['true', 'false']
            Retry-After:
              $ref: '#/components/headers/IdempotencyRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    get:
      summary: List Contacts
      description: |
        Returns account-global contacts in newest-first `(created_at, id)` order.
        `after` and `before` are mutually exclusive. Attribute values are
        recursive legacy JSON; they are intentionally broader than canonical
        values accepted by contact writes.
      operationId: getContacts
      tags:
        - Contacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of contacts to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Cursor for the next page; cannot be combined with `before`
          schema:
            type: string
        - name: before
          in: query
          description: Cursor for the previous page; cannot be combined with `after`
          schema:
            type: string
        - name: email
          in: query
          description: Normalized exact email filter
          schema:
            type: string
            format: email
        - name: status
          in: query
          description: Contact status filter
          schema:
            type: string
            enum: [enabled, disabled, blocked]
        - name: subscribed
          in: query
          description: Filter by global subscription state
          schema:
            type: boolean
        - name: from_time
          in: query
          description: Include contacts created at or after this RFC3339 time
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: Include contacts created at or before this RFC3339 time
          schema:
            type: string
            format: date-time
      security:
        - BearerAuth: ["contacts:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.ContactsAPI.GetContacts(ctx, accountID, requests.GetContactsParams{
                Limit:      ahasend.Int32(100),
                Status:     ahasend.String("enabled"),
                Subscribed: ahasend.Bool(true),
              })
              if err != nil {
                log.Fatalf("Error listing contacts: %v", err)
              }
              for _, contact := range response.Data {
                fmt.Printf("%s %s\n", contact.ID, contact.Email)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.contacts.list({ status: "enabled", subscribed: true, limit: 20 });
            console.log("Contacts listed.", { count: page.data.length });

      responses:
        '200':
          description: Paginated contacts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedContactsResponse'
        '400':
          description: Invalid filter or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/contacts/batch:
    post:
      summary: Batch upsert contacts
      description: |
        Synchronously creates or updates one through 1,000 account-global
        contacts. Duplicate normalized emails all fail without application;
        deterministic record failures do not block valid records. Successful
        effects and the optional idempotency replay response commit atomically
        and use the ordinary 24-hour replay window. Malformed, empty, and
        over-cap request 400s are also deterministic and replayable. Reusing a
        key with a different method, resolved path, or semantic JSON payload
        returns 422. An identical keyed request replays the same status and a
        semantically equivalent JSON response.

        Measured pacing guidance: batch size is a cap, not a throughput target.
        Measure end-to-end latency with representative data, submit the next
        synchronous batch only after the previous response, and reduce batch
        size or pace requests when latency grows. Honor 429 responses before
        continuing; the API makes no fixed throughput guarantee.
      operationId: batchUpsertContacts
      tags: [Contacts]
      parameters:
        - name: account_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["contacts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.ContactsAPI.BatchUpsertContacts(ctx, accountID, requests.BatchUpsertContactsRequest{
                Data: []requests.BatchUpsertContactInput{
                  {Email: "one@example.com"},
                  {Email: "two@example.com", Unsubscribed: ahasend.Bool(true)},
                },
              })
              if err != nil {
                log.Fatalf("Error upserting contacts: %v", err)
              }
              // Each item succeeds or fails on its own.
              fmt.Printf("created=%d updated=%d failed=%d\n", response.Created, response.Updated, response.Failed)
              for _, result := range response.Data {
                if result.Reason != "" {
                  fmt.Printf("%s: %s (%s)\n", result.Email, result.Outcome, result.Reason)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.contacts.batchUpsert({
              data: [
                { email: "new@example.com", attributes: { customer: true } },
                { email: "existing@example.com", attributes: { obsolete: null } },
              ],
            });
            console.log("Contact batch completed.", { created: result.created, updated: result.updated });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchUpsertContactsRequest'
      responses:
        '200':
          description: Ordered per-contact outcomes
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchUpsertContactsResponse'
        '400':
          description: |
            Malformed, empty, or over-cap request. An identical keyed retry
            replays the stored response with `Idempotent-Replayed: true`.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: An idempotency-key execution is already in progress
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotencyInProgress'
            Retry-After:
              $ref: '#/components/headers/IdempotencyRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/contacts/{id_or_email}:
    get:
      summary: Get Contact
      description: |
        Returns one account-scoped contact. UUID-shaped values resolve by ID;
        all other values resolve by trimmed, lowercased email. Email identifiers
        must be percent-encoded exactly once as URL path segments; pass a raw
        email to SDKs, including addresses such as `user+tag@example.com`.
      operationId: getContact
      tags:
        - Contacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
      security:
        - BearerAuth: ["contacts:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Address a contact by its ID or its email address; the SDK escapes the path.
              response, _, err := client.ContactsAPI.GetContact(ctx, accountID, "user+tag@example.com")
              if err != nil {
                log.Fatalf("Error getting contact: %v", err)
              }
              fmt.Printf("%s %s unsubscribed=%t\n", response.ID, response.Email, response.Unsubscribed)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const idOrEmail = "User+Tag@Example.COM";
            const contact = await client.contacts.get(idOrEmail);
            console.log("Contact found.", { id: contact.id, status: contact.status });

      responses:
        '200':
          description: Contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Contact not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      summary: Update Contact
      description: |
        Partially updates one account-scoped contact. UUID-shaped path values
        resolve by ID; all other values resolve by trimmed, lowercased email.
        Email identifiers must be percent-encoded exactly once as URL path
        segments. A null attribute member deletes that key; the other writable
        values remain canonical and dashboard-definition driven. Subscription
        changes are independent of suppression records.
      operationId: updateContact
      tags:
        - Contacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContactRequest'
      security:
        - BearerAuth: ["contacts:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Only the fields you set change. A nil attribute value deletes that key.
              response, _, err := client.ContactsAPI.UpdateContact(ctx, accountID, "user+tag@example.com", requests.UpdateContactRequest{
                FirstName:  ahasend.String("Jordan"),
                Attributes: map[string]any{"obsolete_key": nil},
              })
              if err != nil {
                log.Fatalf("Error updating contact: %v", err)
              }
              fmt.Printf("Updated contact %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const idOrEmail = "User+Tag@Example.COM";
            const contact = await client.contacts.update(idOrEmail, {
              unsubscribed: true,
              attributes: { obsolete: null },
            });
            console.log("Contact updated.", { id: contact.id, unsubscribed: contact.unsubscribed });

      responses:
        '200':
          description: Updated contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Invalid request body, replacement email, or attribute
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Contact not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Replacement email conflicts with another contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      summary: Delete Contact
      description: |
        Permanently deletes one account-scoped contact. UUID-shaped path values
        resolve by ID; all other values resolve by trimmed, lowercased email;
        email identifiers must be percent-encoded exactly once as URL path
        segments.

        This is destructive history removal. Running and paused workflow
        instances and their active jobs are cancelled, while the cancelled
        workflow rows and events remain for audit. The contact and its
        unsubscribe record, list memberships, campaign-recipient records, opens,
        and clicks are permanently removed by cascades. Complaint, permanent,
        and protected suppressions remain byte-for-byte unchanged. Prefer unsubscribe over
        delete when the intent is only to prevent marketing delivery.
      operationId: deleteContact
      tags:
        - Contacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
      security:
        - BearerAuth: ["contacts:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.ContactsAPI.DeleteContact(ctx, accountID, "user+tag@example.com")
              if err != nil {
                log.Fatalf("Error deleting contact: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const idOrEmail = "User+Tag@Example.COM";
            const result = await client.contacts.delete(idOrEmail);
            console.log("Contact deleted.", { message: result.message });

      responses:
        '200':
          description: Contact deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `contacts:delete` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Contact not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Lists endpoints
  /v2/accounts/{account_id}/lists:
    get:
      summary: List Lists
      description: |
        Returns the account's lists in newest-first `(created_at, id)` order.
        `after` and `before` are mutually exclusive. Every list carries
        `contact_count`, the number of members a campaign to it would reach.
      operationId: getLists
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of lists to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Cursor for the next page; cannot be combined with `before`
          schema:
            type: string
        - name: before
          in: query
          description: Cursor for the previous page; cannot be combined with `after`
          schema:
            type: string
        - name: name
          in: query
          description: |
            Case-insensitive substring match on the list name. `%`, `_`, and `\`
            match literally rather than as wildcards. The value is trimmed
            before it is matched, so one that is empty or all whitespace filters
            nothing and the full collection is returned.
          schema:
            type: string
      security:
        - BearerAuth: ["lists:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.ListsAPI.GetLists(ctx, accountID, requests.GetListsParams{
                Limit: ahasend.Int32(100),
                Name:  ahasend.String("newsletter"),
              })
              if err != nil {
                log.Fatalf("Error listing lists: %v", err)
              }
              for _, list := range response.Data {
                fmt.Printf("%s %s (%d contacts)\n", list.ID, list.Name, list.ContactCount)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.lists.list({ name: "newsletter", limit: 20 });
            console.log("Lists listed.", { count: page.data.length });

      responses:
        '200':
          description: Paginated lists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedContactListsResponse'
        '400':
          description: Invalid filter or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      summary: Create List
      description: |
        Creates one list. Lists are static, private, and single opt-in, and
        names are not unique within an account. A new list has no members, so
        its `contact_count` is 0.

        The optional `Idempotency-Key` follows the ordinary 24-hour v2 replay
        contract. An identical request replays the same status and semantically
        equivalent JSON body with `Idempotent-Replayed: true`. Reusing the key
        with a different method, resolved path, or semantic JSON payload
        returns 422.
      operationId: createList
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["lists:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.ListsAPI.CreateList(ctx, accountID, requests.CreateListRequest{
                Name:        "Product updates",
                Description: ahasend.String("Monthly release notes"),
                Tags:        []string{"product"},
              })
              if err != nil {
                log.Fatalf("Error creating list: %v", err)
              }
              fmt.Printf("Created list %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const list = await client.lists.create({
              name: "Product updates",
              description: "Monthly release notes",
              tags: ["product"],
            });
            console.log("List created.", { id: list.id, contactCount: list.contact_count });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContactListRequest'
      responses:
        '201':
          description: List created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '400':
          description: |
            Invalid request body, or a property that is not writable. An
            identical keyed retry replays the stored response with
            `Idempotent-Replayed: true`.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/lists/{list_id}:
    get:
      summary: Get List
      description: |
        Returns one account-scoped list, including `contact_count`.
      operationId: getList
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["lists:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.ListsAPI.GetList(ctx, accountID, listID)
              if err != nil {
                log.Fatalf("Error getting list: %v", err)
              }
              fmt.Printf("%s (%d contacts)\n", response.Name, response.ContactCount)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const list = await client.lists.get(listId);
            console.log("List found.", { id: list.id, contactCount: list.contact_count });

      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '400':
          description: Malformed `list_id`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: List not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      summary: Update List
      description: |
        Partially updates one account-scoped list. An omitted or null property
        leaves the stored value unchanged; a present `tags` array replaces the
        stored tags, and an empty one clears them. A stored value that breaks a
        bound this API introduces reads back fine and fails only when it is
        re-sent here.
      operationId: updateList
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContactListRequest'
      security:
        - BearerAuth: ["lists:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Only the fields you set change; an empty tags slice clears the tags.
              response, _, err := client.ListsAPI.UpdateList(ctx, accountID, listID, requests.UpdateListRequest{
                Name: ahasend.String("Product updates"),
                Tags: &[]string{},
              })
              if err != nil {
                log.Fatalf("Error updating list: %v", err)
              }
              fmt.Printf("Updated list %s\n", response.Name)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const list = await client.lists.update(listId, { name: "Product news", tags: [] });
            console.log("List updated.", { id: list.id, name: list.name });

      responses:
        '200':
          description: Updated list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '400':
          description: Malformed `list_id`, invalid request body, or a property that is not writable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: List not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      summary: Delete List
      description: |
        Soft-deletes one account-scoped list. The list stops being readable and
        stops contributing recipients to any campaign, but every membership row
        is kept, so deleting a list never discards the record that someone
        unsubscribed from it or reported it as spam. Those memberships stop
        being readable with the list: this list answers 404 afterwards, and it
        no longer appears among a contact's lists.

        Deleting a list that is already gone returns 404, so a client retrying
        to convergence converges.
      operationId: deleteList
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["lists:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.ListsAPI.DeleteList(ctx, accountID, listID)
              if err != nil {
                log.Fatalf("Error deleting list: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const result = await client.lists.delete(listId);
            console.log("List deleted.", { message: result.message });

      responses:
        '200':
          description: List deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Malformed `list_id`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:delete` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: List not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            A campaign that still needs this list references it. Campaigns
            in `draft`, `validating`, `scheduled`, `sending`,
            `paused`, or `waiting_for_review` block the delete, while `sent`,
            `failed`, and `canceled` campaigns do not. This conflict carries no
            idempotency headers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/lists/{list_id}/contacts:
    get:
      summary: List Contacts on a List
      description: |
        Returns one list's members, newest first on the membership's own
        `(created_at, id)`. Each item carries the membership, its `contact_id`,
        and the contact's normalized email; `include_contacts=true` embeds the
        whole contact and additionally requires the `contacts:read` scope.
        `after` and `before` are mutually exclusive.

        `subscription_status=confirmed` answers who a campaign on this list can
        reach, subject to the account-wide checks campaign initialization also
        applies. The filter accepts `complained` as well, because such a
        membership exists and must be findable even though no caller can write
        that status.
      operationId: getListContacts
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of memberships to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Cursor for the next page; cannot be combined with `before`
          schema:
            type: string
        - name: before
          in: query
          description: Cursor for the previous page; cannot be combined with `after`
          schema:
            type: string
        - name: subscription_status
          in: query
          description: Per-list consent filter
          schema:
            type: string
            enum: [unconfirmed, confirmed, unsubscribed, complained]
        - name: email
          in: query
          description: |
            Exact email filter. The value is normalized before it is
            compared, so one that is empty or all whitespace filters nothing
            and the full collection is returned. It is compared against the
            address as stored, so an address that was stored unnormalized
            renders normalized in this collection and is still not matched by
            this filter.
          schema:
            type: string
            format: email
        - name: include_contacts
          in: query
          description: |
            Embed the whole contact in every item. Requires the `contacts:read`
            scope in addition to `lists:read`.
          schema:
            type: boolean
      security:
        - BearerAuth: ["lists:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              response, _, err := client.ListsAPI.GetListContacts(ctx, accountID, listID, requests.GetListContactsParams{
                SubscriptionStatus: ahasend.String(requests.ListContactStatusConfirmed),
              })
              if err != nil {
                log.Fatalf("Error listing list contacts: %v", err)
              }
              for _, member := range response.Data {
                fmt.Printf("%s %s\n", member.Email, member.SubscriptionStatus)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const page = await client.lists.contacts.list(listId, {
              subscription_status: "confirmed",
              limit: 20,
            });
            console.log("List members listed.", { count: page.data.length });

      responses:
        '200':
          description: Paginated list memberships
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedListContactsResponse'
        '400':
          description: Malformed `list_id`, invalid filter, or invalid cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Missing `lists:read` scope, or `include_contacts=true` without the
            `contacts:read` scope. The expansion check runs before the list is
            resolved, so an unauthorized expansion naming a list that does not
            exist answers 403 rather than 404.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: List not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/lists/{list_id}/contacts/batch:
    post:
      summary: Batch add contacts to a list
      description: |
        Synchronously adds one through 1,000 existing contacts to one list.
        This operation only ever inserts: an entry whose membership already
        exists is reported `already_member` and the stored row is left exactly
        as it stands, whatever its status, so bulk work can never erase an
        unsubscribe. A membership it creates is `confirmed`. No contact is
        created, and an entry naming nobody in this account is reported
        `not_found`.

        One malformed entry does not cost the others their result. An entry is
        reported `invalid` when it is not an object, names neither or both of
        `email` and `id`, carries any other property, holds an `id` that is
        not a string or not a UUID, or an `email` that is not a string or fails
        the validation contact writes apply, or repeats an identifier another
        entry already used. Duplicates are
        counted on the normalized identifier, so `A@x.com` and `a@x.com` are
        one identifier and both entries fail. A contact named twice by
        different kinds of identifier is settled by payload order: the earliest
        position that would create the membership takes `added`, and every
        later one takes `already_member`. An unknown property beside `data` is
        still a 400 for the whole request.

        The memberships and the optional idempotency replay response commit
        atomically and use the ordinary 24-hour replay window. Every status
        this operation's handler produces is stored and replayable: the 200,
        the 404 for a list that is not there, and each of the 400s below.
        Reusing a key with a different method, resolved path, or semantic JSON
        payload returns 422. An identical keyed request replays the same status
        and a semantically equivalent JSON response.

        Measured pacing guidance: batch size is a cap, not a throughput target.
        Measure end-to-end latency with representative data, submit the next
        synchronous batch only after the previous response, and reduce batch
        size or pace requests when latency grows. Honor 429 responses before
        continuing; the API makes no fixed throughput guarantee.
      operationId: batchAddListContacts
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["lists:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/AhaSend/ahasend-go/models/responses"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Name each contact by email or by ID.
              contactID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.ListsAPI.BatchAddListContacts(ctx, accountID, listID, requests.BatchAddListContactsRequest{
                Data: []requests.BatchAddListContactInput{
                  {Email: ahasend.String("one@example.com")},
                  {ID: &contactID},
                },
              })
              if err != nil {
                log.Fatalf("Error adding contacts to the list: %v", err)
              }
              // Each item succeeds or fails on its own.
              fmt.Printf("added=%d skipped=%d failed=%d\n", response.Added, response.Skipped, response.Failed)
              for _, result := range response.Data {
                if result.Outcome != responses.BatchListContactOutcomeAdded {
                  fmt.Printf("item %d: %s %s\n", result.Position, result.Outcome, result.Reason)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.lists.contacts.batchAdd(listId, {
              data: [{ email: "one@example.com" }, { id: "00000000-0000-4000-8000-000000000011" }],
            });
            console.log("List batch completed.", { added: result.added, failed: result.failed });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchAddListContactsRequest'
      responses:
        '200':
          description: Ordered per-entry outcomes
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchAddListContactsResponse'
        '400':
          description: |
            A malformed `list_id`, a body that is not a single JSON object, an
            unknown property beside `data`, a `data` that is absent, null or not
            an array, an empty `data` array, or more than 1,000 entries. An
            identical keyed retry replays the stored response with
            `Idempotent-Replayed: true`.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            List not found. The handler produces this status, so it is stored
            against the idempotency key: an identical keyed retry replays it
            with `Idempotent-Replayed: true` rather than looking the list up
            again.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/lists/{list_id}/contacts/{id_or_email}:
    put:
      summary: Add or Update a List Contact
      description: |
        Adds one existing contact to one list, or updates the membership that
        is already there, and returns the membership either way. No contact is
        created.

        A membership this call creates is `confirmed` unless the body names
        another status. On a membership that already exists, a body naming no
        status leaves the stored status exactly as it is: a PUT is not an
        implicit resubscribe, and naming a status is the deliberate act that
        may resubscribe. A write that names the status the membership already
        holds changes nothing at all, `updated_at` included, so a retry leaves
        the row identical.

        `confirmed` sets `subscribed_at` and clears `unsubscribed_at`;
        `unsubscribed` sets `unsubscribed_at` and leaves `subscribed_at` as it
        was; `unconfirmed` sets neither.

        UUID-shaped path values resolve by ID; all other values resolve by
        trimmed, lowercased email. Email identifiers must be percent-encoded
        exactly once as URL path segments; pass a raw email to SDKs, including
        addresses such as `user+tag@example.com`.
      operationId: upsertListContact
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertListContactRequest'
      security:
        - BearerAuth: ["lists:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Record that the contact unsubscribed from this list. Setting confirmed
              // would subscribe them again, so only do that when they asked for it.
              response, _, err := client.ListsAPI.UpsertListContact(ctx, accountID, listID, "user+tag@example.com", requests.UpsertListContactRequest{
                SubscriptionStatus: ahasend.String(requests.ListContactStatusUnsubscribed),
              })
              if err != nil {
                log.Fatalf("Error updating the list membership: %v", err)
              }
              fmt.Printf("%s is %s\n", response.Email, response.SubscriptionStatus)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const idOrEmail = "User+Tag@Example.COM";
            const membership = await client.lists.contacts.upsert(listId, idOrEmail, {
              subscription_status: "unsubscribed",
            });
            console.log("List membership saved.", { status: membership.subscription_status });

      responses:
        '200':
          description: The membership, whether this request created it or updated one
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListContact'
        '400':
          description: |
            Malformed `list_id`, an absent or non-object body, a property that
            is not writable, or a `subscription_status` outside `confirmed`,
            `unconfirmed`, and `unsubscribed`. The body is validated before the
            membership is read, so naming `complained` is 400 whatever the
            stored row holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            The list does not exist on this account, or no contact matches
            `{id_or_email}`. The message says which.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            The membership is `complained`: the contact reported this list as
            spam, that verdict is the mailbox provider's rather than the
            caller's, and writing over it would clear the only record of when
            the complaint landed. The membership is frozen and nothing is
            changed, including by a request naming no status. This conflict
            carries no idempotency headers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      summary: Remove a Contact from a List
      description: |
        Removes one contact from one list. UUID-shaped path values resolve by
        ID; all other values resolve by trimmed, lowercased email. Email
        identifiers must be percent-encoded exactly once as URL path segments;
        pass a raw email to SDKs, including addresses such as
        `user+tag@example.com`.

        This deletes the membership row, and with it any record that the
        contact unsubscribed from this list, so re-adding them later is silent.
        Prefer unsubscribe over delete when the intent is only to prevent
        marketing delivery: `PUT` the same path with
        `{"subscription_status": "unsubscribed"}` instead, which is reversible
        and keeps the record. A `complained` membership, one whose contact
        reported the list as spam, cannot be removed and returns 409, so the
        contact cannot be added back as `confirmed`. Removing a membership that
        is not there returns 404.
      operationId: deleteListContact
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
      security:
        - BearerAuth: ["lists:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              listID := uuid.MustParse("11111111-1111-4111-8111-111111111111")

              // Removing drops the membership. To stop sending to the contact while
              // keeping the record, set its status to unsubscribed instead.
              response, _, err := client.ListsAPI.DeleteListContact(ctx, accountID, listID, "user+tag@example.com")
              if err != nil {
                log.Fatalf("Error removing the contact from the list: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const listId = "00000000-0000-4000-8000-000000000010";
            const idOrEmail = "User+Tag@Example.COM";
            const result = await client.lists.contacts.delete(listId, idOrEmail);
            console.log("Contact removed from list.", { message: result.message });

      responses:
        '200':
          description: Contact removed from the list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Malformed `list_id`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            The list does not exist on this account, no contact matches
            `{id_or_email}`, or the contact is not on this list. The message
            says which.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: The contact reported this list as spam; the membership is kept
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/contacts/{id_or_email}/lists:
    get:
      summary: List a Contact's Lists
      description: |
        Returns the lists one contact is on, newest first on the membership's
        own `(created_at, id)`. Each item carries the membership with the list
        embedded, and soft-deleted lists are excluded. This is the reverse of
        `GET /v2/accounts/{account_id}/lists/{list_id}/contacts`, and the only
        way to answer what one person is subscribed to without walking every
        list. `after` and `before` are mutually exclusive.

        UUID-shaped path values resolve by ID; all other values resolve by
        trimmed, lowercased email. Email identifiers must be percent-encoded
        exactly once as URL path segments; pass a raw email to SDKs, including
        addresses such as `user+tag@example.com`.

        To stop marketing mail to someone everywhere at once, `PUT
        /v2/accounts/{account_id}/contacts/{id_or_email}` with
        `{"unsubscribed": true}`; there is no remove-from-all-lists operation.
        That is an account-wide verdict campaigns honour, and it does not
        change any membership here: every row still reads back with the status
        it holds, though the contact no longer counts toward any list's
        `contact_count`. To record the decision on one list, `PUT` that
        membership with `{"subscription_status": "unsubscribed"}`.
      operationId: getContactLists
      tags:
        - Lists
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: id_or_email
          in: path
          required: true
          description: |
            Contact UUID or email address. Percent-encode an email exactly once
            as a path segment; SDK callers pass the raw email value.
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum number of memberships to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Cursor for the next page; cannot be combined with `before`
          schema:
            type: string
        - name: before
          in: query
          description: Cursor for the previous page; cannot be combined with `after`
          schema:
            type: string
        - name: subscription_status
          in: query
          description: Per-list consent filter
          schema:
            type: string
            enum: [unconfirmed, confirmed, unsubscribed, complained]
      security:
        - BearerAuth: ["lists:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.ListsAPI.GetContactLists(ctx, accountID, "user+tag@example.com", requests.GetContactListsParams{
                SubscriptionStatus: ahasend.String(requests.ListContactStatusConfirmed),
              })
              if err != nil {
                log.Fatalf("Error getting the contact's lists: %v", err)
              }
              for _, membership := range response.Data {
                if membership.List != nil {
                  fmt.Printf("%s %s\n", membership.List.Name, membership.SubscriptionStatus)
                }
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const idOrEmail = "User+Tag@Example.COM";
            const page = await client.contacts.lists.list(idOrEmail, { subscription_status: "confirmed" });
            console.log("Contact lists listed.", { count: page.data.length });

      responses:
        '200':
          description: Paginated list memberships, each with its list embedded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedListContactsResponse'
        '400':
          description: Invalid filter or cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:read` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Contact not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Suppressions endpoints
  /v2/accounts/{account_id}/suppressions:
    get:
      summary: Get Suppressions
      description: |
        Returns a list of suppressions for the account

        **Query Parameters:**
        - `domain`: Filter by domain (optional)
        - `email`: Filter by email (optional)
        - `from_time`: Filter suppressions created after this time (RFC3339 format, optional)
        - `to_time`: Filter suppressions created before this time (RFC3339 format, optional)
        - `limit`: Maximum number of items to return (1-100, default: 100)
        - `after`: Pagination cursor for the next page
        - `before`: Pagination cursor for the previous page
      operationId: getSuppressions
      tags:
        - Suppressions
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: query
          description: Filter by domain
          schema:
            type: string
        - name: email
          in: query
          description: Email address
          schema:
            type: string
            format: email
        - name: from_time
          in: query
          description: Filter suppressions created after this time (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: Filter suppressions created before this time (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page. Provide the value provided in `previous_cursor` from the response.
          schema:
            type: string
      security:
        - BearerAuth: ["suppressions:read"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.SuppressionsAPI.GetSuppressions(ctx, accountID, requests.GetSuppressionsParams{})
              if err != nil {
                log.Fatalf("Error getting suppressions: %v", err)
              }
              for _, suppression := range response.Data {
                fmt.Printf("%s until %s: %s\n", suppression.Email, suppression.ExpiresAt, suppression.Reason)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.suppressions.list({ limit: 20 });
            console.log("Suppressions listed.", { count: page.data.length });

      responses:
        '200':
          description: List of suppressions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedSuppressionsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Suppression
      description: |
        Creates a new suppression for an email address

        **Validation Requirements:**
        - `email` must be a valid email address
        - `expires_at` must be in RFC3339 format
        - `domain` is optional - if not provided, applies to all account domains
      operationId: createSuppression
      tags:
        - Suppressions
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["suppressions:write"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.SuppressionsAPI.CreateSuppression(ctx, accountID, requests.CreateSuppressionRequest{
                Email:     "test@example.com",
                Reason:    ahasend.String("Inbox full"),
                ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
              })
              if err != nil {
                log.Fatalf("Error creating suppression: %v", err)
              }
              fmt.Printf("Created %d suppression(s)\n", len(response.Data))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const result = await client.suppressions.create({
              email: "recipient@example.net",
              reason: "User requested removal",
              expires_at: "2030-01-01T00:00:00Z",
            });
            console.log("Suppression created.", { count: result.data.length });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSuppressionRequest'
      responses:
        '201':
          description: Suppression created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateSuppressionResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Either the suppression already exists or the idempotency-key
            execution is in progress. The in-progress case is identified only
            by `Idempotent-Replayed: false` plus a positive integer
            `Retry-After`. A new duplicate-suppression response has neither
            header; a stored replay of that response has
            `Idempotent-Replayed: true`. The message is not a stable
            discriminator.
          headers:
            Idempotent-Replayed:
              description: |
                `false` with a positive `Retry-After` means an execution is in
                progress. `true` means a stored duplicate-suppression 409 was
                replayed. Absent means a fresh duplicate-suppression conflict.
              schema:
                type: string
                enum: ['true', 'false']
            Retry-After:
              $ref: '#/components/headers/IdempotencyRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      summary: Delete Suppression
      description: |
        Deletes suppressions for an email address

        **Query Parameters:**
        - `email`: Required email address to delete from suppression list
        - `domain`: Optional domain filter to delete suppressions for specific domain only
      operationId: deleteSuppression
      tags:
        - Suppressions
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: email
          in: query
          required: true
          description: Email address
          schema:
            type: string
            format: email
        - name: domain
          in: query
          description: Optional domain filter
          schema:
            type: string
      security:
        - BearerAuth: ["suppressions:delete"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Removes the suppression for one address; pass nil instead of a domain
              // to remove it for every domain.
              response, _, err := client.SuppressionsAPI.DeleteSuppression(
                ctx,
                accountID,
                "info@example.com",
                ahasend.String("notifications.example.com"),
              )
              if err != nil {
                log.Fatalf("Error deleting suppression: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const result = await client.suppressions.delete({ email: "recipient@example.net" });
            console.log("Suppression deleted.", { message: result.message });

      responses:
        '200':
          description: Suppression deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Suppression not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/suppressions/all:
    delete:
      summary: Delete All Suppressions
      description: |
        Deletes the account's suppressions, except protected ones. A suppression is
        protected when the recipient made the decision themselves, by unsubscribing or by
        reporting the message as spam; those are kept. The response message reports how
        many were kept when any were.

        **Query Parameters:**
        - `domain`: Optional domain filter to delete suppressions for specific domain only
      operationId: deleteAllSuppressions
      tags:
        - Suppressions
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: query
          description: Optional domain filter
          schema:
            type: string
      security:
        - BearerAuth: ["suppressions:wipe"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // Pass a domain instead of nil to limit the wipe to that domain.
              response, _, err := client.SuppressionsAPI.DeleteAllSuppressions(ctx, accountID, nil)
              if err != nil {
                log.Fatalf("Error deleting all suppressions: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const result = await client.suppressions.wipe({ domain: "example.com" });
            console.log("Domain suppressions deleted.", { message: result.message });

      responses:
        '200':
          description: Suppressions deleted successfully, protected ones kept
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Domain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Routes endpoints
  /v2/accounts/{account_id}/routes:
    get:
      summary: Get Routes
      description: |
        Returns a list of routes for the account. A domain-scoped key must
        provide the matching `domain` query parameter; the global read role can
        list without that filter.
      operationId: getRoutes
      tags:
        - Routes
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: domain
          in: query
          description: Domain to filter routes by. Required when using a domain-scoped routes:read:{domain} API key.
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["routes:read:all"]
        - BearerAuth: ["routes:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.RoutesAPI.GetRoutes(ctx, accountID, nil)
              if err != nil {
                log.Fatalf("Error getting routes: %v", err)
              }
              for _, route := range response.Data {
                fmt.Printf("%s %s -> %s\n", route.ID, route.Recipient, route.URL)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.routes.list({ domain: "example.com", limit: 20 });
            console.log("Routes listed.", { count: page.data.length });

      responses:
        '200':
          description: List of routes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedRoutesResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Route
      description: |
        Creates a new route for inbound email routing. Authorization uses the
        domain in `recipient`: the global write role or its matching
        domain-specific write role is sufficient.
      operationId: createRoute
      tags:
        - Routes
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["routes:write:all"]
        - BearerAuth: ["routes:write:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.RoutesAPI.CreateRoute(ctx, accountID, requests.CreateRouteRequest{
                Name:         "My Route",
                URL:          "https://example.com/tickets",
                Recipient:    "ticket-*@example.com",
                Enabled:      ahasend.Bool(true),
                Attachments:  true,
                Headers:      true,
                StripReplies: true,
              })
              if err != nil {
                log.Fatalf("Error creating route: %v", err)
              }
              fmt.Printf("Created route %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const route = await client.routes.create({
              name: "Inbound messages",
              url: "https://example.com/inbound",
              recipient: "inbound@example.com",
            });
            console.log("Route created.", { id: route.id, name: route.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRouteRequest'
      responses:
        '201':
          description: Route created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedRoute'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/routes/{route_id}:
    get:
      summary: Get Route
      description: |
        Returns a specific route by ID. Access requires the global read role or
        a read role matching the route's recipient domain.
      operationId: getRoute
      tags:
        - Routes
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: route_id
          in: path
          required: true
          description: Route ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["routes:read:all"]
        - BearerAuth: ["routes:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              routeID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.RoutesAPI.GetRoute(ctx, accountID, routeID)
              if err != nil {
                log.Fatalf("Error getting route: %v", err)
              }
              fmt.Printf("%s %s -> %s\n", response.Name, response.Recipient, response.URL)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const routeId = "00000000-0000-4000-8000-000000000006";
            const route = await client.routes.get(routeId);
            console.log("Route found.", { id: route.id, name: route.name });

      responses:
        '200':
          description: Route details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Route'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Route not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Route
      description: |
        Updates an existing route. A domain-scoped key must authorize the
        existing recipient domain and, when `recipient` changes, the new domain;
        `routes:write:all` satisfies both checks.
      operationId: updateRoute
      tags:
        - Routes
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: route_id
          in: path
          required: true
          description: Route ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["routes:write:all"]
        - BearerAuth: ["routes:write:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              routeID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.RoutesAPI.UpdateRoute(ctx, accountID, routeID, requests.UpdateRouteRequest{
                Name: ahasend.String("Updated Name"),
                URL:  ahasend.String("https://example.com/new-tickets"),
              })
              if err != nil {
                log.Fatalf("Error updating route: %v", err)
              }
              fmt.Printf("Updated route %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const routeId = "00000000-0000-4000-8000-000000000006";
            const route = await client.routes.update(routeId, {
              url: "https://example.com/inbound-v2",
            });
            console.log("Route updated.", { id: route.id, name: route.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRouteRequest'
      responses:
        '200':
          description: Route updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Route'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Route not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete Route
      description: |
        Deletes a route. Access requires the global delete role or a delete role
        matching the route's recipient domain.
      operationId: deleteRoute
      tags:
        - Routes
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: route_id
          in: path
          required: true
          description: Route ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["routes:delete:all"]
        - BearerAuth: ["routes:delete:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              routeID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.RoutesAPI.DeleteRoute(ctx, accountID, routeID)
              if err != nil {
                log.Fatalf("Error deleting route: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const routeId = "00000000-0000-4000-8000-000000000006";
            const result = await client.routes.delete(routeId);
            console.log("Route deleted.", { message: result.message });

      responses:
        '200':
          description: Route deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Route not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Webhooks endpoints
  /v2/accounts/{account_id}/webhooks:
    get:
      summary: Get Webhooks
      description: |
        Returns a list of webhooks for the account

        The global read role sees all webhooks. Domain-scoped read roles return
        only webhooks associated with at least one authorized domain.

        **Query Parameters:**
        - `enabled`: Filter by enabled status
        - Event filters: `on_reception`, `on_delivered`, `on_transient_error`, `on_failed`, `on_bounced`, `on_suppressed`, `on_opened`, `on_clicked`, `on_suppression_created`, `on_dns_error`
        - `limit`: Maximum number of items to return (1-100, default: 100)
        - `after`: Pagination cursor for the next page
        - `before`: Pagination cursor for the previous page
      operationId: getWebhooks
      tags:
        - Webhooks
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: enabled
          in: query
          description: Filter by enabled status
          schema:
            type: boolean
        - name: on_reception
          in: query
          description: Filter by reception event trigger
          schema:
            type: boolean
        - name: on_delivered
          in: query
          description: Filter by delivery event trigger
          schema:
            type: boolean
        - name: on_transient_error
          in: query
          description: Filter by transient error event trigger
          schema:
            type: boolean
        - name: on_failed
          in: query
          description: Filter by failure event trigger
          schema:
            type: boolean
        - name: on_bounced
          in: query
          description: Filter by bounce event trigger
          schema:
            type: boolean
        - name: on_suppressed
          in: query
          description: Filter by suppression event trigger
          schema:
            type: boolean
        - name: on_opened
          in: query
          description: Filter by open event trigger
          schema:
            type: boolean
        - name: on_clicked
          in: query
          description: Filter by click event trigger
          schema:
            type: boolean
        - name: on_suppression_created
          in: query
          description: Filter by suppression creation event trigger
          schema:
            type: boolean
        - name: on_dns_error
          in: query
          description: Filter by DNS error event trigger
          schema:
            type: boolean
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["webhooks:read:all"]
        - BearerAuth: ["webhooks:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              params := api.GetWebhooksParams{Enabled: ahasend.Bool(true)}
              params.Limit = ahasend.Int32(100)

              response, _, err := client.WebhooksAPI.GetWebhooks(ctx, accountID, params)
              if err != nil {
                log.Fatalf("Error getting webhooks: %v", err)
              }
              for _, webhook := range response.Data {
                fmt.Printf("%s %s -> %s\n", webhook.ID, webhook.Name, webhook.URL)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.webhooks.list({ limit: 20 });
            console.log("Webhooks listed.", { count: page.data.length });

      responses:
        '200':
          description: List of webhooks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedWebhooksResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create Webhook
      description: |
        Creates a new webhook for event notifications. A `scoped` webhook
        requires at least one domain and the key must have write permission for
        every supplied domain. A `global` webhook requires
        `webhooks:write:all`; supplied domains are accepted but ignored, and
        the response contains `domains: []`.
      operationId: createWebhook
      tags:
        - Webhooks
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["webhooks:write:all"]
        - BearerAuth: ["webhooks:write:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.WebhooksAPI.CreateWebhook(ctx, accountID, requests.CreateWebhookRequest{
                Name:             "Failures",
                URL:              "https://example.com/webhook",
                Scope:            "global",
                Enabled:          ahasend.Bool(true),
                OnBounced:        true,
                OnTransientError: true,
                OnFailed:         true,
              })
              if err != nil {
                log.Fatalf("Error creating webhook: %v", err)
              }
              // Use the secret to verify the signature of each delivery.
              fmt.Printf("Created webhook %s, secret: %s\n", response.ID, response.Secret)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const webhook = await client.webhooks.create({
              name: "Delivery events",
              url: "https://example.com/webhooks/ahasend",
              scope: "global",
              on_delivered: true,
            });
            console.log("Webhook created.", { id: webhook.id, name: webhook.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Webhook created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedWebhook'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/webhooks/{webhook_id}:
    get:
      summary: Get Webhook
      description: |
        Returns a specific webhook by ID. Access requires the global read role
        or a read role matching at least one associated domain.
      operationId: getWebhook
      tags:
        - Webhooks
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: webhook_id
          in: path
          required: true
          description: Webhook ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["webhooks:read:all"]
        - BearerAuth: ["webhooks:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              webhookID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.WebhooksAPI.GetWebhook(ctx, accountID, webhookID)
              if err != nil {
                log.Fatalf("Error getting webhook: %v", err)
              }
              fmt.Printf("%s -> %s (enabled: %t)\n", response.Name, response.URL, response.Enabled)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const webhookId = "00000000-0000-4000-8000-000000000007";
            const webhook = await client.webhooks.get(webhookId);
            console.log("Webhook found.", { id: webhook.id, name: webhook.name });

      responses:
        '200':
          description: Webhook details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      summary: Update Webhook
      description: |
        Updates an existing webhook. Omitted or null fields are unchanged.
        Omitted/null `domains` preserves associations, while `[]` clears the
        associations of a resulting-scoped webhook. Moving from global to
        scoped requires a non-empty `domains` array in the same request. Moving
        to global clears associations and ignores supplied domains.

        A domain-scoped key must be authorized for the existing webhook and
        every newly supplied domain; changing to global requires
        `webhooks:write:all`.

        **Note:** The webhook secret is not updatable
      operationId: updateWebhook
      tags:
        - Webhooks
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: webhook_id
          in: path
          required: true
          description: Webhook ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["webhooks:write:all"]
        - BearerAuth: ["webhooks:write:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              webhookID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.WebhooksAPI.UpdateWebhook(ctx, accountID, webhookID, requests.UpdateWebhookRequest{
                Name:      ahasend.String("Failures"),
                URL:       ahasend.String("https://example.com/webhook"),
                Enabled:   ahasend.Bool(true),
                OnBounced: ahasend.Bool(true),
              })
              if err != nil {
                log.Fatalf("Error updating webhook: %v", err)
              }
              fmt.Printf("Updated webhook %s\n", response.ID)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const webhookId = "00000000-0000-4000-8000-000000000007";
            const webhook = await client.webhooks.update(webhookId, {
              name: "Transactional delivery events",
            });
            console.log("Webhook updated.", { id: webhook.id, name: webhook.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWebhookRequest'
      responses:
        '200':
          description: Webhook updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete Webhook
      description: |
        Deletes a webhook. Access requires the global delete role or a delete
        role matching at least one associated domain.
      operationId: deleteWebhook
      tags:
        - Webhooks
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: webhook_id
          in: path
          required: true
          description: Webhook ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["webhooks:delete:all"]
        - BearerAuth: ["webhooks:delete:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              webhookID := uuid.MustParse("c5a32c40-b351-439f-8230-779daed3e42c")

              response, _, err := client.WebhooksAPI.DeleteWebhook(ctx, accountID, webhookID)
              if err != nil {
                log.Fatalf("Error deleting webhook: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const webhookId = "00000000-0000-4000-8000-000000000007";
            const result = await client.webhooks.delete(webhookId);
            console.log("Webhook deleted.", { message: result.message });

      responses:
        '200':
          description: Webhook deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # SMTP Credentials endpoints
  /v2/accounts/{account_id}/smtp-credentials:
    get:
      summary: Get SMTP Credentials
      description: |
        Returns a list of SMTP credentials for the account. The global read role
        sees all credentials; domain-scoped roles return credentials associated
        with at least one authorized domain.
      operationId: getSMTPCredentials
      tags:
        - SMTP Credentials
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          description: Maximum number of items to return (1-100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: after
          in: query
          description: Pagination cursor for the next page. Provide the value provided in `next_cursor` from the response.
          schema:
            type: string
        - name: before
          in: query
          description: Pagination cursor for the previous page.
          schema:
            type: string
      security:
        - BearerAuth: ["smtp-credentials:read:all"]
        - BearerAuth: ["smtp-credentials:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              response, _, err := client.SMTPCredentialsAPI.GetSMTPCredentials(ctx, accountID, nil)
              if err != nil {
                log.Fatalf("Error getting SMTP credentials: %v", err)
              }
              for _, credential := range response.Data {
                fmt.Printf("%s %s %s\n", credential.ID, credential.Name, credential.Username)
              }
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const page = await client.smtpCredentials.list({ limit: 20 });
            console.log("SMTP credentials listed.", { count: page.data.length });

      responses:
        '200':
          description: List of SMTP credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedSMTPCredentialsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create SMTP Credential
      description: |
        Creates a new SMTP credential for SMTP authentication. A `scoped`
        credential requires at least one domain and the key must have write
        permission for every supplied domain. A `global` credential requires
        `smtp-credentials:write:all`; supplied domains are accepted but ignored,
        and the response contains `domains: []`. SMTP credentials have no
        update endpoint.
      operationId: createSMTPCredential
      tags:
        - SMTP Credentials
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      security:
        - BearerAuth: ["smtp-credentials:write:all"]
        - BearerAuth: ["smtp-credentials:write:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.SMTPCredentialsAPI.CreateSMTPCredential(ctx, accountID, requests.CreateSMTPCredentialRequest{
                Name:    "My SMTP Credential",
                Scope:   "global",
                Sandbox: true,
              })
              if err != nil {
                log.Fatalf("Error creating SMTP credential: %v", err)
              }
              // response.Password is returned only on create: put it in your secret
              // store now, and never log it.
              fmt.Printf("Created SMTP credential for username %s\n", response.Username)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            // The SDK sends a fresh Idempotency-Key automatically and reuses it on its own retries.
            const credential = await client.smtpCredentials.create({
              name: "Production SMTP",
              scope: "global",
            });
            console.log("SMTP credential created.", { id: credential.id, name: credential.name });

      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSMTPCredentialRequest'
      responses:
        '201':
          description: SMTP credential created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedSMTPCredential'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/smtp-credentials/{smtp_credential_id}:
    get:
      summary: Get SMTP Credential
      description: |
        Returns a specific SMTP credential by ID. Access requires the global
        read role or a read role matching at least one associated domain.
      operationId: getSMTPCredential
      tags:
        - SMTP Credentials
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: smtp_credential_id
          in: path
          required: true
          description: SMTP Credential ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["smtp-credentials:read:all"]
        - BearerAuth: ["smtp-credentials:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              smtpCredentialID := uuid.MustParse("0194f7ac-2f6a-7d29-9f0c-9a27b19f2d1c")

              response, _, err := client.SMTPCredentialsAPI.GetSMTPCredential(ctx, accountID, smtpCredentialID)
              if err != nil {
                log.Fatalf("Error getting SMTP credential: %v", err)
              }
              fmt.Printf("%s %s (%s)\n", response.Name, response.Username, response.Scope)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const credentialId = "00000000-0000-4000-8000-000000000008";
            const credential = await client.smtpCredentials.get(credentialId);
            console.log("SMTP credential found.", { id: credential.id, name: credential.name });

      responses:
        '200':
          description: SMTP credential details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SMTPCredential'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: SMTP credential not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete SMTP Credential
      description: |
        Deletes an SMTP credential. Access requires the global delete role or a
        delete role matching at least one associated domain.
      operationId: deleteSMTPCredential
      tags:
        - SMTP Credentials
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: smtp_credential_id
          in: path
          required: true
          description: SMTP Credential ID
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: ["smtp-credentials:delete:all"]
        - BearerAuth: ["smtp-credentials:delete:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"

              "github.com/AhaSend/ahasend-go/api"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              smtpCredentialID := uuid.MustParse("0194f7ac-2f6a-7d29-9f0c-9a27b19f2d1c")

              response, _, err := client.SMTPCredentialsAPI.DeleteSMTPCredential(ctx, accountID, smtpCredentialID)
              if err != nil {
                log.Fatalf("Error deleting SMTP credential: %v", err)
              }
              fmt.Println(response.Message)
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const credentialId = "00000000-0000-4000-8000-000000000008";
            const result = await client.smtpCredentials.delete(credentialId);
            console.log("SMTP credential deleted.", { message: result.message });

      responses:
        '200':
          description: SMTP credential deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: SMTP credential not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Statistics endpoints
  /v2/accounts/{account_id}/statistics/transactional/deliverability:
    get:
      summary: Get Deliverability Statistics
      description: |
        Returns transactional deliverability statistics grouped by status

        The global statistics role can query all sender domains. A
        domain-scoped key is limited to authorized domains and must authorize
        every comma-separated value supplied in `sender_domain`.

        **Query Parameters:**
        - `from_time`: Filter statistics after this date (RFC3339 format)
        - `to_time`: Filter statistics before this date (RFC3339 format)
        - `sender_domain`: Filter by sender domain
        - `recipient_domains`: Filter by recipient domain
        - `tags`: Filter by tags (comma-separated)
        - `group_by`: Group by time period (hour, day, week, month)
      operationId: getDeliverabilityStatistics
      tags:
        - Statistics
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: from_time
          in: query
          description: Filter statistics after this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: Filter statistics before this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: sender_domain
          in: query
          description: Comma-separated sender domains; the key must authorize every requested domain
          schema:
            type: string
        - name: recipient_domains
          in: query
          description: Filter by a comma separated list of recipient domains
          schema:
            type: string
          example: gmail.com,googlemail.com
        - name: tags
          in: query
          description: Filter by tags (comma-separated)
          schema:
            type: string
        - name: group_by
          in: query
          description: Group by time period
          schema:
            type: string
            enum: [hour, day, week, month]
            default: day
      security:
        - BearerAuth: ["statistics-transactional:read:all"]
        - BearerAuth: ["statistics-transactional:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              fromTime := time.Now().Add(-30 * 24 * time.Hour)

              response, _, err := client.StatisticsAPI.GetDeliverabilityStatistics(ctx, accountID, requests.GetDeliverabilityStatisticsParams{
                FromTime:         &fromTime,
                SenderDomain:     ahasend.String("example.com"),
                RecipientDomains: ahasend.String("gmail.com"),
                GroupBy:          ahasend.String("week"),
              })
              if err != nil {
                log.Fatalf("Error getting deliverability statistics: %v", err)
              }
              fmt.Printf("Got %d weekly buckets\n", len(response.Data))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const statistics = await client.statistics.deliverability({
              from_time: "2026-01-01T00:00:00Z",
              to_time: "2026-01-02T00:00:00Z",
            });
            console.log("Deliverability statistics loaded.", { buckets: statistics.data.length });

      responses:
        '200':
          description: Deliverability statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeliverabilityStatisticsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests (rate limited to 1 request per second)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/statistics/transactional/bounce:
    get:
      summary: Get Bounce Statistics
      description: |
        Returns transactional bounce statistics grouped by classification

        The global statistics role can query all sender domains. A
        domain-scoped key is limited to authorized domains and must authorize
        every comma-separated value supplied in `sender_domain`.

        **Query Parameters:**
        - `from_time`: Filter statistics after this date (RFC3339 format)
        - `to_time`: Filter statistics before this date (RFC3339 format)
        - `sender_domain`: Filter by sender domain
        - `recipient_domains`: Filter by recipient domain
        - `tags`: Filter by tags (comma-separated)
        - `group_by`: Group by time period (hour, day, week, month)
      operationId: getBounceStatistics
      tags:
        - Statistics
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: from_time
          in: query
          description: Filter statistics after this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: Filter statistics before this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: sender_domain
          in: query
          description: Comma-separated sender domains; the key must authorize every requested domain
          schema:
            type: string
        - name: recipient_domains
          in: query
          description: Filter by a comma separated list of recipient domains
          schema:
            type: string
          example: gmail.com,googlemail.com
        - name: tags
          in: query
          description: Filter by tags (comma-separated)
          schema:
            type: string
        - name: group_by
          in: query
          description: Group by time period
          schema:
            type: string
            enum: [hour, day, week, month]
            default: day
      security:
        - BearerAuth: ["statistics-transactional:read:all"]
        - BearerAuth: ["statistics-transactional:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              fromTime := time.Now().Add(-30 * 24 * time.Hour)

              response, _, err := client.StatisticsAPI.GetBounceStatistics(ctx, accountID, requests.GetBounceStatisticsParams{
                FromTime:         &fromTime,
                SenderDomain:     ahasend.String("example.com"),
                RecipientDomains: ahasend.String("gmail.com"),
                GroupBy:          ahasend.String("week"),
              })
              if err != nil {
                log.Fatalf("Error getting bounce statistics: %v", err)
              }
              fmt.Printf("Got %d weekly buckets\n", len(response.Data))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const statistics = await client.statistics.bounces({
              from_time: "2026-01-01T00:00:00Z",
              to_time: "2026-01-02T00:00:00Z",
            });
            console.log("Bounce statistics loaded.", { buckets: statistics.data.length });

      responses:
        '200':
          description: Bounce statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BounceStatisticsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests (rate limited to 1 request per second)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v2/accounts/{account_id}/statistics/transactional/delivery-time:
    get:
      summary: Get Delivery Time Statistics
      description: |
        Returns transactional delivery time statistics grouped by recipient domain

        The global statistics role can query all sender domains. A
        domain-scoped key is limited to authorized domains and must authorize
        every comma-separated value supplied in `sender_domain`.

        **Query Parameters:**
        - `from_time`: Filter statistics after this date (RFC3339 format)
        - `to_time`: Filter statistics before this date (RFC3339 format)
        - `sender_domain`: Filter by sender domain
        - `recipient_domains`: Filter by recipient domains
        - `tags`: Filter by tags (comma-separated)
        - `group_by`: Group by time period (hour, day, week, month)
      operationId: getDeliveryTimeStatistics
      tags:
        - Statistics
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: from_time
          in: query
          description: Filter statistics after this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: to_time
          in: query
          description: Filter statistics before this datetime (RFC3339 format)
          schema:
            type: string
            format: date-time
        - name: sender_domain
          in: query
          description: Comma-separated sender domains; the key must authorize every requested domain
          schema:
            type: string
        - name: recipient_domains
          in: query
          description: Filter by a comma separated list of recipient domains
          schema:
            type: string
          example: gmail.com,googlemail.com
        - name: tags
          in: query
          description: Filter by tags (comma-separated)
          schema:
            type: string
        - name: group_by
          in: query
          description: Group by time period
          schema:
            type: string
            enum: [hour, day, week, month]
            default: day
      security:
        - BearerAuth: ["statistics-transactional:read:all"]
        - BearerAuth: ["statistics-transactional:read:{domain}"]
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

            import (
              "context"
              "fmt"
              "log"
              "os"
              "time"

              "github.com/AhaSend/ahasend-go"
              "github.com/AhaSend/ahasend-go/api"
              "github.com/AhaSend/ahasend-go/models/requests"
              "github.com/google/uuid"
            )

            func main() {
              client := api.NewAPIClient(api.WithAPIKey(os.Getenv("AHASEND_API_KEY")))
              accountID := uuid.MustParse(os.Getenv("AHASEND_ACCOUNT_ID"))
              ctx := context.Background()

              fromTime := time.Now().Add(-30 * 24 * time.Hour)

              response, _, err := client.StatisticsAPI.GetDeliveryTimeStatistics(ctx, accountID, requests.GetDeliveryTimeStatisticsParams{
                FromTime:         &fromTime,
                SenderDomain:     ahasend.String("example.com"),
                RecipientDomains: ahasend.String("gmail.com"),
                GroupBy:          ahasend.String("week"),
              })
              if err != nil {
                log.Fatalf("Error getting delivery time statistics: %v", err)
              }
              fmt.Printf("Got %d weekly buckets\n", len(response.Data))
            }

        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: |
            import { AhaSendClient } from "@ahasend/sdk";

            const client = AhaSendClient.fromEnv();
            const statistics = await client.statistics.deliveryTimes({
              from_time: "2026-01-01T00:00:00Z",
              to_time: "2026-01-02T00:00:00Z",
            });
            console.log("Delivery-time statistics loaded.", { buckets: statistics.data.length });

      responses:
        '200':
          description: Delivery time statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeliveryTimeStatisticsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many requests (rate limited to 1 request per second)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

tags:
  - name: Utility
    description: Utility endpoints for health checks and diagnostics
  - name: API Keys
    description: Manage API keys for authentication and access control
  - name: Domains
    description: Manage sending domains
  - name: Messages
    description: Send and manage transactional messages
  - name: Accounts
    description: Manage account settings and members
  - name: Contacts
    description: Read, create, update, batch upsert, and delete account-global contacts
  - name: Templates
    description: Create, change, publish and delete the account's transactional templates, and read them with their drafts, their published versions and the variables a send must supply
  - name: Campaign Templates
    description: Create, change, read and delete the account's campaign templates, the designs a campaign copies when it is made from one
  - name: Campaigns
    description: Create, change, read, duplicate, delete, test and send the account's marketing campaigns, and read their stats and recipients
  - name: Lists
    description: Manage contact lists and the contacts on them
  - name: Suppressions
    description: Manage email suppressions
  - name: Routes
    description: Manage inbound email routing
  - name: Webhooks
    description: Manage webhook notifications
  - name: SMTP Credentials
    description: Manage SMTP authentication credentials
  - name: Statistics
    description: Access transactional email statistics
  - name: Message Events
    description: Webhooks for outbound message delivery events
  - name: Suppression Events
    description: Webhooks for suppression list changes
  - name: Domain Events
    description: Webhooks for domain configuration issues
  - name: Route Events
    description: Webhooks for inbound email routing
