Building dispute response prep with Stripe and Claude
A charge.dispute.created event fires from Stripe at 4:11am. A few seconds later, Claude has read the dispute reason ("product_not_received"), pulled the matching order from your system, located the shipping carrier tracking number and the delivery confirmation timestamp, retrieved the customer's two prior support conversations, identified that the customer received an automated delivery confirmation at the email on file, drafted a structured dispute response that maps each piece of evidence to one of Stripe's required evidence fields (shipping_documentation, customer_communication, service_documentation), and dropped the whole package into a Linear ticket for the revenue ops lead to review and submit before the response deadline. By the time revenue ops opens Linear at 9am, the case is one human review away from submission — not three hours of evidence gathering.
That's the dispute response workflow most teams are trying to build with Stripe and an LLM. Stripe gives you generous time to respond (typically 7-21 days), but the evidence gathering is tedious, the categorization is error-prone, and disputes that miss the deadline are automatically lost. Building a half-decent AI step here pays for itself the first time it wins a $400 dispute that would have otherwise auto-lost.
The hard part isn't the AI step. It's the event flow. Stripe webhooks are ordered, signed, idempotent-by-event-id, and high-stakes: a missed charge.dispute.created is, eventually, a missed deadline. This guide walks through the glue layer end to end: the architecture, seven concrete steps to wire it up, and the production concerns you need to know about.
The flow
flowchart TB
A[Cardholder<br/>files chargeback] --> B[Stripe webhook<br/>charge.dispute.created]
B -->|POST signed JSON| C[Hookdeck<br/>inbound source]
C -->|dedupe + transform<br/>+ order + retry| D[Claude<br/>dispute prep handler]
D -.->|gather evidence| E[Order DB,<br/>shipping API,<br/>support history]
D -.->|messages API| F[claude-sonnet-4]
D -->|POST response draft| G[Hookdeck<br/>callback source]
G -->|route| H1[Linear<br/>revenue ops ticket]
G -->|route| H2[Object store<br/>evidence package]
G -->|route| H3[Slack<br/>revenue ops channel]
There are two webhook flows that need to be reliable:
- Stripe's dispute events into the AI step — is mission-critical and sensitive to ordering. A
charge.dispute.createdfollowed by acharge.dispute.updatedmust be processed in that order; otherwise you risk overwriting a later state with an earlier one. Stripe signs every event and assigns each one a unique event ID for idempotency, but it's up to you to use both correctly. - The AI's outputs back into Linear, Slack, and your evidence storage — must reach all of them reliably. The drafted response, the supporting documents, and the audit log must all land in their respective systems, even when one is briefly down.
Most teams build this with a POST /webhooks/stripe endpoint that calls Claude inline, then writes to a Linear ticket. That's enough to demo. The reasons it isn't enough to run on real chargeback volume are why Hookdeck Event Gateway sits in the middle.
What you'll need
- A Stripe account with admin access to register webhook endpoints
- An Anthropic API key with access to a Claude model
- A Hookdeck Event Gateway account — the free tier covers this workflow at low volume
- Hookdeck CLI installed:
npm install hookdeck-cli -gorbrew install hookdeck/hookdeck/hookdeck - A handler endpoint that gathers internal evidence, calls Claude, and POSTs the result back
- A destination for the response — Linear (or Jira, Asana), object storage for the evidence package, Slack for revenue ops
Step 1: Create the Hookdeck Event Gateway source for Stripe
In the Hookdeck Event Gateway dashboard:
- Create Connection → New Source
- Type: Stripe — Hookdeck Event Gateway has a pre-configured Stripe source that verifies the
Stripe-Signatureheader automatically (see the Stripe webhooks guide) - Name:
stripe-dispute-events - Provide your Stripe webhook signing secret (
whsec_…)
Copy the generated source URL.
Step 2: Register the Stripe webhook endpoint
In the Stripe Dashboard:
- Developers → Webhooks → Add endpoint
- Endpoint URL: paste the Hookdeck source URL
- Description: "AI dispute response prep"
- Events to send:
charge.dispute.created,charge.dispute.updated,charge.dispute.closed,charge.dispute.funds_withdrawn,charge.dispute.funds_reinstated - Save
Trigger a test event from the Stripe CLI or dashboard:
stripe trigger charge.dispute.created
The full event payload should appear in the Hookdeck Event Gateway dashboard within a second. If Stripe Thin Events are enabled for your account, see Stripe Thin Events best practices for the slightly different payload shape.
Step 3: Add the destination — your Claude dispute-prep handler
The handler:
- Receives the dispute event
- Gathers internal evidence — order details, shipping records, support history, customer interactions
- Calls Claude with an evidence-mapping prompt that returns Stripe-compatible structured output
- POSTs the result to a second Hookdeck source for fan-out
A minimal handler:
export default {
async fetch(request, env) {
const event = await request.json();
if (event.type !== 'charge.dispute.created') {
return new Response('ignored', { status: 200 });
}
const dispute = event.data.object;
const chargeId = dispute.charge;
// Gather internal evidence in parallel
const [order, shipping, support] = await Promise.all([
fetch(`${env.INTERNAL_API}/orders/by-charge/${chargeId}`, {
headers: { authorization: `Bearer ${env.INTERNAL_TOKEN}` },
}).then(r => r.json()),
fetch(`${env.INTERNAL_API}/shipping/by-charge/${chargeId}`, {
headers: { authorization: `Bearer ${env.INTERNAL_TOKEN}` },
}).then(r => r.json()),
fetch(`${env.INTERNAL_API}/support/by-charge/${chargeId}`, {
headers: { authorization: `Bearer ${env.INTERNAL_TOKEN}` },
}).then(r => r.json()),
]);
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: DISPUTE_PROMPT,
messages: [{
role: 'user',
content: JSON.stringify({
dispute_reason: dispute.reason,
dispute_amount: dispute.amount,
dispute_currency: dispute.currency,
response_deadline: dispute.evidence_details?.due_by,
order,
shipping,
support_conversations: support,
}),
}],
}),
});
const result = await response.json();
const draft = JSON.parse(result.content[0].text);
await fetch(env.HOOKDECK_CALLBACK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
dispute_id: dispute.id,
charge_id: chargeId,
reason: dispute.reason,
amount: dispute.amount,
currency: dispute.currency,
deadline: dispute.evidence_details?.due_by,
draft,
}),
});
return new Response('ok', { status: 200 });
},
};
Configure the Hookdeck Event Gateway destination:
- Type: HTTP
- URL: your handler URL
- Authentication: an HTTP header carrying a shared secret
Step 4: Add transformation, dedupe, ordering, and rate-limit rules
Filter — drop event types you don't care about for this pipeline:
{
"body": {
"type": {
"$in": [
"charge.dispute.created",
"charge.dispute.updated",
"charge.dispute.closed"
]
}
}
}
Transformation — flatten Stripe's nested event into the canonical shape your handler uses. Preserve the Stripe event ID for idempotency:
addHandler('transform', (request, context) => {
const event = request.body;
const obj = event.data.object;
request.body = {
stripe_event_id: event.id,
type: event.type,
dispute_id: obj.id,
charge_id: obj.charge,
reason: obj.reason,
amount: obj.amount,
currency: obj.currency,
status: obj.status,
deadline: obj.evidence_details?.due_by,
created: event.created,
};
return request;
});
Dedupe — Stripe assigns each event a unique ID and retries with the same ID on transient failures. Use the Stripe event ID as the idempotency key on the destination:
- Idempotency key:
body.stripe_event_id - Idempotency window:
48 hours
Your handler runs at most once per unique event. The same applies if Stripe retries because your initial 200 response was slow.
Ordering — for the same dispute_id, events must be delivered in created order. Configure the destination's delivery rate to sequential keyed on dispute_id:
- Delivery:
Sequential by body.dispute_id
Hookdeck Event Gateway holds back later events for a given dispute until the earlier ones have been processed, so a dispute.updated doesn't race past a dispute.created.
Rate limit — disputes are typically low-volume, but a vendor-side issue can produce a burst. A conservative cap keeps Claude calls in check:
- Rate:
3per second - Burst:
10
Retry policy — aggressive on the inbound leg, because missing a dispute event means missing a deadline:
- Initial delay:
30 seconds - Max attempts:
25 - Max age:
7 days - Apply on status codes:
408, 429, 500, 502, 503, 504, 529
The 7-day max age is deliberately generous: dispute deadlines run 7-21 days, so even an extended outage shouldn't cause a missed case.
Step 5: Test the inbound leg locally with the CLI
Route the inbound connection to the CLI:
hookdeck login
hookdeck listen 3000 stripe-dispute-events
A local inspector:
// inspect.js
const http = require('http');
http.createServer((req, res) => {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
console.log('Dispute event:', JSON.parse(body));
res.writeHead(200);
res.end('ok');
});
}).listen(3000);
Trigger from Stripe:
stripe trigger charge.dispute.created
Verify the canonical event lands in your terminal and the stripe_event_id is preserved. Iterate on the transformation; press r to replay the most recent event when changing the handler.
Step 6: Wire dispute drafts back through Hookdeck
The drafted response fans out:
- Linear / Jira / Asana — create a ticket with the draft attached for revenue ops review
- Object storage (S3, R2) — store the structured evidence package as a JSON file
- Slack — notify the revenue ops channel that a new dispute needs review, with deadline highlighted
Create a second connection with the source dispute-response-drafts and one destination per downstream.
For Linear, a transformation builds the ticket creation:
addHandler('transform', (request, context) => {
const { dispute_id, reason, amount, currency, deadline, draft } = request.body;
const formattedAmount = (amount / 100).toFixed(2);
const deadlineISO = new Date(deadline * 1000).toISOString().slice(0, 10);
request.url = 'https://api.linear.app/graphql';
request.method = 'POST';
request.headers = {
...request.headers,
authorization: context.secrets.LINEAR_TOKEN,
'content-type': 'application/json',
};
request.body = {
query: `mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) { success issue { id identifier } }
}`,
variables: {
input: {
teamId: context.secrets.LINEAR_REVENUE_OPS_TEAM,
title: `Dispute ${dispute_id} — ${reason} — ${currency.toUpperCase()} ${formattedAmount} — due ${deadlineISO}`,
description: draft.markdown_summary,
priority: 2, // High
labelIds: [context.secrets.LINEAR_DISPUTE_LABEL],
},
},
};
return request;
});
A second connection from the same source writes the full JSON evidence package to object storage; a third pings Slack with a deadline-highlighted summary.
Retry policy on these callbacks:
- Initial delay:
15 seconds - Max attempts:
20 - Max age:
7 days
Step 7: Run the full chain end to end
Trigger a real dispute event:
stripe trigger charge.dispute.created
You should see, in order:
- The signed event lands in
stripe-dispute-events - Hookdeck Event Gateway verifies, dedupes, transforms, and delivers to your handler
- Your handler gathers evidence and calls Claude
- The drafted response lands in
dispute-response-drafts - A Linear ticket is created
- A JSON package is written to object storage
- A Slack message lands in the revenue ops channel with the deadline highlighted
Re-trigger the same event (Stripe retains the event.id). Confirm that nothing duplicates downstream — the idempotency rule caught it.
If anything fails, the Hookdeck Event Gateway dashboard tells you exactly where, with payload and response visibility at every hop.
Why Hookdeck and not just a try/except in your app server?
Three properties of billing-event workflows make a direct integration the wrong choice once you're past the demo:
Billing events are sensitive to ordering and idempotency. Stripe guarantees at-least-once delivery and assigns each event a unique ID. Without idempotency on the AI step, every retry becomes another Claude call and another Linear ticket. Without ordering, a dispute.updated can race past dispute.created and process stale state. Hookdeck Event Gateway's idempotency rules and sequential-by-key delivery solve both at configuration level, not application level.
Audit trail is non-negotiable for revenue ops. When the finance lead asks "what evidence did we submit for dispute X, when did the event arrive, and what did Claude say?", you need a single answer. Hookdeck Event Gateway stores every event, every transformation, every retry, and every response. The revenue ops team gets a dashboard, not a Slack message to engineering asking for log queries.
Observability protects revenue. A silent failure in the dispute pipeline doesn't show up in product metrics; it shows up two weeks later when finance reconciles auto-lost cases. Hookdeck Event Gateway's Issues feature surfaces failures the day they happen, so revenue ops can react before the deadline passes. You can build all of this on your own — a durable queue with ordering and idempotency, retry workers with long-tail policies, an audit log with finance-grade retention, an observability layer, a replay tool. That's the work Hookdeck collapses into a connection in a dashboard. The hours you don't spend on infrastructure are hours you spend on improving the dispute prompt, the evidence library, and the win-rate.
Going to production
Observability for finance and revenue ops. Wire Issues alerts to a #revenue-ops-alerts Slack channel. A stuck dispute pipeline should be visible to finance, not just engineering.
Tune retries against the deadline. Stripe gives you days to respond, so retry policies can afford to be patient. But the first 24 hours matter most — the human review still needs to happen. Aggressive early retries and a slow tail is the right shape.
Replay deliberately when the prompt changes. When you update the evidence-mapping prompt, Hookdeck Event Gateway's replay lets you re-run the last 30 days of disputes through the new prompt against a preview destination, so revenue ops can A/B the output before flipping live.
Handle PII responsibly. Dispute payloads contain customer details, last-four card digits, and amounts. Configure Hookdeck's payload redaction on sensitive fields. The dashboard should be safe to share with revenue ops without exposing PCI-relevant data.
Plan for Stripe API rate limits on the write-back leg if you submit evidence automatically. This article stops at the drafted response, but if you extend the pipeline to call Stripe's POST /v1/disputes/{id} to submit evidence directly, the rate-limit configuration on that connection should respect Stripe's 100/sec limit (lower in test mode).
What to build next
This pattern generalizes: Apply it to subscription churn risk analysis (customer.subscription.deleted), failed-payment retry orchestration (invoice.payment_failed), fraud signal aggregation (radar.early_fraud_warning.created), or revenue-anomaly detection across charge.succeeded events.
If you're building any of this, the fastest way to get past the demo phase is to stop maintaining your own webhook infrastructure. Start with the Hookdeck free tier (you can run this entire workflow without paying anything until you hit real volume) and use the CLI to keep your development loop fast.