A refund that left no record
On 2026-10-04 we refunded a customer’s subscription charge in Stripe. Stripe sent our webhook a charge.refunded event, its notice that a charge was refunded. The event showed pending_webhooks: 0, its count of deliveries that have not yet succeeded. No delivery was still pending, so Stripe would not send it again on its own.
The refund itself went through in Stripe. Our refund ledger, the table where our app records each refund it has acted on, had no row for it. That day the ledger held no refund for our subscription product at all. This refund is how we found the bug.
The same code path sends the customer a refund confirmation email. By our reading of the code, that email did not go out either.
Where the invoice field went
On 2026-10-04 we fetched the refunded charge twice. At 2024-06-20 it had an invoice key. At 2026-01-28.clover, our webhook endpoint’s version, it had none. We have no copy of the event as our webhook received it.
Stripe versions its API. The version decides the shape of the objects inside the events it sends. A webhook endpoint either has its own version or uses the account’s default. When an endpoint has its own version, Stripe always sends it events in that version’s shape. Ours is set to 2026-01-28.clover. An endpoint’s version can’t be changed after the endpoint is created.
Version 2025-03-31.basil removed the invoice field from the Charge object, Stripe’s record of one payment. The link from a payment to its invoice moved to a new object, the Invoice Payment.
Our product lookup works out which product a refunded charge was for. To spot a subscription renewal, it read charge.invoice from the event. By our reading of the code, the lookup then found no invoice, named no product and logged that it was skipping the refund, and our webhook answered 200.
Why nothing warned us
TypeScript would have. In stripe-node 20.3.1, Stripe’s Node.js library, the Charge type has no invoice field. A direct read of it fails to compile. We committed the read on 2026-03-30 through a type cast, as unknown as { invoice?: string | null }, which tells TypeScript to accept the shape we give it. The comment beside it said Stripe’s v20 types omit invoice from Charge but the webhook payload includes it.
Our unit test for the renewal path stayed green. We had set invoice by hand in its fixture, the fake charge the test feeds in. The real charge, fetched at our endpoint’s version, had no such key.
Check your own webhook handlers
These steps fit webhook handlers written in TypeScript whose stripe-node names the same API version as your endpoint. The README for stripe-node says its types “only reflect the latest API version”. Version 20.3.1 names 2026-01-28.clover, our endpoint’s version, as its API version in types/apiVersion.d.ts. With it, our direct read of charge.invoice failed to compile. A type cast hides that failure. Step 1 lists the reads made through one. We tested only that match. If your handlers have no types, or your stripe-node names any version other than your endpoint’s, we have not shown that TypeScript catches a direct read. Have step 1 list every Stripe field they read.
Steps 2 and 3 need a Stripe key that can read your webhook endpoints and the object types you check. Stripe recommends a restricted API key, a key with only the permissions you give it. The steps change nothing in Stripe or in your code.
-
List the fields your webhook code reads through a cast. Ask your agent to search your webhook handlers, and the code they call, for Stripe objects read through a type cast:
as unknown as, an inlineas { ... },as any, or a line under// @ts-ignoreor// @ts-expect-error. Have it list each Stripe field read that way, with its object type. Our missing field was read in our product lookup, which our refund handler calls, not in the handler.Check: the list names the file, the object type and the field for each cast read. It leaves out casts of your own database values and of caught errors, and keeps every cast of a Stripe object, even to your own type.
-
Find the version your endpoint receives. Fetch the endpoint with
GET /v1/webhook_endpoints/<id>and read itsapi_version. If that isnull, your events use your account’s default version. Workbench, Stripe’s tools for building and debugging an integration, shows it on its Overview tab.Check: you have one version string, such as
2026-01-28.clover. -
Fetch a real object of each type at that version. Take the ID of a recent real object of that type from your account. Send the version in the
Stripe-Versionheader:curl https://api.stripe.com/v1/charges/<charge-id> \ -u "$STRIPE_KEY:" \ -H "Stripe-Version: 2026-01-28.clover"Check: each field on your list is a key in the response. A key whose value is
nullcounts. A key that is absent does not.If a field is absent, fetch the same object again with an older version in the header. Our charge had
invoiceat2024-06-20. A field that shows up there and not at your endpoint’s version points to a release in between. Stripe’s changelog names the change. Then read the field from where the change moved it, as we did forCharge.invoicebelow.
PASS: every field on your list is a key on each object you fetched at your endpoint’s version.
Step 1 on our own code: two more fields the types lack, neither yet shown missing
On 2026-10-07 we ran step 1 on our own code at one commit. It covered our webhook routes, the billing code they import and our shared Stripe package: 88 files, leaving out tests.
The search found 22 casts. Ten were our own database values, caught errors or code outside the webhook path. The other 12 read 13 fields on Stripe objects.
We compiled a direct read of each of the 13 against stripe-node 20.3.1. Nine compiled. Three failed with TS2339 because the type has no such field: Charge.invoice, Invoice.subscription and Subscription.current_period_start. The remaining one, an id read on an event’s object, which can be any of dozens of object types, failed because one of those types has no id.
Each of those three cast reads sits beside a typed read of the same value:
- For
Charge.invoice, the Invoice Payments lookup below. - For
Invoice.subscription, the invoice’sparent.subscription_details.subscription. - For
Subscription.current_period_start, the same field on the subscription’s items, which our code reads first.
Our lookup now asks Invoice Payments for the invoice
Our lookup now asks Stripe for the Invoice Payments on the charge’s payment intent, Stripe’s object for collecting one payment, and takes the invoice from them:
const page = await stripe.invoicePayments.list({
payment: { type: 'payment_intent', payment_intent: paymentIntentId },
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
paymentIntentId is the refunded charge’s payment_intent. startingAfter is the ID of the last row on the previous page.
- Finding the invoice again would have brought dead code back to life. By our reading of the code, the old code caught a failed invoice fetch and returned no product, and that catch could not run while the charge lacked the field. With the invoice found again, a Stripe error there would have made our webhook answer 200 and skip the refund for good. We removed that catch.
- It still reads
invoicedirectly when a charge arrives in an older version’s shape, as an ID or as an expanded object. - It pages through the list until it finds a second invoice or the list ends. When one payment intent paid two different invoices, it names none. By our reading of the code, the lookup then tries the checkout session the charge was paid through. If that names no product either, our webhook logs a skip and answers 200, the same silent skip as the bug.
- When the Invoice Payments call fails, the error now reaches our webhook route, which answers 500. In live mode Stripe then retries the delivery for up to three days, with an exponential back off. That call runs before our lookup of the charge’s checkout session. So while it fails, refunds for the products we identify from the checkout session wait too. By our reading of the code, if a failure outlasts the retries, those refunds stay unrecorded until someone resends the event by hand. Stripe allows a resend for up to 15 days after the event from its Dashboard, or 30 days with its CLI.
- We rebuilt the test’s fake charge in our endpoint’s version’s shape. The test now asserts the charge has no
invoicekey:assert.equal('invoice' in charge, false).
Stripe’s docs recommend that an endpoint return a 2xx quickly, before any complex logic. They also recommend handling events through an asynchronous queue. Our handler calls Stripe before it answers. It answers 500 when one of those calls fails, and Stripe then sends the event again. We did not test the queue form.
What the live charge and our tests showed
- On 2026-10-04 we fetched the refunded charge twice. At
2026-01-28.cloverit had noinvoicekey. At2024-06-20it had one. - The same day, the Invoice Payments list for that charge’s payment intent, fetched at
2026-01-28.clover, returned its invoice, marked paid. - The same day, we fetched that invoice at
2026-01-28.clover. Itsparent.subscription_details.subscriptionheld a subscription ID. - The refund’s
charge.refundedevent showedpending_webhooks: 0. Our ledger had no row for the refund. - On 2026-10-05, after the fix shipped, we had Stripe resend that event to our webhook. Our ledger then held a row for the refund, and our production log showed the refund confirmation email sent.
- On the old lookup, the tests ran 7 passing and 2 failing. Both failures were among the tests for a charge in our endpoint’s version’s shape and for errors that must reach the route.
- Before the paging change, we broke the fix three ways, one at a time: putting the catch back, counting Invoice Payment rows instead of distinct invoices, and dropping the read of an
invoicethat arrives as an expanded object instead of an ID. Each time exactly its own test failed: 11 passing, 1 failing. - Before the paging change, the fixed lookup passed 12 of 12 tests. Our final review, by an AI model, then found that it read at most two rows. If the first two rows named the same invoice, a different invoice on a later row went unseen. We added paging and one more test. That test failed before the paging change and passed after it.
- The merged code passed our full CI run.
- On 2026-10-07 we ran TypeScript 5.9.3 against stripe-node 20.3.1 on a direct
charge.invoiceread. It failed to compile with TS2339.
What you will see
error TS2339: Property 'invoice' does not exist on type 'Charge'.TypeScript printed this in our run on a direct read, with stripe-node 20.3.1. You see it when you take the cast off a read and compile. When we compiled direct reads of the 13 fields step 1 found in our code, the same error namedsubscriptiononInvoiceandcurrent_period_startonSubscription. The same error appears for a typo, or for a field that type never had. It points to a version change only when the field shows up at an older version, which step 3 checks.
What this doesn’t show
- One live refund, and that one resent. The fixed code has recorded one real refund: the 2026-10-04 refund, after we resent its event. We have not seen a new subscription refund go through it.
- No count of missed refunds. The cast read was committed on 2026-03-30. We have not checked when it reached production, or when our endpoint was created. We have not counted the subscription refunds since 2026-03-30 either.
- Step 3 not done on two of our three fields. At our endpoint’s version we fetched the refunded charge and its invoice, but no subscription. We did not record whether that invoice had a
subscriptionkey. So step 3 onInvoice.subscriptionis not done. Whether our typed read ofcurrent_period_startfinds its value on a real subscription is unchecked. Step 1 searched 88 files, not every file our handlers import. Your step 1 reaches only the code you point it at. - One object per type, read through the API. Step 3 fetches one object of each type. A key on that object does not show the key is on every object of that type. We fetched the charge, not the payload Stripe delivered to our webhook. That the payload had the same shape rests on Stripe’s docs. Our field was absent. We have not seen a version change leave a key present with a
nullvalue, so the rule that anullkey counts is ours, not one we tested. - Disputes not seen through the fix. By our reading of the code, our dispute handler uses the same product lookup. A dispute on a subscription charge would have skipped the dispute steps for our subscription, such as cancelling it. That handler alerts us when it cannot name a dispute’s product. We have not seen a dispute on a subscription charge go through the fixed code.
- One account, TypeScript and stripe-node 20.3.1. We tested one Stripe account, stripe-node 20.3.1 and TypeScript 5.9.3. We did not test another stripe-node version, untyped handlers, or another language’s library. Stripe’s docs say a webhook’s version should match the version its .NET, Java or Go library was generated for.