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 returns the first answer instead of creating a second invoice.

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

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.

The problem

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.

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 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.

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.

network

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.

your code

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.

your users

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.

Endpoint coverage

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.

Endpoint
POST /bookingProposals

Capture an external invoice as a booking proposal

POST /bookingProposals/async

The same capture, queued and answered with a task ID

POST /journalEntries/bulk

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.

How Maesn handles it

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.

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

No second posting 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 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.

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 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.

The second call

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, 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.

409

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.

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.

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.

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 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.

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. You can read it in a log six weeks later and know which operation 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 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.

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 second record.

Which endpoints support idempotency in the Maesn API?

POST /bookingProposals for Lexware Office, Microsoft Business Central, Procountor, Sage Accounting, SnelStart, Visma e-conomic and Xero; POST /bookingProposals/async for DATEV Unternehmen Online; and POST /journalEntries/bulk for DATEV Rechnungswesen. These are the writes where a duplicate costs a correcting entry, which is why they came first. The key is switched on per endpoint once its behaviour has been verified against every system behind it.

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 the first 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 a second 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.