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 returns the first answer instead of creating a second invoice.
What is idempotency
An operation is idempotent when doing it twice has the same effect as doing it once. Reading is naturally idempotent, and so is setting a value. Creating something is not: two calls mean two records, which is why every API that writes has to answer a question it cannot avoid. If the caller never learned whether the first attempt worked, what should the second one do?
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 rather than being carried out again. 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 decides what your failure handling is allowed to do: knowing which errors are worth retrying is one half of the problem, and unified error handling covers it. Being able to act on that knowledge without risking a second posting is this half.
A dropped connection and a duplicate posting
The same call, twice, with one difference. The second time the answer never gets home, and the write has already happened.
- 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 second proposal.
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. 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 first attempt failed and a duplicate if it succeeded. The second option 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.
A connection that drops mid-call
Your request arrives, the write runs, and the answer cannot get back to you. Nothing on your side distinguishes that from a request that never arrived.
A retry with no outcome to check
Retry logic reacts to a missing answer, and a missing answer is the one signal that says nothing about the status of the call. Without a key it has to guess.
An end user pressing Send again
The same invoice goes out twice because somebody clicked twice, and the queue behind your button has no way to know the two are the same operation.
The third one is worth dwelling on, because it is the one that reaches production most often and the one 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 endpoints that take the key
Three write endpoints accept the header today, across nine target systems. They are the writes where a duplicate is a correcting entry rather than a cleanup.
Capture an external invoice as a booking proposal
The same capture, queued and answered with a task ID
Post a set of journal entries in one request
That list is where it starts rather than where it stops. The key is switched on per endpoint once its behaviour has been verified against every system behind it, because a write that reaches one system in one call and another in several does not become safe by the same rule. Capturing an external invoice and posting journal entries came first for the obvious reason: those are the two operations a customer notices twice.
For a write that does not carry a key yet, the question a key answers is still answerable. Every request, response and webhook is recorded, so what was sent and what came back is something you look up through logging and monitoring rather than reconstruct. On the asynchronous endpoints you also hold a task ID, and the task lookup reports whether the work succeeded, failed or is still running.
One header, and the second call replays the first
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.
No second posting 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 a second time.
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 the second call answers from the first 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 first 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 a key is yours to design rather than ours to issue: 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 rather than quietly accepted. 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, the first call has finished
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 first call is still running
The first attempt is still in flight. Wait a moment and ask again rather than opening a second one.
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.
The first row is the one to read twice. A replayed answer is the original, with the same status code and the same body, which means your code does not need a 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 can take a while, so somebody watching a spinner sends the request again. With a key, that second call hands back the same task ID instead of queueing a second 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. You can read it in a log six weeks later and know which operation 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 rather than by your monitoring, it has to be reversed inside the target system rather than deleted, 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 rather than a decision with a cost attached, 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?
Which endpoints support idempotency in the Maesn API?
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.











