# Idempotency

> How to safely retry Mojeeb Public API requests with the Idempotency-Key header — replay, in-progress, and mismatch behavior explained.

Network failures are inevitable. When you send WhatsApp messages and templates, or reply in a conversation, the Mojeeb Public API uses an `Idempotency-Key` header to make retries safe — the same key plus the same body returns the original response, never sends twice.

## How do I make my retries safe?

Send any client-chosen string up to 255 characters in the `Idempotency-Key` header when you send a WhatsApp message or template (`POST /v1/whatsapp/messages`, `POST /v1/whatsapp/templates`) or reply in a conversation (`POST /v1/agents/{agentId}/conversations/{conversationId}/messages`):

```bash
curl -X POST https://api.mojeeb.app/v1/whatsapp/templates \
  -H "Authorization: Bearer mk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-12345-confirmation" \
  -d '{
    "agent_id": "12345678-1234-1234-1234-123456789012",
    "from":     "+15557654321",
    "to":       "+15551234567",
    "template": {
      "name":     "order_shipped",
      "language": "en",
      "parameters": {
        "customer_name": "Sara",
        "tracking_url":  "https://example.com/track/A123"
      }
    }
  }'
```

The header is **optional but strongly recommended** for every send. Without it, a network retry can produce a duplicate message. Other endpoints ignore it: a retried `POST` that creates a lead, note, knowledge base or custom API tool creates it again, so check whether it exists before you retry.

## What happens on retry?

Only a successful answer is stored: an accepted send (`202`), or a reply in a conversation (`201`). The key is claimed only after the request passes authentication, scope, rate-limit and format checks, and on a reply the agent and conversation checks. A request refused there (`401`, `403`, `413`, `429`, a format `422` such as `invalid_request_body`, `invalid_agent_id`, `invalid_phone_number` or `template_header_media_url_invalid`, or on a reply a `404` for an agent or conversation the key can't reach, or a `503`) never touches the key: fix it and retry with the same key straight away.

Once the key is claimed, the behavior depends on how the original request ended and whether the body matches:

| Scenario | Response | HTTP |
|---|---|---|
| First send with this key | Normal response | 202 (201 for a reply) |
| Same key with any body, while a request with it is still running or less than 60 seconds after the start of one that was refused or failed after claiming it (for example a WhatsApp send's `agent_not_found`, `from_phone_not_found_for_agent` or `template_not_found`, a reply's `messaging_window_closed` or `channel_not_supported`, or a `5xx`) | `idempotency_key_in_progress` (with `Retry-After`) | 409 |
| Same key + same body, original accepted | Replays original response, `Idempotent-Replayed: true` header | 202 (201 for a reply) |
| Same key + **different body, endpoint or conversation**, original accepted | `idempotency_key_in_use_with_different_params` | 422 |
| Same key, 60 or more seconds after the start of that refused or failed request, or a different key | Processed as a new request | — |

## How long are responses cached?

At least 24 hours: a retry with the same key and body in that time always replays the stored answer. Don't count on a key becoming new again after that. Give every new operation a new key, and pick stable keys tied to your business operation, not random UUIDs that change between attempts.

## How do I pick good idempotency keys?

A good key is **stable, unique, and tied to a single business operation**.

**Good:**
- `order-12345-confirmation`
- `signup-456-welcome`
- `appointment-reminder-2026-04-30-user-789`

**Bad:**
- Timestamps (each retry gets a different one — defeats the point)
- Random UUIDs generated at request time (same problem)
- Generic strings like `retry-1` (collisions across operations)
- Keys reused across different message bodies or conversations (triggers `422` mismatch)

If you don't have a natural stable identifier, generate one once at the start of your operation and reuse it for every retry of that specific send.

## What does a mismatch look like?

When the same key is reused with a different body, or on another endpoint or conversation, the API rejects the request and gives you the correlation id of the original send so you can find it in your own logs:

```json
{
  "error": {
    "type":                    "idempotency_error",
    "code":                    "idempotency_key_in_use_with_different_params",
    "message":                 "The idempotency key was used previously with a different request body. Use a different key for this operation.",
    "correlation_id":          "req_01KQE...",
    "original_correlation_id": "req_01KQE..."
  }
}
```

`original_correlation_id` is the trace id of the request that originally claimed the key. Search your application logs for that id to see what was sent.

## What does an in-progress retry look like?

If you retry, with any body, while the original send is still being processed (rare, usually only inside a few seconds), or within 60 seconds of the start of a request with the key that was refused or failed after claiming it:

```json
{
  "error": {
    "type":           "idempotency_error",
    "code":           "idempotency_key_in_progress",
    "message":        "A request with this idempotency key is currently being processed. Retry shortly.",
    "correlation_id": "req_01KQE..."
  }
}
```

The response also carries a `Retry-After` header in seconds. Wait that long, then retry the same key + same body — you'll get a replay of the original (`202`, or `201` for a reply) if it was accepted, a new attempt once 60 seconds have passed since the refused or failed request started, or another `409` until then or while it's still in flight.

## Common questions

### Does the API hash my body to detect mismatches?

Yes. The first request with a given key stores a SHA-256 hash of the request: its method, its path and its canonicalized body. Subsequent requests recompute the hash and compare. Field order doesn't matter; we sort keys before hashing — `{"a":1,"b":2}` and `{"b":2,"a":1}` are the same logical body. The path counts too: once the original was accepted, the same key with the same body on another endpoint, or in a reply to another conversation, is a mismatch (`422`), not a replay.

### What about idempotency on GET requests?

GET endpoints are inherently idempotent — calling them multiple times has no side effect. The `Idempotency-Key` header is ignored on GET; you don't need to send it.

### Do I need idempotency keys for status lookups?

No. `GET /v1/whatsapp/messages/{id}` is read-only.

### What if the cached response was an error?

It isn't cached. Only successful answers are stored. After a request that was refused or failed after claiming the key (a `4xx` such as `template_not_found` or a reply's `messaging_window_closed`, or a `5xx`), the same key answers `409 idempotency_key_in_progress`, whatever the body, until 60 seconds after that request started, and then runs the request again. To retry a corrected request straight away, use a different key. A request refused before the key is claimed, such as a format `422`, doesn't hold the key.

### Is a replay the same response as the original?

Yes. A replay returns the original status code and the same body: the same fields, null ones included, with the same values. Only the `Idempotent-Replayed: true` header is added, so you can tell a replay from the original. The fields may come in a different order, so compare the parsed JSON, not the raw bytes.
