# Retry requests safely with idempotency keys

:::summary Quick answer
An idempotency key is a value you send in the `Idempotency-Key` header and repeat on every retry of the same request, such as `shopify-anna-store-order-4827-confirmed`. A retried send then returns the original WhatsApp message instead of sending a second one, and a repeated event returns `409` instead of being recorded twice. Build the key from your own order data, so a restarted job reuses it.

**One action, one key. Every retry, the same key.**
:::

A request can succeed in Flownally and still look failed to you, for example when the connection drops before the response arrives. Without a key, retrying it can send the customer the same order update twice.

## What is an idempotency key?

An idempotency key is a string you generate that names one action, such as "send the confirmation for order 4827". You send it in the `Idempotency-Key` header. When the same request arrives again with the same key, Flownally doesn't apply it a second time.

- **The header is optional.** Without it, a retried request can send the message twice.
- **A key is 1 to 255 characters long.**
- **Keys belong to the workspace.** They aren't scoped to an API key or a conversation.
- **The key comes back on the message** as `clientMessageId`, so you can match the message to your order.

## Why do store integrations need idempotency keys?

Store integrations repeat requests more often than you'd expect:

- **Your HTTP client times out** after Flownally has already accepted the message, and your job retries.
- **Your store reports the same order event twice.** Shopify delivers a webhook again when it doesn't get `200`, and WooCommerce can fire a status hook twice.
- **A queue worker crashes** after the send and runs the job again.
- **Flownally answers `503` with code `messaging_messaging_send_result_pending`.** That response must be retried with the same key.

With the same key on each repeat, the customer gets one WhatsApp message. A contact event is recorded once, so it triggers your journeys once.

## Which endpoints accept an idempotency key?

