What Is Idempotency? Why Accounting and Payment APIs Need It
How a retried request creates a duplicate invoice, what an idempotency key guarantees, the failure modes it must handle, and how to implement it correctly.
An operation is idempotent if performing it twice has the same effect as performing it once. Reading a record is naturally idempotent. Creating an invoice is not — call it twice and you have two invoices, two receivables, and a customer asking why they were billed twice.
That gap matters because networks fail in a specific, nasty way: the request succeeds, and the response is lost. The client sees a timeout and cannot distinguish "never arrived" from "arrived, worked, reply lost". Its only safe-looking option is to retry, and the retry is what creates the duplicate.
Why accounting makes it worse
In most systems a duplicate is an annoyance. In a ledger it is a compounding problem:
- A duplicate invoice consumes a number from a gapless sequence, so deleting it is not an option — immutability means correcting it requires a credit note, which is a document your customer sees.
- The duplicate posts journal entries, so revenue, VAT and receivables are all overstated until someone notices.
- It enters the VAT return and, in Lithuania, the i.SAF registers.
- If it triggered a payment, money actually moved.
"Just check for duplicates" does not work either, because two genuinely separate invoices to the same customer for the same amount on the same day are perfectly normal. The system cannot infer intent from content — the client has to state it.
How idempotency keys work
The client generates a unique key per logical operation and sends it with the request:
POST /v1/sales/invoices/create
Idempotency-Key: 8f14e45f-ea1b-4c2b-9f3a-2d7c1e0b5a91
The server then guarantees:
- First request with this key — process normally, store the outcome against the key.
- Retry with the same key and the same payload — do not re-execute; return the stored response.
- Same key, different payload — reject. The key is a claim that this is the same operation; a different body means it isn't, and silently returning the old result would be worse than an error.
- Retry while the original is still running — reject with a retryable error rather than executing concurrently.
Point 4 is the one naive implementations miss. A client that times out at 5 seconds and retries at 6 will collide with its own in-flight request; without a concurrency guard, both proceed and you get the duplicate the mechanism existed to prevent.
Generating keys correctly
The key must identify the operation, not the attempt:
- Right: a UUID generated once when the user clicks "issue invoice", reused for every retry of that click.
- Right: a deterministic key derived from your own domain —
order-1041-invoice. - Wrong: a fresh UUID per HTTP attempt. Every retry gets a new key, so every retry is a new operation, and you have implemented nothing.
- Wrong: a hash of the payload. Two legitimately identical operations then collide, and the second is silently swallowed.
Keys need a retention period — long enough to cover realistic retry windows and client outages, not forever.
What it does not solve
Idempotency protects against duplicate execution of the same request. It does not protect against:
- Two different users creating the same invoice from different sessions — that is a business-rule problem, solved by uniqueness constraints such as one invoice per order.
- Logical duplicates with different keys.
- Non-determinism inside the operation — if the handler generates a timestamp or a number, the stored response captures the first one, which is the desired behaviour but worth understanding.
It pairs with, rather than replaces, database constraints. Nordlet's ledger uses both: idempotency keys stop retried requests, while unique indexes stop duplicate settlement batches per payout ID, duplicate purchase invoices per supplier and document number, and duplicate depreciation runs per asset and period.
How Nordlet implements it
Send an Idempotency-Key header on any state-changing request — a non-empty string of at most 255 characters.
The first request with a given key executes and its outcome is stored. A retry with the same key and the same payload returns the stored response without re-executing, so the invoice is created exactly once no matter how many times the call is repeated.
Two failure modes are explicit rather than silent:
- Same key, different payload → rejected: "Idempotency-Key was already used with a different request payload." This catches the genuinely dangerous case where a client reuses a key while changing the amount.
- Same key, original still in flight → rejected as retryable: "A request with this Idempotency-Key is still being processed, retry shortly." No concurrent double-execution.
Keys are scoped per company, so two tenants generating the same UUID never collide.
The complementary guarantee runs in the other direction. Webhook delivery is at-least-once, meaning your endpoint will occasionally receive the same event twice — after a timeout where delivery actually succeeded, or on redelivery. Every event carries an ID, and your handler should record processed IDs and ignore repeats. Idempotency is a property both sides of an integration need; the API conventions guide covers the request side in detail.
FAQ
What is an idempotency key?
A client-generated identifier sent with a request that lets the server recognize a retry of the same logical operation. The first request executes; retries with the same key return the stored result instead of executing again.
Why do accounting APIs need idempotency?
Because a duplicated write creates a real document with real consequences — a second invoice consuming a sequential number, posting to the ledger, and entering a VAT return. Unlike a duplicated read, it cannot simply be deleted; it must be corrected with a credit note.
Should I generate a new key for each retry?
No — that defeats the mechanism entirely. Generate one key per logical operation and reuse it for every retry of that operation. A new key per attempt means every attempt is treated as a new operation.
What happens if I reuse a key with a different payload?
The request is rejected. The key asserts that this is the same operation, so a changed body means something is wrong — most likely a key-reuse bug on the client. Returning the earlier result silently would be worse.
Does idempotency prevent all duplicates?
No. It prevents duplicate execution of the same request. Two different users independently creating the same invoice have made two different requests, which is a business-rule problem for uniqueness constraints, not something an idempotency key can see.