Skip to content
CaptiFi Connect

Behind the scenes9 min read

Sending a text exactly once: idempotency

A dropped connection leaves you not knowing whether a text went. The Idempotency-Key header, what the IETF draft and Stripe say, and how to build a safe retry.

24 hours: is the least time Stripe keeps an API v1 idempotency key, and its window for a retry

24 hours: is the least time Stripe keeps an API v1 idempotency key, and its window for a retry. Source: Stripe, API documentation, read 17 October 2026.

Say your reminder job runs at two in the morning and posts 400 texts to the API, one request each. At number 181 the connection drops before any response comes back. Did 181 go? You don't know. The request may have died on the way out, or the text may be queued and the answer lost on the way back. Retry it and the customer might get the same reminder twice. Skip it and they might get nothing. Every integration that sends anything of consequence over HTTP runs into this, and the answer has had a name since about 2020: the Idempotency-Key header.

Why a POST can't just be retried

HTTP sorts its methods by whether repeating them is safe. RFC 9110, the June 2022 edition of HTTP Semantics, defines a method as idempotent "if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request", and lists PUT, DELETE and the safe methods as idempotent. POST is not on the list. The RFC spells out why that matters: idempotent methods "are distinguished because the request can be repeated automatically if a communication failure occurs before the client is able to read the server's response". Then the rule for everything else: a client "SHOULD NOT automatically retry a request with a non-idempotent method" unless it has some means to know the request is idempotent regardless of the method, "or some means to detect that the original request was never applied".

Sending a text is a POST, and it has a side effect the customer notices. So an SMS API needs to give you one of those two things: a way to make the send idempotent, or a way to find out whether it happened. The header does the first, and a good API gives you the second as well.

The Idempotency-Key header

The idea is simple. The client makes up a unique value for each logical operation and sends it in a header. The server remembers the key with the result of the first request. If the same key turns up again, the server hands back the stored result and does nothing new. The retry becomes safe because the server, not the client, decides whether the work has already been done.

The IETF has been standardising this for a while. The draft began as an individual submission in November 2020, was adopted by the HTTPAPI working group in July 2021, and has been revised seven times since; the latest, draft-ietf-httpapi-idempotency-key-header-07 by J. Jena and S. Dalal, is dated 15 October 2025 and expired on 18 April 2026 without a replacement, as working group drafts routinely do between revisions. It isn't an RFC yet. It is the closest thing to a shared specification, and it matches what the big APIs already do.

What it specifies, in short. The header is Idempotency-Key and its value "MUST be a String". The key "MUST be unique and MUST NOT be reused with another request with a different request payload", and the draft recommends "a UUID or a similar random identifier". On the server side, a first request is processed normally. A retry after the original finished "SHOULD respond with the result of the previously completed operation, success or an error", so a stored error is replayed too. A retry while the original is still running should get a 409 conflict. A reused key with a different payload gets a 422 with the title "Idempotency-Key is already used". A missing key, where the endpoint requires one, gets a 400. The server may also compute a fingerprint of the payload to tell the difference between a true retry and a reuse, may expire keys on a published schedule, and must document all of it.

The security section is worth a minute. Keys with low entropy let an attacker guess other clients' keys and read their cached responses, so the draft says to validate the key's format before any lookup and to scope the cache by client. A sequential integer is a bad key. So is anything a customer could guess.

How the payment APIs do it

Payments got here first, because a duplicate charge is worse than a duplicate text, and their documentation is the best guide to the details the draft leaves open.

Stripe's API v1 takes an Idempotency-Key of up to 255 characters on any POST, suggests V4 UUIDs "or another random string with enough entropy to avoid collisions", and warns against using "sensitive data (for example, email addresses or personal identifiers)" as the key. It saves the status and body of the first request once execution begins and replays them on a retry, "including 500 errors". A reused key with different parameters gets an error. Keys are kept "for at least 24 hours" and may be removed after that, so Stripe tells you to limit retries of an unresolved operation to that first day with the same key and parameters; if you still don't know the outcome when the day is up, stop retrying and reconcile by fetching the object and reading the webhook events. A replayed response carries the header Idempotent-Replayed: true, and a 409 means a request with that key is still executing, in which case you wait and retry with the same key. Stripe's API v2 keeps keys for 30 days and takes a different approach to replays, retrying failed work to completion where it can.

How long each API says it remembers an idempotency key, in days
012.52537.550Stripe API v1Stripe API v1, Days: 11AdyenAdyen, Days: 7Stripe API v2Stripe API v2, Days: 30PayPal (refunds)PayPal (refunds), Days: 45

Stripe v1 keeps a key for at least 24 hours and may remove it after that. Adyen gives a range of 7 to 14 days; the lower bound is plotted. PayPal states 45 days in its refund example only. Square does not publish a period.

Source: Stripe, Adyen and PayPal developer documentation, read 17 October 2026

See the figures as a table
 Days
