Playbook

Idempotency keys: how an integration survives a retry

An idempotency key lets you retry a request without creating a second record. See how long four vendors keep a key, and how to deduplicate incoming webhooks.

By Fluidlabs. Reviewed by Shamil Malachiyev, founder, on .

Short answer

An idempotency key is a unique value your system generates once for each operation and sends with each attempt. The server stores the key with the result, answers a repeat with that result and creates no second record. Create the key before the first attempt and save it with the job. Finish retrying inside the vendor's retention period. For incoming webhooks the roles swap: you store each event's identifier and skip an event you hold.

On this page

What an idempotency key does

An idempotency key lets a server recognise a retry and return the first result, so one operation creates one record.

Your integration sends a POST to create an invoice. The accounting system creates it, and the connection times out before the response arrives. Your code cannot tell a lost request from a lost response, so it sends again, and the customer receives two invoices.

RFC 9110 defines PUT, DELETE and the safe methods such as GET as idempotent, and POST is absent from that list. The same section says a client should not retry a non-idempotent request by default, unless it has a means to know that the repeat is safe. An idempotency key supplies that means. In an idempotent API, the server answers a repeated request with the first result and creates nothing new.

http
POST /invoices HTTP/1.1
Host: api.example.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{ "order": "SO-10482", "amount": 4200 }

The Idempotency-Key header rests on vendor practice and on an expired draft from the IETF's HTTPAPI working group, The Idempotency-Key HTTP Header Field. Version 07 carries the date 15 October 2025 and expired on 18 April 2026. On 27 September 2026 the IETF datatracker listed it as an expired Internet-Draft, and its record showed no RFC number. With no standard in force, each vendor's documentation is the specification you build against, and the vendors below differ on retention and on status codes. The draft and Stripe differ on syntax: the draft writes the value inside double quotes, and Stripe's example sends it without them.

Who generates the key

Your system generates the key, because it sends the request. The IETF draft defines the key as a unique value generated by the client and recommends a UUID. Stripe suggests V4 UUIDs or another random string with enough entropy to avoid collisions, and asks you to keep email addresses and other personal identifiers out of the key.

Generate the key once, at the moment your system creates the job, and save it in the job's database row. Read the key from that row on every attempt. If your code generates the key inside the retry loop, each attempt carries a new key, and the server processes each one as a new request.

You can derive the key from your own data, such as the envelope ID joined to the action name. Xero permits keys derived from other object IDs, provided the keys are unique. A derived key survives a crash that loses the job row, because any worker can rebuild it. After an edit to the order, the changed order goes out under the old key, and Stripe and Xero reject a known key that arrives with a new payload.

The idempotency key header on four APIs

Four vendors document the header. The three that state a retention period range from 6 minutes to 14 days.

StripeAdyenXeroNetSuite
HeaderIdempotency-Keyidempotency-keyIdempotency-KeyX-NetSuite-idempotency-key
Requests coveredAll POST requests.POST requests.POST, PUT and PATCH.Asynchronous REST requests.
Longest key255 characters.64 characters.128 characters.Not stated in Oracle's documentation.
Vendor keeps the keyAt least 24 hours.7 to 14 days.6 minutes.Not stated in Oracle's documentation.
A repeat returnsThe saved status code and body.The response to the first attempt.The cached response.An IDEMPOTENCY_ERROR and a Location header for the earlier job.

Plan your retry schedule around the retention period. Xero keeps a key for 6 minutes. A queue that backs off to a 10-minute wait sends its next attempt after the key has expired, and Xero processes that request as new. Finish your retries inside the period. Past that point, make a GET request to check whether the resource exists, then create it under a new key. Xero gives that advice for a request that returns an error more than once.

Adyen stores keys at the level of the company account, so two integrations on one account share a key space. Xero checks key re-use per app.

What the server does on a repeat

The answer to a repeat depends on the state of the first request.

The first request finished. The server returns the saved result. Stripe saves the status code and body of the first request, success or failure, and returns the same result for the same key, 500 errors included. Xero caches an internal error and returns it on a re-run, even after the fault clears. You get the stored failure back on each retry under the same key. Confirm with a GET that the record does not exist, then send the request under a new key.

The first request is still running. The IETF draft has the server answer with a 409 status, and the client retries later under the same key. Adyen returns HTTP 422 or HTTP 409 with error code 704. It marks a response you may retry with a transient-error header set to true, and tells you not to retry if that header is missing or false.

The key arrives with a different payload. Stripe compares the incoming parameters with the original request and returns an error if they differ. Xero returns a 400 response, and the IETF draft specifies a 422. Adyen's page and Oracle's page state no rule for a known key with a new payload. A mismatch points to a bug in your key generation, so each retry repeats the error until you fix the code.

For NetSuite, Oracle documents the key as part of asynchronous request execution, where idempotent retry helps to avoid duplicate records and to find a job after a connection failure. NetSuite rejects the duplicate with a 400 response and a Location header that points to the job you submitted before. Your code follows the header to read the outcome of the first attempt.