| Operation | Endpoint | A retry with the same key |
| --- | --- | --- |
| [Send message](/api/reference/messaging#send-message) | `POST /messages` | Returns the original message, with the same `id`. |
| [Send message in conversation](/api/reference/messaging#send-conversation-message) | `POST /conversations/{id}/messages` | Returns the original message, with the same `id`. |
| [Create contact event](/api/reference/events#create-contact-event) | `POST /contacts/{contactId}/events` | Returns `409`, because the event is already recorded. |
| [Add or remove reaction](/api/reference/messaging#send-reaction) | `POST /messages/{id}/reactions` | Isn't applied twice. |
| [Import contacts from CSV](/api/reference/contacts#import-contacts) | `POST /contacts:import` | Isn't applied twice. |
| [Schedule campaign](/api/reference/contacts#schedule-campaign) | `POST /campaigns/{campaignId}:schedule` | Isn't applied twice. |
| [Unschedule campaign](/api/reference/contacts#unschedule-campaign) | `POST /campaigns/{campaignId}:unschedule` | Isn't applied twice. |

Contact events also accept the key as `idempotencyKey` in the JSON body. The deprecated `POST /events` takes a key the same way.

**Other `POST` operations don't take a key.** `POST /contacts` is one of them, so search for a customer before you create them, as shown in [Sync store customers and phone numbers](/api/sync-store-customers).

## How do I build a good idempotency key?

Build the key from the record that should cause exactly one action: your store, the order and the update.

- **Derive it from your own data.** A key like `{store}-order-{orderId}-{status}` comes out the same when a job restarts or a webhook repeats.
- **Don't generate a random value on each attempt.** A new UUID per retry makes every attempt a new message. If you prefer UUIDs, create one with the job and store it with the job.
- **Use one key per message.** Two updates for the same order need two keys, and so do a text message and the template that replaces it.
- **Leave personal data out.** The key comes back as `clientMessageId` and ends up in your logs, so use order IDs rather than phone numbers or email addresses.

| What should happen once | Key |
| --- | --- |
| Shopify order confirmation | `shopify-{shop}-order-{orderId}-confirmed` |
| Shopify shipment notice | `shopify-{shop}-fulfillment-{fulfillmentId}` |
| WooCommerce status update | `woocommerce-{host}-order-{orderId}-{status}` |
| `order.paid` event from a custom backend | `{storeId}-order-{orderId}-paid` |
| Abandoned cart event | `{storeId}-checkout-{checkoutId}-started` |

:::callout Keys are shared by the whole workspace
Every integration in a workspace draws from one set of keys. If a second store, or your staging site, sends `order-1001-confirmed` within 24 hours of the first store, its request fails with `409`. Start every key with the platform and the store, for example `shopify-anna-store-`.
:::

## How do I retry a message without sending it twice?

**Retry with the same key and the same body, and only when a retry can help.**

| Response | What to do |
| --- | --- |
| Network error or timeout | Retry with the same key. |
| `429` | Retry with the same key, with exponential backoff and jitter. The response has no body and no `Retry-After` header. |
| `503` with code `messaging_messaging_send_result_pending` | Retry with the same key. A new key could send a second message. |
| Other `5xx` | Retry with the same key, with backoff. |
| `409` with code `messaging_messaging_idempotency_conflict` | Don't retry. The key was already used for a different request or sender within 24 hours. Fix how you build keys. |
| `409` with code `messaging_inbox_context_changed` | Reload the conversation, then send with a new key. This code comes back only when you send `expectedContext`. |
| Other `4xx` | Don't retry. Fix the request first, as described in [Errors](/api/errors). |

Build the request body once, before the first attempt. If the text or the template parameters change between attempts, for example because they include the current time, the retry is a different request and fails with `409`.

The code samples retry network errors, timeouts, `429` and `5xx` responses with the same key, and throw anything else with its status. With curl, `--retry` repeats timeouts and `408`, `429`, `500`, `502`, `503` and `504` responses with the same headers, so each attempt carries the same key:

:::code
```bash
curl -X POST "https://api.flownally.com/v1/messages" \
  --retry 4 --retry-connrefused --max-time 15 \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Idempotency-Key: shopify-anna-store-order-4827-confirmed" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
    "recipient": { "type": "phone", "phoneNumber": "+48123456789" },
    "content": {
      "type": "template",
      "template": {
        "name": "order_confirmed",
        "language": "en",
        "components": [
          {
            "type": "body",
            "parameters": [
              { "type": "text", "text": "Anna" },
              { "type": "text", "text": "#4827" }
            ]
          }
        ]
      }
    }
  }'
```

```js
const API = "https://api.flownally.com/v1";
const STORE = "shopify-anna-store";

export async function postWithRetry(path, body, idempotencyKey, attempts = 5) {
  const init = {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(body),
  };

  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${API}${path}`, {
      ...init,
      signal: AbortSignal.timeout(15_000),
    }).catch(() => null);
    if (response?.ok) return response.json();

    const retryable = !response || response.status === 429 || response.status >= 500;
    if (!retryable || attempt === attempts) {
      const status = response?.status ?? "network error";
      throw Object.assign(new Error(`Flownally ${path}: ${status}`), { status: response?.status });
    }
    await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 500 + Math.random() * 500));
  }
}

export function sendOrderConfirmation(order) {
  return postWithRetry(
    "/messages",
    {
      inboxId: process.env.FLOWNALLY_INBOX_ID,
      recipient: { type: "phone", phoneNumber: order.phone },
      content: {
        type: "template",
        template: {
          name: "order_confirmed",
          language: "en",
          components: [
            {
              type: "body",
              parameters: [
                { type: "text", text: order.firstName },
                { type: "text", text: order.name },
              ],
            },
          ],
        },
      },
    },
    `${STORE}-order-${order.id}-confirmed`,
  );
}
```

```python
import os
import random
import time

import requests

API = "https://api.flownally.com/v1"
STORE = "shopify-anna-store"

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"


def post_with_retry(path, body, idempotency_key, attempts=5):
    for attempt in range(1, attempts + 1):
        try:
            response = session.post(
                f"{API}{path}", json=body, headers={"Idempotency-Key": idempotency_key}, timeout=15
            )
            retryable = response.status_code == 429 or response.status_code >= 500
            if not retryable or attempt == attempts:
                response.raise_for_status()
                return response.json()
        except (requests.ConnectionError, requests.Timeout):
            if attempt == attempts:
                raise
        time.sleep(2**attempt * 0.5 + random.random() * 0.5)


def send_order_confirmation(order):
    return post_with_retry(
        "/messages",
        {
            "inboxId": os.environ["FLOWNALLY_INBOX_ID"],
            "recipient": {"type": "phone", "phoneNumber": order["phone"]},
            "content": {
                "type": "template",
                "template": {
                    "name": "order_confirmed",
                    "language": "en",
                    "components": [
                        {
                            "type": "body",
                            "parameters": [
                                {"type": "text", "text": order["first_name"]},
                                {"type": "text", "text": order["name"]},
                            ],
                        }
                    ],
                },
            },
        },
        f"{STORE}-order-{order['id']}-confirmed",
    )
```

```php
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;

const API = 'https://api.flownally.com/v1';
const STORE = 'shopify-anna-store';

function postWithRetry(string $path, array $body, string $idempotencyKey, int $attempts = 5): array
{
    $client = new GuzzleHttp\Client(['timeout' => 15]);

    for ($attempt = 1; ; $attempt++) {
        try {
            $response = $client->post(API . $path, [
                'headers' => [
                    'Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN'),
                    'Idempotency-Key' => $idempotencyKey,
                ],
                'json' => $body,
            ]);
            return json_decode((string) $response->getBody(), true);
        } catch (ConnectException | RequestException $error) {
            $status = $error instanceof RequestException ? $error->getResponse()?->getStatusCode() : null;
            $retryable = $status === null || $status === 429 || $status >= 500;
            if (!$retryable || $attempt === $attempts) {
                throw $error;
            }
        }
        usleep((2 ** $attempt * 500 + random_int(0, 500)) * 1000);
    }
}

function sendOrderConfirmation(array $order): array
{
    return postWithRetry('/messages', [
        'inboxId' => getenv('FLOWNALLY_INBOX_ID'),
        'recipient' => ['type' => 'phone', 'phoneNumber' => $order['phone']],
        'content' => [
            'type' => 'template',
            'template' => [
                'name' => 'order_confirmed',
                'language' => 'en',
                'components' => [
                    [
                        'type' => 'body',
                        'parameters' => [
                            ['type' => 'text', 'text' => $order['first_name']],
                            ['type' => 'text', 'text' => $order['name']],
                        ],
                    ],
                ],
            ],
        ],
    ], STORE . "-order-{$order['id']}-confirmed");
}
```
:::

Run it twice. Both responses carry the same message `id`, and the customer gets one message.

Trimmed response:

```json
{
  "message": {
    "id": "msg_d7pj3rljs4umccbo6tdg",
    "conversationId": "cnv_d7pigoip4tfcdebp5mlg",
    "deliveryStatus": "pending",
    "clientMessageId": "shopify-anna-store-order-4827-confirmed"
  }
}
```

- **The body is built once,** so every attempt sends the same request.
- **`409` and other `4xx` responses aren't retried.** The helper throws them right away with their status, so you can log them and fix the key. `429` and `5xx` responses throw once the attempts run out.

## What happens if I reuse a key?

| Request | Result |
| --- | --- |
| Same key, same request, same service account or user | The original message, with the same `id`. Nothing is sent again. |
| Same key, different request or sender, within 24 hours | `409` with code `messaging_messaging_idempotency_conflict`. |
| Same key after 24 hours | Not guaranteed. Finish retries within 24 hours, and never reuse a key for a new message. |
| Same key on a contact event | `409`, because the workspace already has an event with this key. |

**A key covers the request, not the delivery.**

- **A failed message stays failed.** If an accepted message later fails, for example with `failureCode` `messaging_window_closed`, a retry with its key returns the same failed message. To send a template instead, use a new key, as shown in [Handle the WhatsApp 24-hour reply window](/api/reply-window).
- **Don't resend a message in `unknown` state.** `deliveryStatus` `unknown` means the message may have reached the customer. A resend with a new key would be a second message.

## How do I record a contact event only once?

[Create contact event](/api/reference/events#create-contact-event) records an event, and active journeys whose trigger matches it can start a run for the contact. A duplicate event can mean a duplicate journey message.

- **Send the key in the header or as `idempotencyKey` in the body.** When you send both, the header wins.
- **Treat `409` as success.** It means the workspace already has an event with this key, so an earlier attempt was recorded.
- **Use a different key for each event.** `order.paid` and `order.shipped` for the same order need different keys.

Every attempt sends the same key. The code samples use the retry helper from above and treat `409` as success:

:::code
```bash
curl -X POST "https://api.flownally.com/v1/contacts/con_d7pibvs6gljrc2pfdc1g/events" \
  --retry 4 --retry-connrefused --max-time 15 \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Idempotency-Key: shopify-anna-store-order-4827-paid" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.paid",
    "payload": { "orderId": "4827", "total": 129.99, "currency": "PLN" }
  }'
