By the end of this you will have a Stripe dispute webhook that turns a chargeback into fraud feedback. charge.dispute.created fires, your handler resolves the disputed charge back to the account that made it, reports the outcome, and the identity graph carries that mark forward. When the same person signs up again next week with a new email and a different card, the verdict comes back review or block instead of allow.
The dispute itself is already lost money. Stripe pulls the disputed amount plus the dispute fee out of your balance the moment the chargeback lands, $15 in the US, and the docs are blunt about that fee: "we never return the dispute received fee". What you get to keep is the knowledge that this person disputes charges.
Most teams keep that in a spreadsheet. shash7, running a SaaS, put the working version on Hacker News: "when you get a chargeback you need to completely ban the customer from your db. This includes: - card ban - email address ban - fingerprint their access and ban." That is the right instinct and it is roughly forty lines of code. Here they are.
Before you start
- Node 18.17+ and a server that can receive webhooks. The examples use Express; the shape is the same in a Next.js route handler or a Hono app.
stripeandportreeveon npm, a Stripe secret key, and a Portreeve secret key. Start withsk_test_, since test mode is fully isolated from live and nothing you do here touches your real graph.- You are already calling
portreeve.verdict()somewhere. If you are not, the quickstart takes about ten minutes; acheckout_attemptverdict is the one this post feeds. - The Stripe CLI, for forwarding webhooks to localhost.
Step 1: catch the dispute and resolve the user
A dispute arrives as charge.dispute.created, with the Dispute object as event.data.object. The only identifier on it that always points somewhere useful is dispute.charge. The payment_intent field is nullable, and metadata on the dispute is yours to write, not something Stripe fills in.
So: verify the signature, retrieve the charge, read your own ids off it.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.sendStatus(400);
}
if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
const charge = await stripe.charges.retrieve(dispute.charge as string);
await recordDispute({
disputeId: dispute.id,
reason: dispute.reason, // "fraudulent", "unrecognized", "subscription_canceled", ...
userId: charge.metadata.user_id,
portreeveEventId: charge.metadata.portreeve_event_id,
});
}
res.sendStatus(200);
},
);
charge.metadata.user_id only exists if you put it there. Set it when you create the PaymentIntent, not after: a PaymentIntent copies its metadata to the charge at charge creation, and later updates do not modify charges that already exist. Attaching your ids an hour after checkout leaves you with an empty charge.metadata at dispute time and no way back to the user. Store the Portreeve result.id from the checkout verdict in the same place, under a portreeve_event_id key, which costs nothing and saves a database lookup here. If you have not been doing that, see what else belongs in Stripe metadata.
Verify it works. Run stripe listen --forward-to localhost:4242/webhooks/stripe, then pay through your own checkout flow with the test card 4000000000000259, which succeeds and is then disputed as fraudulent. The dispute arrives within seconds and your handler logs a real user_id.
Do not use stripe trigger charge.dispute.created for this step. It creates its own charge, which has none of your metadata, so the handler will log undefined and you will spend an hour debugging code that is fine.
Step 2: mark the account fraudulent after the dispute
Feedback takes either an event or an account. Use the event id when you have it, because it points at the exact screening decision that let this charge through:
import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
if (portreeveEventId) {
await portreeve.feedback(portreeveEventId, "chargeback");
} else {
await portreeve.feedback(
{ kind: "external_user_id", value: userId },
"chargeback",
`stripe dispute ${disputeId}`,
);
}
The account form is the fallback, and you will use it more often than you expect. Card networks typically allow cardholders 120 days to dispute, so the chargeback often lands against a subscription cycle you stopped thinking about months ago. { kind: "external_user_id", value: userId } addresses the account rather than a single event, which is what you want when the abuse is the person and not the transaction.
Portreeve is the tool I built for this loop: an abuse firewall that returns allow, review, or block in under 100 ms at signup, trial, checkout, and login. Accounts are linked across hashed identity keys (email, device fingerprint, card fingerprint, phone, payer wallet), so a report against one account marks the whole linked cluster. It is in open beta, free for 1,000 screened events a month.
Feedback is idempotent per (event, outcome), which matters because Stripe retries a webhook until it gets a 2xx. The same dispute reported five times is one record. Two different outcomes on one event are two records, and that is deliberate: chargeback is the fact that a dispute arrived, confirmed_abuse is your judgment after looking at the account. File both when both are true.
One argument, since the code above invites it: do not gate this on dispute.reason === "fraudulent". The reason string is whatever the issuer's agent picked from a dropdown while the cardholder was on the phone, and unrecognized, general, and subscription_canceled cover plenty of people who knew exactly what they bought. The money left your balance either way. Report the dispute and let the engine weigh the reason as one signal among several. Friendly fraud is the clearest case: the reason code says product_not_received and the pattern says the same device has done it four times.
Step 3: the other half of the loop
Almost everyone builds half of this. They report the abusers and never report the mistakes, and a chargeback feedback loop that only ever hears about its misses drifts toward blocking more of everything.
review is where the mistakes surface. The flow proceeds, the event lands in a queue, and when you approve it, say so:
await portreeve.feedback(reviewedEventId, "false_positive");
Wire that into the same admin action that clears the account, not into a weekly cleanup script you will stop running. The same call belongs in your support path: when a customer emails because their card was declined and you find nothing wrong with them, that is a false positive whether or not it ever reached a review queue. The deny direction of that queue is covered in the review webhook post.
Step 4: verify the mark carries
Run the whole loop in test mode before you trust it in live. Three calls:
- Screen a
checkout_attemptwith a device token and a card fingerprint, and keepresult.id. - Report
chargebackagainst that id. - Screen a
signupfrom the same browser with a different email and a different card.
The third call should come back review or block with an identity reason code, in the same sub-100 ms call you already make. The reason codes reference lists what each one means. If it comes back allow with nothing about identity in result.reasons, the two events are not linked, and the usual cause is a missing device token: collectDeviceToken() from @portreeve/browser supplies the device key, and without it you are linking on email and card fingerprint alone, which are the two things an abuser rotates first.
What you will not get is a link by IP. IP is a soft signal that never links accounts on its own, because a shared office egress or a mobile carrier NAT would otherwise merge a hundred unrelated customers into one cluster, and the first chargeback would take them all out. Per-IP velocity still counts. Per-IP identity does not.
review is the common outcome here, and it is the point of the design. Someone charges back a $49 plan, comes back two weeks later on the same browser, and the signup completes. They are not blocked. The event enters your queue, and if you deny it later, a signed webhook reaches your server so you can revoke. That is what makes it safe to flag aggressively on a single chargeback: being wrong costs a queue item, not a customer. block stays reserved for signals strong enough to decline on sight.
Before you ship this
Return 2xx fast and do the feedback call off the request path, in a queue or after you have already responded. Stripe retries the whole webhook if your handler throws, so a slow feedback call should never take down your dispute handler. A try/catch with a log line is enough.
Make the handler idempotent on dispute.id, and do not lean on feedback idempotency to cover you. Your own side effects (emails, account suspension, Slack alerts) fire once per retry otherwise.
Then backfill. You have a dispute history sitting in Stripe right now. Page through stripe.disputes.list(), resolve each one to a user id, and call the account form of feedback for each. A few hundred convictions on day one is worth more than the next month of live traffic.
The mistakes people make
Blocking on email alone. Stripe's own convenience path does exactly this: refunding a charge with reason: "fraudulent" adds the associated card and email to your block lists. Both rotate in under a minute. Address the account and let the graph decide what else it touches.
Waiting for charge.dispute.closed. The lifecycle runs into months and the person will have signed up twice more by then. Report on created. A dispute you later win is still a person who called their bank about you.
Reporting only the bad outcomes. Covered above, and it is the failure that quietly ruins the loop, because you never see the users you wrongly turned away.
Ignoring early fraud warnings. radar.early_fraud_warning.created fires before any chargeback exists, and Stripe's figure is that 80% of EFWs convert into a fraud dispute if you do nothing, unless 3D Secure liability shift covers the payment. Their refund guidance is keyed to that $15: it stops being worth refunding on charges more than about 35% above your dispute fee. If you refund and ban on an EFW, report that decision as confirmed_abuse, not chargeback. There is no chargeback yet, and the labels should mean what they say.
Screening at checkout only. The dispute teaches you about a person, and the person comes back at the signup form. If you only call verdict() on checkout_attempt, the mark has nowhere to land until they are already through the door.
Start free with 1,000 screened events a month, no card required: create an account and wire the dispute handler up in test mode first.