Chargebacks were killing my margins.
Not in a dramatic way. It was slow and invisible — a disputed charge here, a stolen card there. By the time I actually sat down and looked at the numbers, I was losing somewhere between $300 and $500 a month to fraud I had zero visibility into. My Stripe risk score was creeping up. At a certain threshold Stripe starts flagging your account as high-risk and at that point it doesn't matter how good your product is.
The fix ended up being embarrassingly simple. One check, before checkout completes, that runs the buyer's IP and asks three questions:
- Is this a VPN or proxy?
- Is this a TOR exit node?
- Does the IP country match the billing country?
If any of those come back suspicious, the transaction gets flagged for manual review instead of auto-completing. That's it. No ML model, no complex risk engine, no third party fraud suite charging you $200 a month.
Here's exactly how I built it.
The Stack
- Vercel Edge Functions — runs the check at the edge before checkout completes, adds about 12ms latency
- DataSentry — IP intelligence API for geolocation, VPN/proxy/TOR detection
- Supabase — logs every flagged transaction for review
Step 1: The Edge Function
Create a new file at /api/checkout-guard.ts in your Next.js project:
import { NextRequest, NextResponse } from 'next/server'
export const config = {
runtime: 'edge',
}
interface GeoResponse {
country_code: string
is_vpn: boolean
is_proxy: boolean
is_tor: boolean
threat_score: number
}
export default async function handler(req: NextRequest) {
const { billingCountry, cartTotal } = await req.json()
// Get the real IP, works behind Vercel's proxy
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0] ??
req.headers.get('x-real-ip') ??
'127.0.0.1'
// Skip check for local dev
if (ip === '127.0.0.1' || ip === '::1') {
return NextResponse.json({ allow: true })
}
const geoRes = await fetch(
`https://api.datasentry.site/v1/geo?ip=${ip}&api_key=${process.env.DATASENTRY_API_KEY}`
)
const geo: GeoResponse = await geoRes.json()
const flags = {
vpn: geo.is_vpn,
proxy: geo.is_proxy,
tor: geo.is_tor,
countryMismatch: geo.country_code !== billingCountry,
highThreat: geo.threat_score > 70,
}
const flagged = Object.values(flags).some(Boolean)
// Log flagged transactions to Supabase for manual review
if (flagged) {
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/flagged_transactions`, {
method: 'POST',
headers: {
apikey: process.env.SUPABASE_SERVICE_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ip,
flags,
billing_country: billingCountry,
ip_country: geo.country_code,
cart_total: cartTotal,
threat_score: geo.threat_score,
created_at: new Date().toISOString(),
}),
})
}
return NextResponse.json({
allow: !flagged,
flags: flagged ? flags : null,
})
}
Step 2: The Supabase Table
Run this in your Supabase SQL editor:
create table flagged_transactions (
id uuid default gen_random_uuid() primary key,
ip text not null,
flags jsonb not null,
billing_country text,
ip_country text,
cart_total numeric,
threat_score integer,
reviewed boolean default false,
created_at timestamptz default now()
);
-- Index for fast lookups on review dashboard
create index on flagged_transactions (reviewed, created_at desc);
Step 3: Call It From Your Checkout
Before you call Stripe's confirmPayment, hit the guard first:
async function handleCheckout(billingCountry: string, cartTotal: number) {
const guard = await fetch('/api/checkout-guard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingCountry, cartTotal }),
})
const { allow, flags } = await guard.json()
if (!allow) {
// Don't hard block — show a verification step instead
// Hard blocking legitimate users who use VPNs for privacy costs you sales
setCheckoutState('requires_verification')
setFraudFlags(flags)
return
}
// Proceed to Stripe
const { error } = await stripe.confirmPayment({ ... })
}
Important: Don't hard block flagged transactions. A lot of legitimate users browse with VPNs for privacy reasons. Flag them for manual review or add a verification step like requiring a phone number. Hard blocking costs you real sales.
Step 4: The Review Dashboard (Optional but Worth It)
A simple Supabase query to pull your review queue:
const { data: flagged } = await supabase
.from('flagged_transactions')
.select('*')
.eq('reviewed', false)
.order('created_at', { ascending: false })
Render it however you want. Even a basic table where you can mark transactions as approved or rejected is enough. You'll spend maybe 5 minutes a day on it and it saves you hundreds in chargebacks.
The Results
After running this for 90 days:
- Chargebacks dropped ~40%
- Stripe risk score recovered to normal
- Zero legitimate customers were hard blocked because of the verification step instead of hard block approach
- Total latency added to checkout: ~12ms at the edge
The whole thing is about 15 lines of actual logic. The rest is just types and Supabase boilerplate.
What This Doesn't Solve
IP geolocation is one signal, not a complete fraud solution. It won't catch:
- Stolen cards used from a legitimate residential IP in the correct country
- Friendly fraud (buyer disputes a legitimate charge)
- Account takeovers where the attacker is in the same country
For those you need additional signals — device fingerprinting, velocity checks, purchase history. But for stopping the most obvious fraud patterns, an IP check at checkout is the highest ROI thing you can add in an afternoon.
If you're doing any real transaction volume and not running something like this you're probably leaking more than you think. The check costs fractions of a cent per transaction. The chargeback it prevents costs you $15+ in fees plus the lost revenue plus the Stripe risk score damage.
Happy to answer questions in the comments if anything is unclear.
Top comments (0)