maesn
Product insight

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.

Two calls, one keyidempotency-key
1POST /bookingProposalsanswered 201
2POST /bookingProposalstimed out, retried
maesn recognises the key
In the target system
One booking proposal
A repeated call returns the stored answer with the same status code and body. The target system is never asked again.
Trusted by winning software teams
HubSpotTipaltiPaywiseRallyQredNordhealthFindityFintoClockinHEROHolviLanes & PlanesAgicapHubSpotTipaltiPaywiseRallyQredNordhealthFindityFintoClockinHEROHolviLanes & PlanesAgicap
The concept

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.

The problem

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.

The happy path
  1. Your appmaesn
    POST /bookingProposals
  2. maesnLexware Office
    the write, translated
  3. Lexware Officemaesn
    201 Created
  4. maesnYour app
    201 Created
Your app knows the proposal exists

The answer carries the identifier of the object that was created, so your side can record it and move on.

The same call, one dropped connection
  1. Your appmaesn
    POST /bookingProposals
  2. Your app
    the connection drops
  3. maesnLexware Office
    the write, translated
  4. Lexware Officemaesn
    201 Created
  5. maesnYour app
    the 201 has nowhere to land
The write succeeded, your app cannot tell

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.

Where duplicates come from

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 same invoice, sent twice
network
A connection that drops mid-call

The write runs, the answer cannot get back to you.

your code
A retry with no outcome to check

Retry logic reacts to a missing answer, which says nothing about the call.

your users
An end user pressing Send again

Two clicks, and the queue behind the button cannot tell they are one invoice.

Without a key
POST /bookingProposals ×2
Two booking proposals in the ledger

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.

How Maesn handles it

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.

Your app
One POST, one key you chose
idempotency-key: inv-4471
the idempotency layer
Has this exact operation been answered already?
Target system
Called once per operation

Nothing to find and reverse later.

First time with this key

The call is forwarded once and the answer it produces is kept for 24 hours.

This key has been here before

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.

capture.http
POST /bookingProposals
 
x-api-key: YOUR_API_KEY
x-account-key: CUSTOMER_ACCOUNT_KEY
idempotency-key: order-12345-invoice-001
 
Content-Type: multipart/form-data
file=@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.

Repeat calls

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.

the stored answer

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.

409

Same key while the original is in flight

The original attempt has not finished. Wait a moment and ask again instead of opening another.

422

Same key, but the request body changed

That key already belongs to a different payload. A genuinely new operation needs a new key.

503

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.

Asynchronous writes

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.

The first callAnswers 202 with a task ID, and the key is held against that answer.
A retry with the same keyReturns the same task ID, so you keep polling one task instead of starting a second write.
The task succeedsThe key stays bound to that answer for the rest of the 24 hours.
The task failsThe key is released, so you can correct the request and send it again under the same key.

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.

Where the layer stops

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.

The layer does
  • 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
Your code does
  • 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.

Why it matters

One header instead of a reconciliation job

Building it yourself
  • Store every write, per customer
  • Work out if a failure landed
  • Reconcile to find the duplicates
  • Reverse a posting in the ledger
With Maesn
  • 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.

Idempotency FAQ

Common questions

What is an idempotency key?

A value you choose that identifies one logical operation, sent in the idempotency-key header on a POST. The first call with that key is processed normally and its answer is stored. Any later call with the same key returns the stored answer instead of running the write again, so a retry after a timeout cannot create a duplicate.

Can I use idempotency on the endpoints my integration writes to?

Yes. Coverage is being extended continuously, and idempotency is rolled out for every endpoint step by step. If you want it today on an endpoint that does not carry it yet, tell us and it goes straight to the top of the queue, so any customer can use idempotency for any system and any endpoint they write to.

How long is an idempotency key valid?

24 hours, starting when the first request completes. After that the same value can be used again for a new operation. Treat that as a safety net rather than a mechanism: the docs are explicit that you should not rely on key expiration as part of a normal workflow, and should generate a unique key per distinct operation instead.

How should I generate idempotency keys?

Use something that uniquely identifies the operation rather than the attempt. A UUID works, a business identifier such as order-12345-invoice-001 works and reads better when you are debugging, and a composite of two of your own identifiers works as well. The key is any string, so the deciding question is whether two different operations could ever produce the same value.

What happens if I reuse a key with a different request body?

You get a 422. The key is already bound to one request, so changing the data behind it would make the guarantee meaningless. If you genuinely want a different operation, send a new key. If you are correcting a request that failed, see the question about failed asynchronous writes below.

What happens if I retry while the first request is still running?

You get a 409. The first attempt has not finished, so there is nothing to replay yet and starting another write would defeat the purpose. Wait a short moment and send the same key again. On a long-running write the more comfortable route is the asynchronous endpoint, where the same key returns the task ID you are already polling.

Does idempotency work on asynchronous endpoints?

Yes, and it attaches to the call that hands you the task ID. The first call answers 202 with a task ID, and a retry with the same key returns that same task ID rather than queueing a second write, so you keep polling one task. The status of the work itself is read through the async task lookup, which reports success, failure or still in progress.

Can I reuse a key after a request failed?

On the asynchronous endpoints, yes. If the queued operation fails, the key is released, so you can correct the request and send it again under the same key. If it succeeded, the key stays bound to that answer for the rest of the 24 hours. A deliberately different operation always needs a new key.

What if the idempotency check itself is unavailable?

You get a 503, and nothing was forwarded to the target system. That is the deliberate choice: rather than letting a write through unchecked and risking a duplicate, the request is refused. Because nothing happened, the same key is safe to send again after a short delay.

Do I still need my own retry logic?

Yes, and that division is the same one as everywhere else in the Unified API. Maesn classifies the failure, names the system, attaches its original response and, on the asynchronous endpoints, paces and retries inside the queue. On a synchronous call the decision to try again is yours. Idempotency does not replace that decision, it removes the risk from it.

Is an idempotency key mandatory?

No. It is optional, and a request without the header behaves exactly as it did before. The docs recommend it for any production POST where a duplicate would cause problems, which in accounting means capturing external invoices and creating journal entries, and especially wherever you have retry logic in front of those calls.

Build once on the Unified API.

See how idempotency works for your integration, or dive into the technical reference.