Idempotency en retries in API-integratie: betrouwbaarheid zonder dubbele transacties
Een retry-loop is geen exotische edge case. Een client probeert een POST opnieuw omdat de TCP-connectie wegviel net voordat het antwoord binnenkwam. Een message broker levert hetzelfde event tweemaal door een consumer-restart. Een gebruiker klikt twee keer op "Bestellen" omdat de eerste klik niets leek te doen. In productie gebeurt dit dagelijks, op elke schaal.
De vraag is niet óf retries plaatsvinden. Die vraag is allang beantwoord — ze vinden plaats. De vraag is wat er dan gebeurt aan de andere kant. Bouwt het ontvangende systeem dezelfde transactie nog eens op, of herkent het de retry en geeft het gewoon het oorspronkelijke antwoord terug? Het verschil tussen die twee uitkomsten is precies waar idempotency over gaat.
Hieronder een ontwerp dat werkt in productie: hoe je idempotency-keys uitdeelt, waar je ze opslaat, hoe je foutcodes zo formuleert dat retries veilig zijn, en welke anti-patronen ervoor zorgen dat je dezelfde betaling drie keer uitvoert.
Wat retries oplossen — en wat niet
Retries lossen tijdelijke verstoringen op: kort wegvallend netwerk, overbelaste downstream service, een database die heel even niet bereikbaar is. Het patroon is bewezen — een consumer probeert opnieuw met exponential backoff, en in het overgrote deel van de gevallen slaagt de tweede of derde poging.
Wat retries níét oplossen — en juist verergeren — is het scenario waarin de eerste poging in werkelijkheid wél geslaagd is, maar het antwoord onderweg verloren ging. De server heeft de transactie verwerkt; de client zag alleen een timeout. De client probeert opnieuw. De server, die geen idee heeft dat dit een herhaling is, voert dezelfde mutatie nog eens uit.
Het resultaat: dubbele betaling, dubbele bestelling, dubbele klantrecord. De gebruiker krijgt twee mails, de boekhouding twee posten, het CRM twee profielen. En het zat 'm niet eens in een bug — het zat 'm in een correct uitgevoerde retry op een API die niet ontworpen was om die retry te ontvangen.

Idempotency: dezelfde operatie, hetzelfde eindresultaat
De definitie is simpel en strikt: een operatie is idempotent als herhaalde uitvoering hetzelfde eindresultaat oplevert als één enkele uitvoering. Drie keer hetzelfde POST-bericht versturen mag hooguit de side-effects van één POST opleveren.
In de praktijk wordt dit zelden cadeau gedaan. GET en DELETE zijn idempotent op protocolniveau (RFC 9110), maar POST, PATCH en bepaalde PUT-varianten zijn dat niet automatisch. Voor die methodes moet de service-eigenaar idempotency expliciet ontwerpen.
Het standaardpatroon: de client genereert een unieke key per logische operatie en stuurt die mee in een header — typisch Idempotency-Key. De server gebruikt die key om duplicate requests te herkennen en geeft op een herhaling het oorspronkelijke antwoord terug, zonder de operatie nog een keer uit te voeren.
De key — wat het wel en niet is
Een goede idempotency-key heeft drie eigenschappen. Hij is uniek per logische operatie (niet per HTTP-poging — een retry hergebruikt de key), onafhankelijk van de payload (een hash van de body is geen idempotency-key, dat is een content-fingerprint), en lang genoeg om collision-bestendig te zijn (UUID v4 of v7 doen prima werk).
De client is verantwoordelijk voor de generatie. Een payment service genereert één key per checkout-poging en hergebruikt die voor elke retry binnen diezelfde poging. Pas wanneer de gebruiker een nieuwe poging start — bewust, expliciet — wordt een nieuwe key gegenereerd.
Het meest voorkomende ontwerpfoutje: de timestamp meenemen in de key. payment-{userId}-{timestamp} lijkt logisch, totdat je beseft dat elke retry een nieuwe timestamp produceert, dus elke retry door de server wordt gezien als nieuwe operatie. Niet idempotent. Wel verwarrend bij analyse.
De dedupe-store: meer dan alleen een key onthouden
Een server die idempotency ondersteunt heeft een dedupe-store nodig: opslag die per (key, scope) onthoudt wat er is gebeurd. Drie keuzes daarin zijn essentieel.
Sla het volledige antwoord op, niet alleen de key. Op een retry moet de server hetzelfde antwoord teruggeven dat de eerste request kreeg — inclusief status code, body en relevante headers. Anders krijgt de client bij retry-1 "201 Created" en bij retry-2 misschien "404 Not Found", terwijl de resource gewoon bestaat. Bewaar de oorspronkelijke response bytes.
Scope de key aan de juiste boundary. Een idempotency-key is meestal scoped per consumer en per endpoint. (consumerId, endpoint, key) is een veelgebruikte tuple. Dat voorkomt dat consumer A's key per ongeluk match maakt met consumer B's key, en het voorkomt dat dezelfde key per ongeluk twee verschillende endpoints adresseert.
Kies de TTL pragmatisch. Voor de meeste transactionele integraties is 24 tot 72 uur ruim genoeg — langer dan elke realistische retry-window, korter dan dat de store onnodig groeit. Voor financiële transacties kiezen organisaties vaak 7 dagen. Wat vooral voorkomen moet worden: een retry die ná de TTL nog binnenkomt en daardoor als nieuwe operatie wordt verwerkt.
Voor de implementatie: Redis met expiry voor warm-pad performance, een DB-tabel voor durability als een crash de Redis-state mag wissen. In zwaardere systemen beide.