Duplicate webhook events: deduplicate on the event

For incoming webhooks the roles swap. The sender is now the client that retries, and your endpoint is the server. You use the event's identifier as the idempotency key.

Webhook retries produce duplicate events in normal operation. A sender that gets no success response in time sends the event again, and your handler may have finished the work before the timeout. Stripe tells you to log the event IDs you have processed and skip the ones you hold. Procore tells you to track processed events by ulid or event id.

Have your endpoint write the raw message to a queue and answer with a 2xx status before any work starts. Stripe asks for a 2xx status before any complex logic. Answer a repeat with a 2xx status as well, because an error status tells the sender to try again. A worker then takes the message from the queue and runs this check:

sql
-- Runs in the worker, after the endpoint has answered.
INSERT INTO processed_events (source, event_key, received_at)
VALUES ('procore', '01JMYXMZRBVKK0PC6XS8SA4QRE', now())
ON CONFLICT (source, event_key) DO NOTHING;
-- A repeat raises no error. Read the affected row count.
-- 0 rows: a repeat. Drop the message.
-- 1 row: a new event. Do the work in this transaction.

The sample uses PostgreSQL. With ON CONFLICT DO NOTHING, a repeat inserts no row and raises no error, so the worker reads the affected row count to tell a first delivery from a repeat.

Run the insert and the business write in one database transaction, with a unique constraint on the key. That transaction covers writes to your own database. It cannot roll back a call to another system, such as the call that creates an invoice in the ERP. For that call, send an idempotency key built from the event key, or store the outcome of the call and have the worker check it before it calls again.

Keep each key longer than the sender retries. Stripe retries for up to three days in live mode, and Docusign Connect retries once per day for a 15-day span. Xero saves events for up to 31 days while a subscription is failing and replays them in order. A table that expires its rows after 24 hours lets a day-five redelivery through. The guide to webhooks, polling and request-time calls compares the retry periods.

What goes wrong in production

These five failures produce duplicates or lost events in integrations that already use keys.

The key changes on each retry. The code calls the UUID function inside the retry loop. Create the key with the job and store both in one row.

The retry outlives the key. A dead-letter queue replays a request the next morning, hours after a 6-minute key expired. Stamp each job with the time its key expires, and send a job past that time down a check-then-create path.

Two workers process one event at the same moment. Each one checks the table and finds nothing, so each one writes the invoice. Put a unique constraint on the key and wrap the insert and the write in one transaction. The second worker's insert writes no row and raises no error, so that worker reads a row count of 0 and stops.

The identifier repeats. HubSpot states that it does not guarantee a unique eventId, so a worker that deduplicates on eventId alone drops real events. Build a compound key from portalId, subscriptionType, objectId, propertyName and occurredAt. HubSpot sends propertyName with each property change, so two properties changed in one update keep separate keys. For an association change, add associationType, fromObjectId and toObjectId.

One business event arrives as two events. Stripe generates two separate Event objects in some cases, each with its own ID. For event types that occur once per object, add a second key from the object's ID and the event type.

On the platforms you connect

Docusign. Docusign Connect retries a failed delivery on a backoff that starts at 5 minutes and ends with one attempt per day for a 15-day span. Docusign runs these retries for Organization-Level Connect configurations, and for Account-Level ones with Require Acknowledgment selected. With that option on, Connect records a failure if your listener does not return a 200 within 100 seconds. A listener that writes the record and answers at second 101 has done the work and receives the message again. Docusign documents a second cause of repeats: you might get duplicate messages if you receive events through Connect and through API calls. The Connect message names the event and the envelope, and carries a retryCount. For envelope-completed, the event name joined to data.envelopeId makes a stable key.

On the sending side, the envelope definition takes a transactionId. Docusign describes it as a sender-generated value, valid for 7 days, and recommends it for offline signing so that one envelope goes out once. After a lost connection, a status request with the transaction_ids parameter shows whether Docusign created the envelope.

HubSpot. The Webhooks API guide for HubSpot, written for legacy public apps, sets out the delivery rules. HubSpot retries a failed notification up to 10 times across 24 hours, and warns that the same notification can arrive more than once. Each notification carries an attemptNumber that starts at 0. For writes into HubSpot, its documentation describes two tools that make a repeat safe. A unique identifier property stops a second record with the same value, and HubSpot allows up to ten per object. The contacts API has a batch upsert endpoint, keyed on email or on a unique identifier property. It updates a contact that exists and creates one that does not.

Xero. Xero documents a key for calls to its API and a rule for the webhooks it sends. For API calls, Xero keeps each key for 6 minutes and counts duplicate requests towards the rate limit, because it checks idempotency after it applies rate limits. For webhooks, Xero retries for 24 hours and then disables the subscription. Xero states on the same page that consumer apps must implement idempotency logic and support replay.

Sources (20)