← Blog

Process automation

Idempotency keys: de bug die 73 klanten dubbel factureerde

Vrijdag, 14:47. Een uitzendbureau uit Almere belt: 'Onze grootste klant kreeg net twee identieke facturen voor dezelfde plaatsing.' Dan een tweede telefoontje. Dan een derde.

Jacob Molkenboer· Oprichter · A Brand New Company· 27 mrt 2025· 9 min
Twee crème enveloppen met rode stempels en een groene lakzegel, messing paperclip en jute touw op linnen.

De vrijdagmiddag waarop de telefoon niet ophield met rinkelen

Het eerste telefoontje kwam binnen om 14:47 op een vrijdag in mei. De operations lead van een uitzendbureau van 28 mensen in Almere. Vlakke stem, openingszin: "Ik kreeg net een mail van een van onze grootste klanten. Ze hebben twee identieke facturen ontvangen voor dezelfde plaatsing. Andere factuurnummers. Hetzelfde bedrag. Dezelfde week. Zijn we gehackt?"

Nee, niet gehackt. We zeiden dat we binnen tien minuten zouden terugbellen. Dat deden we niet. We belden pas twee uur later terug, want inmiddels had een tweede klant gemaild, daarna een derde, daarna een vierde, en we vertrouwden geen enkel antwoord meer dat we niet zelf in de database hadden geverifieerd.

Om 17:30 stond de teller op elf getroffen klanten. Maandagochtend, na een heel weekend forensisch werk in Bullhorn, Inngest, Stripe en het dashboard van het bureau zelf, stond de teller op 73. Dit waren facturen die wij hadden verstuurd. Wij hadden de automatisering gebouwd die ze verstuurde. En de agent had een volle week lang in stilte elke plaatsing dubbel gefactureerd.

Hoe een plaatsing een factuur wordt

Het bureau gebruikt Bullhorn als ATS. Wanneer een recruiter een kandidaat bij een klant plaatst, wordt er een Placement-record aangemaakt. Daarin staan de startdatum, het tarief, de marge, de contractvoorwaarden en de koppeling terug naar de kandidaat en het klantaccount.

De automatisering die wij bouwden luistert via Bullhorn-webhooks naar nieuwe plaatsingen, valideert de data, stelt een conceptfactuur op in Stripe en zet die in de wachtrij voor menselijke goedkeuring. De finance lead van het bureau loopt de wachtrij elke woensdagmiddag na en keurt de batch goed. De agent doet al het andere, inclusief het rekenwerk voor halve weken, feestdagtoeslagen en de margetabel per klant.

Dit is de Bullhorn-webhooksubscription die we hadden geregistreerd. Het is exact wat elke Bullhorn-integratietutorial op internet laat zien, en daar zit een deel van het probleem.

curl -X PUT https://rest.bullhornstaffing.com/rest-services/{corp}/event/subscription/placementChanges \
  -H "BhRestToken: $TOKEN" \
  -d '{
    "type": "entity",
    "names": ["Placement"],
    "eventTypes": ["INSERTED", "UPDATED"]
  }'

De extra UPDATED was bewust. We wilden weten wanneer een recruiter het tarief corrigeerde of de startdatum achteraf verschoof, omdat de facturatielogica daarvan afhangt. In theorie nuttig.

Wat we misten: Bullhorn vuurt INSERTED af op het moment dat de Placement wordt opgeslagen, en daarna heel vaak UPDATED binnen een seconde, omdat het platform zelf afgeleide velden bij het opslaan schrijft. Provisieberekeningen. Normalisatie van tariefplafonds. Statusovergangen. Voor elke plaatsing in productie stuurde onze subscription twee events kort achter elkaar, meestal binnen dezelfde seconde, soms binnen dezelfde honderd milliseconden.

De Inngest-functie die niet wist dat hij twee keer draaide

Onze facturatielogica draait op Inngest. We gebruiken het voor alles waar een step duurzaam, herhaalbaar en observeerbaar moet zijn. Het is, wat ons betreft, de schoonste workflow-engine voor AI-agent- en webhook-fan-outwerk die je voor een klein team kunt zetten.

Hier is de functie, licht geanonimiseerd, zoals hij was uitgerold.

export const draftInvoice = inngest.createFunction(
  { id: "draft-invoice-from-placement" },
  { event: "bullhorn/placement.event" },
  async ({ event, step }) => {
    const placement = await step.run("fetch-placement", async () => {
      return bullhorn.placements.get(event.data.placementId);
    });

    const draft = await step.run("create-stripe-draft", async () => {
      return stripe.invoices.create({
        customer: placement.clientStripeId,
        collection_method: "send_invoice",
        days_until_due: 30,
        metadata: { placementId: placement.id },
      });
    });

    await step.run("queue-for-approval", async () => {
      return db.invoices.insert({
        stripeInvoiceId: draft.id,
        placementId: placement.id,
        status: "pending_approval",
      });
    });
  }
);

