> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ibubbl.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Make sends retry-safe with idempotency keys.

Sending the same message twice — because of a network retry, a request timeout, or a deploy mid-request — is a real risk in any production integration. iBubbl handles this with **required** idempotency keys on every send.

## How it works

`POST /v1/messages` requires an `idempotency_key` field on every request. iBubbl stores the key with the message it created, alongside a hash of the request payload. **Keys never expire.**

When you send with a key that your account has used before:

* **Same key + same payload** → iBubbl returns the original message (HTTP `202`, the same message ID). The response shows the message's **current** state, for example `delivered`, not the state it had when you first sent it. Nothing is sent again — however long ago the original was sent.
* **Same key + different payload** → `409 idempotency_conflict`, whatever the original message's status. This usually means your code generated one key for two genuinely different messages.
* **Same key + same payload, and the original message ended `failed` or `expired`** → the key is released and the request is sent as a new message, with a new ID. This is the one case where reusing a key sends again, so retrying a failed send with its original key is safe. A `cancelled` message does **not** release its key: the request returns the cancelled message.
* **New key** → treated as a fresh request.

> [!WARNING]
>
> **The key is the only duplicate protection.** iBubbl never compares message bodies. A new key with an identical body sends a second, real message, even milliseconds after the first. See [Duplicates and retries](https://docs.ibubbl.com/concepts/message-lifecycle.md#duplicates-and-retries), including two edge cases: simultaneous requests with one key, and timed-out sends that create a group.

## Generating keys

Use a new UUIDv4 for every logical send. Generate it **once per message**, before the first attempt, and reuse that same value across retries of that one message.

```bash cURL theme={null}
# One key per message: generate it once, and reuse it if you retry.
IDEMPOTENCY_KEY=$(uuidgen)

# Run this same command again to retry: the key and the payload stay identical.
curl -X POST https://api.ibubbl.com/v1/messages \
  -H "Authorization: Bearer $IBUBBL_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"to\": \"+14155551234\",
    \"body\": \"Hey Sam — quick check-in.\",
    \"idempotency_key\": \"$IDEMPOTENCY_KEY\"
  }"
```

```typescript TypeScript theme={null}
import { randomUUID } from "node:crypto";

async function sendWithRetry(to: string, body: string, maxAttempts = 3) {
  // One key per message: generate it once, and reuse it if you retry.
  const idempotencyKey = randomUUID();
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let res: Response;
    try {
      res = await fetch("https://api.ibubbl.com/v1/messages", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          to,
          body,
          idempotency_key: idempotencyKey, // the SAME key on every retry
        }),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      if (attempt === maxAttempts - 1) throw err; // a timeout or network error: retry
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
      continue;
    }
    if (res.status < 500) {
      const data = await res.json();
      if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); // 4xx: final, never retry
      return data;
    }
    await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 5xx: back off, retry
  }
  throw new Error("all retries failed");
}
```

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

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]


def send_with_retry(to: str, body: str, max_attempts: int = 3) -> dict:
    # One key per message: generate it once, and reuse it if you retry.
    idempotency_key = str(uuid.uuid4())
    for attempt in range(max_attempts):
        try:
            res = requests.post(
                "https://api.ibubbl.com/v1/messages",
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "to": to,
                    "body": body,
                    "idempotency_key": idempotency_key,  # the SAME key on every retry
                },
                timeout=10,
            )
        except requests.RequestException:  # a timeout or network error: retry
            if attempt == max_attempts - 1:
                raise
        else:
            if res.status_code < 500:
                data = res.json()
                if not res.ok:  # 4xx: final, never retry
                    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
                return data
        time.sleep(2 ** attempt)  # back off before retrying
    raise RuntimeError("all retries failed")
```

## Scoping

Idempotency keys are scoped **per customer account**. Two different iBubbl accounts can use the same key without collision — but within a single account, the namespace is shared across all API keys for that account. (If you have multiple API keys for the same account, they share the idempotency-key space.)

## What "same payload" means

iBubbl hashes the routing field **exactly as you sent it**, plus the `body`:

| Request shape | Hashed |
| - | - |
| `to` | the `to` string + `body` |
| `contact_id` | the `contact_id` string + `body` |
| `chat_guid` | the `chat_guid` string + `body` |
| `participant_phones` | the phones, sorted, + `group_name` + `body` |

The routing field is compared as text, not by the contact it resolves to. So a retry that switches from `to` to the same contact's `contact_id` (or reformats the phone number) is a different payload and gets `409 idempotency_conflict`. Retry with the exact request you sent the first time.

## Common pitfalls

> [accordion] Regenerating the key on retry
>
> If you generate the key inside your retry loop, each retry gets a fresh key and the deduplication never fires. Generate it **once per message**, before the first attempt, and reuse it.

> [accordion] Reusing a key across different sends
>
> The key represents *one logical send*. If your code sends to multiple contacts in a campaign, each one needs its own key. A key generated once at module level and shared by every send gets `409 idempotency_conflict` on the second message.

> [accordion] Reusing old keys
>
> Keys never expire, so a key reused months later still replays (or conflicts with) the original message. Never derive keys from something that repeats, such as a date or a contact ID alone.

> [accordion] Getting 409 when you think the payload matches
>
> Any difference in the hashed fields above — including whitespace in `body`, or a different spelling of the same phone number — triggers a conflict. Compare your retry's payload character by character against the original.


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