The Connect webhooks that matter for money
Twelve event types carry every dollar fact a Connect platform needs; consume them idempotently and reconcile expectations continuously.
The Connect webhooks that matter for money
A Connect platform cannot see fees and refunds truthfully unless it consumes a specific set of webhook events and treats them as pointers rather than truth. This article names that set, gives a handler skeleton that survives duplicates and retries, and turns each delivery into a reconciliation expectation.
Events beat polling for money movement
Stripe pushes an Event object as JSON to your HTTPS endpoint whenever something happens in the account (webhooks). Three documented properties define how your side must behave.
First, delivery is at-least-once. Stripe attempts delivery for up to three days with exponential backoff in live mode, so an endpoint outage delays your information but does not destroy it — provided you can accept late arrivals gracefully.
Second, every delivery is signed. Verify the signature with the official library by passing the raw request body, the Stripe-Signature header, and the endpoint secret to stripe.webhooks.constructEvent. The docs are blunt about the stakes: without verification, an attacker could send fake webhook events to trigger actions like fulfilling orders or modifying records. Stripe also requires the raw body specifically — frameworks that parse JSON before your code runs break verification, so the webhook route must read bytes, not parsed objects.
Third, your endpoint must respond quickly. Return a successful 2xx status prior to any complex logic that might cause a timeout, and process heavy work through an asynchronous queue. An endpoint that reconciles inline will hang on slow days, Stripe will retry what looked like failures, and you will have built a duplicate generator.
Polling cannot substitute. A poll sees what is true now; it never saw the transition. Money facts are transitions — this refund appeared, that reversal landed — and the event stream is the only record of when each happened.
Two supporting controls round out the setup. Stripe sends webhook events from a documented set of IP addresses, so servers and firewalls can allowlist them; signature verification remains the primary control because it proves content integrity, not just origin. And roll endpoint signing secrets periodically: the Dashboard can delay a secret's expiry by up to 24 hours so old and new secrets both verify while handlers redeploy. Neither control costs more than a config line; both close real doors.
The twelve events that carry dollar facts
Subscribe selectively: Stripe explicitly advises configuring endpoints to receive only required event types. For a platform that cares where its money went, the required set is small and stable. The table below lists each event, the object anchoring its payload, the money fact it carries, and the immediate action a handler should take.
| Event | Payload anchor | Money fact it carries | Immediate handler action |
|---|---|---|---|
charge.refunded | Charge | Charge refunded, including partial refunds | Refetch charge and refunds; open or update expectation findings |
refund.created | Refund | A refund exists: amount, status, charge linkage | Record refund state; start reconciliation for it |
refund.updated | Refund | Status transitions and reference details | Refresh stored refund state |
refund.failed | Refund | Funds returned toward your balance; failure_reason present | Branch handling; expect re-credit within up to about 30 days |
application_fee.created | Application fee | Fee amount recorded at charge time | Store baseline for fee-refund expectations |
application_fee.refunded | Application fee | Fee amount_refunded increased | Decrement missing-fee finding |
transfer.created | Transfer | Funds moved to a connected account | Baseline for reversal expectations |
transfer.reversed | Transfer | Reversal recorded against the transfer | Decrement missing-reversal finding |
payout.paid | Payout | Money left the account to a bank | Mark payout settled in the ledger view |
payout.failed | Payout | Payout did not land; external account disabled until updated | Alert operations; funds remain in balance |
charge.dispute.created | Dispute | Disputed amount opened against the charge | Open exposure record; freeze related refunds |
charge.dispute.closed | Dispute | Outcome decided; loss realized if lost | Convert exposure into recovery task when lost |
charge.dispute.funds_reinstated | Dispute | Funds reinstated after close, including partially refunded payments | Clear exposure; release freeze |
Two naming notes prevent classic mistakes. charge.refunded fires for partial refunds too — do not treat it as full-refund-only. And charge.refund.updated is deprecated: the refund documentation says to listen to refund.updated for updates on all refunds instead (refunds). If an old listener still subscribes to the deprecated name, retire it during this work. Deeper dives on three load-bearing events live at charge.refunded, transfer.reversed, and application_fee.refunded.
Timing quirks deserve their own call-out for direct charges. There, fee objects are created asynchronously by default, and application_fee.created is the event announcing the collected fee; expanding application_fee at charge time forces synchronous creation instead. A handler assuming the fee object exists the instant a charge succeeds will race it and lose intermittently. Payout events bracket the other end: payout.paid confirms settled money actually left for a bank, while payout.failed blocks that assumption outright — when a payout fails, the external account involved is disabled and no automatic or manual payouts process until it is updated. The dispute trio behaves like a slow-motion refund with its own evidence calendar, which is why it warrants separate exposure records rather than reuse of the refund pipeline.
A handler skeleton that survives retries
The skeleton below does four things in the right order: reads the raw body, verifies the signature, deduplicates by event ID, and enqueues work before answering. It runs as written with Express and the official stripe-node SDK.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
const app = express();
const seen = new Set<string>();
const queue: Stripe.Event[] = [];
function recordEvent(id: string): boolean {
if (seen.has(id)) return false;
seen.add(id);
return true;
}
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
} catch {
res.status(400).send("invalid signature");
return;
}
if (recordEvent(event.id)) {
queue.push(event);
}
res.status(200).send("ok");
}
);
async function reconcileRefund(refundId: string): Promise<void> {
const refund = await stripe.refunds.retrieve(refundId);
const charge = await stripe.charges.retrieve(refund.charge as string, {
expand: ["application_fee"],
});
const fee = charge.application_fee as Stripe.ApplicationFee | null;
const share = refund.amount / charge.amount;
const lines = [`refund ${refund.id} on charge ${charge.id}`];
if (charge.transfer) {
const transfer = await stripe.transfers.retrieve(charge.transfer as string);
const reversals = await stripe.transferReversals.list(transfer.id);
const reversedCents = reversals.data.reduce((sum, r) => sum + r.amount, 0);
const expectedReversal = Math.round(share * transfer.amount);
lines.push(`missing reversal ${expectedReversal - reversedCents} cents`);
}
if (fee && fee.amount > 0) {
const expectedFeeRefund = Math.round(share * fee.amount);
lines.push(`missing fee refund ${expectedFeeRefund - fee.amount_refunded} cents`);
}
console.log(lines.join(" | "));
}
async function processEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "charge.refunded": {
const chargeId = (event.data.object as Stripe.Charge).id;
const refunds = await stripe.refunds.list({ charge: chargeId });
for (const refund of refunds.data) {
await reconcileRefund(refund.id);
}
break;
}
case "refund.created":
case "refund.updated":
await reconcileRefund((event.data.object as Stripe.Refund).id);
break;
default:
break;
}
}
async function worker(): Promise<void> {
while (true) {
const event = queue.shift();
if (event) {
await processEvent(event).catch(() => undefined);
} else {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
void worker();
app.listen(4242);
Be honest about the two shortcuts. The seen set and the array queue demonstrate the contract — exactly-once enqueue keyed by event.id, acknowledgment before work — but they live in memory and die on restart. Production replaces the set with a durable insert into a table whose event.id column carries a unique constraint, where a duplicate insert simply reports "already handled," and replaces the array with a real queue. Both patterns are laid out in the queue-based webhook processing guide, and the serverless variant of the same contract in the serverless webhook guide.
From event to expectation
An event earns its keep by triggering arithmetic, not by being stored. On charge.refunded, the sequence is:
- Refetch the charge. Event payloads are frozen snapshots; the object may have changed since. Trust objects, not embedded payloads, and retrieve current state.
- Resolve the transfer. For a destination charge, the associated transfer identifies the seller-side movement the refund should claw back proportionally.
- Sum existing reversals. List reversals on that transfer and total their amounts in integer cents.
- Compute the expectation. Expected reversal = round((amount_refunded ÷ charge.amount) × transfer.amount). Missing = expected − sum of reversals.
- Open or update a finding record keyed by the charge, carrying the missing amount and the evidence IDs.
On transfer.reversed and application_fee.refunded, the same records move in the opposite direction: the finding decrements because reality caught up to expectation. On refund.failed, the branch differs entirely — the customer was not credited, so reversal expectations pause pending whatever you decide next.
Findings need states, because recovery is a process with partial progress.
A finding record should carry enough to act on without re-deriving anything: charge ID, transfer ID, application fee ID, currency, expected reversal and expected fee refund in integer cents, sums observed at last compute, current missing amounts, state, and the event IDs that last touched it. Integer cents throughout — floating-point ratios on money invite rounding drift larger than the errors being hunted. The rounding rule stays fixed everywhere: round the proportional expectation once, subtract the exact observed sum, and never round twice.
The machine below covers the lifecycle.
| State | Entered when | Leaves when |
|---|---|---|
open | Missing computed greater than zero | Any recovery movement posts against the finding |
partially_recovered | Some, not all, of the missing amount is reversed or fee-refunded | Remainder recovered, or loss formally absorbed |
resolved | Missing reaches zero | Terminal |
unrecoverable | Recovery abandoned: seller balance negative past collection attempts, or write-off approved | Terminal |
Two design rules keep the machine honest. Compute transitions from refetched objects, never from event arrival — the state must survive out-of-order deliveries unchanged. And let the dispute events drive their own parallel exposure record: charge.dispute.created opens it, charge.dispute.closed realizes the loss, charge.dispute.funds_reinstated clears it.
Duplicates, reordering, and late deliveries
Three documented behaviors punish naive handlers, and all three have the same antidote.
Duplicates: endpoints might occasionally receive the same event more than once, and Stripe's suggested guard is logging processed event IDs and skipping repeats. Sometimes two distinct Event objects describe one underlying change, so pair the check with the data.object identifier and the event type. The unique-constraint insert from the skeleton handles both shapes.
Reordering: Stripe does not guarantee delivery order. In practice a transfer.reversed can arrive before the refund.created that explains it, which would look alarming to any handler that interprets events as a narrative. Hence the rule this article keeps repeating: treat events as hints, objects as truth. Every computation starts from a refetch, so arrival order changes nothing — the reconciler converges on the same numbers regardless of sequence.
Lateness: automatic retries run up to three days in live mode, manual resend from the Dashboard works for 15 days after creation, and stripe events resend works for 30. Late events are normal inputs, not anomalies; the only requirement is that your finding logic is idempotent, which refetch-and-recompute gives you for free.
Watch convergence neutralize ordering with real numbers. Suppose transfer.reversed arrives first. The handler refetches: the transfer shows a $36.00 reversal against a $90.00 transfer, but no refund exists yet, so no finding opens — the handler logs an unexplained-reversal observation and moves on. When refund.created arrives next, the refetch shows the $40.00 refund on a $100.00 charge, the formula computes round((4000 ÷ 10000) × 9000) = 3600 cents, the observed reversals already sum to 3600 cents, and Missing = 3600 − 3600 = 0 opens and resolves the finding instantly. Reverse the arrival order and the first pass opens the finding at $36.00 while the later delivery closes it to zero. Same terminal state either way — that is what order-insensitivity buys.
Direct-charge fleets and event scopes
Fleets of direct charges add one structural question: those resources belong to connected accounts, so how do their events reach the platform?
Connect defines webhook scopes (Connect webhooks). When you create an endpoint in Workbench you set "Events from" to either Your account or Connected accounts, or programmatically set the connect parameter to false or true. The split follows resource ownership: destination charges and separate charges and transfers are charges on your account, so their events arrive under the Your account scope. Direct charges live on connected accounts, and their events — refunds, disputes, application fee activity among them — arrive under the Connected accounts scope.
Each event for a connected account carries a top-level account property identifying that account. Because the connected account owns the object that triggered the event, follow-up API requests for that object must authenticate as the connected account. Operationally, then, the recipe is short:
- Register one production endpoint, scoped to Connected accounts, subscribed to the twelve-event money set — Stripe allows up to 16 endpoints, but one well-scoped endpoint beats several overlapping ones. Managing endpoints declaratively keeps the subscription list reviewable; see the Terraform approach.
- Route inside the handler on the event's
accountfield, so findings attach to the right seller ledger. - Keep a second subscription — Your account scope — for destination and separate charge activity, pointed at the same reconciliation core.
One endpoint, two scopes, one expectation engine: the fleet stays legible no matter which charge shape a given seller uses.
Testing locally with the Stripe CLI
The Stripe CLI forwards real sandbox events to your machine and fires fixture-based test events on demand (CLI reference).
After installing and running stripe login, start a listener:
stripe listen --forward-to localhost:4242/webhook
The command prints your webhook signing secret — put that value in STRIPE_WEBHOOK_SECRET so constructEvent verifies locally exactly as it will in production. Narrow the firehose to the money set with a comma-separated filter:
stripe listen --events charge.refunded,refund.created,refund.updated,refund.failed --forward-to localhost:4242/webhook
For the Connected accounts scope, the CLI has a dedicated forward flag, and triggers accept an account header — both documented on the Connect webhooks page:
stripe listen --forward-connect-to localhost:4242/webhook
stripe trigger --stripe-account acct_123 charge.refunded
Triggering creates real objects: the docs note that trigger commands issue HTTP requests against the API, so fixtures materialize actual charges and refunds in the sandbox. stripe trigger charge.dispute.created and stripe trigger charge.refunded are both supported fixture events. Flows without fixtures — a specific bank-debit failure, say — can be produced by creating the objects directly in the sandbox while your listener runs.
To rehearse lateness deliberately, resend an existing event to the endpoint:
stripe events resend evt_123 --webhook-endpoint=we_123
If the second delivery produces a second finding, your dedupe is broken; fix it before production teaches you the same lesson louder.
Mind environment separation while testing. Development webhook URLs receive only test webhooks, while production URLs receive both live and test traffic, so check the livemode value before acting on a delivery. Sandbox accounts need their own endpoints entirely — they do not share the production subscription.
Frequently asked questions
Should I act inside the webhook handler?
Acknowledge fast, act outside. Stripe's guidance is to return a 2xx response quickly, prior to any complex logic, and to configure asynchronous queues for processing. Inside the handler, verify the signature, record the event ID, and enqueue. Retrieval, recomputation, and any writes to findings happen in workers, where retries, backpressure, and long-running calls cannot turn into failed deliveries.
Which single event matters most if we can only subscribe to one?
refund.created. Stripe's own refund documentation says that at a minimum, integrations should listen for refund.created. It announces every refund at birth with its amount and charge linkage, which is enough to open expectations even if nothing else arrives. The rest of the money set exists so updates, failures, and recoveries close the loop automatically.
Is charge.refund.updated still worth listening to?
No. It is deprecated and fires only for refunds with a corresponding charge. The refund documentation directs everyone to refund.updated for updates on all refunds instead. Migrating the listener is a one-line change in the endpoint's event-type subscription plus a renamed case in the switch.
Do webhooks replace reconciliation?
No. Events tell you when something happened; they do not tell you whether the right things happened afterward. A default-flag refund produces perfectly delivered events and a still-missing reversal. Continuous expectation checking — refetching objects and computing what should exist — catches the silent cases, and it also backstops the three-day retry window after which undelivered events stop trying.
Where do funds land when a refund fails on a destination charge?
Into the platform account's Stripe balance. The refund documentation notes that for Connect destination charges, funds from a failed refund deposit to the platform balance, surfaced through failure_balance_transaction and failure_reason on the Refund object. Handle refund.failed by crediting back the finding, not by assuming the buyer received anything.
Run the 90-day audit
FeeGuard exists because this arithmetic runs silently on every refund your platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, missing application-fee refund, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring is optional afterward.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.