> ## 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 a Transactional Template via the API

> Send a published AhaSend transactional template by its ID to up to 100 recipients. Each recipient gives its own values for the template's variables.

The AhaSend API sends a transactional template to one or more recipients. Each recipient gets a separate message. The template supplies the body, and it supplies the subject, the sender and the reply-to when the request leaves them out. To send content that is not stored in a template, use [Create Message](/docs/api-reference/messages/create-message).

Authorization uses the domain of the sender, `from.email` or the template's default sender: either `messages:send:all` or the matching domain-specific send role is sufficient. Sending a template needs no `templates` scope. A request with no `from` is refused with `403` when the API key cannot send from any domain.

**Template:**

* `template_id` is required. It must be the ID of one of this account's transactional templates. [List Templates](/docs/api-reference/templates/get-templates) returns the IDs. Any other ID, a deleted template included, is refused with `404` `template not found`.
* The send uses the published copy of the template. Changes in its draft are not sent until you [publish them](/docs/api-reference/templates/publish-template). A template with no published content is refused with `400`.
* The message has the template's HTML body with its preview text, and the template's text body. It has no AMP part. The request cannot carry `text_content`, `html_content` or `amp_content`, and is refused with `400` when it does.
* [Get Message](/docs/api-reference/messages/get-message) returns the ID of the template in the message's `template_id`.

**Sender and subject:**

* Leave out `from` to send from the template's default sender. A `from` in the request is used even when the template has a default sender.
* When the request has no `from` and the template has no default sender, the request is refused with `400` `from is required because the template has no default sender`.
* The sender, the request's or the template's, must be from a domain you own with valid DNS records.
* When the request has no `reply_to` and no `reply-to` header, the template's reply-to is used.
* `subject` replaces the template's subject. When it is left out, empty or only spaces, the template's published subject is used. When neither has a subject, the request is refused with `400`.

**Variables:**

* Each recipient's `substitutions` hold its values for the template's variables. They are the only place for these values: a `substitutions` object at the top of the request is refused with `400`.
* Each recipient must give a value for each required variable of the template. The `variables` list of [Get Template](/docs/api-reference/templates/get-template) shows which variables are required. A variable that the design wraps in a fallback is not required. An empty value counts as a value.
* When a recipient does not give a required variable, the request is refused with `400`, and no message is sent. The error names each recipient and the variables it does not give, for example `missing required template variables: user@example.com needs first_name, order_id`.
* A value for a name that the template does not use is accepted. Your `subject` and `headers` resolve from the same values, and a `{{ name }}` with no value becomes empty.
* In the HTML body, each value is HTML-escaped. A design that writes `{{ name|safe }}` inserts that value as HTML. The subject, the headers and the text body get values as they are.
* The send supplies `email` and `unsubscribe_url` itself and replaces a value a recipient gives for them. `email` is the recipient's address.

**Unsubscribe link:**
When the template's design uses `unsubscribe_url`, each recipient gets its own unsubscribe link. The message carries `List-Unsubscribe` with that link and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, so the unsubscribe button of a mail client works. The button suppresses the recipient for the sending domain. The link opens a confirmation page, and the recipient is suppressed after they confirm. A `List-Unsubscribe` or `List-Unsubscribe-Post` in your `headers` is dropped. When the design does not use `unsubscribe_url`, the send makes no link and sends your headers as you give them.

**Other fields:**
`attachments`, `headers`, `tags`, `tracking`, `retention`, `schedule`, `sandbox` and `sandbox_result` follow the rules of [Create Message](/docs/api-reference/messages/create-message). The response is the same per-recipient list. When sending from the domain is paused, the request is refused with `403`. A sandbox message is accepted from a paused domain.

This operation accepts an `Idempotency-Key` header. 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).
* [Create Template](/docs/api-reference/templates/create-template), [Sending guide](/docs/send-api/send-email), [Sandbox behavior](/docs/send-api/sandbox).
* [API errors](/docs/api-reference/errors), [API rate limits](/docs/api-reference/rate-limits).


