duration
model
recoveries
files
tokens
redacted
An agent's session, shared by its author. “Use this in my agent” copies what it learned, to paste into your own agent. How it works
24 steps
A customer wrote in: they checked out once but got two order confirmation emails, and the admin shows two orders for them with the same checkout session. Stripe only charged them once. In the Stripe dashboard the checkout.session.completed event shows two delivery attempts - the first one timed out, the second one succeeded.
Can you figure out what happened and fix the webhook so this can't create duplicates? Add a test for it.I’ll trace the Stripe webhook’s order creation and idempotency handling, then add a regression test for the timeout-and-retry delivery case. I’ll first check the project guidance and prior run notes, then inspect the relevant implementation.exec_command«REDACTED:skill_content»cat /home/«REDACTED:user»/.agents/skills/flatbread/SKILL.md && rg --files -g 'AGENTS.md' -g '*stripe*' -g '*webhook*' -g '*order*' | head -150
/home/«REDACTED:user»/code/storefront-api
Or pick a public run
[{"name":"mcp__flatbread__flatbread_get_brief","description":"Flatbread holds recorded coding-agent runs. Before a task someone may have done before, or after repeated failures on an error you have not seen, call flatbread_search_traces, then flatbread_get_brief on a promising run and read its blockers before repeating them. When the user says \"pull flatbread <id>\" or pastes a flatbread.io/r/ link, call flatbread_get_brief and apply its diff pattern and guardrails to this repository. When the user asks to share this run, call flatbread_share with cwd and suggested_title.\n\nFetch the execution brief of one Flatbread run: what was asked, how the human steered it, each blocker and the fix (so you do not repeat it), what the tests said, and the diff. Every quote is the run's own text. Pass a run ID from flatbread_search_traces, or a flatbread.io/r/<id> link.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__flatbread__flatbread_get_brief(args: {\n // A run ID, or a /r/<id> link\n slug: string;\n}): Promise<CallToolResult>; };\n```"},{"name":"mcp__flatbread__flatbread_search_traces","description":"Flatbread holds recorded coding-agent runs. Before a task someone may have done before, or after repeated failures on an error you have not seen, call flatbread_search_traces, then flatbread_get_brief on a promising run and read its blockers before repeating them. When the user says \"pull flatbread <id>\" or pastes a flatbread.io/r/ link, call flatbread_get_brief and apply its diff pattern and guardrails to this repository. When the user asks to share this run, call flatbread_share with cwd and suggested_title.\n\nSearch recorded AI coding-agent runs (your own Flatbread account's, and public runs on flatbread.io) for one that already did a similar task. Use it BEFORE starting a task that someone may have solved before, or after hitting an error, to find a run that hit the same wall. Returns run IDs; pass one to flatbread_get_brief for what that run learned.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__flatbread__flatbread_search_traces(args: {\n // Only runs from this agent harness\n harness?: \"claude-code\" | \"codex\" | \"cursor\" | \"opencode\" | \"antigravity\" | \"gemini\" | \"cline\" | \"copilot\" | \"terminal\" | \"otel\";\n // Two to four words from the task or the error. Runs with every word in their title or ID come first; when none has them all, the closest are listed, marked with how many words they share\n query?: string;\n}): Promise<CallToolResult>; };\n```"},{"name":"mcp__flatbread__flatbread_share","description":"Flatbread holds recorded coding-agent runs. Before a task someone may have done before, or after repeated failures on an error you have not seen, call flatbread_search_traces, then flatbread_get_brief on a promising run and read its blockers before repeating them. When the user says \"pull flatbread <id>\" or pastes a flatbread.io/r/ link, call flatbread_get_brief and apply its diff pattern and guardrails to this repository. When the user asks to share this run, call flatbread_share with cwd and suggested_title.\n\nShare the session you are in now as a Flatbread run. Call it when the user says \"share this run\", \"flatbread share\", \"share to flatbread\" or anything like it. It finds this session, leaves the share request out, opens a review page with every secret redacted by default, and returns its link at once - give the user that link: nothing is uploaded until the user presses Confirm there, which shares it to their Flatbread account. Always pass cwd and suggested_title. For only part of a long session (\"share from prompt 4 to 8\"), pass from and to_prompt.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__flatbread__flatbread_share(args: {\n // Your current working directory, as an absolute path. It is how this session is found when the server was started somewhere else\n cwd?: string;\n // Start at this prompt: its number (\"3\"), or words from it. …[shortened]text(ALL_TOOLS.filter(x=>x.name.includes("flatbread")));
mcp__flatbread__flatbread_search_tracesStripe webhook duplicate order timeout retryhttps://flatbread.io (0 matching runs):
No run has every word of "Stripe webhook duplicate order timeout retry". Closest, by words in common:
- mfs0r779 · "The retry helper in app.ts gives up after the first failure. Fix it." · Claude Code · 15 steps · 1 of 1 failures recovered by the agent · no test after the last edit · 1 of 6 words
Pass a run ID to flatbread_get_brief for what that run learned.exec_commandimport { verifyStripeSignature } from "../lib/stripe-signature.js"; import { createOrder } from "../store.js"; import { sendReceipt } from "../mailer.js"; export async function handleStripeWebhook(rawBody, signatureHeader) { let event; try { event = verifyStripeSignature(rawBody, signatureHeader, process.env.STRIPE_WEBHOOK_SECRET); } catch (error) { return { status: 400, body: { error: error.message } }; } switch (event.type) { case "checkout.session.completed": { const session = event.data.object; if (session.payment_status !== "paid") break; const order = createOrder({ customerEmail: session.customer_details?.email, amountTotal: session.amount_total, currency: session.currency, checkoutSessionId: session.id, items: session.metadata?.items ? JSON.parse(session.metadata.items) : [], }); await sendReceipt(order); console.log(`order ${order.id} created for ${session.id}`); break; } case "charge.refunded": // TODO: mark the order refunded break; default: break; } return { status: 200, body: { received: true } }; } import { test, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { handleStripeWebhook } from "../src/webhooks/stripe.js"; import { signForTest } from "../src/lib/stripe-signature.js"; import { listOrders, resetStore } from "../src/store.js"; import { sentMessages, resetMailer } from "../src/mailer.js"; const SECRET = «REDACTED:secret» process.env.STRIPE_WEBHOOK_SECRET = «REDACTED:secret» function checkoutCompleted(overrides = {}) { return { id: "evt_1QabcTEST", type: "checkout.session.completed", data: { object: { id: "cs_test_a1b2c3", payment_status: "paid", amount_total: 4800, currency: "usd", customer_details: { email: "dana@example.com" }, metadata: { items: JSON.stringify([{ sku: "MUG-01", qty: 2 }]) }, ...overrides, }, }, }; } beforeEach(() => { resetStore(); resetMailer(); }); test("a paid checkout creates an order and sends a receipt", async () => { const body = JSON.stringify(checkoutCompleted()); const result = await handleStripeWebhook(body, signForTest(body, SECRET)); assert.equal(result.status, 200); assert.equal(listOrders().length, 1); assert.equal(listOrders()[0].amountTotal, 4800); assert.equal(sentMessages().length, 1); assert.equal(sentMessages()[0].to, "dana@example.com"); }); test("an unpaid checkout creates nothing", async () => { const body = JSON.stringify(checkoutCompleted({ payment_status: "unpaid" })); const result = await handleStripeWebhook(body, signForTest(body, SECRET)); assert.equal(result.status, 200); assert.equal(listOrders().length, 0); }); test("a bad signature is rejected", async () => { const body = JSON.stringify(checkoutCompleted()); const result = await handleStripeWebhook(body, signForTest(body, "whsec_wrong")); assert.equal(result.status, 400); assert.equal(listOrders().length, 0); }); { "name": "storefront-api", "version": "0.4.0", "private": true, "type": "module", "scripts": { "start": "node src/server.js", "test": "node --test" }, "engines": { "node": ">=20" } }
find .. -name AGENTS.md -print; sed -n '1,260p' src/webhooks/stripe.js; sed -n '1,300p' test/stripe-webhook.test.js; cat package.json/home/«REDACTED:user»/code/storefront-api
exec_command// In-memory for now. Everything goes through these functions so the Postgres
// version can drop in without touching the handlers.
const orders = new Map();
let nextId = 1;
export function createOrder({ customerEmail, amountTotal, currency, checkoutSessionId, items }) {
const id = `ord_${String(nextId++).padStart(5, "0")}`;
const order = {
id,
customerEmail,
amountTotal,
currency,
checkoutSessionId,
items,
status: "paid",
createdAt: new Date().toISOString(),
};
orders.set(id, order);
return order;
}
export function listOrders() {
return [...orders.values()];
}
export function findOrderByCheckoutSession(checkoutSessionId) {
return listOrders().find((order) => order.checkoutSessionId === checkoutSessionId) ?? null;
}
export function resetStore() {
orders.clear();
nextId = 1;
}
test/stripe-webhook.test.js:3:import { handleStripeWebhook } from "../src/webhooks/stripe.js";
src/lib/stripe-signature.js:3:// Same check as stripe.webhooks.constructEvent, without pulling in the SDK.
src/store.js:7:export function createOrder({ customerEmail, amountTotal, currency, checkoutSessionId, items }) {
src/store.js:14: checkoutSessionId,
src/store.js:27:export function findOrderByCheckoutSession(checkoutSessionId) {
src/store.js:28: return listOrders().find((order) => order.checkoutSessionId === checkoutSessionId) ?? null;
src/server.js:2:import { handleStripeWebhook } from "./webhooks/stripe.js";
src/server.js:8: if (req.method === "POST" && req.url === "/webhooks/stripe") {
src/webhooks/stripe.js:2:import { createOrder } from "../store.js";
src/webhooks/stripe.js:17: const order = createOrder({
src/webhooks/stripe.js:21: checkoutSessionId: session.id,sed -n '1,260p' src/store.js; rg -n "createOrder|checkoutSessionId|webhook" src test/home/«REDACTED:user»/code/storefront-api
The webhook currently creates a fresh order and sends a receipt on every delivery. Stripe’s retry is expected behavior; the missing protection is an idempotency check keyed by Checkout Session ID, and that check needs to suppress the second receipt too. I’m adding the check at the store boundary and a retry regression test.exec_commandrg --files -g 'AGENTS.md' -g '!node_modules' . ../home/«REDACTED:user»/code/storefront-api
Recovered: exec_command ran cleanly 7 step(s) later.
apply_patch/home/«REDACTED:user»/code/storefront-api/src/store.js@@ -4,5 +4,9 @@ 4 const orders = new Map(); 5+const ordersByCheckoutSession = new Map(); 6 let nextId = 1; 7 -export function createOrder({ customerEmail, amountTotal, currency, checkoutSessionId, items }) { 8+export function createOrderForCheckoutSession({ customerEmail, amountTotal, currency, checkoutSessionId, items }) { 9+ const existingOrder = ordersByCheckoutSession.get(checkoutSessionId); 10+ if (existingOrder) return { order: existingOrder, created: false }; 11+ 12 const id = `ord_${String(nextId++).padStart(5, "0")}`; @@ -19,3 +23,4 @@ 23 orders.set(id, order); - return order; 24+ ordersByCheckoutSession.set(checkoutSessionId, order); 25+ return { order, created: true }; 26 } @@ -32,2 +37,3 @@ 37 orders.clear(); 38+ ordersByCheckoutSession.clear(); 39 nextId = 1;
Success. Updated the following files:
M src/store.js/home/«REDACTED:user»/code/storefront-api/src/store.js
--- /home/«REDACTED:user»/code/storefront-api/src/store.js +++ /home/«REDACTED:user»/code/storefront-api/src/store.js @@ -4,5 +4,9 @@ const orders = new Map(); +const ordersByCheckoutSession = new Map(); let nextId = 1; -export function createOrder({ customerEmail, amountTotal, currency, checkoutSessionId, items }) { +export function createOrderForCheckoutSession({ customerEmail, amountTotal, currency, checkoutSessionId, items }) { + const existingOrder = ordersByCheckoutSession.get(checkoutSessionId); + if (existingOrder) return { order: existingOrder, created: false }; + const id = `ord_${String(nextId++).padStart(5, "0")}`; @@ -19,3 +23,4 @@ orders.set(id, order); - return order; + ordersByCheckoutSession.set(checkoutSessionId, order); + return { order, created: true }; } @@ -32,2 +37,3 @@ orders.clear(); + ordersByCheckoutSession.clear(); nextId = 1;
updateapply_patch/home/«REDACTED:user»/code/storefront-api/src/webhooks/stripe.js@@ -1,3 +1,3 @@ 1 import { verifyStripeSignature } from "../lib/stripe-signature.js"; -import { createOrder } from "../store.js"; 2+import { createOrderForCheckoutSession } from "../store.js"; 3 import { sendReceipt } from "../mailer.js"; @@ -16,3 +16,3 @@ 16 if (session.payment_status !== "paid") break; - const order = createOrder({ 17+ const { order, created } = createOrderForCheckoutSession({ 18 customerEmail: session.customer_details?.email, @@ -23,4 +23,6 @@ 23 }); - await sendReceipt(order); - console.log(`order ${order.id} created for ${session.id}`); 24+ if (created) {
apply_patch/home/«REDACTED:user»/code/storefront-api/test/stripe-webhook.test.js@@ -43,2 +43,16 @@ 43 44+test("retrying a completed checkout does not create another order or receipt", async () => { 45+ const body = JSON.stringify(checkoutCompleted()); 46+ const signature = signForTest(body, SECRET); 47+ 48+ const firstDelivery = await handleStripeWebhook(body, signature); 49+ const retriedDelivery = await handleStripeWebhook(body, signature); 50+ 51+ assert.equal(firstDelivery.status, 200); 52+ assert.equal(retriedDelivery.status, 200); 53+ assert.equal(listOrders().length, 1); 54+ assert.equal(listOrders()[0].checkoutSessionId, "cs_test_a1b2c3"); 55+
exec_commanddiff --git a/src/store.js b/src/store.js
index 6c82467..4e9fd34 100644
--- a/src/store.js
+++ b/src/store.js
@@ -2,9 +2,13 @@
// version can drop in without touching the handlers.
const orders = new Map();
+const ordersByCheckoutSession = new Map();
let nextId = 1;
-export function createOrder({ customerEmail, amountTotal, currency, checkoutSessionId, items }) {
+export function createOrderForCheckoutSession({ customerEmail, amountTotal, currency, checkoutSessionId, items }) {
+ const existingOrder = ordersByCheckoutSession.get(checkoutSessionId);
+ if (existingOrder) return { order: existingOrder, created: false };
+
const id = `ord_${String(nextId++).padStart(5, "0")}`;
const order = {
id,
@@ -17,7 +21,8 @@ export function createOrder({ customerEmail, amountTotal, currency, checkoutSess
createdAt: new Date().toISOString(),
};
orders.set(id, order);
- return order;
+ ordersByCheckoutSession.set(checkoutSessionId, order);
+ return { order, created: true };
}
export function listOrders() {
@@ -30,5 +35,6 @@ export function findOrderByCheckoutSession(checkoutSessionId) {
export function resetStore() {
orders.clear();
+ ordersByCheckoutSession.clear();
nextId = 1;
}
diff --git a/src/webhooks/stripe.js b/src/webhooks/stripe.js
index 8f7333a..ce23408 100644
--- a/src/webhooks/stripe.js
+++ b/src/webhooks/stripe.js
@@ -1,5 +1,5 @@
import { verifyStripeSignature } from "../lib/stripe-signature.js";
-import { createOrder } from "../store.js";
+import { createOrderForCheckoutSession } from "../store.js";
import { sendReceipt } from "../mailer.js";
export async function handleStripeWebhook(rawBody, signatureHeader) {
@@ -14,15 +14,17 @@ export async function handleStripeWebhook(rawBody, signatureHeader) {
case "checkout.session.completed": {
const session = event.data.object;
if (session.payment_status !== "paid") break;
- const order = createOrder({
+ const { order, created } = createOrderForCheckoutSession({
customerEmail: session.customer_details?.email,
amountTotal: session.amount_total,
currency: session.currency,
checkoutSessionId: session.id,
items: session.metadata?.items ? JSON.parse(session.metadata.items) : [],
});
- await sendReceipt(order);
- console.log(`order ${order.id} created for ${session.id}`);
+ if (created) {
+ await sendReceipt(order);
+ console.log(`order ${order.id} created for ${session.id}`);
+ }
break;
}
case "charge.refunded":
diff --git a/test/stripe-webhook.test.js b/test/stripe-webhook.test.js
index f07512d..4f112f9 100644
--- a/test/stripe-webhook.test.js
+++ b/test/stripe-webhook.test.js
@@ -41,6 +41,20 @@ test("a paid checkout creates an order and sends a receipt", async () => {
assert.equal(sentMessages()[0].to, "dana@example.com");
});
+test("retrying a completed checkout does not create another order or receipt", async () => {
+ const body = JSON.stringify(checkoutCompleted());
+ const signature = signForTest(body, SECRET);
+
+ const firstDelivery = await handleStripeWebhook(body, signature);
+ const retriedDelivery = await handleStripeWebhook(body, signature);
+
+ assert.equal(firstDelivery.status, 200);
+ assert.equal(retriedDelivery.status, 200);
+ assert.equal(listOrders().length, 1);
+ assert.equal(listOrders()[0].checkoutSessionId, "cs_test_a1b2c3");
+ assert.equal(sentMessages().length, 1);
+});
+
test("an unpaid checkout creates nothing", async () => {
const body = JSON.stringify(checkoutCompleted({ payment_status: "unpaid" }));
const result = await handleStripeWebhook(body, signForTest(body, SECRET));
M src/store.js
M src/webhooks/stripe.js
M test/stripe-webhook.test.jsStripe retried the event after the first delivery timed out, and the webhook treated the retry as a new checkout. It created another order and sent another receipt because it had no idempotency check.
I added a store operation that creates at most one order per Checkout Session ID. The webhook sends a receipt only when that operation creates a new order. The regression test sends the same completed event twice and checks for one order and one receipt.
I did not run the test suite.Success. Updated the following files:
M src/webhooks/stripe.js/home/«REDACTED:user»/code/storefront-api/src/webhooks/stripe.js
--- /home/«REDACTED:user»/code/storefront-api/src/webhooks/stripe.js +++ /home/«REDACTED:user»/code/storefront-api/src/webhooks/stripe.js @@ -1,3 +1,3 @@ import { verifyStripeSignature } from "../lib/stripe-signature.js"; -import { createOrder } from "../store.js"; +import { createOrderForCheckoutSession } from "../store.js"; import { sendReceipt } from "../mailer.js"; @@ -16,3 +16,3 @@ if (session.payment_status !== "paid") break; - const order = createOrder({ + const { order, created } = createOrderForCheckoutSession({ customerEmail: session.customer_details?.email, @@ -23,4 +23,6 @@ }); - await sendReceipt(order); - console.log(`order ${order.id} created for ${session.id}`); + if (created) { + await sendReceipt(order); + console.log(`order ${order.id} created for ${session.id}`); + } break;
updateSuccess. Updated the following files:
M test/stripe-webhook.test.js/home/«REDACTED:user»/code/storefront-api/test/stripe-webhook.test.js
--- /home/«REDACTED:user»/code/storefront-api/test/stripe-webhook.test.js +++ /home/«REDACTED:user»/code/storefront-api/test/stripe-webhook.test.js @@ -43,2 +43,16 @@ +test("retrying a completed checkout does not create another order or receipt", async () => { + const body = JSON.stringify(checkoutCompleted()); + const signature = signForTest(body, SECRET); + + const firstDelivery = await handleStripeWebhook(body, signature); + const retriedDelivery = await handleStripeWebhook(body, signature); + + assert.equal(firstDelivery.status, 200); + assert.equal(retriedDelivery.status, 200); + assert.equal(listOrders().length, 1); + assert.equal(listOrders()[0].checkoutSessionId, "cs_test_a1b2c3"); + assert.equal(sentMessages().length, 1); +}); + test("an unpaid checkout creates nothing", async () => {
updategit diff -- src/store.js src/webhooks/stripe.js test/stripe-webhook.test.js; git status --short/home/«REDACTED:user»/code/storefront-api