# Idempotency

> Send an Idempotency-Key on the five creates that honour it and a retry replays the stored response instead of doing the thing twice.

Source: https://docs.amomic.in/api/idempotency

---

A request that times out tells you nothing. The socket closed; the message may already be on a customer's phone, the call may already be ringing in their pocket. You have two options and both are wrong: retry and risk doing it twice, or give up and risk never doing it at all.

`Idempotency-Key` removes the choice. You label the operation, we store the first response against that label, and every subsequent attempt with the same label gets the stored response back instead of executing again.

## Where it works

```params
[
  {"name": "Idempotency-Key", "type": "header", "required": false, "description": "Any string you choose, up to 200 characters. Read case-insensitively, surrounding whitespace stripped, and truncated at 200 characters if you send more."}
]
```

Five creates honour it. One create deliberately does not, and the action routes use a version check instead.

| Endpoint | `Idempotency-Key` | Replayed status |
|---|---|---|
| `POST /api/v1/messages` | **Honoured** | `201` |
| `POST /api/v1/calls` | **Honoured** | `201` |
| `POST /api/v1/tickets` | **Honoured**, and deduplicates even without it | `201` |
| `POST /api/v1/campaigns` | **Honoured** | `201` |
| `POST /api/v1/apps/duesly/subscribers` | **Honoured** | `201` |
| `POST /api/v1/contacts` | Ignored | – |
| `POST /campaigns/{id}/launch`, `/pause`, `/resume`, `/cancel`, `PATCH /tickets/{id}`, `POST /apps/duesly/dues/{id}/paid` | Ignored. These take `expected_version` in the body, and a repeat with a stale version returns `409 conflict` instead of acting twice | – |
| `POST /calls/{call_id}/hangup`, `POST /campaigns/{id}/evaluate` | Ignored. Repeating them is harmless | – |

Every `GET` on the API is naturally idempotent — reading twice costs you a rate-limit slot and nothing else — so the header has no meaning there and is not read.

`POST /contacts` is the interesting omission. It is an **upsert**: it matches an existing contact on email, then on phone, and updates it in place, creating one only if nothing matched. Sending it twice with the same body resolves to the same contact both times. There is no duplicate to prevent, so there is no mechanism, and adding one would imply a hazard that does not exist. [Contacts](/api/contacts) covers the matching rules and the one case where a repeat *does* fail — `identity_conflict`, which no idempotency key would have saved you from.

## The mechanism

```mock
{"component": "IdempotencyDiagram", "props": {"caption": "First attempt executes and the response is stored. A retry with the same key and the same bytes returns that stored response without touching the provider."}}
```

::: steps

::: step You send a key

The header travels with your `POST`. We hash your workspace, the specific API key you authenticated with, the endpoint, and your key string into one record id.
:::

::: step We atomically claim the key

Once your body has passed schema validation, the request creates a pending record before any external action. A completed record with the same body replays its stored JSON immediately. A request that finds the key still pending receives `409 idempotency_in_progress`; it never reaches WhatsApp, telephony, or ticket creation.
:::

::: step The response is committed before it is returned

The handler runs and its response is written to the idempotency record before you receive the `201`. The completed response remains replayable for 24 hours.