Stripe API v11
Adyen7
Stripe API v230
PayPal (refunds)45

Adyen's header is idempotency-key, up to 64 characters, with a version 4 UUID recommended; keys stay valid "for a period of 7 to 14 days after first submission", a processed request's first response "will be returned without duplication", and a retry while the original is in progress gets a 422 or 409 with error code 704. PayPal uses a PayPal-Request-Id header that the server "stores for a period of time", with 45 days quoted in its refund example. Square says keys "can be anything, but they need to be unique", replays the first successful response on a repeat, and returns an error when a key is reused with a different request, with the note that the behaviour "might vary depending on the API".

The pattern across all four is the same as the draft: random key, replay on repeat, refuse on reuse with a different body, and a stated or at least implied window after which the key is forgotten. The window is the detail integrators miss. Retry a Stripe key after 25 hours and you can create a second object.

Building a safe retry around an SMS API

The shape of a retry loop that sends each text exactly once, as far as the API can see:

  1. Make the key before you send, and store it with the thing being sent. The key belongs to the logical operation (this reminder for this booking), not to the HTTP attempt. If your job restarts, it must find the same key and reuse it, which means writing it to your database before the first request goes out.
  2. Send with the key in the header and the body you intend to repeat. Keep the body byte-for-byte identical on retries; most APIs, ours included, compare the body and refuse a key that comes back with a different one.
  3. On a dropped connection, a timeout, a 500 or a 429, wait and send the same request again with the same key. Back off between attempts (one second, then two, then four) and cap the count; Stripe's own examples configure a maximum of two.
  4. On a 409 that says the original is still running, wait and retry with the same key. On any other 4xx, stop: something in the request or the account needs fixing, and a new attempt with the same body will fail the same way.
  5. If you run out of retries without an answer, don't invent a new key. Look the operation up instead, by whatever the API gives you (a client reference on the message, a list filtered by time, or the webhook events), and only send again once you know it didn't go.

A table of the responses and what to do with them, using the status codes the IETF draft and the APIs above agree on:

ResponseWhat it meansWhat to do
201 or 200 with a replay markerAccepted, or a replay of an earlier acceptRecord the result, stop
400Missing or malformed key, or bad requestFix the request before retrying
409 (in flight)The first request is still runningWait, retry with the same key
409 or 422 (reused key)Same key, different bodyStop, use a new key for the new request
429Rate limitedWait for Retry-After, retry with the same key
500 or no responseUnknown outcomeRetry with the same key within the window

The thing that makes this work is step 1. A key generated fresh on every attempt is no key at all, because the server has no way to link the attempts. The easiest bug to write in a reminder job is a UUID created inside the retry loop.

What CaptiFi Connect does with the key

POST /messages accepts an Idempotency-Key header of up to 200 characters. The header is optional, but as the quickstart puts it, sending one "means a retry of the same request can never send twice". A retry with the same key and the same body returns the first request's answer: the same batch id, the same messages, the same rejected list, with meta.replayed set to true and a 200 status instead of the 201 the original got. Nothing is sent and nothing is charged for the replay. The same key with a different body returns a 409 with the code idempotency_conflict and the message "This Idempotency-Key was used before with a different body. Use a new key for a new request."

The error reference says which responses to retry: a 429 after the Retry-After wait, a 500 or a dropped connection after a short pause, each with the same key. Two details that help with the edge cases: a key used with a test key and the same key used with a live key never answer each other, so a live retry can't be satisfied by a test batch; and if two requests with one key arrive at the same moment, one is processed and the other is answered with its result. Credits are taken when a request is accepted, one per SMS part, and refunded for any text that fails before it leaves, so a replay doesn't move the balance. The API reference and the glossary entry have the rest.

Choosing the key

The draft and Stripe both say UUID, and a random UUID stored next to the booking is the safe default. There's a second pattern Stripe mentions that suits reminders better: derive the key from the thing you're sending. A booking reference plus the type of text, something like 8841-reminder-48h, is unique by construction, survives a restart without a lookup, and reads well in a log. The one rule is that the key must change when the operation changes: the 24-hour reminder for the same booking is a different operation and needs a different key, and if the appointment moves and you send a fresh reminder, that's a new operation too.

Don't build the key from the phone number or the customer's name. Stripe's advice against personal identifiers in keys applies to any API that logs them, which is all of them. And don't make the key a timestamp, which fails in the other direction: two attempts a second apart get two keys, and you're back to sending twice.

The last check is the one the RFC asked for at the start, a way to detect whether the original request was applied. Keep the client_ref on each message and listen for the sent and delivered webhooks. If a retry loop ever gives up, those are what tell you whether reminder 181 went out, and the 2am job can get on with 182.

Sources

Read for this post on 17 October 2026.

Written by the CaptiFi Connect team. Published 17 October 2026. More in Behind the scenes

Send your first text this week

Set up your account and contacts now; we check every new account before it can send.