```

```js
export async function recordOrderPaid(contactId, order) {
  try {
    await postWithRetry(
      `/contacts/${contactId}/events`,
      {
        name: "order.paid",
        payload: { orderId: order.id, total: order.total, currency: order.currency },
      },
      `${STORE}-order-${order.id}-paid`,
    );
  } catch (error) {
    // 409 means the workspace already has an event with this key.
    if (error.status !== 409) throw error;
  }
}
```

```python
def record_order_paid(contact_id, order):
    try:
        post_with_retry(
            f"/contacts/{contact_id}/events",
            {
                "name": "order.paid",
                "payload": {
                    "orderId": order["id"],
                    "total": order["total"],
                    "currency": order["currency"],
                },
            },
            f"{STORE}-order-{order['id']}-paid",
        )
    except requests.HTTPError as error:
        # 409 means the workspace already has an event with this key.
        if error.response.status_code != 409:
            raise
```

```php
use GuzzleHttp\Exception\ClientException;

function recordOrderPaid(string $contactId, array $order): void
{
    try {
        postWithRetry("/contacts/{$contactId}/events", [
            'name' => 'order.paid',
            'payload' => ['orderId' => $order['id'], 'total' => $order['total'], 'currency' => $order['currency']],
        ], STORE . "-order-{$order['id']}-paid");
    } catch (ClientException $error) {
        // 409 means the workspace already has an event with this key.
        if ($error->getResponse()->getStatusCode() !== 409) {
            throw $error;
        }
    }
}
```
:::

The first request returns `201` with the event. A repeat returns `409`.

## FAQ

### Is `Idempotency-Key` required?

No, it's optional on every endpoint that accepts it. Without one, Flownally can't tell a retry from a new message, so a retried send can reach the customer twice.

### Can I use a UUID as the key?

Yes, as long as you create it once per action and keep it with the job. A UUID generated inside the retry loop is new on every attempt, so it protects nothing.

### How long does Flownally remember a message key?

The guarantee lasts 24 hours. In that window, a retry with the same request returns the original message, and any other request or sender gets `409`. Don't rely on a key after that: finish retries within 24 hours, and never reuse a key for a new message.

### Why did my first attempt return `409`?

Something in the workspace already used the key: another store, a staging site, or an earlier job with a different body. Add the platform, the store and the update type to the key.

### Should I retry after a `409`?

Not with the same request. On a send, `messaging_messaging_idempotency_conflict` means two different requests share a key, and `messaging_inbox_context_changed` means you should reload the conversation and send with a new key. On a contact event, `409` means the event is already recorded, so you're done.

### Can I resend a failed message with the same key?

No. The key returns the original message, failed status included. Send a new message with a new key, for example an approved template after `messaging_window_closed`.

### Do Flownally webhooks carry an idempotency key?

Yes. Each delivery's `webhook-id` header stays the same on every retry. Store it with a unique constraint and skip IDs you've already seen, as shown in [Deduplicate deliveries on webhook-id](/api/webhook-signatures#deduplicate-deliveries-on-webhook-id).

## Related articles

- [Errors](/api/errors)
- [Handle the WhatsApp 24-hour reply window](/api/reply-window)
- [Send Shopify order updates on WhatsApp](/api/shopify-order-updates)
- [Send WooCommerce order updates on WhatsApp](/api/woocommerce-order-updates)
- [Recover abandoned carts with events and journeys](/api/abandoned-cart-events)
