> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# Create a Campaign via the API

> Create a draft AhaSend marketing campaign with its sender, audience, subject, preview text and design, from MJML, HTML, AhaML or a campaign template.

The AhaSend API creates a draft marketing campaign. Access requires `campaigns:write`.

Only `name` is required. You can set all other fields in this request, or later with [Update Campaign](https://ahasend.com/docs/api-reference/campaigns/update-campaign.md). The response gives `ready` and `issues`: while the campaign has issues, it cannot be sent. See [Issues](https://ahasend.com/docs/api-reference/campaigns/overview.md#issues).

**Design:**
The design comes from one source:

* `content.mjml` creates an `advanced` design, and `content.html` creates an `html` design. `content.markup` creates a `simple` design from [AhaML](https://ahasend.com/docs/send-api/ahaml.md): a `content.html` beside it is ignored, and a `content.mjml` beside it is refused. With `content` that has none of them, set `editor`. An `editor` that does not agree with the content is refused.
* `editor` without `content` creates an empty design, which you can complete in the dashboard's editor.
* `template_id` copies the design of a [campaign template](https://ahasend.com/docs/api-reference/campaign-templates/create-campaign-template.md). The copy belongs to the campaign: later changes to the campaign template do not change it. A campaign template with no HTML is refused. A `template_id` that names no campaign template of your account is refused with `400` `campaign template not found`. `template_id` cannot go with `editor` or `content`.
* Without any of them, the campaign has no design yet.

**Content rules:**

* MJML must pass strict validation. For a validation error, the message gives the line of each problem.
* AhaML must follow the [AhaML rules](https://ahasend.com/docs/send-api/ahaml.md). A refused write lists the problems in `errors`, each with its line and column when it has a place in the AhaML. See [AhaML errors](https://ahasend.com/docs/send-api/ahaml.md#errors).
* In MJML and HTML, images and stylesheets must use full `https://` URLs, such as `https://example.com/logo.png`. The API refuses relative paths, such as `images/logo.png`, and `http:`, `//`, `data:`, `cid:` and other schemes. A URL that starts with a template variable, such as `{{ logo_url }}`, is allowed. AhaML URLs follow the [AhaML URL rules](https://ahasend.com/docs/send-api/ahaml.md#urls). The API cannot upload files.
* The MJML, the HTML, the AhaML and a text written by hand can each be at most 200 KB.
* The HTML must have an unsubscribe link before you can send the campaign. In AhaML, write it as `<Link href="{{ unsubscribe_url }}">…</Link>`. See [Unsubscribe link](https://ahasend.com/docs/api-reference/campaigns/overview.md#unsubscribe-link).

**Sender, subject and audience:**

* `from.email` must be on a domain of your account with valid DNS records. A paused domain is accepted here, but Send Campaign and Test Campaign refuse it.
* `reply_to` is an address only, without a display name.
* In `subject` and `preheader`, line breaks become spaces, and spaces at the start and end are removed. An empty subject or preview text counts as none.
* The API checks the subject, the preview text, the sender and the reply-to by the same rules as the dashboard, and its error messages use the same words.
* `list_ids` and `segment_ids` hold the lists and segments of your account to send to. `exclude_list_ids` and `exclude_segment_ids` hold the lists and segments whose contacts get no message. A list or segment that you give twice counts once. See [Audience](https://ahasend.com/docs/api-reference/campaigns/overview.md#audience) for the rules.
* An audience field that you send must leave the campaign at least one list or segment to send to, so `list_ids` can be `[]` with a segment in `segment_ids`. A list or segment that does not exist, more than 50 lists or 10 segments, or a list or segment that is both included and excluded is refused with `400`.
* To find the ID of a list or a segment, use [List Lists](https://ahasend.com/docs/api-reference/lists/get-lists.md) or [List Segments](https://ahasend.com/docs/api-reference/segments/get-segments.md), or see [where to find the ID](https://ahasend.com/docs/api-reference/campaigns/overview.md#build-and-send-a-campaign) in the dashboard.
* When a segment of the audience is being built again, the API answers `409` with `The campaign's audience is being updated. Try again in a minute.` This refusal is stored for the `Idempotency-Key`, so retry with a new key.

This operation accepts an `Idempotency-Key` header. See [Idempotency](https://ahasend.com/docs/api-reference/idempotency.md).

## Related Guides

* [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [Permission scopes](https://ahasend.com/docs/api-reference/scopes.md), [Idempotency](https://ahasend.com/docs/api-reference/idempotency.md).
* [Campaigns API overview](https://ahasend.com/docs/api-reference/campaigns/overview.md), [Test Campaign](https://ahasend.com/docs/api-reference/campaigns/test-campaign.md), [Send Campaign](https://ahasend.com/docs/api-reference/campaigns/send-campaign.md), [API errors](https://ahasend.com/docs/api-reference/errors.md), [API rate limits](https://ahasend.com/docs/api-reference/rate-limits.md).


## OpenAPI

````yaml openapi.yaml POST /v2/accounts/{account_id}/campaigns
openapi: 3.1.0
info:
  title: AhaSend API v2
  description: >
    The AhaSend API v2 allows you to send transactional emails, manage domains,
    webhooks, routes, API keys, and view statistics.


    ## Authentication

    All API requests must be authenticated using a Bearer token in the
    Authorization header:

    ```

    Authorization: Bearer aha-sk-64-CHARACTER-RANDOM-STRING

    ```


    ## Scopes

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


    ### Message Scopes

    - `messages:send:all` - Send messages from any domain in the account

    - `messages:send:{domain}` - Send messages from a specific domain

    - `messages:cancel:all` - Cancel messages from any domain

    - `messages:cancel:{domain}` - Cancel messages from a specific domain

    - `messages:read:all` - Read messages from any domain

    - `messages:read:{domain}` - Read messages from a specific domain


    ### Domain Scopes

    - `domains:read` - Read all domains

    - `domains:write` - Create and update domains

    - `domains:delete:all` - Delete any domain

    - `domains:delete:{domain}` - Delete a specific domain


    ### Account Scopes

    - `accounts:read` - Read account information

    - `accounts:write` - Update account settings

    - `accounts:billing` - Access billing information

    - `accounts:members:read` - Read account members

    - `accounts:members:add` - Add account members

    - `accounts:members:update` - Update account members

    - `accounts:members:remove` - Remove account members


    ### Webhook Scopes

    - `webhooks:read:all` - Read all webhooks

    - `webhooks:read:{domain}` - Read webhooks for a specific domain

    - `webhooks:write:all` - Create and update webhooks

    - `webhooks:write:{domain}` - Create and update webhooks for a specific
    domain

    - `webhooks:delete:all` - Delete any webhook

    - `webhooks:delete:{domain}` - Delete webhooks for a specific domain


    ### Route Scopes

    - `routes:read:all` - Read all routes

    - `routes:read:{domain}` - Read routes for a specific domain

    - `routes:write:all` - Create and update routes

    - `routes:write:{domain}` - Create and update routes for a specific domain

    - `routes:delete:all` - Delete any route

    - `routes:delete:{domain}` - Delete routes for a specific domain


    ### Suppression Scopes

    - `suppressions:read` - Read suppressions

    - `suppressions:write` - Create suppressions

    - `suppressions:delete` - Delete suppressions

    - `suppressions:wipe` - Delete the account's suppressions except protected
    ones (dangerous)


    ### Template Scopes

    - `templates:read` - Read transactional templates, their drafts and their
    published versions, and campaign templates

    - `templates:write` - Create and update transactional templates, and
    publish, discard and restore their drafts; create and update campaign
    templates

    - `templates:delete` - Delete transactional templates and campaign templates


    ### Campaign Scopes

    - `campaigns:read` - Read campaigns, their stats and their recipients

    - `campaigns:write` - Create, update and duplicate campaigns

    - `campaigns:delete` - Delete draft campaigns

    - `campaigns:send` - Send, unschedule and test-send campaigns


    ### Contact Scopes

    - `contacts:read` - Read contacts

    - `contacts:write` - Create and update contacts

    - `contacts:delete` - Delete contacts


    ### Contact lifecycle and attributes

    Contacts are account-global. A contact created through this API joins no

    list on its own; campaigns target lists, so add it to one with the Lists

    endpoints before a campaign can reach it.

    Contact subscription state and suppression records are independent:

    creating, updating, unsubscribing, resubscribing, or deleting a contact

    never creates, changes, or removes complaint, permanent, or protected

    suppressions. Prefer unsubscribe over delete when the goal is only to stop

    marketing mail, because unsubscribe is reversible and preserves contact

    history.


    Writable attribute keys and types come from the account's contact-attribute

    definitions managed in the dashboard; this API does not expose property

    definition CRUD. Writes accept only canonical values for those definitions,

    while reads remain legacy-safe and can return any recursively nested JSON

    value previously stored.


    An email used as `{id_or_email}` is a raw SDK argument and must be

    percent-encoded exactly once as a URL path segment by an HTTP client. For

    example, do not pre-encode `user+tag@example.com` before passing it to an

    SDK that encodes path parameters.


    ### List Scopes

    - `lists:read` - Read lists and list membership

    - `lists:write` - Create and update lists, and add or remove their contacts

    - `lists:delete` - Delete lists


    ### Lists and membership

    A list is a static, private, single opt-in container of contacts, and it is

    what a campaign targets. A membership carries its own `subscription_status`

    (`unconfirmed`, `confirmed`, `unsubscribed`, or `complained`), which is

    per-list consent and is independent of the contact's account-wide

    `unsubscribed` flag. A membership created without a status named is

    `confirmed`, because that is what a campaign reaches. `complained` is the

    mailbox provider's verdict, never writable, and a membership holding it is

    frozen.


    Deleting a list keeps its memberships, so an unsubscribe survives it.

    Removing a contact from a list deletes the membership row and discards that

    record with it, which is why unsubscribing is the reversible way to stop

    marketing mail on one list. Expanding a list's members with

    `include_contacts=true` requires `contacts:read` in addition to

    `lists:read`.


    ### Segment Scopes

    - `segments:read` - Read segments, their members and a contact's segments,
    and preview a definition

    - `segments:write` - Create and update segments

    - `segments:delete` - Delete segments


    ### Segments

    A segment is a saved set of rules over the account's contacts: their

    fields, their list memberships, and the campaigns they received, opened

    or clicked. AhaSend keeps its members up to date as that data changes,

    usually within a minute; rules about campaigns follow within an hour of a

    send, and relative times such as `30 days ago` are read again on a

    schedule. Membership is the rule match only, whatever the contact's

    consent: a campaign to a segment reaches the members who are enabled, not

    unsubscribed, not found invalid, and either confirmed on at least one list

    or on no list at all. A contact who unsubscribed from or complained on

    every list they were on, or has not confirmed a double opt-in, is not

    reached. A contact who unsubscribes from a segment send, or reports it as

    spam, gets no more marketing email from the account.


    ### SMTP Credentials Scopes

    - `smtp-credentials:read:all` - Read all SMTP credentials

    - `smtp-credentials:read:{domain}` - Read SMTP credentials for a specific
    domain

    - `smtp-credentials:write:all` - Create SMTP credentials

    - `smtp-credentials:write:{domain}` - Create SMTP credentials for a specific
    domain

    - `smtp-credentials:delete:all` - Delete any SMTP credentials

    - `smtp-credentials:delete:{domain}` - Delete SMTP credentials for a
    specific domain


    ### Statistics Scopes

    - `statistics-transactional:read:all` - Read all transactional statistics

    - `statistics-transactional:read:{domain}` - Read transactional statistics
    for a specific domain


    ### API Key Scopes

    - `api-keys:read` - Read API keys

    - `api-keys:write` - Create and update API keys

    - `api-keys:delete` - Delete API keys


    ### Sub-Account Scopes

    - `sub-accounts:read` - List and read sub accounts under the parent

    - `sub-accounts:write` - Create and update sub accounts

    - `sub-accounts:delete` - Soft-delete sub accounts

    - `sub-accounts:suspend` - Pause and resume sub accounts, and unpause their
    domains

    - `sub-accounts:usage` - Read per-sub-account usage and allocated cost

    - `sub-account-api-keys:read` - List and read API keys owned by sub accounts

    - `sub-account-api-keys:write` - Create and update API keys owned by sub
    accounts

    - `sub-account-api-keys:delete` - Delete API keys owned by sub accounts


    ## Rate Limiting

    - General API endpoints: 100 requests per second, 200 burst

    - Statistics endpoints: 1 request per second, 1 burst


    A request over the limit gets HTTP 429 with a `Retry-After` header: the

    number of seconds to wait before retrying.


    ## Pagination

    List endpoints use cursor-based pagination with the following parameters:

    - `limit`: Maximum number of items to return (default: 100, max: 100)

    - `after`: Pass `pagination.next_cursor` to fetch the next page

    - `before`: Pass `pagination.previous_cursor` to fetch the previous page


    `after` and `before` are mutually exclusive. A legacy `cursor` query may be

    accepted by parts of the server for backwards compatibility, but it is not

    part of the v2 public contract.


    ## Time Formats

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


    ## Idempotency

    The operations that explicitly declare the optional `Idempotency-Key`

    header support idempotency. Other POST operations do not gain idempotency

    implicitly. When the header is provided:

    - A fresh execution does not return `Idempotent-Replayed`.

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

    - Encrypted API-key creation replay responses expire after 5 minutes.


    ## IP Allow Lists

    Each API key can carry an `ip_allow_list`: a set of source IPs that restrict
    where the key may authenticate from.

    - Entries are CIDR blocks (e.g. `203.0.113.0/24`) or bare IPv4/IPv6
    addresses (stored as a `/32` or `/128`). They are canonicalized and
    de-duplicated; the allow-all prefixes `0.0.0.0/0` and `::/0` are rejected,
    and at most 100 entries are allowed after de-duplication (a longer list is
    rejected with HTTP 400).

    - An empty list (the default) places no restriction.

    - When the list is non-empty, any authenticated request whose client IP is
    not covered by an entry is rejected with HTTP 403 on every v2 endpoint,
    regardless of the key's scopes.

    - Manage the list with the `ip_allow_list` field on the API-key create and
    update endpoints (including the sub-account API-key endpoints).

    - When a key updates its own `ip_allow_list` to a value that excludes the
    caller's current IP, the update is rejected with HTTP 409 to prevent
    self-lockout. Updating a sub-account key from a parent key has no such
    guard.
  version: 2.0.0
  contact:
    email: support@ahasend.com
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.ahasend.com
    description: Production server
security:
  - BearerAuth: []
tags:
  - name: Utility
    description: Utility endpoints for health checks and diagnostics
  - name: API Keys
    description: Manage API keys for authentication and access control
  - name: Domains
    description: Manage sending domains
  - name: Messages
    description: Send and manage transactional messages
  - name: Accounts
    description: Manage account settings and members
  - name: Contacts
    description: Read, create, update, batch upsert, and delete account-global contacts
  - name: Templates
    description: >-
      Create, change, publish and delete the account's transactional templates,
      and read them with their drafts, their published versions and the
      variables a send must supply
  - name: Campaign Templates
    description: >-
      Create, change, read and delete the account's campaign templates, the
      designs a campaign copies when it is made from one
  - name: Campaigns
    description: >-
      Create, change, read, duplicate, delete, test and send the account's
      marketing campaigns, and read their stats and recipients
  - name: Lists
    description: Manage contact lists and the contacts on them
  - name: Segments
    description: >-
      Manage segments, the saved rules that keep a set of contacts up to date,
      and read their members
  - name: Suppressions
    description: Manage email suppressions
  - name: Routes
    description: Manage inbound email routing
  - name: Webhooks
    description: Manage webhook notifications
  - name: SMTP Credentials
    description: Manage SMTP authentication credentials
  - name: Statistics
    description: Access transactional email statistics
  - name: Message Events
    description: Webhooks for outbound message delivery events
  - name: Suppression Events
    description: Webhooks for suppression list changes
  - name: Domain Events
    description: Webhooks for domain configuration issues
  - name: Route Events
    description: Webhooks for inbound email routing
paths:
  /v2/accounts/{account_id}/campaigns:
    post:
      tags:
        - Campaigns
      summary: Create Campaign
      description: |
        Creates a draft marketing campaign. Every field but `name` is
        optional and can be set later with Update Campaign; the campaign
        answers its `issues` until it is ready to send.

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

        The subject, preview text, sender and reply-to are checked by the
        rules of the dashboard, and their refusals use the dashboard's
        sentences.
      operationId: createCampaign
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignRequest'
      responses:
        '201':
          description: Campaign created
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        '400':
          description: >-
            Invalid request: the body or a field, a list, segment or campaign
            template that does not exist, too many lists or segments, or an
            audience with no list or segment to send to. The message names the
            field and the problem. Refused AhaML in `content.markup` also lists
            its problems in `errors`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarkupErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `campaigns:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/CampaignCreateConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: >-
            Too many requests: the API's rate limit, or the limit on writes that
            carry `content.markup`. A 429 is not stored for the
            `Idempotency-Key`, so a retry may use the same key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          $ref: '#/components/responses/TemplateWriteBusy'
      security:
        - BearerAuth:
            - campaigns:write
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

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

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

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

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

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


            const client = AhaSendClient.fromEnv();

            // The SDK sends a fresh Idempotency-Key automatically and reuses it
            on its own retries.

            const campaign = await client.campaigns.create({
              name: "October newsletter",
              subject: "What is new in October",
              preheader: "Three things we shipped this month",
              from: { email: "news@example.com", name: "Example" },
              list_ids: ["00000000-0000-4000-8000-000000000010"],
              template_id: "00000000-0000-4000-8000-000000000013",
            });

            console.log("Campaign created.", { id: campaign.id, ready:
            campaign.ready });
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Optional idempotency key for safe request retries. Must be a unique
        string for each logical request.

        An identical request with a completed stored outcome returns the
        original status and body. An in-progress

        execution returns 409, a changed method/path/body returns 422, and a
        released 5xx execution may run again.

        Keys for non-secret responses expire after 24 hours. API-key create
        responses include a one-time `secret_key`,

        so successful encrypted replay responses for those operations expire
        after 5 minutes.
      schema:
        type: string
        maxLength: 255
      example: user-12345-create-domain-20240101
  schemas:
    CreateCampaignRequest:
      type: object
      additionalProperties: false
      required:
        - name
      description: |
        A new draft campaign. Every field but `name` can be set later with
        Update Campaign. The design comes from at most one source: an
        `editor`, `content`, both, or a `template_id`. With none of them the
        campaign has no design yet.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Campaign name. Spaces around it are dropped.
        subject:
          type:
            - string
            - 'null'
          description: >-
            The subject line. Line breaks become spaces and spaces around it are
            dropped; an empty subject is no subject.
        preheader:
          type:
            - string
            - 'null'
          description: The preview text, cleaned as `subject` is.
        from:
          oneOf:
            - $ref: '#/components/schemas/TemplateAddressInput'
            - type: 'null'
          description: >-
            The sender. `email` must be on a domain of this account with valid
            DNS. A paused domain is accepted here and refused when the campaign
            is sent.
        reply_to:
          type:
            - string
            - 'null'
          format: email
          description: The reply-to address, an address only, without a display name.
        list_ids:
          type: array
          maxItems: 50
          items:
            type: string
            format: uuid
          description: |
            The lists to send to, in order. A list named twice counts once.
            Each must be a list of this account. A campaign uses at most 50
            lists and 10 segments, included and excluded together, and an
            audience field that is set must leave it at least one list or
            segment to send to, so `[]` is accepted with `segment_ids`.
        segment_ids:
          type: array
          maxItems: 10
          items:
            type: string
            format: uuid
          description: >-
            The segments to send to. A segment named twice counts once. Each
            must be a segment of this account. A contact reached through a list
            and a segment gets one message, recorded under the list. Responses
            return the segments by ID.
        exclude_list_ids:
          type: array
          maxItems: 50
          items:
            type: string
            format: uuid
          description: >-
            Lists whose contacts get no message from the campaign. A contact
            with any membership of one, whatever its status, is left out, also
            when an included list or segment reaches it. A list cannot be both
            in `list_ids` and here. Exclusions need at least one list or segment
            to send to. Responses return the lists by ID.
        exclude_segment_ids:
          type: array
          maxItems: 10
          items:
            type: string
            format: uuid
          description: >-
            Segments whose members get no message from the campaign, also when
            an included list or segment reaches them. A segment cannot be both
            in `segment_ids` and here. Exclusions need at least one list or
            segment to send to. Responses return the segments by ID.
        editor:
          $ref: '#/components/schemas/TemplateEditor'
          description: >-
            The editor of the design. Without `content` the campaign gets an
            empty design of this editor, which the dashboard's editor opens.
            With `content`, it is required when the content holds none of
            `markup`, `mjml` and `html`, and must agree with it otherwise;
            `markup` means `simple`, also beside `html`. Not accepted with
            `template_id`, whose editor the design keeps.
        content:
          $ref: '#/components/schemas/TemplateContentInput'
          description: >-
            The content of the design, by the rules of `TemplateContentInput`. A
            campaign has no draft, so where those rules name the draft, they
            mean the campaign's design. Not accepted with `template_id`.
        template_id:
          type: string
          format: uuid
          description: >-
            A campaign template whose design the campaign copies. The copy is
            the campaign's own; later changes to the template do not reach it. A
            campaign template with no HTML is refused.
    Campaign:
      allOf:
        - $ref: '#/components/schemas/CampaignListItem'
        - type: object
          required:
            - list_ids
            - segment_ids
            - exclude_list_ids
            - exclude_segment_ids
            - editor
            - content
            - ready
            - issues
          properties:
            list_ids:
              type: array
              items:
                type: string
                format: uuid
              description: >-
                The lists the campaign sends to, in the order they were saved. A
                list that was deleted is left out.
            segment_ids:
              type: array
              items:
                type: string
                format: uuid
              description: >-
                The segments the campaign sends to, by ID. A segment that was
                deleted is left out.
            exclude_list_ids:
              type: array
              items:
                type: string
                format: uuid
              description: >-
                The lists whose contacts the campaign does not send to, by ID. A
                contact with any membership of one of them, whatever its status,
                is left out. A list that was deleted is left out.
            exclude_segment_ids:
              type: array
              items:
                type: string
                format: uuid
              description: >-
                The segments whose members the campaign does not send to, by ID.
                A segment that was deleted is left out.
            editor:
              oneOf:
                - $ref: '#/components/schemas/TemplateEditor'
                - type: 'null'
              description: >-
                The editor of the campaign's design. Null while the campaign has
                no design.
            content:
              oneOf:
                - $ref: '#/components/schemas/TemplateContent'
                - type: 'null'
              description: >-
                The content of the campaign's design. Null while it has no
                design, or its design has neither HTML nor text.
            ready:
              type:
                - boolean
                - 'null'
              description: >-
                Whether the draft has no issue, so it can be sent. Null unless
                the campaign is a draft.
            issues:
              oneOf:
                - type: array
                  items:
                    $ref: '#/components/schemas/CampaignIssue'
                - type: 'null'
              description: >-
                The reasons the draft cannot be sent yet, in a fixed order;
                empty when it is ready. Null unless the campaign is a draft.
            markup_warnings:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/MarkupProblem'
              description: >-
                Warnings about the AhaML in `content.markup`, up to the first
                100; empty when there are none. Present only in the answer to a
                write that carried `content.markup`.
    MarkupErrorResponse:
      type: object
      description: |
        The 400 answer of a write that takes `content.markup`. Every 400 of
        such a write has `message`. When the write refuses AhaSend Markup
        (AhaML), `errors` lists the problems found, in document order, up to
        the first 100, and `message` says how many there are in all.
      additionalProperties: false
      required:
        - message
      properties:
        message:
          type: string
          description: Error description. For refused AhaML, how many problems it has.
        errors:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/MarkupProblem'
          description: The problems of refused AhaML. Left out for every other 400.
      example:
        message: content.markup has 1 problem
        errors:
          - code: unknown_attribute
            message: Paragraph has no attribute "color"
            line: 4
            column: 14
            path: Email > Section[2] > Paragraph[1]
            hint: wrap the text in <Color value="..."> instead
    ErrorResponse:
      type: object
      description: |
        Human-readable API error. The server currently sends no stable machine
        error code. In particular, clients must not parse `message` to
        distinguish IP-allow-list, scope, ownership, plan, self-lockout,
        suppression-duplicate, or other errors that share an HTTP status.
      additionalProperties: false
      required:
        - message
      properties:
        message:
          type: string
          description: Error description
      example:
        message: Error message
    TemplateAddressInput:
      type: object
      additionalProperties: false
      required:
        - email
      description: An address in a template write, in the shape a Template answers it.
      properties:
        email:
          type: string
          format: email
          description: The email address.
        name:
          type: string
          description: The display name.
    TemplateEditor:
      type: string
      enum:
        - advanced
        - simple
        - html
      description: |
        The editor the template is designed with. It never changes after the
        template is created.

        - `advanced`: designed in MJML, with the dashboard's advanced editor.
        - `simple`: designed with the dashboard's simple editor. Its HTML is
          made from the design and can be read but not changed through the API.
          A read or write answer of one item gives the design as AhaSend
          Markup (AhaML) in `content.markup`; a list does not. A write changes
          the design with `content.markup`.
        - `html`: an HTML template, uploaded or written by hand.
    TemplateContentInput:
      type: object
      additionalProperties: false
      description: |
        The content of a template write. It holds at most one of `mjml` and
        `html`, never both `markup` and `mjml`, and at least one of `markup`,
        `mjml`, `html` and `text`. The `content` that Get Template answers,
        the working copy, can be sent back as it is: an `mjml` or `html` equal
        to the current one is held to the size limit but not compiled again
        or held to the HTML checks, and keeps the stored HTML. While a draft
        exists, a design equal to the published copy's, which Get Published
        Template answers, takes the published copy's stored design in the
        same way, and a draft that then equals the published copy is
        dropped. Leave `content` out of a write that does not change it: for
        a `simple` template, a write that sends `markup` back is held to the
        AhaML rules and limits too. A write has no check of what the caller
        read: a `content` read before a dashboard save and sent back after it
        replaces the design that save stored.

        A `simple` design is written as AhaSend Markup (AhaML) in `markup`.
        Its `html` is made from the design: beside `markup` it is ignored
        without any check, and without `markup` it is ignored when it equals
        the working copy's stored HTML, and refused otherwise, an empty `html`
        included. The `content` read from a
        `simple` design can so be sent back as it is, `markup_error`
        included: AhaML that prints the same as the stored design keeps that
        design and its HTML, and AhaML that prints the same as the published
        copy takes the published copy's design and HTML. A design that
        breaks a rule of AhaML, also one saved in the dashboard, is refused
        when it is written back, such as an image that is not an `https://`
        URL.

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

        Line ends written as CRLF are stored as LF, and a blank text is
        refused. `markup`, `mjml`, `html` and a custom `text` each have a size
        limit.
      properties:
        mjml:
          type: string
          minLength: 1
          description: |
            The MJML source. Only for `advanced` templates. It is compiled in
            strict mode, and every problem strict validation finds is refused
            with its line. Images must use full `https://` URLs: the API cannot
            upload files.
        html:
          type: string
          minLength: 1
          description: |
            The HTML. Only for `html` templates; the HTML of a `simple`
            template is made from its design, which `markup` changes, and is
            accepted only as described above. Images and stylesheets must use
            full `https://` URLs: the API cannot upload files.
        text:
          type:
            - string
            - 'null'
          description: The plain text version. Null makes it from the HTML.
        text_is_custom:
          type: boolean
          description: >-
            Accepted so a read `content` can be sent back, and ignored. The text
            rules decide it.
        markup:
          type:
            - string
            - 'null'
          description: |
            The design as AhaSend Markup (AhaML). Only for `simple` designs;
            on a create, and on a campaign with no design yet, it means
            `editor: simple`. The stored design is the JSON and HTML read from
            it, and the answer lists warnings about it in `markup_warnings`.
            Null is the same as no `markup`, and an empty string is refused.
            The writes that carry it count against a per-account limit, beside
            the API's own rate limit.
          example: |
            <Email version="1">
              <Heading level="3">Welcome</Heading>
              <Paragraph>Thanks for signing up.</Paragraph>
            </Email>
        markup_error:
          description: >-
            Accepted so a read `content` can be sent back, and ignored, whatever
            it holds.
    CampaignListItem:
      type: object
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - subject
        - preheader
        - from
        - reply_to
        - status
        - status_reason
        - send_at
        - send_resumes_at
        - finished_at
      description: >-
        A campaign as List Campaigns returns it, without its audience, content
        and issues.
      properties:
        object:
          type: string
          enum:
            - campaign
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Campaign ID
        created_at:
          type: string
          format: date-time
          description: When the campaign was created
        updated_at:
          type: string
          format: date-time
          description: When the campaign's own fields last changed
        name:
          type: string
          description: >-
            Campaign name, as it appears in the dashboard. Recipients do not see
            it.
        subject:
          type:
            - string
            - 'null'
          description: The subject line. Null until one is set.
        preheader:
          type:
            - string
            - 'null'
          description: >-
            The preview text, which most inboxes show after the subject. Null
            until one is set.
        from:
          oneOf:
            - $ref: '#/components/schemas/Address'
            - type: 'null'
          description: >-
            The sender. Null until a complete sender is set, and when its domain
            was deleted. `name` is an empty string when the campaign has no
            sender name, so the object can be sent back as it is.
        reply_to:
          type:
            - string
            - 'null'
          format: email
          description: The reply-to address. Null when none is set.
        status:
          $ref: '#/components/schemas/CampaignStatus'
        status_reason:
          type:
            - string
            - 'null'
          description: >-
            Why the campaign is in its status, when there is a reason to give.
            Null otherwise.
        send_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When the campaign sends or sent, in UTC. Null until it is scheduled.
        send_resumes_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When a send that reached the account's daily marketing limit
            continues, in UTC. Null otherwise.
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When the send ended, in UTC. Null until then.
    TemplateContent:
      type: object
      required:
        - text_is_custom
      description: |
        A template's content. A key that does not apply to the template's
        editor is left out, and so are `mjml`, `html` and `text` when they are
        empty.

        An answer that holds one `simple` design also gives that design as
        AhaSend Markup (AhaML) in `markup`. When AhaML cannot be made, the
        answer still succeeds: `markup` is null and `markup_error` says why.
        List operations give no content, so they give no `markup`.
      properties:
        mjml:
          type: string
          description: The MJML source. Only for `advanced` templates.
        html:
          type: string
          description: >-
            The HTML of an `html` template, or the HTML made from the design of
            a `simple` template.
        text:
          type: string
          description: The plain text version of the email.
        text_is_custom:
          type: boolean
          description: >-
            Whether the text was written rather than made from the HTML. A text
            made from the HTML is made again whenever the design changes.
        markup:
          type:
            - string
            - 'null'
          description: >-
            The design as AhaSend Markup (AhaML). Only for `simple` designs, and
            then always present. Null when AhaML cannot be made; `markup_error`
            then says why.
          example: |
            <Email version="1">
              <Heading level="3">Welcome</Heading>
              <Paragraph>Thanks for signing up.</Paragraph>
            </Email>
        markup_error:
          type: object
          required:
            - code
            - message
          description: >-
            Why `markup` is null. Only present when it is. In the answer to a
            write, it says only that the answer holds no AhaML; the write itself
            succeeded. A write answer replayed for its `Idempotency-Key` gives
            the `markup_error` of the first answer; read the object again to get
            its AhaML.
          properties:
            code:
              type: string
              enum:
                - no_design
                - html_block
                - unmapped_block
                - bad_stored_value
                - renderer_unavailable
                - rate_limited
              description: |
                - `no_design`: the design has no blocks yet.
                - `html_block`: the design holds an HTML block, which AhaML
                  cannot show. Remove the block in the dashboard to read and
                  write the design as AhaML.
                - `unmapped_block`: the design holds a block or a mark that
                  AhaML has no tag for. The message names its type.
                - `bad_stored_value`: the design holds a value that AhaML
                  cannot write. The message names the block and the attribute.
                - `renderer_unavailable`: AhaML cannot be made now. Read the
                  design again later.
                - `rate_limited`: the account reads and writes AhaML more
                  often than its limit allows. Read the design again in a
                  second. A caller that does not use AhaML is never slowed
                  by this limit.
            message:
              type: string
              description: What went wrong, in words for a person.
          example:
            code: html_block
            message: >-
              The design holds an HTML block, which AhaML cannot show; remove
              the block in the dashboard to read and write the design as AhaML
    CampaignIssue:
      type: object
      required:
        - code
        - message
      description: A reason the campaign cannot be sent yet.
      properties:
        code:
          type: string
          enum:
            - sender_missing
            - lists_missing
            - subject_missing
            - preheader_missing
            - design_missing
            - text_broken
            - text_unchecked
            - unsubscribe_link_missing
          description: >
            - `sender_missing`: the campaign has no complete sender, or its
            domain was deleted.

            - `lists_missing`: the campaign has no list or segment that still
            exists.

            - `subject_missing`: the campaign has no subject.

            - `preheader_missing`: the campaign has no preview text.

            - `design_missing`: the campaign has no design, or its design has no
            HTML.

            - `text_broken`: the subject, the preview text or the design holds
            template syntax that cannot be rendered, so every message would
            fail.

            - `text_unchecked`: the subject, the preview text and the design
            could not be checked right now. Nothing is wrong with them that is
            known; try again in a moment. Under load it can come and go between
            reads.

            - `unsubscribe_link_missing`: the HTML does not use `{{
            unsubscribe_url }}`, with or without filters.
        message:
          type: string
          description: What to do, in the words the dashboard uses.
    MarkupProblem:
      type: object
      description: >-
        One problem of AhaSend Markup (AhaML) that a write refused, or one
        warning about AhaML a write stored.
      required:
        - code
        - message
      properties:
        code:
          type: string
          description: |
            What is wrong, as a code that stays the same. The AhaML reference
            lists every code. A warning's code is `jinja_in_text`: text holds
            `{{`, which stays text; use a `<Var>` for a variable.
        message:
          type: string
          description: What is wrong, in words for a person.
        line:
          type: integer
          minimum: 1
          description: >-
            The line where the problem starts, counted from 1. Left out when the
            problem has no place in the AhaML, as for a size limit.
        column:
          type: integer
          minimum: 1
          description: >-
            The column where the problem starts, counted from 1 in Unicode
            characters. Left out with `line`.
        path:
          type: string
          description: >-
            The element of the problem, each tag with its position among its
            siblings of the same tag, such as `Email > Section[2] >
            Paragraph[1]`. Left out with `line`.
        hint:
          type: string
          description: How to fix the problem, when a fix is known. Warnings have none.
    Address:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: >-
            The email address. A `from` address must be on a domain defined in
            your account with valid DNS records.
        name:
          type: string
          description: >-
            The display name shown with the address. It can hold any Unicode
            text; the API encodes it for the header. On a send with
            substitutions or a template, a name with non-ASCII text can use `{{
            name }}` references only; other template syntax in it is refused.
      example:
        email: noreply@example.com
        name: Example Corp
    CampaignStatus:
      type: string
      enum:
        - draft
        - validating
        - waiting_for_review
        - scheduled
        - sending
        - paused
        - sent
        - canceled
        - failed
      description: |
        Where the campaign is in its life. Only a `draft` can be changed or
        deleted.

        - `draft`: being prepared.
        - `validating`: scheduled, and its lists and segments are being checked.
        - `waiting_for_review`: held for a review before it sends.
        - `scheduled`: accepted, waiting for its send time.
        - `sending`: sending now.
        - `paused`: sending stopped for now; `status_reason` says why.
        - `sent`: every message was sent.
        - `canceled`: stopped before it finished; `status_reason` says why.
        - `failed`: could not be sent; `status_reason` says why.
  headers:
    IdempotentReplayed:
      description: >
        Indicates that this response is a stored replay of a previous identical
        request.

        When this header is absent, the response is not a stored replay.
        Ordinary fresh

        responses do not send `false`; that value is reserved for an in-progress
        409.
      schema:
        type: string
        enum:
          - 'true'
    IdempotencyRetryAfter:
      description: |
        Positive whole number of seconds after which the in-progress execution
        lease may be retried with the same key and unchanged request.
      schema:
        type: integer
        minimum: 1
  responses:
    CampaignCreateConflict:
      description: |
        Either a segment of the new campaign's audience is being rebuilt
        (`The campaign's audience is being updated. Try again in a
        minute.`), or an execution with this idempotency key is still in
        progress. The in-progress case is identified only by
        `Idempotent-Replayed: false` plus a positive integer `Retry-After`;
        retry it after that many seconds with the same key. The audience
        refusal is stored for the `Idempotency-Key` like other client
        errors: an identical request with the same key replays it with
        `Idempotent-Replayed: true` for 24 hours, so retry it with a new
        key. A fresh refusal has neither header. The message is not a
        stable discriminator.
      headers:
        Idempotent-Replayed:
          description: |
            `false` with a positive `Retry-After` means an execution is in
            progress. `true` means a stored 409 was replayed. Absent means a
            fresh refusal.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        Retry-After:
          $ref: '#/components/headers/IdempotencyRetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: The campaign's audience is being updated. Try again in a minute.
    IdempotencyPayloadMismatch:
      description: |
        Idempotency key was already used with a different HTTP method, resolved
        request path, or request body. This response includes neither
        `Idempotent-Replayed` nor `Retry-After`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: idempotency key was already used with a different request payload
    TemplateWriteBusy:
      description: |
        The write could not be finished now, for one of two reasons. Nothing
        was written.

        - The template or campaign changed in the dashboard or through
          another request while this one was writing it. Only a write of a
          transactional template that already exists, after it retried by
          itself, and an update of a campaign whose body changed, can get
          this.
        - AhaSend Markup (AhaML) in `content.markup` could not be read now:
          the service that reads it is down, failed or busy.

        Retry after `Retry-After` seconds, with the same `Idempotency-Key`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            changed:
              summary: The template changed during the write
              value:
                message: template is being changed by another request, try again
            markup:
              summary: The AhaML could not be read now
              value:
                message: content.markup cannot be read now, try again
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: aha-sk-64-CHARACTER-RANDOM-STRING
      description: |
        API key for authentication. Non-empty Security Requirement values are
        AhaSend API-key roles. Roles listed within one requirement object are
        jointly required; separate requirement objects are alternatives.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.