openapi: 3.0.3
info:
  title: SendFox API
  description: |
    # Introduction
    SendFox's REST API lets you manage contacts, campaigns, lists, automations, and forms programmatically. It uses OAuth 2.0 for authentication.

    Compatible with AI agents (Claude, ChatGPT, etc.) via the OpenAPI spec. Available at `GET /openapi.yaml`.

    # Authentication
    ### Personal Access Token
    Create a personal access token at https://sendfox.com/account/oauth. Once created, use it in the `Authorization` header:
    ```
    Authorization: Bearer {TOKEN}
    ```

    ### OAuth 2.0 Client
    For integrations that require user authentication, create an OAuth 2.0 client at https://sendfox.com/account/oauth

    * Authorization URL: https://sendfox.com/oauth/authorize
    * Access Token URL: https://sendfox.com/oauth/token

    # Rate Limits
    API requests are limited to **60 requests per minute** per authenticated user. Rate limit status is returned in response headers:
    - `X-RateLimit-Limit`: Maximum requests per minute
    - `X-RateLimit-Remaining`: Remaining requests in current window
    - `Retry-After`: Seconds until rate limit resets (only on 429 responses)

    # Error Responses
    All error responses use standard HTTP status codes and Laravel's default error format:
    - `message`: Human-readable error description
    - `errors`: Field-level validation errors (on 422 responses)

    # Plans & API Access
    Every plan can create an API key. Lifetime and Pro plans get the whole API. The Free plan gets a limited set of endpoints: `GET /me`; reading lists, tags, contact tags, contact fields, campaigns and campaign stats, saved templates and sections, automations, and forms; creating contacts, adding a contact to a list, and tagging a contact; transactional email; and, on accounts with transactional access, sending domains. Any other endpoint returns `402 Payment Required` for a Free account. A key's access follows the account's plan, so upgrading opens the rest without a new key.

    Accounts restricted by SendFox cannot use authenticated API endpoints. These requests return `403 Forbidden` with the error code `account_restricted` and a link to the account status page.
  version: 1.5.0
  x-logo:
    url: /img/sendfox-logo.svg
servers:
  - url: https://api.sendfox.com

