Auto-build Stripe chargeback dispute evidence packets via webhook, enriched with the QubitOn API.
Companion repo for the article: "The Stripe dispute that eats every merchant's weekend — and the 50-line webhook that prevents it"
When Stripe fires charge.dispute.created, this server fans out four parallel calls to QubitOn (address validation, IP quality, phone validation, email validation), composes a structured evidence packet, and submits it back to Stripe via disputes.update — typically in under a second.
| Signal | QubitOn endpoint | Stripe evidence field |
|---|---|---|
| Address validates + standardizes | validateAddress |
billing_address |
| IP geolocation, VPN/proxy/Tor flags | checkIPQuality |
customer_purchase_ip |
| Phone validity + line type / carrier | validatePhone |
(markdown summary) |
| Email syntax + domain age + DNS | validateEmail |
customer_email_address |
Pull the prebuilt multi-arch image (linux/amd64 + linux/arm64) from GitHub Container Registry:
docker run --rm -p 3000:3000 \
-e STRIPE_SECRET_KEY=sk_test_... \
-e STRIPE_WEBHOOK_SECRET=whsec_... \
-e QUBITON_API_KEY=svm... \
ghcr.io/qubitonhq/qubiton-stripe-dispute-evidence:latestAvailable tags (pick the one that matches your update tolerance):
| Tag | Points to | Use when |
|---|---|---|
:latest |
head of main (moves) |
local dev, hacking |
:main |
same as :latest |
same |
:main-<sha> |
a specific commit on main |
reproducible CI builds against a non-released snapshot |
:0.1.0 (or any :X.Y.Z) |
the exact release tag — immutable | production — pin here |
:0.1 (or any :X.Y) |
latest patch in that minor | want auto-patch updates, no minor bumps |
:0 (or any :X) |
latest minor + patch in that major | want auto-minor + patch, no major bumps |
:v0.1.0 (full ref) |
same as :0.1.0 |
for tooling that prefers the v-prefix form |
The image is public — no auth needed to pull.
git clone https://github.com/qubitonhq/qubiton-stripe-dispute-evidence.git
cd qubiton-stripe-dispute-evidence
npm install
cp .env.example .env
# edit .env with your Stripe and QubitOn keys
npm run devIn a second terminal (either option):
stripe login # test-mode account, not live!
stripe listen --forward-to localhost:3000/webhooks/stripe
# copy the printed `whsec_...` signing secret into .env as STRIPE_WEBHOOK_SECRET
# in a third terminal:
stripe trigger charge.dispute.createdYou'll see the webhook handler fire, the four QubitOn calls go out, and the evidence packet posted back to Stripe. Watch the dispute in your test-mode Stripe Dashboard to see the submitted evidence.
Stripe does not store the customer's IP address on the Charge object. You have to capture it at checkout and stash it on the PaymentIntent yourself, before the customer hits Pay. See src/checkout-example.ts for the pattern:
await stripe.paymentIntents.create({
amount, currency,
metadata: { client_ip: req.ip },
});Without this, customer_purchase_ip evidence will be missing when a dispute opens later.
| Variable | Required | Purpose |
|---|---|---|
STRIPE_SECRET_KEY |
yes | sk_test_... — get from test-mode dashboard |
STRIPE_WEBHOOK_SECRET |
yes | whsec_... — printed by stripe listen or set on a deployed webhook endpoint |
QUBITON_API_KEY |
yes | svm... — get a free key (100 calls/mo) at qubiton.com |
PORT |
no | HTTP port to listen on. Defaults to 3000. |
SHUTDOWN_TIMEOUT_MS |
no | Graceful-shutdown drain timeout in ms. Defaults to 30000. Must be <= your platform's terminationGracePeriodSeconds (Kubernetes / ECS / Fly / Render / etc.) — otherwise the platform SIGKILLs you mid-drain. |
If you're a platform using Connect, disputes live on the connected account, not the platform. The webhook handler in src/app.ts reads event.account from the Connect webhook payload and the orchestrator in src/buildEvidencePacket.ts threads it through stripe.charges.retrieve and stripe.disputes.update as { stripeAccount: connectedAcctId }. Skip that on charges.retrieve and you'll get a 404 against the platform account.
Stripe webhook (charge.dispute.created)
│
▼
server.ts ← reads env, listens, drains on SIGTERM
│
▼ (createApp factory)
app.ts ← verifies Stripe signature, ACKs 200,
│ dispatches charge.dispute.created
▼
buildEvidencePacket.ts ← retrieves charge, fans out 4 QubitOn calls
│
├─ validateAddress ← billing_address
├─ checkIPQuality ← customer_purchase_ip
├─ validatePhone ← (markdown summary)
└─ validateEmail ← customer_email_address
│
▼
composeEvidence.ts ← maps results to Stripe's typed evidence fields
│
▼
renderMarkdown.ts ← human-readable summary for uncategorized_text
│
▼
stripe.disputes.update ← idempotent submit (idempotencyKey = dispute.id:v1)
| File | Purpose |
|---|---|
src/server.ts |
Reads env vars, builds the app, listens, handles SIGTERM/SIGINT drain |
src/app.ts |
Express factory createApp(deps) — signature verification + ACK + event dispatch (testable, no env coupling) |
src/env.ts |
required() and intEnv() — env-var parsing with explicit errors |
src/buildEvidencePacket.ts |
Charge retrieve → 4-way fan-out → Stripe submit |
src/composeEvidence.ts |
Build Stripe's typed evidence fields |
src/renderMarkdown.ts |
Human-readable markdown for uncategorized_text |
src/checkout-example.ts |
Standalone example: capture client IP at checkout |
src/types.ts |
Re-exports SDK types + local EvidenceInputs / DisputeEvidence |
npm run lint # eslint v9 (flat config: eslint.config.js)
npm run typecheck # tsc -p tsconfig.typecheck.json (covers src/ + tests/)
npm run test # vitest run
npm run build # tsc → dist/ (src/ only)
npm run ci # lint + typecheck + test + buildA multi-stage Dockerfile is included. The runtime image is node:20-alpine, runs as a non-root user, and exits cleanly on SIGTERM (drains in-flight evidence builds before exit).
docker build -t qubiton-stripe-dispute-evidence .
docker run --rm -p 3000:3000 \
-e STRIPE_SECRET_KEY=sk_test_... \
-e STRIPE_WEBHOOK_SECRET=whsec_... \
-e QUBITON_API_KEY=svm... \
qubiton-stripe-dispute-evidenceThree things to get right:
- Set the env vars from your secret manager — never commit them.
- Set the platform's grace period (
terminationGracePeriodSecondson Kubernetes; equivalent on others) >=SHUTDOWN_TIMEOUT_MS(default 30s) so the in-flight evidence drain has time to complete on rolling deploys. - Expose
/healthzas your readiness probe. It returns{ ok: true, inflight: <count> }.
Once deployed, register your public URL with Stripe:
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://your-domain.com/webhooks/stripe - Events: subscribe to at minimum
charge.dispute.created. Addcharge.dispute.closedto log outcomes. - Copy the signing secret (starts with
whsec_) intoSTRIPE_WEBHOOK_SECRET.
This webhook is I/O-bound — almost all wall-clock time is spent waiting on Stripe + QubitOn responses. A single Node process handles thousands of disputes per minute easily. Scale horizontally for redundancy, not throughput.
- The
STRIPE_WEBHOOK_SECRETdoesn't match what Stripe is signing with. Re-copy it fromstripe listen(local) or the Dashboard webhook endpoint (deployed). - You're running
express.json()beforeexpress.raw()for the webhook route. The signature check needs the raw bytes —express.raw()must be mounted only on/webhooks/stripe. - Some proxies (e.g., older API gateway configs) re-serialize the body. Make sure the body reaches your handler byte-identical to what Stripe sent.
You forgot to capture the client IP at checkout. See src/checkout-example.ts — you have to stash req.ip into paymentIntent.metadata.client_ip (or charge.metadata.client_ip for direct charges) before the customer hits Pay. Stripe doesn't expose the IP on the Charge object on its own.
QUBITON_API_KEY is missing, expired, or wrong. Get a fresh one at qubiton.com/auth/register.
The capability you called isn't enabled on your API key. The four endpoints used here (validateAddress, checkIPQuality, validatePhone, validateEmail) are core and work on the free tier. If you extend this to use add-on capabilities (domain security report, beneficial ownership, etc.), you'll need a Pro or Enterprise plan.
Check your logs for evidence-build failed. Common causes:
- Stripe API call rejected because of missing
stripeAccounton Connect — the platform account doesn't have the connected account's charges visible. - Network blip during one of the four QubitOn calls — the catch handler logs
qubiton {label} failed. Partial enrichment should still submit; if nothing is submitted, the failure is instripe.disputes.updateitself. - Process killed mid-build (deploy without graceful-shutdown grace-period). See "Kubernetes" above.
- The bank's reviewer found something contradicting your evidence (e.g., the cardholder really didn't make the purchase).
- You're missing the highest-weight typed fields.
shipping_documentation(tracking PDF) andcustomer_communication(order confirmation email) come from your fulfillment / email systems — pull them in alongside the QubitOn enrichment. - The reason code requires evidence this starter doesn't generate (e.g., for "duplicate processing", you need
duplicate_charge_id). - For Visa fraud-CNP disputes (reason code 10.4), you may need Compelling Evidence 3.0 — surfacing 2+ prior matching transactions from your own database. Out of scope for this starter.
Before pointing this at live disputes, do these:
- Wrap
buildEvidencePacketin a real job queue (BullMQ / SQS / Cloud Tasks). Today the call is fired-and-forgotten withconsole.erroron failure — fine for a starter, not for production. Push the dispute ID onto the queue and let the worker handle retries. - Always submit something. If a QubitOn call fails, still submit evidence with whatever signals you have (
customer_name,billing_address,customer_purchase_ipfrombilling_details). A partial packet beats an empty one — never let an enrichment failure block the dispute response. - Add the other typed evidence fields you have.
shipping_documentation(tracking PDF) andcustomer_communication(order confirmation email) carry the highest weight in card-network decisioning. Pull them in from your fulfillment and email systems and add to the samedisputes.updatecall. - Subscribe to
charge.dispute.closedto track outcomes (won,lost,warning_closed). Log them to your data warehouse so you can measure win rate. - For Visa fraud-CNP disputes (reason code 10.4), also consider Visa Compelling Evidence 3.0 — surface 2+ prior matching transactions from the same payment method (120-364 days back, with at least two of: IP, device, shipping address, customer ID matching). Out of scope for this starter; PRs welcome.
- Not a replacement for Stripe Smart Disputes. Run both — Stripe submits its evidence based on AVS / charge metadata / internal risk; this webhook adds external enrichment Stripe doesn't have.
- Not card-network rules compliance. This generates evidence; it doesn't certify your packets meet specific reason-code requirements (CE 3.0, Mastercard Pre-Arbitration, Amex Inquiry rebuttal). Read the network rules for your reason codes.
- Not for non-card disputes. BNPL (Klarna), wallets (PayPal, Cash App Pay), and bank-transfer flows (ACH, SEPA Direct Debit) have their own dispute shapes.