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

# Idempotency Keys for Safe API Retries

> Use AhaSend Idempotency-Key headers on supported API operations to reduce duplicate requests, replay stored results, and handle expiry or conflicts.

AhaSend uses an `Idempotency-Key` to replay a stored result when you retry the same request. It reduces duplicate operations, but a server error, an interrupted request, or an expired record can allow the operation to run again. Reconcile uncertain sends before retrying when duplicates are unacceptable.

Available on Free, Pro and Max. See [plans and features](https://ahasend.com/docs/facts.md#plans-and-features).

> [!NOTE]
>
> Idempotency is particularly important for critical operations like sending emails, creating resources, or processing payments where duplicate actions could cause problems.

## How It Works

When you make a request with an `Idempotency-Key` header:

1. **First Request**: The API processes your request normally and stores the response
2. **Subsequent Requests**: If you retry with the same key, the API returns the stored response instead of processing the request again
3. **Server Errors Are Retriable**: If the original attempt failed with a server error (HTTP 5xx) or never completed, no response is stored — retrying with the same key re-executes the request and can repeat a side effect
4. **Automatic Cleanup**: Stored idempotency responses expire after 24 hours. The exception is API key creation: because its response carries a one-time `secret_key`, the replayable response expires after 5 minutes

> [accordion] Supported Operations
>
> Only the operations in the [Supported Operations](#supported-operations) table declare the idempotency header. These cover:
>
> * **API Keys**: Create API keys
> * **Domains**: Create domains
> * **Messages**: Send messages, including transactional templates
> * **Templates**: Create, update and publish templates, and restore template versions
> * **Campaign Templates**: Create and update campaign templates
> * **Campaigns**: Create, update and duplicate campaigns, and send, unschedule and test them
> * **Account Members**: Add team members
> * **Suppressions**: Create email suppressions
> * **Routes**: Create message routes
> * **Webhooks**: Create webhook endpoints
> * **SMTP Credentials**: Create SMTP credentials
> * **Sub-Accounts**: Create sub-accounts
> * **Sub-Account API Keys**: Create sub-account API keys

> [accordion] One-Time Secrets (API Key Creation)
>
> API-key creation endpoints — [Create API Key](https://ahasend.com/docs/api-reference/api-keys/create-api-key.md) and [Create Sub-Account API Key](https://ahasend.com/docs/api-reference/sub-accounts/create-sub-account-api-key.md) — return a one-time `secret_key` in the response.
>
> Their idempotent replay response is **encrypted and expires after 5 minutes** (instead of the usual 24 hours). Within that window, an exact retry with the same `Idempotency-Key` replays the same `secret_key`. After it, the secret can no longer be retrieved — if you lose it, create a new key.
>
> Other operations that declare idempotency store responses for 24 hours.

> [accordion] Key Requirements
>
> * **Header Name**: `Idempotency-Key`
> * **Key Format**: Any string up to 255 characters
> * **Uniqueness**: Keys are scoped to your account
> * **Expiration**: Stored responses expire after 24 hours — except API key creation, whose secret-bearing response expires after 5 minutes
> * **Request Method**: Only the operations listed below support idempotency; other operations do not gain it implicitly

> [accordion] Request Matching
>
> Requests are matched based on:
>
> * Account ID
> * Idempotency key
> * Request method and path, including path parameters
> * Request body content (SHA256 hash)
>
> If you reuse the same key with a different request body — or send the same body to a different endpoint or resource — the API returns `422 Unprocessable Entity`.

## Supported Operations

| Operation |
| - |
| `POST /v2/accounts/{account_id}/api-keys` |
| `POST /v2/accounts/{account_id}/domains` |
| `POST /v2/accounts/{account_id}/messages` |
| `POST /v2/accounts/{account_id}/messages/template` |
| `POST /v2/accounts/{account_id}/messages/conversation` |
| `POST /v2/accounts/{account_id}/templates` |
| `PUT /v2/accounts/{account_id}/templates/{template_id}` |
| `POST /v2/accounts/{account_id}/templates/{template_id}/publish` |
| `POST /v2/accounts/{account_id}/templates/{template_id}/versions/{version_id}/restore` |
| `POST /v2/accounts/{account_id}/campaign-templates` |
| `PUT /v2/accounts/{account_id}/campaign-templates/{template_id}` |
| `POST /v2/accounts/{account_id}/campaigns` |
| `PUT /v2/accounts/{account_id}/campaigns/{campaign_id}` |
| `POST /v2/accounts/{account_id}/campaigns/{campaign_id}/duplicate` |
| `POST /v2/accounts/{account_id}/campaigns/{campaign_id}/send` |
| `POST /v2/accounts/{account_id}/campaigns/{campaign_id}/unschedule` |
| `POST /v2/accounts/{account_id}/campaigns/{campaign_id}/test` |
| `POST /v2/accounts/{account_id}/contacts` |
| `POST /v2/accounts/{account_id}/contacts/batch` |
| `POST /v2/accounts/{account_id}/lists` |
| `POST /v2/accounts/{account_id}/lists/{list_id}/contacts/batch` |
| `POST /v2/accounts/{account_id}/members` |
| `POST /v2/accounts/{account_id}/sub-accounts` |
| `POST /v2/accounts/{account_id}/sub-accounts/{sub_account_id}/api-keys` |
| `POST /v2/accounts/{account_id}/suppressions` |
| `POST /v2/accounts/{account_id}/routes` |
| `POST /v2/accounts/{account_id}/webhooks` |
| `POST /v2/accounts/{account_id}/smtp-credentials` |

A fresh execution has no `Idempotent-Replayed` header. A completed replay returns the original status and body with `Idempotent-Replayed: true`. An in-progress request returns `409`, `Idempotent-Replayed: false`, and a positive integer `Retry-After`. A mismatched request returns `422` with neither of these headers. The API has no `412` failed-key response.

## Key Selection Best Practices

A client generates an idempotency key, which is a unique key that the server uses to recognize subsequent retries of the same request. How you create unique keys is up to you, but we suggest using V4 UUIDs, or another random string with enough entropy to avoid collisions. Idempotency keys are up to 255 characters long.

### Key Generation Examples

```javascript Javascript theme={null}
import { randomUUID } from 'node:crypto';
const idempotencyKey = randomUUID();
console.log(idempotencyKey);
```

```python Python theme={null}
import uuid

# V4 UUID (recommended)
idempotency_key = str(uuid.uuid4())
# Result: "550e8400-e29b-41d4-a716-446655440000"

# Custom format with timestamp
import time
import secrets

timestamp = int(time.time())
random_suffix = secrets.token_hex(8)
idempotency_key = f"msg_{timestamp}_{random_suffix}"
# Result: "msg_1705317045_a1b2c3d4e5f6g7h8"
```

```php PHP theme={null}
<?php
$idempotencyKey = bin2hex(random_bytes(32));
echo $idempotencyKey, PHP_EOL;
```

```go Go theme={null}
package main

import (
    "crypto/rand"
    "encoding/hex"
    "fmt"
)

func main() {
    key := make([]byte, 32)
    if _, err := rand.Read(key); err != nil { panic(err) }
    fmt.Println(hex.EncodeToString(key))
}
```

> [!NOTE]
>
> **Key Collision Risk**: With V4 UUIDs, the probability of generating duplicate keys is approximately 1 in 5.3 x 10^36. For practical purposes, this is negligible even at massive scale.

## Usage Example

Include the `Idempotency-Key` header only on an operation that declares it:

```bash cURL theme={null}
curl -X POST https://api.ahasend.com/v2/accounts/YOUR_ACCOUNT_ID/messages \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: msg_20240115_001" \
  -d '{
    "from": {"email": "hello@yourdomain.com"},
    "recipients": [{"email": "user@example.com"}],
    "sandbox": true,
    "subject": "Welcome!",
    "html_content": "<h1>Welcome to our service!</h1>"
  }'
```

```javascript JavaScript theme={null}
const response = await fetch('https://api.ahasend.com/v2/accounts/YOUR_ACCOUNT_ID/messages', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer your_api_key',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'msg_20240115_001'
  },
  body: JSON.stringify({
    from: {email: 'hello@yourdomain.com'},
    recipients: [{email: 'user@example.com'}],
    sandbox: true,
    subject: 'Welcome!',
    html_content: '<h1>Welcome to our service!</h1>'
  })
});
```

```python Python theme={null}
import requests

response = requests.post(
    'https://api.ahasend.com/v2/accounts/YOUR_ACCOUNT_ID/messages',
    headers={
        'Authorization': 'Bearer your_api_key',
        'Content-Type': 'application/json',
        'Idempotency-Key': 'msg_20240115_001'
    },
    json={
        'from': {'email': 'hello@yourdomain.com'},
        'recipients': [{'email': 'user@example.com'}],
        'sandbox': True,
        'subject': 'Welcome!',
        'html_content': '<h1>Welcome to our service!</h1>'
    }
)
```

## Response Behavior

The API responds differently based on the idempotency key status:

> [tab] First Request
>
> **Status**: `202 Accepted` for Create Message, Create Template Message, Create Conversation and Send Campaign, `201 Created` for resource creation, and `200 OK` for other operations, such as Update Template
>
> **Headers**:
>
> * Standard response headers
> * No special idempotency headers
>
> **Body**: Normal response content
>
> The Create Message response is the per-recipient list shown in [response handling](https://ahasend.com/docs/send-api/send-email.md#how-do-i-read-the-sending-response).

> [tab] Replayed Response
>
> **Status**: Same as original response
>
> **Headers**:
>
> * `Idempotent-Replayed: true`
> * Same content type as original
>
> **Body**: Exact same response as the original request
>
> The Create Message response is the per-recipient list shown in [response handling](https://ahasend.com/docs/send-api/send-email.md#how-do-i-read-the-sending-response).

> [tab] Concurrent Request
>
> **Status**: `409 Conflict`
>
> **Headers**:
>
> * `Idempotent-Replayed: false`
> * `Retry-After: <seconds>` — when it is safe to retry
>
> **Body**:
>
> ```json theme={null}
> {
>   "message": "A request with this idempotency key is already in progress"
> }
> ```

> [tab] Failed Original
>
> **Replayed**: on ordinary idempotent operations, deterministic `2xx` and `4xx` outcomes produced after the idempotency middleware are stored and replayed. Authentication failures, IP allow list failures and the rate-limit `429` happen before that middleware and are not stored. API key creation stores a successful `201` only; a controller-produced `4xx` leaves the key in progress until its lease expires. [Test Campaign](https://ahasend.com/docs/api-reference/campaigns/test-campaign.md) stores a `2xx` only; a `4xx` releases the key, so a retry with the same key gets a new answer.
>
> **Campaign status conflicts**: Update Campaign, Send Campaign, Unschedule Campaign and Test Campaign answer `409` when the status of the campaign does not allow the request. That `409` has no `Retry-After` header. Except for Test Campaign, it is stored like other `4xx` answers, so a retry with the same key replays it with `Idempotent-Replayed: true`. After the status changes, retry with a new key. See the [forms of a campaign 409](https://ahasend.com/docs/api-reference/campaigns/overview.md#idempotency).
>
> **Re-executed**: server errors (`5xx`) are transient — no response is stored, and a retry with the same idempotency key re-executes the request as if it were the first attempt.

> [tab] Payload Mismatch
>
> **Status**: `422 Unprocessable Entity`
>
> **Body**:
>
> ```json theme={null}
> {
>   "message": "idempotency key was already used with a different request payload"
> }
> ```

## Best Practices

> [card] Unique Keys
>
> Use unique, descriptive keys that won't conflict with other operations. Consider including timestamps or UUIDs.
>
> ```text theme={null}
> Good: user_123_welcome_20240115_001
> Bad: request_1
> ```

> [card] Retry Logic
>
> Retry with the same idempotency key when you do not know the outcome, or the answer was not stored, as the **Failed Original** tab under [Response Behavior](#response-behavior) describes. Examples are a network error, a `5xx` (when a `503` has a `Retry-After` header, wait that delay), a rate-limit `429` (wait as [API rate limits](https://ahasend.com/docs/api-reference/rate-limits.md) describes), and a `409` that is still in progress (wait for its `Retry-After` delay). A stored answer is replayed, so a retry of a stored `4xx` with the same key gets the same answer. For a `5xx` or a network error, wait with exponential backoff between retries. The snippet below handles the in-progress `409`; the [Error Handling](#best-practices) card lists how to treat the other `409` answers.
>
> Pass a `makeRequest(key)` callback that sends the same body each time and, on failure, throws an object with the HTTP `status` and the response `headers` (a `Headers` object). Only use this retry policy after deciding that retrying an uncertain send is acceptable.
>
> ```javascript theme={null}
> export async function retryInProgress(makeRequest, idempotencyKey) {
>   for (let attempt = 0; attempt < 3; attempt++) {
>     try {
>       return await makeRequest(idempotencyKey);
>     } catch (error) {
>       const seconds = Number(error.headers?.get('Retry-After'));
>       const inProgress = error.status === 409
>         && error.headers?.get('Idempotent-Replayed') === 'false'
>         && seconds > 0;
>       if (!inProgress || attempt === 2) throw error;
>       await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
>     }
>   }
> }
> ```

> [card] Key Expiration
>
> Stored responses expire after 24 hours — don't reuse a key after this period, as it can execute again. API key creation is shorter: its one-time `secret_key` is only replayable for 5 minutes.

> [card] Error Handling
>
> Handle different response codes appropriately:
>
> * `409` with `Idempotent-Replayed: false` and `Retry-After`: request in progress — wait for the `Retry-After` interval, then retry with the same key
> * Any other `409`: a conflict with the state of the resource — do not retry with the same key; after you resolve the conflict, use a new key
> * `422`: Key reused with a different request — use a new idempotency key, or retry with the original payload and endpoint
> * `5xx`: Transient failure — retry with the same idempotency key
> * `2xx`/`4xx` with `Idempotent-Replayed: true` header: replayed original outcome

## Error Scenarios

> [accordion] Request Already in Progress (409 Conflict)
>
> This happens when you make concurrent requests with the same idempotency key.
>
> **What it means**: Another request with the same key is currently being processed.
>
> **What to do**: Wait for the interval indicated by the `Retry-After` response header, then retry with the same key. The retry will either get a `409` again (still processing), the stored result once complete, or — if the original request never completed (e.g. it was interrupted) — it will re-execute the request once the in-flight lease expires (at most 5 minutes).
>
> ```json Response theme={null}
> {
>   "message": "A request with this idempotency key is already in progress"
> }
> ```

> [accordion] Original Request Failed with a Server Error
>
> If the original request failed with an HTTP `5xx` error, no response is stored for the idempotency key.
>
> **What it means**: The failure is treated as transient, and the key remains usable.
>
> **What to do**: Retry with the same idempotency key — the request is re-executed as if it were the first attempt. (Other answers can be stored. The **Failed Original** tab under [Response Behavior](#response-behavior) lists what is stored and the exceptions.)

> [accordion] Key Mismatch
>
> If you use the same idempotency key with different request data — or with the same data against a different endpoint or resource — the API returns `422 Unprocessable Entity`.
>
> **What it means**: The request doesn't match the original request for that key.
>
> **What to do**: Ensure you're using the exact same request (payload and endpoint) when retrying, or use a different idempotency key for different requests.
>
> ```json Response theme={null}
> {
>   "message": "idempotency key was already used with a different request payload"
> }
> ```

## Implementation Details

> [!WARNING]
>
> The following technical details are provided for transparency but are not required for basic usage.

### Request Hashing

The API uses SHA256 hashing of the request method, path, and body to detect changes between requests with the same idempotency key. This ensures that different requests don't accidentally match — including the same payload sent to a different resource.

### Concurrency Protection

Claiming an idempotency key happens atomically; While a request is in flight, concurrent requests with the same key receive `409 Conflict` with a `Retry-After` header instead of waiting.

### Execution Leases

An in-flight request holds a lease on its idempotency key for up to 5 minutes. If the original request never completes (for example, it was interrupted by a deploy or crash), the key unblocks automatically when the lease expires and the next retry re-executes the request.

### Storage Duration

Ordinary stored results expire after 24 hours; successful API-key creation results expire after 5 minutes. After expiry, the same key can run a new operation.

Responses that contain a one-time secret — API key creation and sub-account API key creation — are stored encrypted and their replayable response expires after 5 minutes. Within that window an exact retry replays the same `secret_key`; afterward the secret can no longer be returned.

***

> [!TIP]
>
> For high-volume applications, consider implementing client-side deduplication in addition to server-side idempotency to reduce unnecessary API calls.

## Related Guides

* [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [Permission scopes](https://ahasend.com/docs/api-reference/scopes.md), [Send-only API keys](https://ahasend.com/docs/send-api/credentials.md).
* [REST sending guide](https://ahasend.com/docs/send-api/send-email.md), [SMTP settings](https://ahasend.com/docs/smtp.md), [CLI quickstart](https://ahasend.com/docs/cli/quickstart.md).
* [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md), [Go SDK](https://ahasend.com/docs/guides/go-sdk.md), [API errors](https://ahasend.com/docs/api-reference/errors.md).
* [API rate limits](https://ahasend.com/docs/api-reference/rate-limits.md), [Sandbox behavior](https://ahasend.com/docs/send-api/sandbox.md), [Suppression durations](https://ahasend.com/docs/bounces.md#suppression-duration).


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