Skip to main content

Stripe

Test Stripe charge.refunded without a live refund

Checkout grants access. A refund in Stripe does not. If your webhook ignores charge.refunded, customers keep seats after the money comes back. Prove revoke-once without a live charge.

FetchSandbox EngineeringFetchSandbox Engineering

You handled checkout.session.completed. Seats went to 5. Then Stripe refunded the charge.

Your app still showed 5 seats. The money was gone. Access was not.

That is not a signature bug. It is a missing event. Most handlers only move state forward — paid, provisioned, done. charge.refunded (and often refund.created) never hit a branch, so the entitlement stays.

This is different from 3DS requires_action (auth mid-confirm) and from requires_capture (auth hold vs capture). Refund is the reverse path: money out, access in, then money back. If you never test the reverse, production is the first refund.

Why CLI trigger is not enough

Search “test Stripe refund webhook” and you get stripe listen plus stripe trigger charge.refunded. That proves your route returns 200 on a canned event. It does not prove:

  1. A real charge existed first (same charge / PaymentIntent id your grant used)
  2. After refund, seats / quota / membership == 0 (or the remaining amount on a partial)
  3. A second identical charge.refunded does not double-revoke or error
  4. refund.created arriving before or after charge.refunded still ends at the same state

Dashboard “Send test webhook” is worse: placeholder ids that match nothing in your DB. Your handler no-ops, CI stays green, paying-then-refunded customers keep the product.

GitHub is full of this class: refunded top-ups that keep quota, paid = true forever after charge.refunded, Craft/Commerce orders that only refund from the app side. The pattern is always the same — grant on success, ignore the refund event.

The invariant

After a full refund of a 5-seat purchase, entitlement must be exactly zero, not “unchanged” and not “error.” After N identical refund deliveries, still zero. Partial refunds need the remaining paid amount, not a full revoke by accident.

# Exact end state — not “handler returned 200”
grant on checkout.session.completed     → seats == 5
full charge.refunded (same charge id)   → seats == 0
replay the same charge.refunded         → seats == 0

If your check is only “refund webhook did not 500,” a no-op handler passes. Same hole as asserting “the count stopped growing” while granting nothing.

Force the refund on a real charge

You need a sandbox that keeps the charge, fires charge.refunded against that id, and lets you read seats after — without refunding a live card or burning Dashboard clicks.

With the FetchSandbox MCP server and a Stripe sandbox:

./fetchsandbox run: create a charge, fulfill 5 seats on checkout.session.completed.
Then refund that charge. Deliver charge.refunded (and refund.created if my handler
subscribes). Assert seats == 0 keyed on the same charge id. Replay charge.refunded;
assert still 0.

The receipt is whether access actually dropped. Reading the switch cannot.

The rule

If fulfillment is a one-way write, refunds are a production incident. Subscribe to charge.refunded, key revoke on the same id you used to grant, assert the exact remaining entitlement, and replay once. Do that before a real refund is the first time the branch runs.

Related: webhook sandbox · you can’t code-review a webhook retry