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

# Add Contacts to a List in a Batch via the API

> Add up to 1,000 existing AhaSend contacts to a list in one request, by ID or email address. Existing memberships, unsubscribes included, never change.

The AhaSend API adds 1 to 1,000 existing contacts to one list, and answers when the work is done. Access requires `lists:write`.

**Entries:**

* Each entry names exactly one of `email` and `id`.
* This operation only adds. A new membership is `confirmed`. A membership that already exists is reported `already_member` and does not change, whatever its status. So a batch never removes an unsubscribe.
* No contact is created. An entry that names no contact of the account is reported `not_found`.

**Results:**

* `data` gives one result for each entry, in the order of the request. `position` is the index of the entry. `outcome` is `added`, `already_member`, `not_found` or `invalid`. `added` and `already_member` include the membership, and `not_found` and `invalid` include a `reason`.
* `added`, `skipped` and `failed` count the results. `skipped` counts `already_member`, and `failed` counts `not_found` and `invalid`.
* An entry is `invalid` when it names neither or both of `email` and `id`, has any other field, has an `id` that is not a UUID, or has an `email` that fails the checks of contact writes.
* An entry that repeats an identifier of an earlier entry is `invalid`, and so is that earlier entry. Email addresses are compared after they are trimmed and changed to lowercase, so `A@example.com` and `a@example.com` are the same.
* When one contact is named once by email and once by ID, the first entry is `added` and the later one is `already_member`.
* One invalid entry does not stop the others. A field other than `data` in the body, an empty `data` array or more than 1,000 entries is refused with `400` for the whole request.

**Pacing:**
1,000 is a limit, not a target. Measure how long a batch takes with real data. Send the next batch only after the previous response arrives, and send smaller batches or wait longer when the time grows. After a `429`, wait before you continue. The API does not promise a fixed throughput. See [API rate limits](/docs/api-reference/rate-limits).

This operation accepts an `Idempotency-Key` header. The memberships and the stored response are saved together. A retry with the same key replays the `200`, the `404` for a missing list or the `400` for 24 hours. See [Idempotency](/docs/api-reference/idempotency).

## Related Guides

* [API authentication](/docs/api-reference/authentication), [Permission scopes](/docs/api-reference/scopes), [Idempotency](/docs/api-reference/idempotency).
* [Batch Upsert Contacts](/docs/api-reference/contacts/batch-upsert-contacts), [Lists API overview](/docs/api-reference/lists/overview), [API errors](/docs/api-reference/errors), [API rate limits](/docs/api-reference/rate-limits).


## OpenAPI