## OpenAPI

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


        **The message:**

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


        **Variables:**

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


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


        **Reserved variables:**

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


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


        **Fields that are not accepted:**

        These fields at the top of the request are rejected with a 400. Any
        value except `null` is rejected, `""` and `{}` included.

        - `substitutions`: `substitutions go on each recipient, not at the top
        of the request`

        - `text_content`, `html_content` and `amp_content`: `<field> is not
        accepted, the template supplies the body`, with the name of the field


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


        **Sender:**

        - When the request has no `from`, the message is sent from the
        template's default sender. When the template has no default sender, the
        request is rejected with a 400 `from is required because the template
        has no default sender`.

        - A `from` given in the request is used even when the template has a
        default sender. It must have an `email`, and it is checked as on Create
        Message.

        - The sender, the request's or the template's, must be from a domain you
        own with valid DNS records.

        - When the request has no `reply_to` and no `reply-to` in `headers`, the
        template's reply-to is used.


        **Subject:**

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


        **Headers and subject:**

        - Header names must be visible ASCII with no space and no colon. An
        empty name, or one holding a space, a colon or a line break, is
        rejected.

        - A substitution value that a header uses must not contain a line break.
        Such a request is rejected, and the error names the recipient.

        - A `{{ name }}` in a subject that carries an emoji or an accented
        character is replaced before the message is built, on every send. Such a
        subject is rejected when it holds template syntax other than `{{ name
        }}`, when a substitution value holds `{{`, `{%` or `{#`, or when the
        subject and the values combine into template syntax. See `subject`.


        **Order of checks:**

        When the request body is read, the fields that are not accepted are
        checked first, then the request body against its schema. Then the checks
        run in this order, and a request that fails more than one gets the error
        of the first:

        1. The number of recipients, then the header names.

        2. A `from` in the request: its domain, as on Create Message.

        3. No `from`, and an API key that cannot send from any domain: 403 `this
        api key is not authorized to send messages`.

        4. A `template_id` that is not one of this account's transactional
        templates: 404 `template not found`.

        5. A template with neither an HTML nor a text body: 400 `template has no
        published content, design it and publish it`.

        6. An HTML or text body that holds a `{{`, `{%` or `{#` that cannot be
        rendered: 400 `template's published design holds a {{, {% or {# that
        cannot be rendered, fix it and publish it`.

        7. A default sender or reply-to of the template that the send would use
        and that is not valid: 400 `the template's default sender or reply-to is
        not valid, edit it on the template page and publish it`.

        8. No `from`: the template's default sender (see Sender) and its domain.

        9. The subject (see Subject), then the variables of each recipient (see
        Variables).

        10. The sandbox fields, then whether the account can send and whether
        sending from the domain is paused, as on Create Message.


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


        Authorization uses the domain of the sender, `from.email` or the

        template's default sender: either `messages:send:all` or the matching

        domain-specific send role is sufficient. Sending a template requires

        no additional scope.
      operationId: createTemplateMessage
      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/CreateTemplateMessageRequest'
      responses:
        '202':
          description: Message created successfully
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateMessageResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_variables:
                  summary: A recipient does not give a required variable
                  value:
                    message: >-
                      missing required template variables: user@example.com
                      needs first_name, order_id
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >
            Forbidden. For example, the API key cannot send from the domain of
            the sender, the account cannot send, or sending from the domain is
            paused. A sandbox message is accepted from a paused domain. A
            request with no `from` is refused with `this api key is not
            authorized to send messages` when the API key cannot send from any
            domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                domain_paused:
                  summary: Sending from the domain is paused
                  value:
                    message: >-
                      Sending from domain 'example.com' is paused. Check your
                      dashboard for the reason.
        '404':
          description: >
            `template not found`: `template_id` is not the ID of one of this
            account's transactional templates. A deleted template, a campaign
            design and a template of another account get this answer too.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/IdempotencyPayloadMismatch'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - BearerAuth:
            - messages:send:all
        - BearerAuth:
            - messages:send:{domain}
      x-code-samples:
        - lang: go
          label: AhaSend Go SDK
          source: |
            package main

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

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

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

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

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


            const client = AhaSendClient.fromEnv();

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

            const result = await client.messages.sendTemplate({
              template_id: "00000000-0000-4000-8000-000000000009",
              recipients: [{ email: "recipient@example.net", substitutions: { first_name: "Ada" } }],
              sandbox: true,
            });

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


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


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


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


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


            A `{{ variable }}` in a value resolves from the recipient's
            `substitutions` and the reserved variables. On a template whose
            design uses `unsubscribe_url`, a `List-Unsubscribe` or
            `List-Unsubscribe-Post` header given here is dropped.
        tags:
          type: array
          items:
            type: string
          description: Tags for categorizing messages
        sandbox:
          type: boolean
          description: If true, the message will be sent to the sandbox environment
          default: false
        sandbox_result:
          type: string
          enum:
            - deliver
            - bounce
            - defer
            - fail
            - suppress
          description: The result of the sandbox send
        tracking:
          $ref: '#/components/schemas/Tracking'
          description: >-
            Tracking settings for the message, overrides default account
            settings
        retention:
          $ref: '#/components/schemas/Retention'
          description: >-
            Retention settings for the message, overrides default account
            settings
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Schedule for message delivery
      example:
        template_id: 0195c2a4-7d3e-7b6a-9f1c-2e4d5a6b7c8d
        recipients:
          - email: user@example.com
            name: John Doe
            substitutions:
              first_name: John
              order_id: '12345'
    CreateMessageResponse:
      type: object
      required:
        - object
        - data
      properties:
        object:
          type: string
          enum:
            - list
          description: Object type identifier
        data:
          type: array
          items:
            $ref: '#/components/schemas/CreateSingleMessageResponse'
          description: List of messages and their statuses
    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
    Address:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: >-
            Valid email address from a domain defined in your account with valid
            DNS records
        name:
          type: string
          description: Display name for the sender
      example:
        email: noreply@example.com
        name: Example Corp
    Recipient:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: Recipient email address
        name:
          type: string
          description: Display name for the recipient
        substitutions:
          type: object
          additionalProperties: true
          description: >-
            Substitution data for the recipient. Used with jinja2 templating
            language for dynamic content. In the HTML and AMP bodies, values are
            HTML-escaped; write `{{ name|safe }}` to insert a value as HTML. The
            subject, the headers and the text body get values as they are.
      example:
        email: user@example.com
        name: John Doe
        substitutions:
          first_name: John
          order_id: '12345'
    Attachment:
      type: object
      required:
        - data
        - content_type
        - file_name
      properties:
        base64:
          type: boolean
          description: >-
            If true, data must be encoded using base64. Otherwise, data will be
            interpreted as UTF-8
          default: false
        data:
          type: string
          description: >-
            Either plaintext or base64 encoded attachment data (depending on
            base64 field)
        content_type:
          type: string
          description: The MIME type of the attachment
        content_disposition:
          type: string
          description: The disposition of the attachment
        content_id:
          type: string
          description: >
            Content-ID that lets you reference this attachment as an inline
            image from your HTML.


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


            If the angle brackets are omitted, the file is delivered as a
            regular (downloadable) attachment instead of rendering inline.
        file_name:
          type: string
          description: The filename of the attachment
      example:
        base64: true
        data: >-
          iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
        content_type: image/png
        file_name: pixel.png
    Tracking:
      type:
        - object
        - 'null'
      properties:
        open:
          type:
            - boolean
            - 'null'
          description: Whether to track opens
        click:
          type:
            - boolean
            - 'null'
          description: Whether to track clicks
      example:
        open: true
        click: true
    Retention:
      type:
        - object
        - 'null'
      properties:
        metadata:
          type:
            - integer
            - 'null'
          description: Number of days to retain metadata
          minimum: 1
          maximum: 30
        data:
          type:
            - integer
            - 'null'
          description: Number of days to retain data
          minimum: 0
          maximum: 30
      example:
        metadata: 1
        data: 0
    MessageSchedule:
      type: object
      properties:
        first_attempt:
          type: string
          format: date-time
          description: >-
            The time to make the first attempt for delivering the message. Must
            be RFC3339 format, in the future, and within 7 days of the request.
        expires:
          type: string
          format: date-time
          description: >-
            Expire and drop the message if not delivered by this time. Must be
            RFC3339 format, in the future, and within 8 days of the request.
    CreateSingleMessageResponse:
      type: object
      required:
        - object
        - id
        - recipient
        - status
        - error
      properties:
        object:
          type: string
          enum:
            - message
          description: Object type identifier
        id:
          type:
            - string
            - 'null'
          description: Message ID (null if the message was not sent)
          example: <uuid@example.com>
        recipient:
          allOf:
            - $ref: '#/components/schemas/Recipient'
            - type: object
              required:
                - name
        status:
          type: string
          enum:
            - queued
            - scheduled
            - error
          description: Status of the message
        error:
          type:
            - string
            - 'null'
          description: Error message if the message was not sent
        schedule:
          $ref: '#/components/schemas/MessageSchedule'
          description: Provided if the request contained a schedule
  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.