> ## 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.

# Send or Schedule a Campaign via the API

> Send a draft AhaSend marketing campaign to its lists now, or schedule it for a later time. The campaign must be a draft with no issues before it can send.

The AhaSend API sends a draft campaign now, or at the time in `send_at`. Access requires `campaigns:send`.

**Before the send:**

* The campaign must be a draft. Any other status answers `409`.
* A campaign with issues is refused with `400` `campaign is not ready to send`, and the response holds the `issues`. See [Issues](/docs/api-reference/campaigns/overview#issues).
* The API then checks what would stop the campaign at its send time. The domain of the sender must still be verified and not paused, your account must be able to send, and the lists must have subscribed contacts. The error message says what to fix.
* For a send at a later time, a block that a payment or the next billing period can clear does not refuse the send. The response gives it in `warnings`.

**Send time:**

* Without `send_at`, the campaign is sent now.
* `send_at` is an RFC 3339 time at least 1 minute in the future. `null` is refused.
* To change the send time, [unschedule](/docs/api-reference/campaigns/unschedule-campaign) the campaign and send it again.

**After the send:**

* The API answers `202`, and the campaign is in `validating`. Its lists are checked, and the campaign can be held for a review, as for a send from the dashboard. Read `status` and `status_reason` with [Get Campaign](/docs/api-reference/campaigns/get-campaign). See [Campaign status](/docs/api-reference/campaigns/overview#campaign-status).
* `warnings` lists what the send accepted but you should know, for example that your account cannot send now while the campaign is sent later, or that the view-in-browser page cannot show the design. It is empty when there is nothing to report.

This operation accepts an `Idempotency-Key` header. A refusal from this operation is stored for the key, so send a corrected request with a new key. See [Idempotency](/docs/api-reference/idempotency) and [campaign retries](/docs/api-reference/campaigns/overview#idempotency).

## Related Guides

* [API authentication](/docs/api-reference/authentication), [Permission scopes](/docs/api-reference/scopes), [Idempotency](/docs/api-reference/idempotency).
* [Test Campaign](/docs/api-reference/campaigns/test-campaign), [Get Campaign Stats](/docs/api-reference/campaigns/get-campaign-stats), [API errors](/docs/api-reference/errors), [API rate limits](/docs/api-reference/rate-limits).


## OpenAPI

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


    ## Authentication

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

    ```

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

    ```


    ## Scopes

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


    ### Message Scopes

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

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

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

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

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

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


    ### Domain Scopes

    - `domains:read` - Read all domains

    - `domains:write` - Create and update domains

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

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


    ### Account Scopes

    - `accounts:read` - Read account information

    - `accounts:write` - Update account settings

    - `accounts:billing` - Access billing information

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

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

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

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


    ### Webhook Scopes

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

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

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

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

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

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


    ### Route Scopes

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

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

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

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

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

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


    ### Suppression Scopes

    - `suppressions:read` - Read suppressions

    - `suppressions:write` - Create suppressions

    - `suppressions:delete` - Delete suppressions

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


    ### Template Scopes

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

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

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


    ### Campaign Scopes

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

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

    - `campaigns:delete` - Delete draft campaigns

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


    ### Contact Scopes

    - `contacts:read` - Read contacts

    - `contacts:write` - Create and update contacts

    - `contacts:delete` - Delete contacts


    ### Contact lifecycle and attributes

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

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

    endpoints before a campaign can reach it.

    Contact subscription state and suppression records are independent:

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

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

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

    marketing mail, because unsubscribe is reversible and preserves contact

    history.


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

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

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

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

    value previously stored.


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

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

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

    SDK that encodes path parameters.


    ### List Scopes

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

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

    - `lists:delete` - Delete lists


    ### Lists and membership

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

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

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

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

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

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

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

    frozen.


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

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

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

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

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

    `lists:read`.


    ### SMTP Credentials Scopes

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

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

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

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

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

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


    ### Statistics Scopes

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

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


    ### API Key Scopes

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

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

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


    ### Sub-Account Scopes

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

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

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

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

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

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

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

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


    ## Rate Limiting

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

    - Statistics endpoints: 1 request per second, 1 burst


    ## Pagination

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

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

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

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


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

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

    part of the v2 public contract.


    ## Time Formats

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


    ## Idempotency

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

    header support idempotency. Other POST operations do not gain idempotency

    implicitly. When the header is provided:

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

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

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


    ## IP Allow Lists

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

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

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

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

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

    - When a key updates its own `ip_allow_list` to a value that excludes the
    caller's current IP, the update is rejected with HTTP 409 to prevent
    self-lockout. Updating a sub-account key from a parent key has no such
    guard.
  version: 2.0.0
  contact:
    email: support@ahasend.com
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.ahasend.com
    description: Production server
security:
  - BearerAuth: []
tags:
  - name: Utility
    description: Utility endpoints for health checks and diagnostics
  - name: API Keys
    description: Manage API keys for authentication and access control
  - name: Domains
    description: Manage sending domains
  - name: Messages
    description: Send and manage transactional messages
  - name: Accounts
    description: Manage account settings and members
  - name: Contacts
    description: Read, create, update, batch upsert, and delete account-global contacts
  - name: Templates
    description: >-
      Create, change, publish and delete the account's transactional templates,
      and read them with their drafts, their published versions and the
      variables a send must supply
  - name: Campaign Templates
    description: >-
      Create, change, read and delete the account's campaign templates, the
      designs a campaign copies when it is made from one
  - name: Campaigns
    description: >-
      Create, change, read, duplicate, delete, test and send the account's
      marketing campaigns, and read their stats and recipients
  - name: Lists
    description: Manage contact lists and the contacts on them
  - name: Suppressions
    description: Manage email suppressions
  - name: Routes
    description: Manage inbound email routing
  - name: Webhooks
    description: Manage webhook notifications
  - name: SMTP Credentials
    description: Manage SMTP authentication credentials
  - name: Statistics
    description: Access transactional email statistics
  - name: Message Events
    description: Webhooks for outbound message delivery events
  - name: Suppression Events
    description: Webhooks for suppression list changes
  - name: Domain Events
    description: Webhooks for domain configuration issues
  - name: Route Events
    description: Webhooks for inbound email routing
paths:
  /v2/accounts/{account_id}/campaigns/{campaign_id}/send:
    post:
      tags:
        - Campaigns
      summary: Send Campaign
      description: |
        Sends a draft campaign now, or at `send_at`. A campaign with any
        issue is refused with the issues. The send then checks what would
        stop the campaign at its send time: its sender domain must still be
        verified and not paused, the account must be able to send, and its
        lists must have subscribed contacts. For a send at a later time, a
        block that a payment or the next billing period can clear is a
        warning instead of a refusal.

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

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

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

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

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

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


            const client = AhaSendClient.fromEnv();

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

            const campaignId = "00000000-0000-4000-8000-000000000014";

            // Leave send_at out to send now. The campaign is checked before it
            sends.

            const campaign = await client.campaigns.send(campaignId, { send_at:
            "2030-01-01T09:00:00Z" });

            console.log("Campaign send accepted.", { status: campaign.status,
            warnings: campaign.warnings });
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:
    SendCampaignRequest:
      type: object
      additionalProperties: false
      description: When to send. Without `send_at` the campaign is sent now.
      properties:
        send_at:
          type: string
          format: date-time
          description: >-
            The time to send, in RFC 3339, at least 1 minute ahead. `null` is
            refused.
    CampaignSend:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          required:
            - warnings
          properties:
            warnings:
              type: array
              items:
                type: string
              description: >-
                Things the send accepted but you should know, such as an account
                that cannot send right now while the campaign is sent later, or
                a design its view-in-browser page cannot show. Empty when there
                is none.
    CampaignNotReady:
      type: object
      required:
        - message
      description: >-
        A refusal. A campaign with issues is refused with `campaign is not ready
        to send` and the issues that block the request; every other refusal has
        only `message`.
      properties:
        message:
          type: string
          description: Error message
        issues:
          type: array
          items:
            $ref: '#/components/schemas/CampaignIssue'
          description: >-
            The issues that block the request, in the order of `CampaignIssue`.
            For a test, every issue but `unsubscribe_link_missing`.
      example:
        message: campaign is not ready to send
        issues:
          - code: subject_missing
            message: Add a subject before you send the campaign.
    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
    Campaign:
      allOf:
        - $ref: '#/components/schemas/CampaignListItem'
        - type: object
          required:
            - list_ids
            - editor
            - content
            - ready
            - issues
          properties:
            list_ids:
              type: array
              items:
                type: string
                format: uuid
              description: >-
                The lists the campaign sends to, in the order they were saved. A
                list that was deleted is left out.
            editor:
              oneOf:
                - $ref: '#/components/schemas/TemplateEditor'
                - type: 'null'
              description: >-
                The editor of the campaign's design. Null while the campaign has
                no design.
            content:
              oneOf:
                - $ref: '#/components/schemas/TemplateContent'
                - type: 'null'
              description: >-
                The content of the campaign's design. Null while it has no
                design, or its design has neither HTML nor text.
            ready:
              type:
                - boolean
                - 'null'
              description: >-
                Whether the draft has no issue, so it can be sent. Null unless
                the campaign is a draft.
            issues:
              oneOf:
                - type: array
                  items:
                    $ref: '#/components/schemas/CampaignIssue'
                - type: 'null'
              description: >-
                The reasons the draft cannot be sent yet, in a fixed order;
                empty when it is ready. Null unless the campaign is a draft.
    CampaignIssue:
      type: object
      required:
        - code
        - message
      description: A reason the campaign cannot be sent yet.
      properties:
        code:
          type: string
          enum:
            - sender_missing
            - lists_missing
            - subject_missing
            - preheader_missing
            - design_missing
            - text_broken
            - unsubscribe_link_missing
          description: >
            - `sender_missing`: the campaign has no complete sender, or its
            domain was deleted.

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

            - `subject_missing`: the campaign has no subject.

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

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

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

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

        - `advanced`: designed in MJML, with the dashboard's advanced editor.
        - `simple`: designed with the dashboard's simple editor. Its HTML is
          made from the design and can be read but not changed through the API.
        - `html`: an HTML template, uploaded or written by hand.
    TemplateContent:
      type: object
      required:
        - text_is_custom
      description: |
        A template's content. A key that does not apply to the template's
        editor is left out, and so are `mjml`, `html` and `text` when they are
        empty.
      properties:
        mjml:
          type: string
          description: The MJML source. Only for `advanced` templates.
        html:
          type: string
          description: >-
            The HTML of an `html` template, or the HTML made from the design of
            a `simple` template.
        text:
          type: string
          description: The plain text version of the email.
        text_is_custom:
          type: boolean
          description: >-
            Whether the text was written rather than made from the HTML. A text
            made from the HTML is made again whenever the design changes.
    Address:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: >-
            Valid email address from a domain defined in your account with valid
            DNS records
        name:
          type: string
          description: Display name for the sender
      example:
        email: noreply@example.com
        name: Example Corp
    CampaignStatus:
      type: string
      enum:
        - draft
        - validating
        - waiting_for_review
        - scheduled
        - sending
        - paused
        - sent
        - canceled
        - failed
      description: |
        Where the campaign is in its life. Only a `draft` can be changed or
        deleted.

        - `draft`: being prepared.
        - `validating`: scheduled, and its lists are being checked.
        - `waiting_for_review`: held for a review before it sends.
        - `scheduled`: accepted, waiting for its send time.
        - `sending`: sending now.
        - `paused`: sending stopped for now; `status_reason` says why.
        - `sent`: every message was sent.
        - `canceled`: stopped before it finished; `status_reason` says why.
        - `failed`: could not be sent; `status_reason` says why.
  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:
    CampaignConflict:
      description: |
        Either the campaign's status does not allow the request (for most
        requests, the campaign is not a draft; for an unschedule, it is not
        validating or scheduled, or it has queued mail), or an execution
        with this idempotency key is still in progress. The in-progress case
        is identified only by `Idempotent-Replayed: false` plus a positive
        integer `Retry-After`; retry it after that many seconds with the same
        key. A new status conflict has neither header; a stored replay of
        one has `Idempotent-Replayed: true` (a test send's status conflict is
        not stored, so it is never replayed). The message is not a stable
        discriminator.
      headers:
        Idempotent-Replayed:
          description: |
            `false` with a positive `Retry-After` means an execution is in
            progress. `true` means a stored status 409 was replayed. Absent
            means a fresh status conflict.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        Retry-After:
          $ref: '#/components/headers/IdempotencyRetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: campaign is not a draft
    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
  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.