Zoek de bug. Hij zit in wat ontbreekt, niet in wat er staat.

Er staat geen idempotency-veld op de functie. Er is geen deduplicatie op de placement-ID. Elk event dat in Inngest binnenkomt, start een nieuwe run. INSERTED komt binnen, run start, Stripe-concept aangemaakt. UPDATED komt een seconde later binnen, run start opnieuw, tweede Stripe-concept aangemaakt. Verschillende Stripe-factuur-ID's. Identieke placement-metadata. Geen waarschuwing, nergens in de stack.

De finance lead keurt op woensdag beide goed, want haar dashboard toont twee aparte concepten, beide met geldige regelitems, beide met logische totalen, en ze vertrouwt erop dat de agent ze al heeft gevalideerd. De klant ontvangt de volgende ochtend twee identieke PDF's.

Let op

Als je webhookbron hetzelfde logische event onder meer dan één naam kan afvuren, is een idempotency-key aan de consumerkant geen optie. Het is het contract dat je het volgende systeem in de keten verschuldigd bent.

Waarom we het niet vingen in testing

Dit is het stuk dat pijn doet. We hebben de integratie end-to-end getest tegen de Bullhorn-sandbox, met plaatsingen die we handmatig via de UI aanmaakten. Die plaatsingen vuren alleen INSERTED af. Het UPDATED-bij-opslaan-patroon treedt pas in werking als de productievalidatieregels draaien op echte klant- en kandidaatrecords, en die hadden we niet realistisch in de sandbox staan.

Ons Inngest-dashboard had alle runs de hele tijd zichtbaar gestaan. Twee runs per plaatsing. We hadden het standaarddashboard van het team gegroepeerd op datum in plaats van op placement-ID, dus de verdubbeling sprong nooit eruit. Veertig plaatsingen in een week zagen eruit als tachtig runs, precies wat je zou verwachten voor een workflow met één ingebouwde retrypass.

Het signaal was er. We keken er niet naar. Dat is de eerlijke zin.

De diagnose op vrijdagavond

Toen we een vierde klantmelding hadden, stopten we met gokken en draaiden we de join. Eén query tegen de placements-tabel en de invoices-tabel:

SELECT placement_id, COUNT(*) AS invoice_count, ARRAY_AGG(stripe_invoice_id)
FROM invoices
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY placement_id
HAVING COUNT(*) > 1;

73 rijen. Allemaal met precies twee Stripe-factuur-ID's. Aangemaakt binnen seconden van elkaar. Identieke totalen. Het patroon was zo uniform dat we binnen vijf minuten na het zien van het resultaat wisten dat het geen race condition was, geen LLM-hallucinatie en geen spookactie aan Stripe-kant. Het was de webhookbron die twee keer afvuurde en onze consumer die beide events als nieuw werk behandelde.

De fix in één regel config

Inngest ondersteunt idempotency-keys op functieniveau native. Je geeft het een expressie die wordt geëvalueerd tegen het binnenkomende event, en het dedupliceert alles wat binnen een configureerbaar venster op die key matcht. Hun documentatie zegt het glashelder. We hadden het alleen niet zorgvuldig genoeg gelezen toen we live gingen.

De fix:

export const draftInvoice = inngest.createFunction(
  {
    id: "draft-invoice-from-placement",
    idempotency: "event.data.placementId",
  },
  { event: "bullhorn/placement.event" },
  async ({ event, step }) => {
    // body unchanged
  }
);

Die ene regel zegt tegen Inngest: als je binnen de komende 24 uur een ander event krijgt met dezelfde placementId, start dan geen nieuwe run. Laat 'm vallen. Log 'm. Door.

We hebben ook een tweede garantie toegevoegd op de Stripe-laag, want twee lagen idempotency zijn goedkoper dan één weekend creditnota's schrijven. De Idempotency-Key-header van Stripe is een van de meest beproefde implementaties in de betaalindustrie, en hij kost niets om te gebruiken.

const draft = await step.run("create-stripe-draft", async () => {
  return stripe.invoices.create(
    {
      customer: placement.clientStripeId,
      collection_method: "send_invoice",
      days_until_due: 30,
      metadata: { placementId: placement.id },
    },
    { idempotencyKey: `invoice-placement-${placement.id}` }
  );
});

Nu, zelfs als onze Inngest-laag op de een of andere manier een duplicaat laat passeren (operatorfout, een ongelukkige replay tijdens debugging, een toekomstige codewijziging die de dedupe stilletjes breekt), geeft Stripe de oorspronkelijke factuur terug in plaats van een nieuwe aan te maken. Twee lagen. Twee garanties. De kosten zijn twaalf tekens code.

Wat we het bureau maandagochtend stuurden

Voordat ook maar één technische fix naar productie ging, hebben we een briefje van één pagina opgesteld dat het bureau naar hun klanten kon sturen. Pure verontschuldiging, de duplicaat-factuur-ID's naast elkaar, de creditnota's die we al in Stripe hadden gegenereerd, het tijdstip waarop de fix live ging.

