One idempotency key makes a retried write land exactly once
A connection can drop after the write has already happened, and from your side that looks exactly like a request that never arrived. Send an idempotency key and the retry gives you back the answer you missed, with no extra invoice behind it.
What is idempotency
Idempotent means doing something twice leaves you where doing it once would have. Many requests already work like that: reading a list, or setting a field to a value. Creating something does not. Send the same invoice twice and the system holds two invoices. So every API that writes has to answer one question. If you never found out whether your request went through, is it safe to send it again?
An idempotency key is the answer. You name the operation, the name travels with the request, and anything arriving under a name that has already been answered gets the original answer back, with no second execution behind it. In accounting that distinction is sharper than in most domains, because the thing being created is a posting. A duplicate is not a row to delete later, it is an entry someone has to reverse. It also sets the limit on what your failure handling can do. Knowing which errors are worth retrying is one part of the problem, and unified error handling covers that. Acting on it without risking a duplicate posting is the other.
A dropped connection and a duplicate posting
The same call, run twice. Once it works. Once your app times out before the answer arrives, and by then the invoice already exists in the target system.
- Your appmaesnPOST /bookingProposals
- maesnLexware Officethe write, translated
- Lexware Officemaesn201 Created
- maesnYour app201 Created
The answer carries the identifier of the object that was created, so your side can record it and move on.
- Your appmaesnPOST /bookingProposals
- Your appthe connection drops
- maesnLexware Officethe write, translated
- Lexware Officemaesn201 Created
- maesnYour appthe 201 has nowhere to land
Asking again is the only way to find out, and asking again is exactly what creates the duplicate.
The two columns differ by one event, and it is not an event anyone controls. Between your request and its answer sits a network, and a network occasionally drops a connection that was mid-flight. What makes that expensive here is the order of operations: by the time the answer is lost, the target system has already booked the proposal. In the diagram that system is Lexware Office, and the same sequence holds wherever the write lands. Your side holds a timeout and no outcome, which is the one state a write should never leave you in.
Without a key there are two ways out and both cost something. You can ask what happened, which means a support request and a wait. Or you can send the request again, which produces a correct result if the attempt failed and a duplicate if it succeeded. Asking again is what retry logic does by default, because a retry reacts to a missing answer and a missing answer is not evidence of a missing write.
Three ways the same invoice gets sent twice
A dropped connection is the clearest case and not the most common one. Two of the three sit above the API, where the sender cannot see what has already gone out.
The write runs, the answer cannot get back to you.
Retry logic reacts to a missing answer, which says nothing about the call.
Two clicks, and the queue behind the button cannot tell they are one invoice.
Each one has to be reversed by a person, in the target system, after somebody notices.
The click is the one worth dwelling on, because it reaches production most often and is the least likely to be designed for. Somewhere in your product there is a button that sends an invoice, and a person who clicks it again when nothing visible happens. Between that click and the ledger there may be a queue, a job runner and a connector, none of which can tell that the two requests describe one invoice. An idempotency key is the only thing in that chain that can, because it is the only part the caller gets to name.
The header that makes a retry safe
The key arrives with your request and decides one thing: whether this operation has already been answered. Everything else about the call stays as it was.
Nothing to find and reverse later.
The call is forwarded once and the answer it produces is kept for 24 hours.
The kept answer is returned and the target system is not called again.
One header per operation
You pick a key that identifies the operation and send it with the write. Any string works, and a business identifier reads better in a log than a UUID.
The retry you already run
Retry logic you have today becomes safe by adding the header, because a repeat answers from what is stored instead of starting a new write.
Scoped so keys cannot collide
A key belongs to one tenant, one user and one operation, so two of your customers can pick the same value without affecting each other.
Kept for 24 hours
The answer is held for 24 hours after the call completes, which covers a retry loop, an outage window and a support question the next morning.
POST /bookingProposalsx-api-key: YOUR_API_KEYx-account-key: CUSTOMER_ACCOUNT_KEYidempotency-key: order-12345-invoice-001Content-Type: multipart/form-datafile=@invoice.pdf
The scoping is what makes a self-chosen key workable. A key is not read as a global value, it belongs to one tenant, one user and the one operation it was sent with, so two of your customers can both pick invoice-1 on the same morning and never meet. That is also why you design the key and we never issue one: the only thing it has to do is be unique among your own operations.
The request itself is checked as well as the name. If the same key arrives with different data behind it, that is not a retry, it is a new operation wearing an old name, and it is answered with a 422. The combination is what the guarantee rests on: the key says which operation this is, and the request says whether it is still the same one.
Four answers to the same key
A retry is only useful if its answer is unambiguous, so the second call does not get a generic acknowledgement. It gets one of four answers, and each one tells you exactly what to do next. Three of them are a reason to stop worrying, and one is a reason to change the key rather than the timing.
Same key, same request, already answered
You get the original status code and body back, so your code takes the same branch it would have taken the first time.
Same key while the original is in flight
The original attempt has not finished. Wait a moment and ask again instead of opening another.
Same key, but the request body changed
That key already belongs to a different payload. A genuinely new operation needs a new key.
The idempotency check itself cannot run
Nothing was forwarded, so there is no duplicate to worry about. The same key is safe to send again.
A replayed answer is the original, with the same status code and the same body, so your code needs no branch for it. The 201 you would have handled on the first call is the 201 you handle now, and the identifier in it points at the record that already exists. Nothing in your handler has to know it is looking at a second attempt.
On an async write, the key returns the task ID
A queued write answers immediately with a task ID and finishes later. The key attaches to that first answer, which changes what a retry means.
This is the case that an impatient caller runs into, and it is worth knowing before you build around it. A queued write into DATEV Unternehmen Online can take a while, so somebody watching a spinner sends the request again. With a key, that repeat hands back the same task ID instead of queueing another write, and the task ID is the thing you were polling anyway. The pacing behind the queue belongs to asynchronous processing, so the write stays inside each system's limits while you wait.
A failure is handled differently from a success here, and deliberately so. If the queued operation fails, the key is released, so the obvious next step works: fix the data and send it again under the same key, without inventing a new one for what you still think of as one operation. If it succeeded, the key stays bound to that answer, because reusing it would be asking for the duplicate the key exists to prevent.
What the layer carries and what your code sends
Idempotency is a short contract. One side recognises the key and keeps the answer, the other side sends the key.
- Recognises the key and the operation it belongs to
- Keeps the first answer for 24 hours
- Replays it with the same status code and body
- Answers 409 while the first call is still in flight
- Answers 422 when the key is reused for other data
- Chooses a key that identifies the operation
- Sends it again on the retry you already run
- Uses a new key for a genuinely new operation
The lower band is the whole integration cost, and that is the point of putting the two under each other. There is no state to keep, no table of sent writes per customer and no reconciliation job to find the pairs afterwards. The key you already have in your own data model, an order number or an invoice reference, is usually the key the header wants, which is why the docs recommend a business identifier over a generated one. Every request and response around it is recorded, so six weeks later you can look the operation up through logging and monitoring and see exactly which one it was.
One header instead of a reconciliation job
- Store every write, per customer
- Work out if a failure landed
- Reconcile to find the duplicates
- Reverse a posting in the ledger
- Send one header with the write
- A retry returns the first answer
- One call to the system per write
- Nothing to reconcile afterwards
A duplicate posting is expensive in a way that scales badly. It is found late, usually by the customer's accountant and not by your monitoring, it has to be reversed inside the target system by a person, and the conversation about it lands on whoever is nearest. Volume makes it worse: a travel management platform pushing supplier bills into DATEV for hundreds of companies, which is what Lanes & Planes does, has no realistic way to inspect every write by hand.
Naming the operation moves that whole class of problem to the moment the request is made, which is the only moment anyone has enough context to deal with it. The retry becomes a safe thing to do, and the work it saves is the kind product and engineering teams would otherwise be maintaining for years, one state table and one reconciliation job at a time.
Common questions
What is an idempotency key?
Can I use idempotency on the endpoints my integration writes to?
How long is an idempotency key valid?
How should I generate idempotency keys?
What happens if I reuse a key with a different request body?
What happens if I retry while the first request is still running?
Does idempotency work on asynchronous endpoints?
Can I reuse a key after a request failed?
What if the idempotency check itself is unavailable?
Do I still need my own retry logic?
Is an idempotency key mandatory?
Build once on the Unified API.
See how idempotency works for your integration, or dive into the technical reference.











