Payments & Webhooks
Money bugs hide in the retry path. The checkout you clicked through once works fine. The second delivery of the same webhook, the timeout halfway through a lookup, and the refund that arrives a week later are where the money actually leaks.
The mistake in one sentence: payment code is written and tested for the single, successful, non-concurrent request, and webhooks, refunds and duplicate deliveries all happen outside that path, so that is exactly where the bugs live.
Paste into your AI chat
Copy the entire guide as markdown to use as context in Cursor, Claude, ChatGPT, or any AI tool.
Why AI tools generate this
The happy path is one request and easy to describe. Every failure path is a separate scenario you have to ask for by name.
Payment code has an asymmetry that maps badly onto how AI coding tools work. Call checkout, get a webhook, grant the credits: that is a single request, easy to state and easy to test. Concurrent duplicate deliveries, transient upstream errors, refunds, disputes and retried webhooks are each a separate scenario that has to be specifically prompted for, and none of them show up when you click through a checkout once in a test environment. An assistant given “implement Stripe checkout” produces something that correctly handles the one request it was shown. It will not spontaneously reason about what happens if that exact webhook is delivered twice within fifty milliseconds, because nothing in the feature description implies it.
Idempotency in particular is a well-known trap even for experienced engineers. A check-then-insert looks like it prevents duplicates, and it does, right up until concurrency is introduced and concurrency is precisely the condition webhooks are designed to produce. Most payment providers explicitly warn that webhooks can be delivered more than once, and recommend a database-level unique constraint for exactly this reason.
The three ways this goes wrong
1. Idempotency with nothing in the database behind it
A check-then-insert prevents duplicates right up until two deliveries arrive at once, which is exactly what webhooks are built to do.
2. An error that looks exactly like "not one of ours"
A timeout and a product you do not sell both come back as null, and the handler treats both as nothing to do.
3. Money goes out and the entitlement stays
Almost every integration handles "money comes in, grant access". Far fewer handle the reverse.
Why it’s dangerous
Every one of these is money, moving in the wrong direction, quietly.
Duplicated grants are a direct, exploitable revenue loss
Once someone notices that firing a request twice yields double the credits, it is trivially scriptable and repeatable. Real money out, indefinitely, until it is caught.
Silently dropped paid events mean customers paid and got nothing
Because the event is marked processed, the built-in retry never fires. The transaction stays broken until someone reconciles it manually.
No clawback makes refunds and chargebacks pure loss
The money goes back and the entitlement stays, with no visibility unless you are specifically watching for it.
None of it shows up in normal development or QA
Manual testing is sequential and non-adversarial. You do not naturally fire the same webhook twice or simulate a timeout mid-lookup.
How to check your own app
Four greps: find the racy checks, see whether the schema backs them, read what the webhook catch blocks actually swallow, and find out whether refunds are handled at all.
# 1. Find every "idempotency" check that's implemented as a SELECT before an
# INSERT — this is the racy pattern
grep -rn "findFirst\|findOne\|\.select(" --include="*.ts" server/ api/ | grep -i "existing\|already\|duplicate"
# For each hit, check the underlying table's schema — is there a UNIQUE
# constraint that would make a duplicate INSERT fail, independent of the
# application-level check?
# 2. Check your migrations/schema for UNIQUE constraints on the columns that
# should be unique per real-world event (payment ID, webhook event ID)
grep -rn "UNIQUE\|unique:" --include="*.sql" --include="*.prisma" .
grep -rn "relatedPaymentId\|payment_id\|stripe_event_id\|event_id" --include="*.sql" .
# 3. Find webhook handlers and check whether their catch blocks distinguish
# "not applicable" from "the lookup failed" — a bare `catch { return null }`
# that gets treated as "skip" is the bug
grep -rn "catch" -A 3 --include="*.ts" server/src/routes/*webhook* server/src/config/*catalogue*
# 4. Check whether you handle refund/dispute events at all
grep -rln "refund\|chargeback\|dispute" --include="*.ts" server/src/
# If your webhook handler's switch/if-chain has cases for
# checkout.session.completed but nothing for charge.refunded or
# charge.dispute.created (or your platform's equivalent), you have no
# clawback path.The question to hold in your head as you read each hit: if two copies of this request ran at the same instant, what in the database would stop the second one? If the answer is only the code above the insert, it is not an answer.
The fix
Push the guarantee down into the database, and let the paths you did not plan for fail loudly enough that the provider retries them. Five concrete moves:
Let the database refuse the duplicate insert.
A 5xx buys you a redelivery. A silent skip does not.
Verify before any JSON middleware touches the body.
Handle refunds and disputes, alert-only if need be.
Resolve the buyer by checkout metadata, not email.
1. Make idempotency a database guarantee, not an application guess
A UNIQUE constraint, or a partial unique index if the column is reused for other things, turns a race condition into a clean error you can catch. The duplicate insert fails no matter how the two requests interleave.
-- Partial unique index — scoped to the reasons where the payment ID really
-- must be unique, since the same column might be reused for other things
-- (e.g. non-unique audit strings from manual admin adjustments)
CREATE UNIQUE INDEX credit_transaction_payment_unique
ON credit_transactions ("relatedPaymentId")
WHERE reason IN ('purchase', 'subscription_grant');// Catch the constraint violation instead of racing a check-then-insert
async function grantCredits(paymentId: string, userId: string, amount: number) {
try {
await db.creditTransactions.create({
data: { userId, relatedPaymentId: paymentId, amount, reason: 'purchase' },
});
} catch (e) {
if (isUniqueConstraintError(e)) {
// Already granted — this is the expected, safe outcome of a duplicate
// delivery, not an error.
return await getExistingGrant(paymentId);
}
throw e;
}
}2. Separate "does not apply to us" from "the lookup failed"
Let unexpected errors propagate so the handler returns a 5xx and the provider retries delivery, instead of swallowing them into a false skip.
async function getProductIdFromSession(session: Stripe.Checkout.Session) {
const items = await stripe.checkout.sessions.listLineItems(session.id);
// No try/catch here — let a genuine API failure bubble up and cause
// the webhook handler to return a 5xx, so the provider retries delivery.
return items.data[0]?.price?.product ?? null;
}3. Verify the signature on the raw body
Usually done correctly, because every payment provider documents it explicitly. Worth confirming anyway: the check has to run before any JSON-parsing middleware has touched the body.
// Signature verification MUST run on the raw, unparsed request body
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook signature verification failed`);
}
// ... handle event
});4. Write the refund and chargeback path, even if version one only alerts a human
An alert with enough context to claw back manually beats no case at all. Automating the debit can come later.
switch (event.type) {
case 'checkout.session.completed':
await grantCredits(/* ... */);
break;
case 'refund.created':
case 'charge.dispute.created':
// At minimum: alert a human with enough context to manually claw back.
// Better: automatically debit min(currentBalance, originallyGranted).
await alertAdminOfRefund(event);
break;
}5. Resolve the buyer by an identifier you set yourself
Metadata you attached at checkout creation, not a secondary lookup by email.
Emails change, case-sensitivity mismatches happen, and a failed secondary lookup can throw and drop the whole event. Pass a userId in metadata or client_reference_id when you create the checkout session, resolve on that, and keep any email match as a fallback only.
Checklist
Walk this before any flow that takes money or grants something in exchange for it goes live.
- Every "grant once per payment" flow is backed by a database UNIQUE (or partial unique) constraint, not just an application-level check-then-insert.
- Constraint violations are caught and treated as "already processed, return the existing result", not as a hard failure.
- Webhook handlers verify the provider’s signature on the raw request body.
- Any error inside a webhook handler that is not explicitly "this event does not apply to us" is re-thrown so the provider’s retry gets a chance to redeliver. It is never silently logged as success.
- There is an explicit handler for refund and dispute/chargeback events, even if it is alert-only to start.
- Buyer resolution in webhook handlers uses an ID you set yourself at checkout time (metadata), with any secondary lookup by email as a fallback only.
- You have fired the same webhook payload twice in quick succession in a test environment and confirmed no double-grant occurs.
- Checkout session creation passes an idempotency key where the provider supports one.
Prompt your AI assistant
Paste this to have your AI tool audit your payment and webhook handling, and report rather than fix.
Audit this repository's payment and webhook handling for the following
classes of bug, common in AI-assisted payment integrations:
1. Idempotency implemented as an application-level check-then-insert (e.g.
"look up whether this payment ID already has a record, insert if not")
with NO backing database UNIQUE constraint. This is a race condition: two
concurrent/duplicate webhook deliveries or retried requests can both pass
the check before either insert commits, causing a double-grant (credits,
access, entitlements). Find every such pattern and check the schema for a
matching unique/partial-unique index. Flag any that's missing one.
2. Webhook or payment-lookup error handling that collapses "this event/
product doesn't apply to us" and "the lookup API call failed" into the
same silent-skip / null-return path. This causes real paid events to be
marked processed with no clawback attempted after a transient error.
Flag any catch block that swallows an error and returns a value
indistinguishable from a legitimate "not applicable" result.
3. Missing handling for refund, chargeback, or dispute events — check
whether the webhook handler has any case for these event types at all.
If credits/access/entitlements are granted on purchase but never revoked
on refund, flag it.
4. Buyer/user resolution inside webhook handlers that relies on a secondary
lookup (e.g. matching by email) instead of an identifier set at checkout
creation time (e.g. a userId passed in metadata/client_reference_id).
Flag if the secondary lookup is used as primary rather than fallback.
5. Confirm webhook signature verification happens on the raw, unparsed
request body, not a body that's already been through a JSON-parsing
middleware.
For each finding: file/line, the concrete failure scenario, and a fix
(migration + code) following this project's existing schema conventions.
Do not apply fixes yet — just report.Want the full guide as context for your AI coding assistant?