De operations lead vroeg om één wijziging. Ze wilde een alinea die in mensentaal uitlegt wat idempotency betekent en waarom een simpele webhooksubscription kon ontploffen. We schreven die, zij stuurde 'm, en van de 73 getroffen klanten ging er precies één over naar een andere partij. De andere 72 bleven.

Die verhouding is geen geluk. Het is het rendement van een uitleg schrijven alsof de ontvanger een bedrijf runt, niet alsof hij de code heeft geschreven.

De defaults die we studio-breed hebben veranderd

Hierna hebben we ons interne template voor elke webhook-getriggerde Inngest-functie herschreven. Vier wijzigingen, allemaal klein.

  1. Elke functie krijgt een expliciete idempotency-expressie. De PR-reviewer moet de gekozen key tijdens de review verantwoorden, niet stilletjes laten doorglippen zonder.
  2. Elke externe write-step (Stripe, Xero, Exact, Twinfield, Resend, Postmark) krijgt een idempotency-key afgeleid van een stabiele bedrijfsidentifier. Nooit een UUID die binnen de step wordt gegenereerd.
  3. Het standaarddashboardbeeld in Inngest groepeert op de idempotency-expressie, zodat dubbele runs zichtbaar zijn op het moment dat ze gebeuren in plaats van een week later.
  4. Elke nieuwe webhook-integratie gaat live met een monitoringregel van één week: alert op elke bedrijfsentiteit die binnen 60 seconden meer dan één downstream run produceert.

Niets hiervan is nieuw. De Inngest-documentatie behandelt het al jaren. Stripe heeft idempotency als first-class concept sinds 2015. De reden dat we het misten was geen technische onwetendheid, het was de snelheid waarmee de eerste versie 'af' voelde. De webhook vuurde. De facturen kwamen binnen. Het dashboard was groen. Het contract met het downstream systeem, dat had niemand opgeschreven.

Wat je in de komende tien minuten kunt doen

Open het dashboard van welke workflow-engine je ook gebruikt. Inngest, Temporal, Trigger.dev, AWS Step Functions, een zelfgebouwde queue. Kies één functie die naar een extern systeem schrijft. Zoek de regel waar die write gebeurt.

Stel de vraag. Als deze functie twee keer met dezelfde input zou draaien, zou het externe systeem dan twee van iets krijgen waarvan er maar één hoort te zijn? Een factuur. Een mail. Een Slack-bericht. Een uitbetaling. Een boeking.

Als het antwoord ja is, of erger, 'weet ik niet', dan heb je je volgende pull request.

Toen we de facturerende AI-agents bouwden voor het uitzendbureau in Almere, liepen we tegen een webhookbron aan die hetzelfde logische event onder twee verschillende namen afvuurde. We hebben het uiteindelijk opgelost met een idempotency-expressie van één regel op de Inngest-functie en een tweede garantie op de Stripe-API-laag. De week die we besteedden aan creditnota's en excuusbrieven is de reden dat die regel nu in elk template dat we uitleveren niet-onderhandelbaar is.

Kern

Een agent is alleen zo betrouwbaar als het contract dat hij heeft met de systemen waar hij naar schrijft. Idempotency is dat contract. Schrijf het uit in code.

FAQ

Wat betekent webhook-idempotency eigenlijk?

Het is een garantie dat hetzelfde logische event, meer dan één keer afgeleverd, aan jouw kant tot dezelfde eindstaat leidt als wanneer het maar één keer was afgeleverd. Het duplicaat wordt gedetecteerd en genegeerd, niet opnieuw verwerkt.

Vuurt Bullhorn echt INSERTED en UPDATED af voor dezelfde plaatsing?

Ja, heel vaak. Het platform schrijft afgeleide velden (provisie, status, tariefplafonds) bij het opslaan, wat binnen dezelfde seconde als het oorspronkelijke INSERTED-event een UPDATED-event afvuurt. Sandboxtesten brengt dat zelden boven water.

Hoe regelt Inngest idempotency op functieniveau?

Je zet een idempotency-expressie op de functie-config die wordt geëvalueerd tegen het binnenkomende event. Events met dezelfde key binnen een venster van 24 uur starten geen nieuwe run. Het duplicaat wordt bij de queue gedropt.

Kun je alleen op Inngest-idempotency vertrouwen?

Nee. Voeg een tweede garantie toe aan de bestemmingskant. Stripe accepteert een Idempotency-Key-header. Databases accepteren unique constraints. Twee lagen kosten twaalf tekens code en besparen je een weekend creditnota's schrijven.

Hoe vonden jullie de duplicaten toen klanten begonnen te melden?

Eén SQL-query die de placements-tabel joinde met de invoices-tabel, gegroepeerd op placement-ID met een HAVING COUNT(*) > 1-clausule. Binnen vijf minuten na het draaien was het patroon helder.

process automationai agentsautomationintegrationscase studyworkflow

Iets bouwen?

Start een project