The payment webhook fired twice.The money moved once.
The sneaky version of this bug looks just like the fix. A copy with one word changed has the same key store, the same claim, even the same clean-up on failure, and it still pays twice. The obvious test passes it. Watch a real run: one sentence, a handler, a test, and a check that reads the code.
your coding agent
>make this payment webhook handler safe to retry: the provider may deliver the same event more than once
1sentence from you, plus one follow-up
1word is the whole difference between safe and a double credit
10 vs 20balance after the same event arrives twice: safe copy vs one-word copy
$0.00007per check, about (estimated from tokens)
↓ Scroll. The panel follows the story.
1 · You
You type one sentence.
Open your coding agent in a project that has almost nothing in it. Type: make this payment webhook handler safe to retry: the provider may deliver the same event more than once.
A webhook is a message your payment provider sends to your server when something happens, such as “payment succeeded”. If your server is slow to answer, the provider sends it again. Handle that message twice and the customer is credited twice.
Idempotent is the word for what you want. It means: doing it twice has the same result as doing it once. “Safe to retry” and “idempotent” are the same ask. Without a recipe, an agent writes something plausible from memory, and nothing says whether it survives a retry.
The standard picture: an event with its own id reaches the handler. The handler claims that id in the key store in one step, credits the account once, and a repeat finds the id and is skipped.The broken line: the handler claims the delivery id instead, which is new on every attempt, so a repeat looks like a brand-new event. This is what MM3 catches.
>make this payment webhook handler safe to retry: the provider may deliver the same event more than once
$ls
package.json tsconfig.json
$cat package.json
{
"name": "idem-typescript",
"version": "0.0.0",
"private": true,
"type": "module"
}
Two files. There is no handler yet, so the recipe’s example is a small webhook-style handler that credits an account when an event arrives.
2 · Your agent
Your agent asks MM3 how.
You do nothing. Your agent sends your sentence to MM3 and a recipe comes back. It works out the language by itself, from package.json.
The one rule that matters: the handler remembers an event by the event’s own id, the one the provider prints on the event. Not by the delivery id, which the provider makes up again on every attempt.
The name is ours. Nobody calls this an “idempotent handler”. Bobby Woolf’s Enterprise Integration Patterns calls it an Idempotent Receiver; Chris Richardson’s microservices.io and Microsoft’s Azure guide call it an Idempotent Consumer. They also stretch it differently. Azure says “This pattern applies idempotency to message consumers”, then adds that the idea helps “any operation that runs more than once”; Stripe and the web standard RFC 9110 talk about repeated requests. A webhook is both a message and a request, so the recipe picks one handler and one stable key, and says so in its own first lines.
$node mcp/call.mjs apply-pattern '{"request":"make this payment webhook handler safe to retry: the provider may deliver the same event more than once"}'
1. Pattern: code.pattern.idempotent-handler (Idempotent handler (a repeated message or request has the effect of one)), resolved from the request (keyword match: handler, the, the).
2. Language: detect it from the project's OWN files
The handlerTakes the event, asks the key store before doing anything, credits the account, saves the answer next to the key, and gives the key back if the credit fails.
The key storeA notebook of event ids already seen. Its one job: add an id in a single step, and say no if it is already there.
The effectWhat the event is for. Here a ledger of balances: crediting adds an amount, so doing it twice would be wrong.
And five rules. R1 is the one this page breaks.
R1 The key is the event’s own id, never one made again on each delivery.
R2 Ask the key store first; if the key is known, skip the effect.
R3 Claim the key before the effect; give it back if the effect fails.
R4 Claim in one step, not a look-up and then a separate add.
R5 A repeat gets the first answer back, not an error.
The rule “Don’t key on transport-level identifiers that the broker regenerates upon redelivery.” (R1; Microsoft Azure Architecture Center, Idempotent Consumer pattern)
3 · Your agent
It writes the code, in your language.
Still nothing for you to do. The agent writes the files the recipe names and runs the demo: the same event arrives twice, the balance stays at 10, and the second delivery gets the first answer back. The key line is highlighted.
Two small gaps the agent reported back. The recipe’s file list shows a neutral name (key_store) where TypeScript wants key-store.ts, and one step asks a question a terminal agent cannot answer. It followed the TypeScript names, used the project folder, and told MM3, so the recipe can be fixed for the next person.
files the agent wrote
handler.tsthe handler
keys/key-store.tsthe key store
ledger/ledger.tsthe effect: balances
bind.jsona note: which file plays which part
handler.ts
// Idempotent credit handler: a repeated message (same messageId) has the effect of one.// The key is the producer's messageId, never the broker's deliveryId.import { KeyStore } from "./keys/key-store.ts";import { Ledger } from "./ledger/ledger.ts";export interface CreditMessage { messageId?: string; deliveryId: string; account: string; amount: number;}export function handleCredit(store: KeyStore, ledger: Ledger, request: CreditMessage): string { const key = request.messageId; if (!key) throw new Error("missing message id"); if (!store.claim(key)) { return store.outcome(key) ?? "duplicate in progress"; } try { const next = ledger.credit(request.account, request.amount); const outcome = `credited ${request.account} balance ${next}`; store.complete(key, outcome); return outcome; } catch (err) { store.release(key); throw err; }
$ node handler.ts
m-1 d-1 -> credited a1 balance 10 m-1 d-2 -> credited a1 balance 10 m-2 d-3 -> error: amount must be positive final balance a1: 10
Event m-1 arrived twice (delivery ids d-1 and d-2). It was credited once.
4 · Your agent
It proves it with a test.
You ask one more thing: write a test that delivers the same event twice and proves only one charge happens; run it.
The test sends event evt_1 twice, the way a provider retries: the same event id, and a new delivery id the second time. It passes: the balance is 10, and the second delivery returns the first answer.
Two ids, easy to mix up. The event id is printed by the provider on the event and never changes. The delivery id is made up fresh for every attempt to deliver it.
>write a test that delivers the same event twice and proves only one charge happens; run it.
test/webhook.test.ts
// Delivers the same payment event twice the way a webhook provider retries it// (same messageId, new deliveryId) and checks that only one charge happens.import { test } from "node:test";import assert from "node:assert/strict";import { handleCredit } from "../handler.ts";import { KeyStore } from "../keys/key-store.ts";import { Ledger } from "../ledger/ledger.ts";test("a redelivered event credits the account once and returns the first outcome", () => { const store = new KeyStore(); const ledger = new Ledger(); const first = handleCredit(store, ledger, { messageId: "evt_1", deliveryId: "delivery-1", account: "a1", amount: 10, }); const second = handleCredit(store, ledger, { messageId: "evt_1", deliveryId: "delivery-2", account: "a1", amount: 10, }); assert.equal(ledger.balance("a1"), 10); assert.equal(second, first); assert.equal(first, "credited a1 balance 10");});
$ node --test test/webhook.test.ts
ok 1 - a redelivered event credits the account once and returns the first outcome # tests 1 # pass 1 # fail 0
5 · MM3
MM3 checks it.
You say yes to one paid check (we said yes, once). The result, run MM3-0391: unsure. Good code, but not a clean pass, and that is worth reading.
The check is a fixed list of 21 questions: 18 yes/no, one overall goal, two judgement calls. The goal passes (0.80), and five of the six groups of questions pass. The sixth, “race”, asks whether two deliveries could slip past each other. It was only 61% sure that a plain in-memory “check, then add” cannot be interrupted.
The questions are locked with a fingerprint, so nobody can reword them to make a result come out better. The check cost about $0.00007 (estimated from tokens).
Our reading: that doubt is fair. In one Node process nothing can interrupt those two lines, and the file says so in a comment. But a real service runs several processes, and the database should be the one to refuse a duplicate. Azure’s guide says it this way: “Use a uniqueness constraint on the deduplication key such that two transactions can attempt to insert a key, but only one can succeed.”
Q1. Does the handler take its key from the message’s own message id field? (the event’s own id)
0.94
Q10. Does any handler use the delivery id, or an id made on receipt, as the key? (healthy answer: no)
A repeat
0.87
Q5. When the key is already known, does the handler return the outcome recorded for the first message?
Race
0.61
Q14. Is any key store claim written without a lock or an atomic insert? (healthy answer: no)
The other 14 yes/no questions score 0.71 or higher. A score is how sure the checker is that the code is healthy on that line.
6 · Try it
Is it just saying yes? Change one word.
A check that always says yes is worthless. So we made a copy of the handler with exactly one word changed: it takes the delivery id as the key instead of the event id. Everything else is the same. It still has a key store, still claims before it credits, still gives the key back on failure. Flip the switch to do it yourself.
We also deleted the comment that stated the rule, so nothing in the file gives the answer away. Watch the two key questions fall (Q1 and Q10) while the others hold: Q5, a repeat gets the first answer, stays at 0.87. Two more fall that the panel does not show: because a delivery id is never missing, the handler no longer refuses an event that has no event id (Q2 0.34, Q11 0.35).
The good copy: the handler claims the event’s own id.The one-word copy: the handler claims the delivery id, new on every attempt. This is what MM3 catches.
off: the good copy (the key is the event id)
Without JavaScript, here are the two real pairs in words. Good copy: UNSURE, MM3-0391, goal 0.80; Q1 0.99, Q10 0.94. One-word copy: FAIL, MM3-0393, goal 0.62; Q1 0.03, Q10 0.02, Q5 still 0.87. The agent’s test prints ok on the first and 20 !== 10 on the second.
export function handleCredit(store: KeyStore, ledger: Ledger, request: CreditMessage): string { const key = request.messageId; if (!key) throw new Error("missing message id"); if (!store.claim(key)) { return store.outcome(key) ?? "duplicate in progress"; }
okThe agent’s test: retry with a new delivery idbalance 10; the second delivery returns the first answer
okThe lazy test: the same delivery id twicebalance 10
0.99
Q1. Does the handler take its key from the message’s own message id field?
0.94
Q10. Does any handler use the delivery id, or an id made on receipt, as the key? (healthy answer: no)
0.87
Q5. When the key is already known, does the handler return the outcome recorded for the first message?
A score is how sure the checker is that the code is healthy on that line. The marked check is the one about the broken rule. The test lines are real runs of two tests on this very file.
7 · The twist
Why the obvious test passes it.
Why would a good test pass the broken copy? Because of how the test retries. Send the event twice and watch the balance. With the good copy, a retry changes nothing. With the one-word copy, a retry that arrives with a new delivery id, which is what a real provider sends, credits again. A retry that reuses the delivery id, which is what a quick test often does, looks fine.
good copy · the key is the event id
okThe agent’s test: retry with a new delivery idbalance 10
okThe lazy test: same delivery id twicebalance 10
one-word copy · the key is the delivery id
not okThe agent’s test: retry with a new delivery id20 !== 10
okThe lazy test: same delivery id twicebalance 10: blind to the bug
8 · The result
What it comes to.
One sentence from you, and one follow-up. A recipe with its sources. A handler that credits once, and a test that proves it. And a check that read the code and flagged the one word the obvious test could not see.
1 sentence from you, plus one follow-up for the test
3 files and 1 test, written by a small model from the recipe
1 word separates the safe copy from a double credit
$0.00007 a check, about (estimated from tokens)
------
good copy test ok · balance 10 MM3-0391 unsure(goal 0.80)
one-word copy test fails · balance 20 MM3-0393 fail(goal 0.62)
the lazier test (same delivery id twice) says ok to both
9 · A real app
The retry, run for real: two tests, two copies
The app is the one the agent built: a webhook-style handler that credits an account, with its balance and its key store. There is no network call in it, so everything below runs offline. Two tests were run on both copies. The first is the agent’s. The second is a lazier test we wrote ourselves.
The agent’s test retries like a real provider: same event id, new delivery id. The lazy test re-sends the exact same message twice, delivery id included.
undefined
off: the good copy (the key is the event id)
The agent’s test test/webhook.test.ts
// Delivers the same payment event twice the way a webhook provider retries it
// (same messageId, new deliveryId) and checks that only one charge happens.
import { test } from "node:test";
import assert from "node:assert/strict";
import { handleCredit } from "../handler.ts";
import { KeyStore } from "../keys/key-store.ts";
import { Ledger } from "../ledger/ledger.ts";
test("a redelivered event credits the account once and returns the first outcome", () => {
const store = new KeyStore();
const ledger = new Ledger();
const first = handleCredit(store, ledger, {
messageId: "evt_1",
deliveryId: "delivery-1",
account: "a1",
amount: 10,
});
const second = handleCredit(store, ledger, {
messageId: "evt_1",
deliveryId: "delivery-2",
account: "a1",
amount: 10,
});
assert.equal(ledger.balance("a1"), 10);
assert.equal(second, first);
assert.equal(first, "credited a1 balance 10");
});
The lazy test we wrote this one
// A lazier test: the same event delivered twice with the SAME delivery id.
import { test } from "node:test";
import assert from "node:assert/strict";
import { handleCredit } from "../handler.ts";
import { KeyStore } from "../keys/key-store.ts";
import { Ledger } from "../ledger/ledger.ts";
test("a repeated event credits once (same delivery id both times)", () => {
const store = new KeyStore();
const ledger = new Ledger();
const msg = { messageId: "evt_1", deliveryId: "delivery-1", account: "a1", amount: 10 };
handleCredit(store, ledger, msg);
handleCredit(store, ledger, msg);
assert.equal(ledger.balance("a1"), 10);
});
What happened, run for real node --test, Node 22
One row of real terminal output for the broken copy, from the agent’s test:
not ok 1 - a redelivered event credits the account once and returns the first outcome
error: Expected values to be strictly equal:
20 !== 10
expected: 10
actual: 20
# tests 1 # pass 0 # fail 1
And MM3 checked both
The same 21 locked questions, once on the app as built and once on the copy with one word changed, in a folder with no label. The panel’s lists below are the key questions and the one that stayed doubtful.
UNSUREthe app as built · run MM3-0391 · overall goal 0.80
The key
0.99
Q1. Does the handler take its key from the message’s own message id field?
0.94
Q10. Does any handler use the delivery id, or an id made on receipt, as the key? (healthy answer: no)
Race
0.61
Q14. Is any key store claim written without a lock or an atomic insert? (healthy answer: no)
Severity of the worst double change: none (0.78). Everything else scores 0.71 or higher.
FAILthe one-word copy · run MM3-0393 · overall goal 0.62
The key
0.03
Q1. Does the handler take its key from the message’s own message id field?
0.02
Q10. Does any handler use the delivery id, or an id made on receipt, as the key? (healthy answer: no)
Race
0.52
Q14. Is any key store claim written without a lock or an atomic insert? (healthy answer: no)
Severity of the worst double change: still “none” (0.76), although the test shows 20 instead of 10. The questions about the key caught it; the how-bad call did not.
Honestly: one run each, so no measure of noise. We made the broken copy by hand from the agent’s own file. The key store is in memory and lives in one process; a real service needs the database to refuse a duplicate key. The earlier test report for this recipe planted the same delivery-id mistake in Go, Python and TypeScript apps and it was caught in all three (MM3-0366, MM3-0360, MM3-0354).
10 · Passed?
Two tests, two copies
Two tests were run on both copies. The first is the agent’s. The second is a lazier test we wrote ourselves.
Passed?
Good copy
One-word copy
The agent’s test: retry with a new delivery id
✔ ok balance 10
✘ not ok20 !== 10
The lazy test: same delivery id twice
✔ ok balance 10
✔ ok balance 10: blind to the bug
MM3 reads the code
⚠ unsure MM3-0391, goal 0.80
✘ fail MM3-0393, goal 0.62
Why it matters
If a coding agent writes the code that touches money, a passing test only proves what the test tries. Here the obvious test passed the broken copy, a realistic one failed it, and MM3 flagged it by reading the code, before anything ran. You want both: tests for what happens, a check for how it is built.
Reality check
What we tested
One sentence and one follow-up in a bare TypeScript project, given to one small model. Two live MM3 checks (the app it built, and a copy with one word changed). Two tests, the agent’s and a lazier one, run for real on both copies.
What should work
Any handler for events that can arrive more than once (webhooks, queue messages, retried requests) where the sender gives each event an id of its own.
What we learned the hard way
The obvious test passes the broken copy. Re-sending the same message twice said “ok” to a handler that pays twice; only a retry with a new delivery id, the way providers really retry, showed 20 instead of 10 (step 7 lets you try both). MM3 flagged it from the code alone (MM3-0393: key questions 0.03 and 0.02).
MM3 was not fully sure about the good code (unsure, MM3-0391), and on the broken copy its “how bad” call still said “none”. It is reliable on where the key comes from and weaker at judging the damage. An earlier version of this check did worse: it doubted all three good apps until one question was reworded (the change is written down in the recipe).
Not yet
A real web endpoint with a database that refuses duplicate keys, two deliveries arriving at the same instant, keys that expire, and this story in Go and Python (only recorded earlier runs exist for those).
Receipts
Live runs: the app as built MM3-0391 (unsure, goal 0.80); the one-word copy MM3-0393 (fail, goal 0.62). One call each, never repeated. The check is the locked version 2 of the recipe’s check (fingerprint c3f21035…).
Earlier recorded runs, same recipe: the delivery-id mistake planted in Go MM3-0366, Python MM3-0360, TypeScript MM3-0354 (all caught); clean apps MM3-0365, MM3-0359, MM3-0353 (all pass). Version 1 of the check came back unsure on all three clean apps (MM3-0335, MM3-0341, MM3-0347); one question was reworded and the change is disclosed in the spec.
Tests: Node 22 running the TypeScript directly (type-stripping); the code was not type-checked with tsc. The agent wrote the test and ran it once (pass). We ran it again on the broken copy (fail) and ran our lazy test on both (pass, pass).
The broken copy changes one line (request.messageId to request.deliveryId) and drops the comment that stated the rule. It has no label anywhere and sits in a neutral folder.
The agent’s transcript is condensed: the long recipe text and file bodies are trimmed; commands and test output are as printed. No live MM3 call was made by the agent.
Cost: $0.000075 and $0.000074 per check, from MM3’s own log, estimated from tokens (not billed amounts). One run each, so no run-to-run noise measure.