components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://sendfox.com/oauth/authorize
          tokenUrl: https://sendfox.com/oauth/token
          scopes:
            transactional:send: Send transactional email
            transactional:read: Read transactional messages and events
            transactional:domains: Manage sending domains

  responses:
    Forbidden:
      description: >-
        Forbidden. Most often an ownership/authorization failure (Laravel's
        default `{"message": "..."}` shape). If the account is restricted by
        SendFox, the response instead carries the structured
        `account_restricted` body with a link to the account status page.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/AccountRestrictedError'

  schemas:
    TransactionalSendRequest:
      type: object
      required: [from, to, subject]
      properties:
        from:
          type: string
          maxLength: 320
          description: >-
            Sender. Either an address or a display name followed by one in angle
            brackets. The domain must be plain ASCII (for an internationalized
            domain, use its punycode form, starting xn--) and verified under
            Account > Domains (a subdomain of a verified domain also works), or
            be the sandbox sender `onboarding@sendfoxmail.com`, which needs no
            DNS but only delivers to your own account email and the test
            addresses (sandbox mail always shows the SendFox Onboarding display
            name).
          example: Acme <hello@mail.acme.com>
        to:
          type: array
          minItems: 1
          maxItems: 10
          description: >-
            1 to 10 addresses. to, cc and bcc together may hold at most 10, and every address counts as
            one email against your plan's sends, so a message to three people
            uses three.
          items:
            type: string
            format: email
          example: ["jane@example.com"]
        cc:
          type: array
          maxItems: 10
          items:
            type: string
            format: email
        bcc:
          type: array
          maxItems: 2
          description: At most 2 addresses; counts toward the 10-address total.
          items:
            type: string
            format: email
        reply_to:
          type: string
          format: email
          nullable: true
        subject:
          type: string
          maxLength: 998
          example: Your login code
        html:
          type: string
          description: HTML body, at most 500 KB (UTF-8 bytes). Send html, text, or both.
          example: <p>Your code is <strong>483 920</strong>.</p>
        text:
          type: string
          description: Plain-text body, at most 100 KB (UTF-8 bytes). Send html, text, or both.
        tag:
          type: string
          maxLength: 64
          pattern: '^[a-z0-9][a-z0-9_.-]*$'
          nullable: true
          description: >-
            A label for grouping in the dashboard, for per-tag open/click
            tracking switches, and for per-tag alerts. Lowercase letters,
            digits, dots, dashes and underscores. Messages without a tag are
            grouped by subject. Tags that look like authentication mail
            (`login_code`, `password_reset`, `otp`, ...) can never have tracking
            turned on. An account may hold at most 200 distinct tags, so tag by
            kind of email (`receipt`), not per customer or order.
          example: login_code
        headers:
          type: object
          nullable: true
          maxProperties: 20
          additionalProperties:
            type: string
            maxLength: 998
          description: >-
            Custom headers, at most 20, as `{"Name": "value"}`. Allowed:
            `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`,
            `X-Entity-Ref-ID`, `References`, `In-Reply-To`, and any other
            `X-*` header except `X-SendFox-*` and `X-SG-*`. Headers SendFox or
            SendGrid set (`From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`,
            `Content-*`, `MIME-Version`, `Date`, `Message-ID`, `Return-Path`,
            `DKIM-Signature`, `Received`) are refused with a 422. Names may
            hold letters, digits and dashes; values may hold up to 998
            characters and no line breaks. `List-Unsubscribe` must be one or
            more `mailto:` or `https:` URLs in angle brackets, and
            `List-Unsubscribe-Post` must be exactly
            `List-Unsubscribe=One-Click` with an `https:` URL in
            `List-Unsubscribe`, as RFC 8058 requires.
          example:
            List-Unsubscribe: "<https://acme.com/unsubscribe?u=4f0c>, <mailto:unsub@acme.com>"
            List-Unsubscribe-Post: List-Unsubscribe=One-Click
        attachments:
          type: array
          nullable: true
          maxItems: 10
          description: >-
            Files to attach, at most 10 and 5 MB in total once decoded. Each is
            `{filename, content, content_type?}` with `content` the file in
            base64 (line breaks are fine). `content_type` is guessed from the
            extension when omitted and may carry parameters, like
            `text/calendar; method=REQUEST` for a calendar invite. Executable and
            script files (`.exe`, `.js`, `.bat`, `.scr`, ...) are refused, as are
            URLs (`path`): send the file itself. Not accepted on the batch
            endpoint. The file is kept only until the email is sent or fails;
            `GET /transactional/emails/{id}` shows its name, type and size.
          items:
            type: object
            required: [filename, content]
            properties:
              filename:
                type: string
                maxLength: 255
                example: invite.ics
              content:
                type: string
                format: byte
                description: The file, base64-encoded.
              content_type:
                type: string
                maxLength: 255
                example: text/calendar; method=REQUEST
    TransactionalBatchSendRequest:
      type: object
      required: [emails]
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 100
          description: >-
            1 to 100 emails. Each item takes the same fields as
            `POST /transactional/emails` plus an optional `idempotency_key`.
            The `html` and `text` of all items together may total at most 5 MB.
          items:
            allOf:
              - $ref: '#/components/schemas/TransactionalSendRequest'
              - type: object
                properties:
                  idempotency_key:
                    type: string
                    minLength: 1
                    maxLength: 256
                    description: >-
                      Optional, unique per logical email. Works like the
                      `Idempotency-Key` header on the single route: within 24
                      hours the same key with the same item returns the
                      original message (`replayed: true`); a different item is
                      a per-item `invalid_idempotent_request` error.
    TransactionalBatchResult:
      type: object
      required: [index]
      description: >-
        One entry per item, in the order sent. An accepted item carries `id`
        and `status`; a refused one carries `error` in the catalog shape
        (without `request_id`, which is in the `x-request-id` header).
      properties:
        index:
          type: integer
          example: 0
        id:
          type: string
          example: tm_4f0c9b2e7d1a5c3e8b6f2a9d
        status:
          type: string
          example: queued
        replayed:
          type: boolean
          description: Present (`true`) when the item's `idempotency_key` matched an earlier email.
        error:
          type: object
          properties:
            status:
              type: integer
              example: 422
            name:
              type: string
              example: validation_error
            message:
              type: string
            fix:
              type: string
            link:
              type: string
            errors:
              type: object
              description: Field errors, on `validation_error` only.
    TransactionalEvent:
      type: object
      properties:
        event:
          type: string
          enum: [sent, delivered, delayed, bounced, complained, failed]
        occurred_at:
          type: string
          format: date-time
        reason:
          type: string
          nullable: true
          description: The receiving server's response, when there is one.
        status:
          type: string
          nullable: true
          description: SMTP status code, when there is one (e.g. 5.1.1).
    TransactionalMessage:
      type: object
      properties:
        id:
          type: string
          description: Opaque message id (`tm_` and 24 hex characters). Store it as a string; it is random, not sequential.
          example: tm_4f0c9b2e7d1a5c3e8b6f2a9d
        status:
          type: string
          enum: [queued, sent, delivered, bounced, complained, failed, suppressed]
        mode:
          type: string
          enum: [live, test]
          description: test for messages to the sandbox test addresses, which never reach a mailbox and are never counted.
        from:
          type: string
        to:
          type: array
          items:
            type: string
        cc:
          type: array
          items:
            type: string
        bcc:
          type: array
          items:
            type: string
        reply_to:
          type: string
          nullable: true
        subject:
          type: string
        tag:
          type: string
          nullable: true
        headers:
          type: object
          additionalProperties:
            type: string
          description: The custom headers the email was sent with; `{}` when there were none.
        attachments:
          type: array
          description: Only on `GET /transactional/emails/{id}`. The files sent with the email (never their content).
          items:
            type: object
            properties:
              filename:
                type: string
              content_type:
                type: string
              size:
                type: integer
                description: Decoded size in bytes.
        idempotency_key:
          type: string
          nullable: true
        failure:
          type: object
          nullable: true
          properties:
            name:
              type: string
            message:
              type: string
        accepted_at:
          type: string
          format: date-time
        sent_at:
          type: string
          format: date-time
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
        failed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        events:
          type: array
          description: Present on GET /transactional/emails/{id} only.
          items:
            $ref: '#/components/schemas/TransactionalEvent'
    TransactionalError:
      type: object
      description: Every non-2xx response from a /transactional route.
      properties:
        status:
          type: integer
          example: 403
        name:
          type: string
          description: Stable machine-readable error name (see the catalog in the Transactional section).
          example: domain_not_verified
        message:
          type: string
          example: The from address uses a domain this account has not verified.
        fix:
          type: string
          description: What to do about it.
          example: Verify the domain under Account > Domains, or send from the sandbox sender while testing.
        link:
          type: string
          format: uri
        request_id:
          type: string
          description: Also sent as the x-request-id header. Quote it to support.
        errors:
          type: object
          description: Field errors, on validation_error only.
          additionalProperties:
            type: array
            items:
              type: string
        retry_after:
          type: integer
          description: Seconds, on rate_limit_exceeded only.
    Contact:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        first_name:
          type: string
        last_name:
          type: string
        ip_address:
          type: string
        unsubscribed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - email

    Campaign:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        subject:
          type: string
        preview_text:
          type: string
          maxLength: 191
          nullable: true
          description: Inbox preview snippet shown beneath the subject line in most email clients.
        html:
          type: string
        from_name:
          type: string
        from_email:
          type: string
          format: email
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        sent_at:
          type: string
          format: date-time
          nullable: true
        timezone:
          type: string
        is_web_gated:
          type: boolean
          description: The saved per-issue signup requirement. Applies only to published web issues. If the account-wide signup requirement is enabled in Settings, the public page requires signup even when this field is false.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - title
        - subject
        - html
        - from_name
        - from_email

    Error:
      type: object
      properties:
        message:
          type: string
          description: Human-readable error description
        errors:
          type: object
          description: Field-level validation errors (on 422 responses)
          additionalProperties:
            type: array
            items:
              type: string

    AccountRestrictedError:
      type: object
      required:
        - error
        - code
        - account_status_url
      properties:
        error:
          type: string
          example: API access is unavailable while the account is restricted.
        code:
          type: string
          enum:
            - account_restricted
        account_status_url:
          type: string
          format: uri
          example: https://sendfox.com/account/status

    Form:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        landing_page_id:
          type: integer
          nullable: true
        redirect_url:
          type: string
          nullable: true
        gdpr_required:
          type: boolean
        url:
          type: string
          description: Public subscribe URL
        lists:
          type: array
          items:
            $ref: '#/components/schemas/ContactList'
          description: Attached lists (included on create/update)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - title
        - lists

    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
        contacts_count:
          type: integer
        contact_limit:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    UserContactField:
      type: object
      properties:
        id:
          type: integer
        label:
          type: string
          description: Human-readable field label
        name:
          type: string
          description: Machine-readable slug (auto-generated from label)
        type:
          type: string
          enum: [text, number, date]
          description: Field type
      required:
        - label

    WhitelabelDomain:
      type: object
      properties:
        id:
          type: integer
        domain:
          type: string
        sendgrid_whitelabel_domain_id:
          type: integer
          nullable: true
        validated_at:
          type: string
          format: date-time
          nullable: true
        dns:
          type: object
          description: DNS records from SendGrid (included on show)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - domain

    ContactList:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        user_id:
          type: integer
        subscribed_contacts_count:
          type: integer
          description: >-
            Members who can still be mailed from this list. Excludes anyone who
            unsubscribed from the list itself as well as anyone who unsubscribed from
            the account.
        unsubscribed_contacts_count:
          type: integer
          description: >-
            Members who have stopped this list, by either the account-wide unsubscribe
            or this list's own. A hard bounce is included, because bouncing sets the
            account-wide flag. These two counts do not sum to total membership: an
            address flagged invalid is in neither.
        public_description:
          type: string
          nullable: true
          description: >-
            The name subscribers see for this list on the unsubscribe and preference
            pages, or null to show the list's own name.
        average_email_open_percent:
          type: number
          format: float
        average_email_click_percent:
          type: number
          format: float
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - name

    ContactTag:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        name:
          type: string
        color:
          type: string
          description: Brand palette hex color (e.g. "#FF644D").
        contacts_count:
          type: integer
          description: Number of contacts carrying the tag (included on list/show).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - name

    Automation:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        title:
          type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        automation_triggers:
          type: array
          items:
            $ref: '#/components/schemas/AutomationTrigger'
        automation_items:
          type: array
          items:
            $ref: '#/components/schemas/AutomationItem'

    AutomationTrigger:
      type: object
      properties:
        id:
          type: integer
        automation_id:
          type: integer
        type:
          type: string
          enum: [apply_list, apply_tag, open_campaign, click_campaign]
          description: >-
            What starts the automation: a contact joining list_id (apply_list),
            being given contact_tag_id (apply_tag), opening campaign_id
            (open_campaign), or clicking a link in campaign_id (click_campaign).
            A contact is enrolled once per automation; a click also counts as an open.
        list_id:
          type: integer
          nullable: true
        contact_tag_id:
          type: integer
          nullable: true
          description: Set for apply_tag triggers; the automation starts when a contact is given this tag
        campaign_id:
          type: integer
          nullable: true
          description: Set for open_campaign and click_campaign triggers; the campaign whose opens or clicks start the automation
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AutomationItem:
      type: object
      description: >-
        A step in an automation: an email, or a condition that sends each contact down its Yes or
        No path. Steps on a path carry the condition's id in parent_item_id and the path in
        branch. send_order orders the steps within the flow they sit in (the main flow, or one
        path). Conditions are built in the SendFox app; the automation-emails endpoints only act
        on email steps.
      properties:
        id:
          type: integer
        type:
          type: string
          enum: [email, condition]
        automation_id:
          type: integer
        campaign_id:
          type: integer
          nullable: true
          description: The step's email. Null for a condition.
        parent_item_id:
          type: integer
          nullable: true
          description: The condition this step's path belongs to. Null for a step in the main flow.
        branch:
          type: string
          nullable: true
          enum: [yes, no, null]
        delay_hours:
          type: integer
          description: Hours to wait before this step. For a condition, the wait before the contact is checked.
        send_order:
          type: integer
        condition_type:
          type: string
          nullable: true
          enum: [opened_previous, clicked_previous, has_tag, on_list, null]
          description: What a condition checks. "Previous" is the nearest email before the condition.
        condition_list_id:
          type: integer
          nullable: true
        condition_contact_tag_id:
          type: integer
          nullable: true
        condition_email_link_id:
          type: integer
          nullable: true
          description: For clicked_previous, the link that must be clicked. Null means any link.
        yes_path_ends:
          type: boolean
          description: Whether contacts leave the automation at the end of the Yes path, instead of continuing after the condition.
        no_path_ends:
          type: boolean
          description: Whether contacts leave the automation at the end of the No path, instead of continuing after the condition.
        campaign:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/Campaign'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ContactFilter:
      type: object
      description: >-
        An engagement filter over the account's contacts. Every condition is AND-ed. Omitting the
        object entirely means "all contacts".
      properties:
        status:
          type: string
          enum: [active, engaged, inactive, new, unconfirmed, unsubscribed, bounced, invalid]
        last_opened_after:
          type: string
          format: date-time
        last_opened_before:
          type: string
          format: date-time
          description: Excludes contacts who never opened — use never_opened for those
        last_clicked_after:
          type: string
          format: date-time
        last_clicked_before:
          type: string
          format: date-time
        last_sent_after:
          type: string
          format: date-time
        last_sent_before:
          type: string
          format: date-time
        created_after:
          type: string
          format: date-time
        created_before:
          type: string
          format: date-time
        never_opened:
          type: boolean
        never_clicked:
          type: boolean
        never_sent:
          type: boolean
        in_list_ids:
          type: array
          items:
            type: integer
          description: >-
            Contacts currently subscribed to at least one of these lists. Someone who
            unsubscribed from a list (via the unsubscribe page or the preference page)
            remains a member of it but is not matched here, so this filter can never
            reach people who opted out of the list it is built from. Changed with
            per-list unsubscribe: previously every member matched regardless of opt-out
            status, so results and counts may be smaller than before. Membership itself
            is unchanged and is still returned in full by GET /lists/{list_id}/contacts.
        not_in_list_ids:
          type: array
          items:
            type: integer
          description: >-
            Contacts with no membership in any of these lists. Membership is what counts
            here, not subscription status, so someone who unsubscribed from a list is
            still excluded. A contact who opted out of list A therefore matches neither
            in_list_ids [A] nor not_in_list_ids [A].
        tag_ids:
          type: array
          items:
            type: integer
          description: >-
            Contacts still subscribed to at least one of these tags. Someone who opted
            out of a tag (by keeping only some lists when they unsubscribed) keeps the
            tag but is not matched here, so this filter can never reach people who opted out of the tag it is
            built from. Changed with per-list unsubscribe, for the same reason and in the
            same way as in_list_ids: previously every tagged contact matched regardless
            of opt-out status, so results and counts may be smaller than before. The tag
            itself is unchanged and every carrier is still listed by the tag endpoints.
        not_tag_ids:
          type: array
          items:
            type: integer
          description: >-
            Contacts carrying none of these tags. Carrying the tag is what counts here,
            not subscription status, so someone who opted out of a tag is still
            excluded. A contact who opted out of tag A therefore matches neither
            tag_ids [A] nor not_tag_ids [A].
        opened_campaign_id:
          type: integer
          description: Bulk actions only — not available on GET /contacts
        not_opened_campaign_id:
          type: integer
          description: >-
            Contacts the campaign sent to who did not open it. Bulk actions only — not available
            on GET /contacts
        clicked_campaign_id:
          type: integer
          description: Bulk actions only — not available on GET /contacts

    BulkContactAction:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [pending, processing, completed, failed]
        action:
          type: string
          enum: [apply_tag, remove_tag, add_to_list, remove_from_list]
        target_id:
          type: integer
        dry_run:
          type: boolean
        filter:
          type: string
          description: Human-readable rendering of the filter that was applied
        matched_count:
          type: integer
          nullable: true
          description: Number of contacts the filter matched; null until counted
        processed_count:
          type: integer
        error:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true

    CampaignStats:
      type: object
      properties:
        link_stats:
          type: array
          description: >-
            The campaign's links ranked by click count, most-clicked first. Present only when
            include_link_stats is true — it is the one part of this payload that queries rather
            than reading a stored counter.
          items:
            type: object
            properties:
              url:
                type: string
              clicks:
                type: integer
        sent_count:
          type: integer
        unique_open_count:
          type: integer
        unique_click_count:
          type: integer
        unsubscribe_count:
          type: integer
        bounce_count:
          type: integer
        spam_count:
          type: integer
        open_rate:
          type: number
          format: float
        click_rate:
          type: number
          format: float
        unsubscribe_rate:
          type: number
          format: float
        bounce_rate:
          type: number
          format: float
        spam_rate:
          type: number
          format: float

    CampaignTemplate:
      type: object
      required: [id, name, html, created_at, updated_at, subject, preview_text, campaign_layout_id]
      properties:
        id:
          type: integer
        name:
          type: string
        html:
          type: string
          nullable: true
        created_at:
          type: string
          nullable: true
          format: date-time
        updated_at:
          type: string
          nullable: true
          format: date-time
        subject:
          type: string
          nullable: true
        preview_text:
          type: string
          nullable: true
        campaign_layout_id:
          type: string
          nullable: true

    CampaignSection:
      type: object
      required: [id, name, html, created_at, updated_at]
      properties:
        id:
          type: integer
        name:
          type: string
        html:
          type: string
          nullable: true
        created_at:
          type: string
          nullable: true
          format: date-time
        updated_at:
          type: string
          nullable: true
          format: date-time

    BatchResult:
      type: object
      properties:
        created:
          type: integer
        updated:
          type: integer