````yaml openapi.yaml POST /v2/accounts/{account_id}/lists/{list_id}/contacts/batch
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}/lists/{list_id}/contacts/batch:
    post:
      tags:
        - Lists
      summary: Batch add contacts to a list
      description: |
        Synchronously adds one through 1,000 existing contacts to one list.
        This operation only ever inserts: an entry whose membership already
        exists is reported `already_member` and the stored row is left exactly
        as it stands, whatever its status, so bulk work can never erase an
        unsubscribe. A membership it creates is `confirmed`. No contact is
        created, and an entry naming nobody in this account is reported
        `not_found`.

        One malformed entry does not cost the others their result. An entry is
        reported `invalid` when it is not an object, names neither or both of
        `email` and `id`, carries any other property, holds an `id` that is
        not a string or not a UUID, or an `email` that is not a string or fails
        the validation contact writes apply, or repeats an identifier another
        entry already used. Duplicates are
        counted on the normalized identifier, so `A@x.com` and `a@x.com` are
        one identifier and both entries fail. A contact named twice by
        different kinds of identifier is settled by payload order: the earliest
        position that would create the membership takes `added`, and every
        later one takes `already_member`. An unknown property beside `data` is
        still a 400 for the whole request.

        The memberships and the optional idempotency replay response commit
        atomically and use the ordinary 24-hour replay window. Every status
        this operation's handler produces is stored and replayable: the 200,
        the 404 for a list that is not there, and each of the 400s below.
        Reusing a key with a different method, resolved path, or semantic JSON
        payload returns 422. An identical keyed request replays the same status
        and a semantically equivalent JSON response.

        Measured pacing guidance: batch size is a cap, not a throughput target.
        Measure end-to-end latency with representative data, submit the next
        synchronous batch only after the previous response, and reduce batch
        size or pace requests when latency grows. Honor 429 responses before
        continuing; the API makes no fixed throughput guarantee.
      operationId: batchAddListContacts
      parameters:
        - name: account_id
          in: path
          required: true
          description: Account ID
          schema:
            type: string
            format: uuid
        - name: list_id
          in: path
          required: true
          description: List ID
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchAddListContactsRequest'
      responses:
        '200':
          description: Ordered per-entry outcomes
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchAddListContactsResponse'
        '400':
          description: |
            A malformed `list_id`, a body that is not a single JSON object, an
            unknown property beside `data`, a `data` that is absent, null or not
            an array, an empty `data` array, or more than 1,000 entries. An
            identical keyed retry replays the stored response with
            `Idempotent-Replayed: true`.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing `lists:write` scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            List not found. The handler produces this status, so it is stored
            against the idempotency key: an identical keyed retry replays it
            with `Idempotent-Replayed: true` rather than looking the list up
            again.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '429':
          description: Too many requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - BearerAuth:
            - lists:write
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

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

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

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

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

              // Name each contact by email or by ID.
              contactID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
              // The SDK sends a fresh Idempotency-Key with this POST and reuses it on
              // its own retries, so a retried request is not applied twice.
              response, _, err := client.ListsAPI.BatchAddListContacts(ctx, accountID, listID, requests.BatchAddListContactsRequest{
                Data: []requests.BatchAddListContactInput{
                  {Email: ahasend.String("one@example.com")},
                  {ID: &contactID},
                },
              })
              if err != nil {
                log.Fatalf("Error adding contacts to the list: %v", err)
              }
              // Each item succeeds or fails on its own.
              fmt.Printf("added=%d skipped=%d failed=%d\n", response.Added, response.Skipped, response.Failed)
              for _, result := range response.Data {
                if result.Outcome != responses.BatchListContactOutcomeAdded {
                  fmt.Printf("item %d: %s %s\n", result.Position, result.Outcome, result.Reason)
                }
              }
            }
        - lang: javascript
          label: Node.js 22+ (AhaSend SDK)
          source: >
            import { AhaSendClient } from "@ahasend/sdk";


            const client = AhaSendClient.fromEnv();

            const listId = "00000000-0000-4000-8000-000000000010";

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

            const result = await client.lists.contacts.batchAdd(listId, {
              data: [{ email: "one@example.com" }, { id: "00000000-0000-4000-8000-000000000011" }],
            });

            console.log("List batch completed.", { added: result.added, failed:
            result.failed });
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:
    BatchAddListContactsRequest:
      type: object
      additionalProperties: false
      required:
        - data
      properties:
        data:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            $ref: '#/components/schemas/BatchAddListContactInput'
    BatchAddListContactsResponse:
      type: object
      additionalProperties: false
      required:
        - object
        - added
        - skipped
        - failed
        - data
      properties:
        object:
          type: string
          enum:
            - list
        added:
          type: integer
          minimum: 0
          description: Entries whose membership this request created
        skipped:
          type: integer
          minimum: 0
          description: Entries reported `already_member`
        failed:
          type: integer
          minimum: 0
          description: Entries reported `not_found` or `invalid`
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchListContactResult'
    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
    BatchAddListContactInput:
      type: object
      description: |
        Names exactly one of `email` or `id`; a null names neither. No contact
        is created here.

        None of that is a request-level rejection, which is why it is stated
        here rather than constrained above: an entry that names neither
        identifier, names both, or carries any other property is reported
        `invalid` at its own position inside a 200, and one naming nobody in
        this account is reported `not_found`.
      properties:
        email:
          type:
            - string
            - 'null'
          format: email
          description: Contact email; trimmed and lowercased before duplicates are counted
        id:
          type:
            - string
            - 'null'
          format: uuid
          description: Contact ID
    BatchListContactResult:
      type: object
      additionalProperties: false
      description: |
        One entry's outcome. `position` is the entry's index in the request
        array and the only handle an entry that named no single identifier
        carries.
      required:
        - position
        - outcome
      properties:
        position:
          type: integer
          minimum: 0
        email:
          type: string
          description: |
            The normalized input email, echoed only when the entry named one
            identifier and it decoded to a non-empty string. An entry whose
            `email` was not a string or was empty once trimmed, and one naming
            both identifiers, carry `position` alone.
        id:
          type: string
          format: uuid
          description: |
            The input contact ID, echoed only when the entry named one that
            parsed as a UUID.
        outcome:
          type: string
          enum:
            - added
            - already_member
            - not_found
            - invalid
          description: |
            `added` created the membership, `already_member` found one and left
            it untouched whatever its status, `not_found` matched no contact in
            this account, and `invalid` was decidable from the payload alone.
        membership:
          $ref: '#/components/schemas/ListContact'
          description: |
            The membership, present on `added` and on `already_member`, where
            it is the row as it stands.
        reason:
          type: string
          description: Caller-safe reason present only on `not_found` and `invalid`
    ListContact:
      type: object
      additionalProperties: false
      description: |
        One contact's membership of one list. It carries no id of its own: a
        membership is addressed by the `(list_id, contact_id)` pair.
      required:
        - object
        - list_id
        - contact_id
        - email
        - subscription_status
        - subscribed_at
        - unsubscribed_at
        - created_at
        - updated_at
      properties:
        object:
          type: string
          enum:
            - list_contact
          description: Object type identifier
        list_id:
          type: string
          format: uuid
          description: The list this membership is on
        contact_id:
          type: string
          format: uuid
          description: The contact this membership belongs to
        email:
          type: string
          format: email
          description: The contact's normalized email address
        subscription_status:
          type: string
          enum:
            - unconfirmed
            - confirmed
            - unsubscribed
            - complained
          description: |
            Per-list consent, independent of the contact's account-wide
            `unsubscribed` flag. `complained` is the mailbox provider's verdict
            and is never writable.
        subscribed_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            When consent was given. Set when the membership becomes
            `confirmed` and left alone afterwards, so it records when consent
            was given rather than when it was last re-asserted.
        unsubscribed_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            When the membership became `unsubscribed`, or when the complaint
            landed for a `complained` membership. Cleared when the membership
            becomes `confirmed` again.
        created_at:
          type: string
          format: date-time
          description: When the membership was created
        updated_at:
          type: string
          format: date-time
          description: When the membership was last updated
        contact:
          $ref: '#/components/schemas/Contact'
          description: |
            The whole contact. Present only on `GET
            /v2/accounts/{account_id}/lists/{list_id}/contacts` with
            `include_contacts=true`.
        list:
          $ref: '#/components/schemas/EmbeddedContactList'
          description: |
            The list this membership is on. Present only on `GET
            /v2/accounts/{account_id}/contacts/{id_or_email}/lists`.
    Contact:
      type: object
      additionalProperties: false
      description: |
        Public contact projection. Attribute reads are legacy-safe: values can
        contain null, booleans, numbers, strings, arrays, or nested objects even
        though new writes accept only canonical dashboard-defined values.
      required:
        - object
        - id
        - created_at
        - updated_at
        - email
        - first_name
        - last_name
        - status
        - status_reason
        - unsubscribed
        - unsubscribed_at
        - attributes
        - validation_status
        - last_validated_at
      properties:
        object:
          type: string
          enum:
            - contact
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the contact
        created_at:
          type: string
          format: date-time
          description: When the contact was created
        updated_at:
          type: string
          format: date-time
          description: When the contact was last updated
        email:
          type: string
          format: email
          description: Normalized contact email address
        first_name:
          type: string
        last_name:
          type: string
        status:
          type: string
          enum:
            - enabled
            - disabled
            - blocked
        status_reason:
          type: string
        unsubscribed:
          type: boolean
        unsubscribed_at:
          type:
            - string
            - 'null'
          format: date-time
        attributes:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/JSONValue'
        validation_status:
          type: string
          enum:
            - unvalidated
            - valid
            - invalid
            - risky
            - unknown
        last_validated_at:
          type:
            - string
            - 'null'
          format: date-time
    EmbeddedContactList:
      type: object
      additionalProperties: false
      description: |
        The list as a membership carries it: a `contact_list` without
        `contact_count`, which no membership read pays for.
      required:
        - object
        - id
        - created_at
        - updated_at
        - name
        - description
        - tags
      properties:
        object:
          type: string
          enum:
            - contact_list
          description: Object type identifier
        id:
          type: string
          format: uuid
          description: Unique identifier for the list
        created_at:
          type: string
          format: date-time
          description: When the list was created
        updated_at:
          type: string
          format: date-time
          description: When the list was last updated
        name:
          type: string
          description: List name; names are not unique within an account
        description:
          type: string
          description: Free-text description; empty when none was given
        tags:
          type: array
          description: Always present; an empty array when the list carries no tags
          items:
            type: string
    JSONValue:
      description: Any recursively nested JSON value stored in a contact attribute
      oneOf:
        - type: 'null'
        - type: boolean
        - type: number
        - type: string
        - type: array
          items:
            $ref: '#/components/schemas/JSONValue'
        - type: object
          additionalProperties:
            $ref: '#/components/schemas/JSONValue'
  headers:
    IdempotentReplayed:
      description: >
        Indicates that this response is a stored replay of a previous identical
        request.

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

        responses do not send `false`; that value is reserved for an in-progress
        409.
      schema:
        type: string
        enum:
          - 'true'
    IdempotencyInProgress:
      description: |
        Identifies an idempotency-key execution that is still in progress. This
        header is emitted only on the specialized HTTP 409 response.
      schema:
        type: string
        enum:
          - 'false'
    IdempotencyRetryAfter:
      description: |
        Positive whole number of seconds after which the in-progress execution
        lease may be retried with the same key and unchanged request.
      schema:
        type: integer
        minimum: 1
  responses:
    IdempotencyConflict:
      description: |
        Request in progress. This response always includes
        `Idempotent-Replayed: false` and a positive integer `Retry-After`.
        Clients must use that header tuple, not the human-readable message, to
        distinguish this retryable idempotency state from other HTTP 409s.
      headers:
        Idempotent-Replayed:
          $ref: '#/components/headers/IdempotencyInProgress'
        Retry-After:
          $ref: '#/components/headers/IdempotencyRetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: A request with this idempotency key is already in progress
    IdempotencyPayloadMismatch:
      description: |
        Idempotency key was already used with a different HTTP method, resolved
        request path, or request body. This response includes neither
        `Idempotent-Replayed` nor `Retry-After`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: idempotency key was already used with a different request payload
  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.