Retry requests safely with idempotency keys
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
503with codemessaging_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 | POST /messages | Returns the original message, with the same id. |
| Send message in conversation | POST /conversations/{id}/messages | Returns the original message, with the same id. |
| Create contact event | POST /contacts/{contactId}/events | Returns 409, because the event is already recorded. |
| Add or remove reaction | POST /messages/{id}/reactions | Isn't applied twice. |
| Import contacts from CSV | POST /contacts:import | Isn't applied twice. |
| Schedule campaign | POST /campaigns/{campaignId}:schedule | Isn't applied twice. |
| 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.
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
clientMessageIdand 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 |
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. |
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:
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" }
]
}
]
}
}
}'Run it twice. Both responses carry the same message id, and the customer gets one message.
Trimmed response:
{
"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.
409and other4xxresponses aren't retried. The helper throws them right away with their status, so you can log them and fix the key.429and5xxresponses 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
failureCodemessaging_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. - Don't resend a message in
unknownstate.deliveryStatusunknownmeans 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 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
idempotencyKeyin the body. When you send both, the header wins. - Treat
409as 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.paidandorder.shippedfor 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:
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" }
}'The first request returns 201 with the event. A repeat returns 409.
FAQ
Is Idempotency-Key required?
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?
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?
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.