security:
  - oauth2: []

paths:
  /contacts:
    get:
      operationId: listContacts
      tags:
        - Contacts
      summary: List contacts
      description: |
        Returns a paginated list of contacts (100 per page by default, up to 1000 via `per_page`).

        Supports engagement filtering through `filter[...]` query parameters, so you can answer
        questions like "who last opened over a year ago" without paging the whole account. All
        filter conditions are AND-ed. Pass `count_only=true` to get just the number of matches —
        the cheapest way to size an audience before acting on it.
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering contacts
        - name: unsubscribed
          in: query
          schema:
            type: boolean
          description: Filter unsubscribed contacts
        - name: email
          in: query
          schema:
            type: string
          description: Filter by specific email
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
          description: Contacts per page
        - name: count_only
          in: query
          schema:
            type: boolean
          description: Return only the number of matching contacts, with no contact records
        - name: filter[status]
          in: query
          schema:
            type: string
            enum: [active, engaged, inactive, new, unconfirmed, unsubscribed, bounced, invalid]
          description: Engagement status
        - name: filter[last_opened_after]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts whose most recent open is on or after this date
        - name: filter[last_opened_before]
          in: query
          schema:
            type: string
            format: date-time
          description: >-
            Contacts whose most recent open is before this date. Excludes contacts who never
            opened — use filter[never_opened] for those.
        - name: filter[last_clicked_after]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts whose most recent click is on or after this date
        - name: filter[last_clicked_before]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts whose most recent click is before this date
        - name: filter[last_sent_after]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts last sent to on or after this date
        - name: filter[last_sent_before]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts last sent to before this date
        - name: filter[created_after]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts created on or after this date
        - name: filter[created_before]
          in: query
          schema:
            type: string
            format: date-time
          description: Contacts created before this date
        - name: filter[never_opened]
          in: query
          schema:
            type: boolean
          description: Only contacts who have never opened an email
        - name: filter[never_clicked]
          in: query
          schema:
            type: boolean
          description: Only contacts who have never clicked a link
        - name: filter[never_sent]
          in: query
          schema:
            type: boolean
          description: Only contacts who have never been sent an email
        - name: filter[in_list_ids]
          in: query
          schema:
            type: array
            items:
              type: integer
          description: >-
            Only contacts still subscribed to any of these lists. Members who
            unsubscribed from the list are not matched, so results may be smaller than
            before per-list unsubscribe shipped.
        - name: filter[not_in_list_ids]
          in: query
          schema:
            type: array
            items:
              type: integer
          description: >-
            Exclude contacts who are members of any of these lists, whether or not they
            unsubscribed from it.
        - name: filter[tag_ids]
          in: query
          schema:
            type: array
            items:
              type: integer
          description: >-
            Only contacts still subscribed to any of these tags. Somebody who opted out
            of the tag keeps it but is not matched. Same rule as
            filter[in_list_ids]; see the tag_ids note in the bulk-action filter schema.
        - name: filter[not_tag_ids]
          in: query
          schema:
            type: array
            items:
              type: integer
          description: Exclude contacts carrying any of these tags
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Contact'
                      current_page:
                        type: integer
                      total:
                        type: integer
                      per_page:
                        type: integer
                  - type: object
                    description: Returned when count_only is true
                    properties:
                      count:
                        type: integer
                      filter:
                        type: string
                        description: Human-readable rendering of the filter that was applied
        '401':
          description: Unauthorized
        '422':
          description: Invalid filter, or a list/tag id the account does not own
    post:
      operationId: createContact
      tags:
        - Contacts
      summary: Create a new contact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
                first_name:
                  type: string
                last_name:
                  type: string
                ip_address:
                  type: string
                lists:
                  type: array
                  items:
                    type: integer
                  description: Array of list IDs to add the contact to
                contact_fields:
                  type: array
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                      value:
                        type: string
      responses:
        '200':
          description: Contact created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Contact limit exceeded

  /contacts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getContact
      tags:
        - Contacts
      summary: Get a specific contact
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact not found
    patch:
      operationId: updateContact
      tags:
        - Contacts
      summary: Update a contact
      description: Update contact details including name, list memberships, and custom fields
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name:
                  type: string
                last_name:
                  type: string
                lists:
                  type: array
                  items:
                    type: integer
                  description: Array of list IDs (replaces all current list memberships)
                contact_fields:
                  type: array
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                      value:
                        type: string
                        nullable: true
      responses:
        '200':
          description: Contact updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      operationId: deleteContact
      tags:
        - Contacts
      summary: Delete a contact
      description: Soft-deletes a contact and cancels any scheduled deliverables
      responses:
        '200':
          description: Contact deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact not found

  /contacts/{id}/activity:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getContactActivity
      tags:
        - Contacts
      summary: Get email activity for a contact
      description: Returns paginated email deliverables and contact-level engagement summary
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact:
                    type: object
                    properties:
                      last_sent_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_opened_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_clicked_at:
                        type: string
                        format: date-time
                        nullable: true
                      unsubscribed_at:
                        type: string
                        format: date-time
                        nullable: true
                      bounced_at:
                        type: string
                        format: date-time
                        nullable: true
                  deliverables:
                    type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            campaign_title:
                              type: string
                            campaign_id:
                              type: integer
                            sent_at:
                              type: string
                              format: date-time
                              nullable: true
                            opened_at:
                              type: string
                              format: date-time
                              nullable: true
                            clicked_at:
                              type: string
                              format: date-time
                              nullable: true
                            bounced_at:
                              type: string
                              format: date-time
                              nullable: true
                            unsubscribed_at:
                              type: string
                              format: date-time
                              nullable: true
                            spam_at:
                              type: string
                              format: date-time
                              nullable: true
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact not found

  /contacts/batch:
    post:
      operationId: batchImportContacts
      tags:
        - Contacts
      summary: Batch import contacts
      description: Import up to 1,000 contacts in a single request. Creates new contacts or updates existing ones.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - contacts
              properties:
                contacts:
                  type: array
                  maxItems: 1000
                  items:
                    type: object
                    required:
                      - email
                    properties:
                      email:
                        type: string
                        format: email
                      first_name:
                        type: string
                      last_name:
                        type: string
                      lists:
                        type: array
                        items:
                          type: integer
      responses:
        '200':
          description: Batch import results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResult'
        '402':
          description: Contact limit would be exceeded
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /contacts/bulk-actions:
    post:
      operationId: createBulkContactAction
      tags:
        - Contacts
      summary: Apply a tag or list change to every contact matching a filter
      description: |
        Queues one action against every contact the filter matches, applied in chunks in the
        background. Returns immediately with an id to poll.

        **Run with `dry_run: true` first.** A dry run reports how many contacts match and changes
        nothing — it is the only way to catch a filter that matches more than intended, and a bulk
        write is not undoable. An empty filter (which would match the whole account) is rejected
        unless it is a dry run.

        Per-campaign conditions (`opened_campaign_id`, `not_opened_campaign_id`,
        `clicked_campaign_id`) are available here but not on `GET /contacts`, because they are
        resolved in the background rather than inside a request.

        Bulk delete and bulk unsubscribe are deliberately not offered.

        Because `in_list_ids` matches only subscribed members, `remove_from_list` with
        `filter.in_list_ids` set to that same list leaves the memberships of people who
        unsubscribed from it in place. They already receive nothing from that list.

        `tag_ids` behaves the same way, so `remove_tag` with `filter.tag_ids` set to that
        same tag leaves the tag on people who unsubscribed from it. They already receive
        nothing sent to that tag. To reach every carrier regardless of opt-out status,
        select them by some other filter, or read them from the tag endpoints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - action
                - target_id
              properties:
                action:
                  type: string
                  enum: [apply_tag, remove_tag, add_to_list, remove_from_list]
                target_id:
                  type: integer
                  description: The tag id (apply_tag/remove_tag) or list id (add_to_list/remove_from_list)
                dry_run:
                  type: boolean
                  default: false
                  description: When true, only count the matches — nothing is modified
                filter:
                  $ref: '#/components/schemas/ContactFilter'
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkContactAction'
        '403':
          description: The account is restricted
        '422':
          description: >-
            Invalid filter, an unowned target or filter id, or an empty filter on a non-dry run

  /contacts/bulk-actions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getBulkContactAction
      tags:
        - Contacts
      summary: Check the progress of a bulk contact action
      description: >-
        For a dry run, matched_count is the answer and nothing was modified. A run whose match set
        exceeds the per-request ceiling fails without applying anything.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkContactAction'
        '404':
          description: Not found

  /contacts/unsubscribed:
    get:
      operationId: listUnsubscribedContacts
      tags:
        - Contacts
      summary: List unsubscribed contacts
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering contacts
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer

  /unsubscribe:
    patch:
      operationId: unsubscribeContact
      tags:
        - Contacts
      summary: Unsubscribe a contact by email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
              required:
                - email
      responses:
        '200':
          description: Contact unsubscribed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /contact-tags:
    get:
      operationId: listContactTags
      tags:
        - Contact Tags
      summary: List contact tags
      description: Lists the account's tags, newest first, each with its contact count. Distinct from the legacy /tags endpoints, which operate on lists.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ContactTag'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createContactTag
      tags:
        - Contact Tags
      summary: Create a contact tag
      description: Creates a tag. A brand color is auto-assigned when none is provided. Tag names are unique per account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 191
                color:
                  type: string
                  description: Optional brand palette hex color; auto-assigned when omitted.
              required:
                - name
      responses:
        '201':
          description: Tag created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactTag'
        '401':
          description: Unauthorized
        '422':
          description: Validation error (duplicate name or invalid color)

  /contact-tags/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getContactTag
      tags:
        - Contact Tags
      summary: Get a contact tag
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactTag'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Tag not found
    patch:
      operationId: updateContactTag
      tags:
        - Contact Tags
      summary: Update a contact tag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 191
                color:
                  type: string
      responses:
        '200':
          description: Tag updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactTag'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteContactTag
      tags:
        - Contact Tags
      summary: Delete a contact tag
      description: Deletes the tag. Tagged contacts are kept — only the tag and its attachments (including campaign audience use) are removed. Returns 409 if an automation starts on the tag or adds/removes it.
      responses:
        '200':
          description: Tag deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Tag is used by an automation
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  usages:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum: [automation_trigger, automation_event, automation_condition]
                        id:
                          type: integer
                        title:
                          type: string
        '404':
          description: Tag not found

  /contacts/{contact_id}/tags:
    parameters:
      - name: contact_id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: listContactTagsForContact
      tags:
        - Contact Tags
      summary: List a contact's tags
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContactTag'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact not found

  /contacts/{contact_id}/tags/{tag_id}:
    parameters:
      - name: contact_id
        in: path
        required: true
        schema:
          type: integer
      - name: tag_id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: attachContactTag
      tags:
        - Contact Tags
      summary: Attach a tag to a contact
      description: Idempotent. Returns the contact's tags after the change.
      responses:
        '200':
          description: Tag attached
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContactTag'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact or tag not found
    delete:
      operationId: detachContactTag
      tags:
        - Contact Tags
      summary: Remove a tag from a contact
      responses:
        '200':
          description: Tag removed
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContactTag'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact or tag not found

  /campaigns:
    get:
      operationId: listCampaigns
      tags:
        - Campaigns
      summary: List campaigns
      description: Returns a paginated list of campaigns (100 per page)
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Campaign'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
    post:
      operationId: createCampaign
      tags:
        - Campaigns
      summary: Create a new campaign
      description: |
        Creates a campaign as a draft. To send it, use the send endpoint or provide scheduled_at.
        Subject lines cannot start with "RE:" or "FWD:".
        At least one list is required if scheduled_at is provided.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 191
                subject:
                  type: string
                  maxLength: 191
                preview_text:
                  type: string
                  maxLength: 191
                  nullable: true
                  description: Inbox preview snippet shown beneath the subject line. Optional.
                html:
                  type: string
                  maxLength: 1000000
                  description: >-
                    Email body HTML content. Unless the campaign uses the Code Your Own layout, tags
                    outside the editor's set are removed on save, along with event-handler attributes
                    (such as onclick) and javascript: links.
                from_name:
                  type: string
                  maxLength: 191
                from_email:
                  type: string
                  format: email
                  maxLength: 191
                scheduled_at:
                  type: string
                  format: date-time
                  description: Schedule send time (omit for draft). Must include at least one list.
                lists:
                  type: array
                  items:
                    type: integer
                  description: Array of list IDs to send to
                excluded_lists:
                  type: array
                  items:
                    type: integer
                  description: List IDs whose contacts should be excluded from the send. Exclusion wins over inclusion.
                to_contact_tags:
                  type: array
                  items:
                    type: integer
                  description: Tag IDs whose contacts should receive the campaign. Can be combined with lists.
                excluded_contact_tags:
                  type: array
                  items:
                    type: integer
                  description: Tag IDs whose contacts should be excluded from the send. Exclusion wins over inclusion.
                web_publish:
                  type: boolean
                  description: Publish the email to the web on your primary Smart Page once it sends. Requires a Smart Page. Omit it to list the email on every Smart Page whose Newsletters tab is on; send false to keep it off the web.
                web_gate:
                  type: boolean
                  default: false
                  description: On a published email, ask new readers for their email address to read past the opening. The account-wide signup requirement in Settings overrides this choice when enabled, including when false is sent or this field is omitted.
              required:
                - title
                - subject
                - html
                - from_name
                - from_email
      responses:
        '201':
          description: Campaign created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '403':
          description: Forbidden (not subscribed or cannot schedule)
        '422':
          description: Validation error or configuration required (e.g., timezone not set)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /campaigns/stats:
    get:
      operationId: getBatchCampaignStats
      tags: [Campaigns]
      summary: Get statistics for multiple campaigns
      description: >-
        Returns stored counts and rates for one to 100 distinct owned campaigns in request order.
        The entire request fails with the same validation error for missing, deleted or foreign IDs.
        Does not return link stats or query individual delivery events.
      parameters:
        - name: campaign_ids[]
          in: query
          required: true
          style: form
          explode: true
          description: Repeat campaign_ids[] for each campaign ID.
          schema:
            type: array
            minItems: 1
            maxItems: 100
            uniqueItems: true
            items:
              type: integer
              minimum: 1
      responses:
        '200':
          description: Campaign statistics in request order
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/CampaignStats'
                        - type: object
                          required: [id]
                          properties:
                            id:
                              type: integer
        '401':
          description: Unauthorized
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid campaign_ids; no partial results are returned

  /campaigns/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getCampaign
      tags:
        - Campaigns
      summary: Get a specific campaign
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Campaign not found
    patch:
      operationId: updateCampaign
      tags:
        - Campaigns
      summary: Update a draft campaign
      description: Only draft campaigns (not yet sent) can be updated. All fields are optional.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 191
                subject:
                  type: string
                  maxLength: 191
                preview_text:
                  type: string
                  maxLength: 191
                  nullable: true
                  description: Inbox preview snippet. Pass null to clear.
                html:
                  type: string
                  maxLength: 1000000
                  description: >-
                    Email body HTML content. Filtered on save the same way as when creating a
                    campaign.
                from_name:
                  type: string
                  maxLength: 191
                from_email:
                  type: string
                  format: email
                  maxLength: 191
                scheduled_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: Set to null to unschedule, or a datetime to schedule
                lists:
                  type: array
                  items:
                    type: integer
                  description: Replaces all list assignments
                excluded_lists:
                  type: array
                  items:
                    type: integer
                  description: Replaces the excluded lists (contacts in these lists are removed from the send).
                to_contact_tags:
                  type: array
                  items:
                    type: integer
                  description: Replaces the included tag audience (contacts carrying these tags receive the campaign).
                excluded_contact_tags:
                  type: array
                  items:
                    type: integer
                  description: Replaces the excluded tag audience (contacts carrying these tags are removed from the send).
      responses:
        '200':
          description: Campaign updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: >-
            Campaign already sent, or a scheduled_at was supplied for a confirmation
            (double opt-in) email. A confirmation email is delivered to each contact as
            they subscribe and cannot be scheduled; its content can still be updated.
        '422':
          description: Validation error
    delete:
      operationId: deleteCampaign
      tags:
        - Campaigns
      summary: Delete a draft campaign
      description: Only draft campaigns (not yet sent) can be deleted. Uses soft delete.
      responses:
        '200':
          description: Campaign deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Campaign already sent

  /campaigns/{id}/send:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: sendCampaign
      tags:
        - Campaigns
      summary: Send a campaign immediately
      description: |
        Schedules a draft campaign for immediate sending. The campaign must:
        - Not already be sent or scheduled
        - Have at least one list assigned
        - Not be a confirmation (double opt-in) email — those are delivered to each
          contact as they subscribe, never as a broadcast
        - The user must not be in a warmup/throttle period

        All existing abuse prevention applies automatically: content approval workflow,
        sending throttles, spam detection, and bounce rate monitoring.
      responses:
        '200':
          description: Campaign scheduled for sending
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: Campaign has no lists assigned
        '403':
          description: Forbidden (cannot send; a large campaign is already scheduled and must finish first)
        '409':
          description: >-
            Campaign already sent or scheduled, or the campaign is a confirmation
            (double opt-in) email, which is sent to each contact as they subscribe.

  /campaigns/{id}/send-test:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: sendTestCampaign
      tags:
        - Campaigns
      summary: Send a test of a campaign
      description: |
        Delivers the rendered campaign to the addresses given so it can be checked in a
        real inbox. This is a real send to those addresses only: the campaign's audience is
        untouched and the campaign stays a draft. Recipients are added to the account's
        "My Test Contacts" list.

        Limits: up to 30 addresses per call (1 on the free plan) and 30 test sends per
        account per day, shared with Send Test in the SendFox app. A test from a newly
        created account goes to the account's own address only, unless the campaign sends
        from a domain the account has verified; `notice` in the response says so when
        addresses were dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 30
                  items:
                    type: string
                    format: email
                  description: Addresses to deliver the test to. Trimmed and de-duplicated.
      responses:
        '200':
          description: Test delivered
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent:
                    type: boolean
                  campaign_id:
                    type: integer
                  sent_to:
                    type: array
                    items:
                      type: string
                      format: email
                    description: The addresses the test actually went to
                  notice:
                    type: string
                    nullable: true
                    description: Set when the test was narrowed to the account's own address
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Campaign not found
        '422':
          description: No addresses, an invalid address, or more than the per-call limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Daily test-send limit reached

  /campaigns/{id}/stats:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getCampaignStats
      tags:
        - Campaigns
      summary: Get campaign performance statistics
      description: >-
        Returns sent count and open/click/bounce/unsubscribe/spam counts and rates, all read from
        stored counters.


        Pass `include_link_stats=true` to also get `link_stats` — the campaign's links ranked by
        click count. That part is opt-in because it counts rows in `email_link_clicks` once per
        link rather than reading a counter, so leaving it off keeps this endpoint as cheap as it
        has always been.
      parameters:
        - name: include_link_stats
          in: query
          schema:
            type: boolean
            default: false
          description: When true, include the link_stats breakdown in the response
        - name: link_limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: >-
            How many links to return in link_stats, most-clicked first. Ignored unless
            include_link_stats is true.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignStats'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Campaign not found

  /campaigns/{id}/engagement:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: listCampaignEngagement
      tags:
        - Campaigns
      summary: List the contacts behind a campaign's engagement
      description: >-
        Returns the contacts in one of a sent campaign's engagement groups. non_openers covers
        contacts the campaign actually sent to who did not open it — queued and cancelled
        deliverables are excluded, since they never had the chance.
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [openers, clickers, non_openers, bounced, unsubscribed]
          description: Which engagement group to list
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
          description: Records per page
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        contact_id:
                          type: integer
                        email:
                          type: string
                        first_name:
                          type: string
                          nullable: true
                        last_name:
                          type: string
                          nullable: true
                        sent_at:
                          type: string
                          format: date-time
                          nullable: true
                        opened_at:
                          type: string
                          format: date-time
                          nullable: true
                        clicked_at:
                          type: string
                          format: date-time
                          nullable: true
                        bounced_at:
                          type: string
                          format: date-time
                          nullable: true
                        unsubscribed_at:
                          type: string
                          format: date-time
                          nullable: true
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Campaign not found

  /campaigns/{id}/resend:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: resendCampaign
      tags:
        - Campaigns
      summary: Clone a sent campaign at part of its original audience
      description: >-
        Creates a new draft with the original's content, sender, and exclusions, aimed at the
        chosen slice of the original audience. Omit scheduled_at to leave it as a draft. The
        source campaign's lists are deliberately not carried over — the engagement segment is
        the audience.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - audience
              properties:
                audience:
                  type: string
                  enum: [non_openers, openers, clickers]
                subject:
                  type: string
                  maxLength: 191
                  description: Optional new subject line; defaults to the original's
                title:
                  type: string
                  maxLength: 191
                  description: 'Optional internal name; defaults to "Resend: <original title>"'
                scheduled_at:
                  type: string
                  format: date-time
                  description: Optional send time; omit to leave the resend as a draft
      responses:
        '201':
          description: Draft created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Campaign not found
        '422':
          description: The campaign has not been sent, or scheduling is unavailable

  /forms:
    get:
      operationId: listForms
      tags:
        - Forms
      summary: List forms
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering forms
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Form'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createForm
      tags:
        - Forms
      summary: Create a new form
      description: |
        Creates a subscription form linked to one or more lists.
        Free users are limited to 1 form.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 191
                lists:
                  type: array
                  items:
                    type: integer
                  description: Array of list IDs to attach
                redirect_url:
                  type: string
                  format: uri
                  nullable: true
                  description: URL to redirect to after subscription
                gdpr_required:
                  type: boolean
                  description: Whether GDPR consent checkbox is required
              required:
                - title
                - lists
      responses:
        '201':
          description: Form created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Form'
        '403':
          description: Forbidden (free user form limit reached)
        '422':
          description: Validation error

  /forms/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getForm
      tags:
        - Forms
      summary: Get a specific form
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Form'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Form not found
    patch:
      operationId: updateForm
      tags:
        - Forms
      summary: Update a form
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 191
                lists:
                  type: array
                  items:
                    type: integer
                  description: Replaces all list assignments
                redirect_url:
                  type: string
                  format: uri
                  nullable: true
                gdpr_required:
                  type: boolean
      responses:
        '200':
          description: Form updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Form'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteForm
      tags:
        - Forms
      summary: Delete a form
      description: Soft-deletes the form
      responses:
        '200':
          description: Form deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'

  /me:
    get:
      operationId: getCurrentUser
      tags:
        - Users
      summary: Get current user information
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthorized

  /campaign-templates:
    get:
      operationId: listCampaignTemplates
      tags: [Content Library]
      summary: List saved campaign templates
      description: >-
        Returns only content owned by the authenticated account, including saved HTML.
        Shared gallery content is not included. This endpoint does not publish, modify or send content.
        Results are ordered by descending ID, 20 per page.
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: q
          in: query
          description: Filter by name using the existing library search (SQL LIKE wildcards are supported).
          schema:
            type: string
            maxLength: 191
      responses:
        '200':
          description: Saved content
          content:
            application/json:
              schema:
                type: object
                required: [data, current_page, per_page, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CampaignTemplate'
                  current_page:
                    type: integer
                  per_page:
                    type: integer
                    enum: [20]
                  total:
                    type: integer
                  last_page:
                    type: integer
                  next_page_url:
                    type: string
                    nullable: true
                  prev_page_url:
                    type: string
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid page or search input

  /campaign-templates/{id}:
    get:
      operationId: getCampaignTemplate
      tags: [Content Library]
      summary: Get a saved campaign template
      description: >-
        Returns only content owned by the authenticated account, including saved HTML.
        Shared gallery content is not included. This endpoint does not publish, modify or send content.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: Saved content
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignTemplate'
        '401':
          description: Unauthorized
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Content does not exist or is not owned by this account

  /campaign-sections:
    get:
      operationId: listCampaignSections
      tags: [Content Library]
      summary: List saved campaign sections
      description: >-
        Returns only content owned by the authenticated account, including saved HTML.
        Shared gallery content is not included. This endpoint does not publish, modify or send content.
        Results are ordered by descending ID, 20 per page.
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: q
          in: query
          description: Filter by name using the existing library search (SQL LIKE wildcards are supported).
          schema:
            type: string
            maxLength: 191
      responses:
        '200':
          description: Saved content
          content:
            application/json:
              schema:
                type: object
                required: [data, current_page, per_page, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CampaignSection'
                  current_page:
                    type: integer
                  per_page:
                    type: integer
                    enum: [20]
                  total:
                    type: integer
                  last_page:
                    type: integer
                  next_page_url:
                    type: string
                    nullable: true
                  prev_page_url:
                    type: string
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid page or search input

  /campaign-sections/{id}:
    get:
      operationId: getCampaignSection
      tags: [Content Library]
      summary: Get a saved campaign section
      description: >-
        Returns only content owned by the authenticated account, including saved HTML.
        Shared gallery content is not included. This endpoint does not publish, modify or send content.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: Saved content
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignSection'
        '401':
          description: Unauthorized
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Content does not exist or is not owned by this account

  /contact-fields:
    get:
      operationId: listContactFields
      tags:
        - Contact Fields
      summary: List user contact fields
      description: Returns custom contact fields defined by the user (20 per page)
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserContactField'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createContactField
      tags:
        - Contact Fields
      summary: Create a custom contact field
      description: |
        Creates a custom field for contacts. The `name` is auto-generated from the `label` as a slug.

        Provide `type` (`text`, `number`, or `date`) or the existing `contact_field_type_id` UUID.
        If both are provided, they must identify the same type. UUIDs are the same for every account:

        | Type   | `contact_field_type_id`                |
        | ------ | -------------------------------------- |
        | text   | `0abe5601-7738-43c8-858f-81911ecf89ee` |
        | number | `e43c6471-b3ff-4541-80c5-964c62decd15` |
        | date   | `fe7dd76c-5113-4159-bc7c-0dbf0ed8d404` |

        The value is immutable after creation and is not returned by any endpoint - responses
        carry the human-readable `type` (`text`, `number`, `date`) instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 191
                  description: Human-readable field label
                contact_field_type_id:
                  type: string
                  description: >-
                    UUID of the field's data type. Required unless type is provided; must match type if both are provided.
                  enum:
                    - 0abe5601-7738-43c8-858f-81911ecf89ee
                    - e43c6471-b3ff-4541-80c5-964c62decd15
                    - fe7dd76c-5113-4159-bc7c-0dbf0ed8d404
                  example: 0abe5601-7738-43c8-858f-81911ecf89ee
                type:
                  type: string
                  enum: [text, number, date]
                  example: text
                  description: Readable data type; required unless contact_field_type_id is provided.
              required:
                - label
              anyOf:
                - required: [type]
                - required: [contact_field_type_id]
      responses:
        '201':
          description: Contact field created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserContactField'
        '422':
          description: Validation error

  /contact-fields/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getContactField
      tags:
        - Contact Fields
      summary: Get a specific contact field
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserContactField'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact field not found
    patch:
      operationId: updateContactField
      tags:
        - Contact Fields
      summary: Update a contact field label
      description: |
        Updates the label and auto-regenerates the name slug.
        Note: `contact_field_type_id` is immutable and cannot be changed after creation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 191
              required:
                - label
      responses:
        '200':
          description: Contact field updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserContactField'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteContactField
      tags:
        - Contact Fields
      summary: Delete a contact field
      description: Permanently deletes the contact field
      responses:
        '200':
          description: Contact field deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'

  /lists:
    get:
      operationId: listContactLists
      tags:
        - Lists
      summary: List contact lists
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering lists
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ContactList'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createContactList
      tags:
        - Lists
      summary: Create a new contact list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
              required:
                - name
      responses:
        '200':
          description: List created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized

  /lists/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getContactList
      tags:
        - Lists
      summary: Get a specific contact list
      description: Returns list details including average open and click rates
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: List not found
    patch:
      operationId: updateContactList
      tags:
        - Lists
      summary: Update a contact list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 191
              required:
                - name
      responses:
        '200':
          description: List updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteContactList
      tags:
        - Lists
      summary: Delete a contact list
      description: Soft-deletes a list. Returns 409 if the list is used by forms, landing pages, or automations.
      responses:
        '200':
          description: List deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: List is in use
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  usages:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                        id:
                          type: integer
                        title:
                          type: string
        '404':
          description: List not found

  /lists/{list_id}/unsubscribes:
    parameters:
      - name: list_id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: listListUnsubscribes
      tags:
        - Lists
      summary: Get everyone who has stopped this list
      description: >-
        Members who no longer receive this list, and when they stopped. Each row is a
        Contact with two extra fields.

        The two timestamps are never merged. `list_unsubscribed_at` is this list only.
        `unsubscribed_at` keeps the meaning it has everywhere else in this API: the
        account-wide opt-out. Treating a per-list timestamp as an account-wide one would
        suppress that person across your whole integration because they left a single
        list, so read `unsubscribed_scope` if you need one field to branch on.
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering contacts
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Contact'
                        - type: object
                          properties:
                            list_unsubscribed_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: >-
                                When they stopped THIS list. Null if they never named it
                                and only hold an account-wide unsubscribe.
                            unsubscribed_scope:
                              type: string
                              enum: [list, account, both]
                              description: >-
                                `list` they left this list only; `account` they
                                unsubscribed from everything without naming this list;
                                `both` they hold both marks, which is what clicking
                                unsubscribe in one of this list's emails produces.
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '403':
          $ref: '#/components/responses/Forbidden'

  /lists/{list_id}/unsubscribe:
    parameters:
      - name: list_id
        in: path
        required: true
        schema:
          type: integer
    patch:
      operationId: unsubscribeContactFromList
      tags:
        - Lists
      summary: Unsubscribe a contact from one list
      description: >-
        Stops this one list for this one contact. Their other lists and their
        account-wide status are untouched, which is what distinguishes this from
        `PATCH /unsubscribe` — that endpoint is account-wide and is unchanged.

        Idempotent. Repeating the request returns the original timestamp rather than
        moving it, so a retry never loses the date the person actually asked.

        The opt-out is recorded durably, so it survives the contact being removed from
        the list and added back: re-adding them does not start sending again.

        There is no matching resubscribe endpoint. Taking consent away on someone's
        behalf is safe for an integration to do; handing it back is not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  description: Email address of a contact already on this list
              required:
                - email
      responses:
        '200':
          description: The contact, with the same two extra fields the unsubscribes list returns.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Contact'
                  - type: object
                    properties:
                      list_unsubscribed_at:
                        type: string
                        format: date-time
                        description: When they stopped this list.
                      unsubscribed_scope:
                        type: string
                        enum: [list, account, both]
        '400':
          description: Missing or malformed email
        '403':
          description: >-
            Not your list, or your plan does not permit changes. Reading
            GET /lists/{list_id}/unsubscribes stays available either way.
        '404':
          description: That contact is not on this list

  /lists/{list_id}/contacts:
    parameters:
      - name: list_id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: listContactsInList
      tags:
        - Lists
      summary: Get contacts in a list
      description: >-
        Returns every contact on the list. This is membership, not subscription: someone
        who unsubscribed from the list is still a member and is still returned, so that
        an integration mirroring this endpoint can tell "removed from the list" apart
        from "asked to stop receiving it". Pass `status` to narrow it.
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Search query for filtering contacts
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [subscribed, unsubscribed]
          description: >-
            Optional. Omit for every member, which is the long-standing behaviour of this
            endpoint. `subscribed` returns members who can still be mailed from this list;
            `unsubscribed` returns members who have stopped it, whether by leaving this
            list or by unsubscribing from the account. The two do not sum to the
            unfiltered total, because an address flagged invalid is in neither. A hard
            bounce counts as unsubscribed, since bouncing sets the account-wide flag.
            They match the Subscribed and Unsubscribed figures shown on the Lists page
            exactly. An unrecognised value is rejected with 400 rather than ignored.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '400':
          description: Unrecognised status value
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: addContactToList
      tags:
        - Lists
      summary: Add a contact to a list
      description: Adds an existing contact to a list. If the contact is already in the list, no duplicate is created.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                contact_id:
                  type: integer
                  description: ID of the contact to add
              required:
                - contact_id
      responses:
        '200':
          description: Contact added to list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact not found
        '422':
          description: Validation error

  /lists/{list_id}/contacts/{contact_id}:
    parameters:
      - name: list_id
        in: path
        required: true
        schema:
          type: integer
      - name: contact_id
        in: path
        required: true
        schema:
          type: integer
    delete:
      operationId: removeContactFromList
      tags:
        - Lists
      summary: Remove a contact from a list
      responses:
        '200':
          description: Contact removed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Contact or list not found

  /domains:
    get:
      operationId: listDomains
      tags:
        - Domains
      summary: List sender domains
      description: |
        Returns a paginated list of the user's whitelabel/sender domains.

        Sending domains are shared by marketing campaigns and transactional email.
        Every Domains endpoint accepts a paid-plan API key or a transactional key
        that carries the `transactional:domains` scope. A transactional key
        without that scope gets a 403 in the transactional error shape
        (`insufficient_scope`), and one on an account without transactional
        access gets `feature_not_available`.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WhitelabelDomain'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createDomain
      tags:
        - Domains
      summary: Add a sender domain
      description: |
        Adds a new sender domain and creates the corresponding SendGrid whitelabel domain.
        Requires a provisioned SendGrid subuser; accounts get one shortly after signup.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                domain:
                  type: string
                  maxLength: 191
                  description: Domain name (e.g., example.com). Do not include @ or protocol.
              required:
                - domain
      responses:
        '201':
          description: Domain created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WhitelabelDomain'
        '403':
          description: Forbidden (SendGrid subuser not provisioned yet)
        '422':
          description: Validation error (invalid domain, already taken, or already exists)

  /domains/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getDomain
      tags:
        - Domains
      summary: Get domain with DNS records
      description: Returns domain details including DNS records needed for verification
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WhitelabelDomain'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Domain not found
    delete:
      operationId: deleteDomain
      tags:
        - Domains
      summary: Delete a sender domain
      description: Removes the domain from SendGrid and soft-deletes locally
      responses:
        '200':
          description: Domain deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'

  /domains/{id}/validate:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: validateDomain
      tags:
        - Domains
      summary: Validate domain DNS records
      description: |
        Triggers DNS validation for the domain via SendGrid.
        Returns whether validation passed and any errors for specific DNS records.
      responses:
        '200':
          description: Domain validated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  valid:
                    type: boolean
                  domain:
                    $ref: '#/components/schemas/WhitelabelDomain'
        '400':
          description: Validation failed (DNS records not configured correctly)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  valid:
                    type: boolean
                  validation_errors:
                    type: object
        '403':
          $ref: '#/components/responses/Forbidden'

  /automations:
    get:
      operationId: listAutomations
      tags:
        - Automations
      summary: List automations
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Automation'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: Unauthorized
    post:
      operationId: createAutomation
      tags:
        - Automations
      summary: Create an automation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - title
              properties:
                title:
                  type: string
                trigger_type:
                  type: string
                  enum: [apply_list, apply_tag, open_campaign, click_campaign]
                trigger_list_id:
                  type: integer
                  description: Required when trigger_type is apply_list
                trigger_contact_tag_id:
                  type: integer
                  description: Required when trigger_type is apply_tag
                trigger_campaign_id:
                  type: integer
                  description: >-
                    Required when trigger_type is open_campaign or click_campaign. Any campaign
                    you own, sent or not yet sent, including an email inside another automation.
                active:
                  type: boolean
      responses:
        '201':
          description: Automation created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '402':
          description: Payment Required (API access requires a paid plan)
        '403':
          description: Forbidden
        '422':
          description: Validation error

  /automations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      operationId: getAutomation
      tags:
        - Automations
      summary: Get a specific automation
      description: Returns automation with triggers, items, and campaign stats
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '401':
          description: Unauthorized
        '404':
          description: Automation not found
    patch:
      operationId: updateAutomation
      tags:
        - Automations
      summary: Update an automation
      description: Update title, trigger, or active status. Activating reschedules stale deliverables.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                active:
                  type: boolean
                trigger_type:
                  type: string
                  enum: [apply_list, apply_tag, open_campaign, click_campaign]
                trigger_list_id:
                  type: integer
                  description: Required when trigger_type is apply_list
                trigger_contact_tag_id:
                  type: integer
                  description: Required when trigger_type is apply_tag
                trigger_campaign_id:
                  type: integer
                  description: Required when trigger_type is open_campaign or click_campaign
      responses:
        '200':
          description: Automation updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Automation'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteAutomation
      tags:
        - Automations
      summary: Delete an automation
      description: Soft-deletes the automation and cancels all scheduled deliverables
      responses:
        '200':
          description: Automation deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Automation not found

  /automations/{id}/emails:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      operationId: createAutomationEmail
      tags:
        - Automations
      summary: Add an email to an automation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - subject
                - html
                - from_name
                - from_email
              properties:
                subject:
                  type: string
                html:
                  type: string
                from_name:
                  type: string
                from_email:
                  type: string
                  format: email
                delay_hours:
                  type: integer
                  minimum: 0
                  maximum: 5000
                  description: Hours to wait before sending (default 24, first email defaults to 0)
      responses:
        '201':
          description: Automation email created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutomationItem'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error

  /automation-emails/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    patch:
      operationId: updateAutomationEmail
      tags:
        - Automations
      summary: Update an automation email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subject:
                  type: string
                html:
                  type: string
                from_name:
                  type: string
                from_email:
                  type: string
                  format: email
                delay_hours:
                  type: integer
                  minimum: 0
                  maximum: 5000
                send_order:
                  type: integer
                  minimum: 1
      responses:
        '200':
          description: Automation email updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutomationItem'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
    delete:
      operationId: deleteAutomationEmail
      tags:
        - Automations
      summary: Remove an email from an automation
      responses:
        '200':
          description: Automation email deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Automation email not found

  /transactional/emails:
    post:
      operationId: sendTransactionalEmail
      tags:
        - Transactional
      summary: Send an email
      description: |
        Queues one email. The response is immediate; delivery happens in the
        background and is reported through `GET /transactional/emails/{id}`
        and your webhook.

        **Idempotency.** Send an `Idempotency-Key` header (1 to 256 characters)
        to make retries safe. Within 24 hours, the same key with the same body
        returns the original message (with an `idempotent-replayed: true`
        header); the same key with a different body is a `409
        invalid_idempotent_request`.

        **Sandbox.** Send from `onboarding@sendfoxmail.com` with no DNS setup.
        It delivers only to your own account email and to the four test
        addresses. Test addresses never reach a mailbox and never count toward
        your limits; each one synthesizes an outcome so you can exercise your
        integration: `delivered@sendfoxmail.com`, `bounced@sendfoxmail.com`,
        `complained@sendfoxmail.com`, `suppressed@sendfoxmail.com`.

        **Limits.** Transactional email has no quota of its own: every
        address in `to`, `cc` and `bcc` counts as one send against your
        SendFox plan, the same monthly sends your campaigns use, with no
        daily cap. When the plan has a cap, every response carries
        `x-sendfox-sends-remaining`, the sends left across campaigns and
        transactional email; a plan without an enforced cap sends no header.
        Past the cap, sends are refused with `429 monthly_quota_exceeded`
        carrying `remaining` and `requested`. Each message holds at most 10
        addresses across `to`, `cc` and `bcc`, at most 2 in `bcc`. Requests
        are limited to 5 per second and 300 per minute per key.

        **Custom headers.** Pass `headers` to add list-management and
        threading headers. For one-click unsubscribe (RFC 8058, which Gmail
        and Yahoo require of bulk senders) send `List-Unsubscribe` with an
        `https:` URL in angle brackets and `List-Unsubscribe-Post` set to
        `List-Unsubscribe=One-Click`; the URL must accept a `POST` whose body
        is `List-Unsubscribe=One-Click` and unsubscribe the recipient without
        any further click. See the `headers` field for what is allowed.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 256
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransactionalSendRequest'
      responses:
        '202':
          description: Accepted and queued
          headers:
            x-request-id:
              schema:
                type: string
            x-sendfox-sends-remaining:
              description: Sends the plan has left, across campaigns and transactional email. Absent when the plan has no enforced cap.
              schema:
                type: integer
            idempotent-replayed:
              description: Present (`true`) when an Idempotency-Key matched an earlier request.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    example: tm_4f0c9b2e7d1a5c3e8b6f2a9d
                  status:
                    type: string
                    example: queued
        '401':
          description: missing_api_key or invalid_api_key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '403':
          description: >-
            insufficient_scope, feature_not_available, domain_not_verified,
            sandbox_recipient_not_allowed, recipient_suppressed, account_paused,
            account_restricted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '409':
          description: invalid_idempotent_request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '422':
          description: validation_error, with field errors in `errors`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '429':
          description: rate_limit_exceeded or monthly_quota_exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
    get:
      operationId: listTransactionalEmails
      tags:
        - Transactional
      summary: List emails
      description: Newest first. Standard SendFox paginator; bodies are never included.
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [queued, sent, delivered, bounced, complained, failed, suppressed]
        - in: query
          name: tag
          schema:
            type: string
        - in: query
          name: to
          description: Exact match on the first `to` address.
          schema:
            type: string
            format: email
        - in: query
          name: mode
          schema:
            type: string
            enum: [live, test]
        - in: query
          name: page
          schema:
            type: integer
        - in: query
          name: per_page
          schema:
            type: integer
            maximum: 100
            default: 25
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TransactionalMessage'
                  current_page:
                    type: integer
                  total:
                    type: integer
                  per_page:
                    type: integer
        '401':
          description: missing_api_key or invalid_api_key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '403':
          description: insufficient_scope (needs transactional:read), feature_not_available, account_restricted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'

  /transactional/emails/batch:
    post:
      operationId: sendTransactionalEmailBatch
      tags:
        - Transactional
      summary: Send a batch of emails
      description: |
        Queues up to 100 emails in one request and reports a result per item.
        Items are independent: one invalid address, unverified sender or
        suppressed recipient is reported at its `index` and the rest are
        queued. The response is `200` whenever the batch itself was accepted,
        even if every item was refused; read each entry's `error`.

        **Limits.** The whole batch's sends (one per `to`, `cc` and `bcc`
        address across every item) are reserved against your plan before
        any item is looked at. A batch that does not fit is refused with
        `429 monthly_quota_exceeded` and nothing is sent, so a retry can
        never half-send. Sends for refused items, test addresses and replays
        are returned. A batch request counts as one request against the 5
        per second and 300 per minute rate limit; the plan's sends are what
        bound volume.

        **Idempotency.** Two levels. An `Idempotency-Key` header makes the
        whole batch replayable for 24 hours: the same key with the same
        `emails` returns the original results with `idempotent-replayed: true`;
        a different body is a `409 invalid_idempotent_request`. A per-item
        `idempotency_key` protects each email on its own, exactly like the
        header on `POST /transactional/emails`, and is the one to rely on when
        your retry may be built from a different batch.

        Sandbox, test addresses and tags work per item as on the single route.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 256
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransactionalBatchSendRequest'
            example:
              emails:
                - from: Acme <receipts@mail.acme.com>
                  to: ["jane@example.com"]
                  subject: Your receipt for order 1042
                  html: <p>Thanks, Jane.</p>
                  tag: receipt
                  idempotency_key: order-1042
                - from: Acme <receipts@mail.acme.com>
                  to: ["not-an-email"]
                  subject: Your receipt for order 1043
                  html: <p>Thanks.</p>
                  tag: receipt
      responses:
        '200':
          description: Batch accepted; one result per item
          headers:
            x-request-id:
              schema:
                type: string
            x-sendfox-sends-remaining:
              description: Sends the plan has left, across campaigns and transactional email. Absent when the plan has no enforced cap.
              schema:
                type: integer
            idempotent-replayed:
              description: Present (`true`) when an Idempotency-Key matched an earlier batch.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TransactionalBatchResult'
              example:
                data:
                  - index: 0
                    id: tm_4f0c9b2e7d1a5c3e8b6f2a9d
                    status: queued
                  - index: 1
                    error:
                      status: 422
                      name: validation_error
                      message: The request body has invalid or missing fields.
                      fix: See the errors object for each field.
                      link: https://sendfox.com/developer/docs/#tag/Transactional/send-an-email
                      errors:
                        to.0: ["The to.0 field must be a valid email address."]
        '401':
          description: missing_api_key or invalid_api_key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '403':
          description: insufficient_scope, feature_not_available, account_restricted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '409':
          description: invalid_idempotent_request (the Idempotency-Key header was used with a different body)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '422':
          description: validation_error on the envelope (`emails` missing, not a list, empty, over 100 items or over 5 MB)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '429':
          description: rate_limit_exceeded, or monthly_quota_exceeded when the whole batch does not fit the plan's remaining sends
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
  /transactional/emails/{id}:
    get:
      operationId: getTransactionalEmail
      tags:
        - Transactional
      summary: Get an email
      description: The message without its bodies, plus its event timeline.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          example: tm_4f0c9b2e7d1a5c3e8b6f2a9d
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalMessage'
        '401':
          description: missing_api_key or invalid_api_key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '403':
          description: insufficient_scope (needs transactional:read), feature_not_available, account_restricted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'
        '404':
          description: not_found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionalError'

tags:
  - name: Transactional
    description: |
      Send email from your app, one per request or up to 100 per
      batch: login codes, receipts, notifications. Unlike the rest of the API, transactional
      routes are available on every plan, including free. Use any API key
      from Settings > API tokens (the Transactional page makes your first one);
      it works on every route your plan includes (see Plans & API Access), the
      Domains endpoints included, so your app can add, verify and remove the
      domains it sends from.
      Transactional keys made before keys were unified carry `transactional:*`
      scopes and keep working on these routes and `/domains` only; they are
      rejected by the rest of the API.

      Transactional is for messages your users trigger: password resets,
      receipts, notifications about their own account. Newsletters,
      promotions, re-engagement and review requests belong in SendFox
      campaigns.

      ### Switching from Resend
      Keep your code. Resend's SDKs read a base URL from the environment and
      SendFox accepts Resend's request shape at `/transactional/resend`, so
      the switch is two variables plus a verified domain:

      ```
      RESEND_BASE_URL=https://api.sendfox.com/transactional/resend
      RESEND_API_KEY=<your SendFox transactional key>
      ```

      `resend.emails.send()`, `resend.batch.send()` and `resend.emails.get()`
      then go to `POST /transactional/resend/emails`,
      `POST /transactional/resend/emails/batch` and
      `GET /transactional/resend/emails/{id}`: `to`, `cc` and `bcc` may be a
      string or an array, `reply_to` may be a one-item array, and
      `tags: [{name, value}]` becomes the SendFox `tag` (the first value).
      Every recipient counts as one send against your plan, as Resend counts
      them, and a message may carry up to 10 addresses (Resend allows 50). Responses and errors are SendFox's
      (`202 {id, status}`, and the error catalog below with `name`, `message`
      and `fix`). `attachments` work as on Resend (`{filename, content,
      content_type}`, with content in base64 or a byte array), except `path`
      URLs and inline images (`content_id`), which are refused.
      `scheduled_at` is not supported yet. Anything unsupported is refused
      with a `422 validation_error` rather than sent without it. For webhooks, set your endpoint to the Resend format (see
      Webhooks below) and your existing handler and signature check keep
      working with the SendFox secret.

      ### Error catalog
      Every non-2xx response has the same shape: `status`, `name`,
      `message`, `fix`, `link`, `request_id`.

      | Status | name | When |
      |---|---|---|
      | 401 | `missing_api_key` | No Authorization header |
      | 401 | `invalid_api_key` | The key is wrong or revoked |
      | 403 | `insufficient_scope` | A free-plan key without the needed transactional scope |
      | 403 | `feature_not_available` | Transactional email is not enabled on the account |
      | 403 | `domain_not_verified` | The from domain is not verified for this account |
      | 403 | `sandbox_recipient_not_allowed` | Sandbox sender to an address that is not yours |
      | 403 | `recipient_suppressed` | A recipient is on your suppression list (`suppressed` lists them) |
      | 403 | `account_paused` | Transactional sending is paused for review |
      | 403 | `account_restricted` | The SendFox account is restricted |
      | 404 | `not_found` | No message with that id on this account |
      | 409 | `invalid_idempotent_request` | Same Idempotency-Key, different body, inside 24 hours |
      | 422 | `validation_error` | Field errors in `errors` |
      | 429 | `rate_limit_exceeded` | Over 5/s or 300/min; honor `Retry-After` |
      | 429 | `monthly_quota_exceeded` | The plan's sends are used up (`remaining`, `requested`); upgrade the SendFox plan from Account > Billing |

      ### Webhooks
      Add one https URL under Transactional > Webhooks and SendFox POSTs a
      JSON event to it for each of `sent`, `delivered`, `delayed`, `bounced`,
      `complained` and `failed` that you pick. Sandbox test addresses send
      events too. The URL must resolve to a public address; redirects are not
      followed, and SendFox waits up to 10 seconds for an answer.

      ```json
      {
        "id": "whd_5f0c2a9e1b7d4c3a8e6f1d2b",
        "event": "delivered",
        "occurred_at": "2026-10-10T14:03:22+00:00",
        "data": {
          "message": { "id": "tm_…", "status": "delivered", "to": ["ada@example.com"], "subject": "Your code", "tag": "login_code", "…": "same fields as GET /transactional/emails/{id}, without events" },
          "recipient": "ada@example.com",
          "status": null,
          "reason": null
        }
      }
      ```

      `recipient` is the address the event is about (a message with `cc`
      gets one event per recipient). `status` and `reason` carry the
      receiving server's answer on `bounced`, `delayed` and `failed`.

      Any 2xx answer counts as delivered. Anything else, or no answer, is
      retried after 1 minute, 15 minutes, 2 hours and 18 hours, then given
      up. Retries send the same body with the same `id`, so dedupe on it.

      **Verify every request.** The `X-SendFox-Signature` header is
      `t={unix seconds},v1={hex}`, where `hex` is HMAC-SHA256 of
      `{t}.{raw body}` keyed with your signing secret (the whole `whsec_…`
      string). Compute it over the raw body before parsing the JSON, compare
      in constant time, and reject a `t` more than five minutes old. Requests
      also carry `X-SendFox-Event` and `X-SendFox-Delivery` (the body's `id`).

      **Resend format.** Switching from Resend? Set the webhook's format to
      Resend under Transactional > Webhooks and the body is shaped like a
      Resend event instead, with the same event types (`email.sent`,
      `email.delivered`, `email.delivery_delayed`, `email.bounced`,
      `email.complained`, `email.failed`) and `svix-id`, `svix-timestamp`
      and `svix-signature` headers signed the way Svix signs, so your
      existing handler and `webhooks.verify()` call keep working with the
      SendFox secret. `data.email_id` is the SendFox `tm_` id, `data.tags`
      is `{"tag": "<your tag>"}`, a bounce or delay carries Resend's
      `bounce` object with the receiving server's reason, and
      `data.recipient` names the address the event is about.

      ```json
      {
        "type": "email.bounced",
        "created_at": "2026-10-10T14:03:22+00:00",
        "data": {
          "created_at": "2026-10-10T14:03:20+00:00",
          "email_id": "tm_…",
          "from": "Acme <hello@mail.acme.com>",
          "to": ["ada@example.com"],
          "subject": "Your code",
          "tags": { "tag": "login_code" },
          "bounce": { "message": "550 5.1.1 User unknown", "subType": "5.1.1", "type": "Permanent" },
          "recipient": "ada@example.com"
        }
      }
      ```

x-tagGroups:
  -
    name: Transactional
    tags:
      - Transactional
  -
    name: Resources
    tags:
      - Contacts
      - Campaigns
      - Lists
      - Forms
      - Automations
      - Domains
      - Users
      - Contact Fields