Only a success is stored. If the handler fails after the claim, the claim is released, so the same key is free again: see [After an error response, retry with the same key](#after-an-error-response-retry-with-the-same-key).
:::

:::

## How the key is scoped

The stored record is keyed on four things together:

**your workspace** + **the API key you used** + **the endpoint** + **your key string**

Three consequences worth knowing before you design your key format.

**You do not have to namespace by endpoint.** `Idempotency-Key: order-4821-shipped` on `POST /messages` and the identical string on `POST /calls` are two independent records. Each of the five creates has its own namespace (`messages`, `calls`, `tickets`, `campaigns`, and `duesly` for Duesly subscribers). They cannot collide, cannot replay each other's response, and cannot 409 each other. Use whatever name describes the operation and let the scoping do the separating.

**Two API keys in the same workspace do not share records.** If your sender service and your reconciliation job hold different keys, they each get their own idempotency namespace, even for byte-identical requests.

::: warning Rolling a key mid-retry loses the replay
Because the API key is part of the scope, an attempt made with the old half of a [rolled key](/api/authentication) and a retry made with the new half are two separate operations. The retry executes for real. If you are rolling keys and running a retry queue at the same time, drain the queue first.
:::

**The key is yours, the record id is not.** We never return the record id and there is no endpoint to inspect, list, or delete idempotency records. The only way to observe one is to send the request again.

## What a replay looks like

Exactly like the original. That is not a simplification — it is the whole contract.

- The **status code** is the endpoint's normal success status, so `201` for all five, not `200` and not `208`.
- The **body** is the stored JSON: what you got the first time. The same `message_id`, the same `call_id`, the same `ticket_id` or `campaign_id`.
- There is **no marker header** and **no field** saying "this was replayed". No `Idempotent-Replay: true`, nothing in the envelope.

::: warning You cannot detect a replay from the response
If your code needs to know whether this attempt was the one that actually did the work — to decide whether to write an audit row, or increment a counter — the API will not tell you. Decide from your own state instead: check your "already sent" flag before you call, and set it after. The idempotency key protects the customer from a duplicate; only your own record protects your reporting from a double count.
:::

## Reusing a key with a different body

A stored record remembers a hash of the request body it was created from. Send the same key with a different body and you get a `409` rather than either behaviour you might have wanted:

```json title="409 Conflict"
{
  "error": {
    "code": "conflict",
    "message": "This Idempotency-Key was already used with a different request body.",
    "details": { "reason": "idempotency_key_reuse" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

We refuse rather than guess. Replaying the old response would silently drop your new request; executing the new one would break the promise the key made. Neither is safe, so the ambiguity comes back to you.

This is almost never a race. It is almost always one of two ordinary mistakes:

- **The key is too coarse.** `order-4821` used for the "shipped" message and again for the "delivered" message. Two different messages, one key. Put the event in the key.
- **The body was re-serialised.** Read the next section, because this one catches people who did everything else right.

## The body hash is over raw bytes

The match is `sha256` of the exact bytes of your request body. Not a normalised form, not a parsed structure — the bytes.

These two bodies are the same object to every JSON parser on earth, and two different bodies to us:

```json title="First attempt"
{"to":"+919876543210","text":"Your order AM-4821 has shipped."}
```

```json title="Retry — same data, different bytes"
{"text":"Your order AM-4821 has shipped.","to":"+919876543210"}
```

Key order changed, so the hash changed, so the retry is `409 idempotency_key_reuse`. Whitespace does it too: a pretty-printed retry of a compact original is a different body.

::: danger Serialise once, resend the same string
This is the failure that survives code review. A retry loop that rebuilds the payload from a dictionary each time is at the mercy of your serialiser's key ordering — which in several languages is stable within a process and not stable across versions or across a `dict` that was mutated in between.

Build the JSON string **before** the first attempt, hold it in a variable, and pass that same string to every attempt. With Python `requests` that means `data=body` with a pre-encoded body rather than `json=payload`; in Node it means one `JSON.stringify` outside the loop.
:::

The same rule applies to `metadata`. A `metadata` object that includes an attempt counter, a timestamp taken at call time, or a hostname produces a different body on every attempt and defeats the key entirely while looking correct.

## Records live for 24 hours

An idempotency record expires 24 hours after it is written. After that it reads as absent, so the same key executes a fresh request.

That boundary is real and it has a real failure mode: a job that fails, sits in a dead-letter queue over a weekend, and is replayed on Monday will send the message again. The key it carries is three days old and no longer matches anything.

If your queue can replay work that old — and most can — the idempotency key is not your last line of defence. Check your own "already notified" flag before you call. The key protects a retry loop measured in seconds and minutes; your database protects everything longer.

## Concurrent requests and uncertain outcomes

There is no post-response race window: the key is claimed before the side effect and the response is stored before it is returned. If two copies arrive together, one runs and the other receives `409` with `details.reason: idempotency_in_progress` and `retry_after_seconds: 2`. Retry the second one with the same key and identical body after that delay, and you get the first one's stored response once it has finished.

If the worker dies outright after an external provider accepted the action but before the result can be stored, nothing is left to release the claim, so the key stays pending for its 24-hour lifetime. We do not steal it and risk repeating a real message or call whose outcome is unknown. Reconcile from your own record, or contact support with the request id, rather than sending the operation again under a new key.

## After an error response, retry with the same key

A request that fails **schema validation** (a `400` with `details.errors`) is refused before the key is claimed. Any other error response comes after the claim, and the claim is **released** when the request fails: a `400` such as `empty_message`, a `404` `template_not_found`, a `412` such as `channel_not_connected` or `allowance_exhausted`, and a `500` or `502`. Either way nothing is stored for that key, so the next request with it runs fresh.

So when you **get an error response back**:

- Nothing was stored to replay, and the key is free.
- Fix the cause, then send the operation again with the **same** key. The corrected body is fine: there is no earlier body to conflict with.

The `4xx` refusals above are returned before anything is sent. A `5xx` is different: it can come **after** the provider accepted the action, for example when the result could not be stored. The claim is released then too, so a retry with the same key sends again. Before you retry a message or a call after a `5xx`, confirm it did not go out: check the conversation in the [Inbox](/console/inbox), wait for its `message.status` webhook, or look the call up with `GET /calls/{call_id}`.

This is different from a timeout or a dropped connection, where you got **no** response. Then you don't know what happened, so retry with the **same** key: you get the stored response if the first attempt succeeded, `409 idempotency_in_progress` if it is still running, or a fresh attempt if it failed.

## Tickets deduplicate without a key

`POST /tickets` has a second, independent guard. Even with no `Idempotency-Key` header at all, the ticket id is derived from a hash of your request body, so an identical body sent twice converges on the **same ticket** instead of opening a second one.

That is why a `tkt_` id is a hash rather than a time-ordered id like `con_` or `call_`.

It is a useful floor, not a substitute for the header, because it defines "the same request" as "the same bytes" — and a description containing a timestamp, or a subject with the retry count in it, is not the same bytes. The header lets *you* declare what "the same request" means: one key per incident, per order, per reconciliation run. [Tickets](/api/tickets) has the detail.

## Choosing a key

This is the part that decides whether any of the above helps you. The mechanism is simple; picking the label is where integrations go wrong.

**A key names a real-world operation, not an attempt.** "The shipping notification for order 4821" is an operation. "The HTTP request I am about to make" is an attempt. There can be many attempts of one operation, and they must all carry the same key.

**Derive it, do not generate it.** The best key is a pure function of data you already have — an id from your own database, plus the event that triggered the send.

```
order-4821-shipped
renewal-8821-2026-08-01
ticket-sync-INC-9931
cart-88213-recovery
```

Every one of those can be recomputed from scratch after a process crash, a redeploy, or a queue redelivery, without having stored anything extra. That property is the whole point: a derived key survives situations where a generated key is already lost.

### Never a fresh UUID per attempt

```javascript
// Wrong. This defeats the entire mechanism.
headers: { "Idempotency-Key": crypto.randomUUID() }
```

A key generated inside the request function is a new key on every attempt. Every attempt therefore looks like a brand-new operation, executes for real, and sends a second message. The header is present, the code looks careful, and you have exactly the duplicate you were trying to prevent — with the added cost of believing you were protected.

This is the single most common way this feature is misused, and it is invisible until the day a network blip produces two WhatsApp messages to the same customer.

A UUID generated **once** and persisted alongside your record is correct, and is the right answer when nothing in your data identifies the operation. It is still weaker than derivation, because there is a window between generating it and durably storing it. Crash inside that window and the retry generates a new UUID. Derivation has no such window.

### Put the event in the key, not just the entity

`order-4821` is too coarse. The order will be the subject of several messages over its life — shipped, out for delivery, delivered, feedback request — and the second one to reuse that key gets a `409` because its body differs.

`order-4821-shipped` names one message about one order. The next notification is a different operation and gets a different key.

### Add a discriminator when a repeat is intentional

Some operations are *meant* to happen more than once with identical content: a daily reminder, a monthly statement, an OTP the customer asked to be resent. For those, the thing that makes the second one legitimately different is the occurrence, so put the occurrence in the key.

```
statement-8821-2026-08
reminder-INV-4410-2026-08-01
otp-919876543210-1754049600
```

Now the second send is a different operation, gets a different record, and executes — while a retry *within* that occurrence still replays.

### The table

| Key | Verdict |
|---|---|
| `order-4821-shipped` | Good. Derived, names one operation, recomputable. |
| `renewal-8821-2026-08-01` | Good. Repeats are legitimate and the date discriminates them. |
| `a3f0c9e1-…` persisted with the record | Acceptable. Correct if you write it before the first attempt. |
| `order-4821` | Too coarse. The next message about this order will `409`. |
| `crypto.randomUUID()` at call time | Broken. Every attempt is a new operation. |
| `order-4821-attempt-2` | Broken. The attempt number is exactly what must not vary. |
| `order-4821-${Date.now()}` | Broken. Same reason, harder to spot. |
| `whatsapp-send` | Broken the other way. Every send in 24 hours after the first one `409`s. |

Two smaller rules:

- **Keep the distinguishing part early.** The value is truncated at 200 characters. A key that only differs after character 200 is not a different key.
- **Treat the value as loggable.** It travels as a plain header and lands in log lines on both sides. If your logs are less protected than your database, do not build the key out of a raw email address.

## A retry loop that is actually safe

Both examples do the things that matter: derive the key once, serialise the body once, reuse both on every attempt after a timeout, wait out `idempotency_in_progress`, and stop on any other error response, because it needs a decision rather than a blind retry: a `4xx` needs a fix, and a `5xx` needs a check that nothing was sent. After either, the same key is free for the retry.

::: codegroup

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function sendShippedNotice(order) {
  // Derived from data we already have. Recomputable after any crash.
  const idempotencyKey = `order-${order.id}-shipped`;

  // Serialised ONCE. Every attempt sends these exact bytes.
  const body = JSON.stringify({
    to: order.customerPhone,
    template: {
      name: "order_shipped",
      language: "en_US",
      variables: [order.customerName, order.reference],
    },
  });

  for (let attempt = 1; attempt <= 4; attempt++) {
    let response;

    try {
      response = await fetch(`${BASE}/messages`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body,
        signal: AbortSignal.timeout(15_000),
      });
    } catch (err) {
      // Timeout or connection reset. The send may already have happened —
      // the key is what makes retrying it the right move rather than a gamble.
      if (attempt === 4) throw err;
      await sleep(500 * 2 ** (attempt - 1));
      continue;
    }

    // 201 on the first success, and 201 again on every replay. Indistinguishable.
    if (response.ok) return response.json();

    const { error } = await response.json();

    if (error.details?.reason === "idempotency_key_reuse") {
      // Our own bug: the body changed between attempts. Retrying cannot fix it.
      throw new Error(
        `Idempotency-Key ${idempotencyKey} was reused with a different body ` +
          `(request_id ${error.request_id})`,
      );
    }

    if (error.details?.reason === "rate_limited" && attempt < 4) {
      // Refused before the key was claimed, so the same key is still free.
      await sleep((60 - new Date().getSeconds()) * 1000 + 250);
      continue;
    }

    if (error.details?.reason === "idempotency_in_progress" && attempt < 4) {
      // An earlier attempt with this key is still running. Wait, then ask again.
      await sleep((error.details.retry_after_seconds ?? 2) * 1000);
      continue;
    }

    // Any other error response released the key. Surface it: fix a 4xx, and
    // after a 5xx confirm nothing was sent before retrying with the same key.
    throw new Error(`${error.code}: ${error.message} (request_id ${error.request_id})`);
  }
}
```

```python Python
import json
import os
import time

import requests

BASE = "https://services.amomic.in/api/v1"


def send_shipped_notice(order) -> dict:
    # Derived from data we already have. Recomputable after any crash.
    idempotency_key = f"order-{order.id}-shipped"

    # Serialised ONCE, and sent as `data=` so the bytes never change.
    # `json=payload` would rebuild the bytes on every attempt.
    body = json.dumps(
        {
            "to": order.customer_phone,
            "template": {
                "name": "order_shipped",
                "language": "en_US",
                "variables": [order.customer_name, order.reference],
            },
        },
        separators=(",", ":"),
    ).encode("utf-8")

    headers = {
        "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }

    for attempt in range(1, 5):
        try:
            response = requests.post(
                f"{BASE}/messages", headers=headers, data=body, timeout=15
            )
        except requests.RequestException:
            # No response at all. The send may already have happened.
            if attempt == 4:
                raise
            time.sleep(0.5 * 2 ** (attempt - 1))
            continue

        # 201 on the first success, and 201 again on every replay.
        if response.ok:
            return response.json()

        error = response.json()["error"]
        details = error.get("details") or {}

        if details.get("reason") == "idempotency_key_reuse":
            raise RuntimeError(
                f"Idempotency-Key {idempotency_key} was reused with a different body "
                f"(request_id {error.get('request_id')})"
            )

        if details.get("reason") == "rate_limited" and attempt < 4:
            # Refused before the key was claimed, so the same key is still free.
            time.sleep(60 - time.gmtime().tm_sec + 0.25)
            continue

        if details.get("reason") == "idempotency_in_progress" and attempt < 4:
            # An earlier attempt with this key is still running.
            time.sleep(details.get("retry_after_seconds", 2))
            continue

        # Any other error response released the key. Fix a 4xx; after a 5xx,
        # confirm nothing was sent before retrying with the same key.
        raise RuntimeError(
            f"{error['code']}: {error['message']} (request_id {error.get('request_id')})"
        )
```

:::

## Failures

```responses
[
  {"status": 201, "code": "", "reason": "", "description": "Either the request executed, or it replayed a stored response. You cannot tell which, and you should not need to."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "This key already has a record created from a different request body. Either the key is too coarse, or the payload is being re-serialised. (A corrected body after an error response is not this: the failed request released the key.) Do not retry with this key."},
  {"status": 409, "code": "conflict", "reason": "idempotency_in_progress", "description": "A request with this key is still pending. <code>details.retry_after_seconds</code> is <code>2</code>. Retry with the same key and body after that. A request that fails releases its key, so this lasts only while the first request is running."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "Schema validation, with <code>details.errors</code>. Refused before the key is claimed, so the same key is still free."}
]
```

Any other error response is returned after the key was claimed, and releases it, so a retry with the same key runs fresh. See [After an error response, retry with the same key](#after-an-error-response-retry-with-the-same-key).

## Related

::: cards

::: card Errors
[The full catalogue](/api/errors) — nine codes, every `details.reason`, and which of them are worth retrying.
:::

::: card Rate limits
[120 requests per key per minute](/api/rate-limits) — the other half of any retry loop you write.
:::

::: card Messages
[POST /messages](/api/messages): the endpoint where a missing key costs a customer a duplicate.
:::

::: card Order updates recipe
[End to end](/recipes/order-updates) — derived keys, backoff, and the webhooks that confirm delivery.
:::

:::