Foutcodes die het verschil maken
De HTTP-statuscodes die je teruggeeft bepalen hoe slim de client kan retryen. Drie categorieën verdienen scherpe aandacht.
5xx: probeer later opnieuw. 500, 502, 503, 504 — de server heeft een probleem dat tijdelijk kan zijn. De client mag — en zou moeten — retryen met backoff. Dit is precies waar idempotency tegen beschermt.
4xx: stop met retryen. 400 (malformed), 401 (unauthorised), 403 (forbidden), 422 (unprocessable). Een retry zal hetzelfde resultaat geven. De client moet de fout aan de gebruiker melden of in een dead-letter zetten — niet retryen.
409 Conflict op key-hergebruik met andere payload. Dit is de subtiele: dezelfde idempotency-key, andere body. Twee scenario's: óf de client heeft per ongeluk dezelfde key voor twee operaties gebruikt (programmeerfout), óf er is een hash-collision in een te korte key. In beide gevallen is "stilzwijgend de eerste payload uitvoeren" levensgevaarlijk. Geef 409 terug, log het, en laat de client kiezen.
Een status code die bewust ingezet hoort te worden: 202 Accepted voor async processing waarbij de definitieve status later via een GET-status endpoint of webhook komt. Ook in een asynchroon model is idempotency op de accept-stap relevant — het voorkomt dat dezelfde job twee keer in de queue belandt.
Praktijk: vier patronen waar dit zichtbaar wordt
Payment APIs. Elke serieuze payment-provider eist een idempotency-key. Stripe, Adyen, Mollie — allemaal verplichten ze het voor charge- en capture-endpoints. Een retry van dezelfde key levert dezelfde transactie-ID op, niet een nieuwe charge. Onderwerp van de eerste integratie-review: voert de PSP-client de key correct door bij elke retry?
Webhook delivery. Een webhook-provider belooft "at-least-once delivery" — wat in de praktijk "soms vaker" betekent. De consumer moet dedupen op basis van de event-ID die de provider meelevert. Dezelfde event-ID? Negeren. Dit is webhook-idempotency vanuit consumerperspectief, en het is non-negotiable voor productie.
Order placement. Een gebruiker klikt op "Bestellen". De client genereert een idempotency-key zodra de checkout-flow start, en hergebruikt die voor elke retry binnen dezelfde sessie. Drie clicks → één order. Daarna is de key verbruikt en wordt een nieuwe checkout-poging een nieuwe order.
Contact form submissions. Klein in scope, maar dezelfde regels. Als een form-submit timeout, retry de client. Zonder idempotency landen er twee submissions in de inbox. Met een idempotency-key (door de client gegenereerd op het moment van eerste submit) wordt de tweede genegeerd of geeft hetzelfde "ontvangen" antwoord.
Anti-patronen om hard tegen te grijpen
De server genereert de key. Dan is er geen idempotency mogelijk — een retry krijgt een nieuwe key. De client moet de key genereren, of ten minste meesturen.
Body-hash als key. Verleidelijk, want "dezelfde inhoud → dezelfde key, magisch idempotent". Maar twee gebruikers die toevallig dezelfde body sturen (waarschijnlijker dan je denkt bij gestandaardiseerde payloads) collideren. En een retry waarbij de client een veld toevoegt — correlation-ID, attempt-count — breekt de hash.
Geen response replay. Server onthoudt alleen "deze key is gezien" en geeft op retry een vers antwoord. Bij een 201 Created → 200 OK retry is het effect verwarrend; bij een 201 → 404 (omdat de resource elders weggewerkt is) is het kapot. Sla de oorspronkelijke response op.
TTL korter dan de retry-window. Een client doet exponential backoff tot 24 uur. De server-TTL is 1 uur. Retry-21 binnenkomend op uur 22 wordt als nieuwe operatie verwerkt. Twee transacties.
Idempotency alleen op de happy path. Als de eerste request faalt halverwege (database crash na partial commit, externe call gelukt maar lokale write mislukt), moet de retry weten dat een eerdere poging een onvolledige toestand heeft achtergelaten. Idempotency op write zonder transactionele integriteit aan de serverkant is half werk.
Operationele discipline rond replays
Idempotency is geen instellingsvraag, het is een operationele discipline. Drie dingen moeten in productie zichtbaar zijn.
Metrics op duplicate-rate. Hoeveel requests per endpoint zijn duplicate retries? Een lage rate (onder een paar procent) is normaal. Een plotseling hoge rate wijst op een client die te agressief retryed of op een netwerkprobleem dat aandacht verdient.
Logging op key-collisions. Elke 409 op key-hergebruik met andere payload is een signaal: óf clientbug, óf hash-zwakte. Onderzoek deze voordat ze stille corruptie worden.
Replay-safety in de tests. Elke handler die mutaties uitvoert hoort een test te hebben die hem twee keer aanroept met dezelfde key en verifieert dat het resultaat — data in DB, side-effects, response — identiek is aan één aanroep. Niet "ongeveer hetzelfde", maar bit-identiek waar het gedrag zichtbaar is.
Een korte zelftest
Vijf vragen over je huidige integratie-landschap:
- Welke endpoints accepteren een
Idempotency-Keyheader, en is dat gedocumenteerd in het contract? - Wat is de TTL van je dedupe-store, en is die langer dan de maximale retry-window van je clients?
- Wordt op een duplicate request de oorspronkelijke response bytewise teruggegeven, of een vers gegenereerd antwoord?
- Wat gebeurt er bij dezelfde key met verschillende payload — 409, of stilzwijgend de eerste body uitvoeren?
- Heeft elke webhook-consumer een dedupe-laag op event-ID, of vertrouwt hij op de provider om niet te dupliceren?
Een eerlijk "nee" of "weet niet" op een van deze vragen is geen falen — het is het beginpunt voor een review.
Tot slot
Idempotency klinkt als een implementatiedetail dat erna komt. In de praktijk is het een ontwerpkeuze die op dag één gemaakt moet worden, omdat het achteraf invoegen vrijwel altijd duurder is — en in de meeste systemen vereist het breken van retroactieve aannames over wat er gebeurt bij een retry.
Goede integraties falen graceful, en retries zijn de moeitevolle helft van graceful. Een API die idempotency negeert kan tijdens demo's en in tests onberispelijk lijken, en alsnog in productie dubbele transacties produceren zodra het netwerk een keer hapert.
Worstelt uw organisatie met dubbele transacties of onzekerheid over retry-veiligheid van API-integraties? Een korte integratie-audit maakt zichtbaar waar de risico's zitten en welke endpoints het eerst aandacht verdienen.