Back to BlogTroubleshooting

Stripe Webhook Returned 200 but the Subscription Didn't Update

Rupak Amin

Founder & Lead Engineer, RAITHub

13 min read

A 200 only tells Stripe your endpoint received the event; it says nothing about whether your database changed. The usual causes are a handler that catches its own error and still returns 200, an older event overwriting a newer one, a lookup that matches no row, an event ID recorded before the work committed, or code reading current_period_end from the subscription on API 2025-03-31.basil or later.

This guide is for the confusing case: the Event deliveries tab shows green, but the customer who just paid still sees the free plan, or a cancelled customer still has access. Everything here is about what happens inside the handler after delivery succeeds. The Stripe facts come from Stripe's documentation, checked on 29 September 2026; the code uses Next.js route handlers and node-pg. If you are setting billing up for the first time, the Stripe billing guide covers the objects and a complete handler, and this post does not repeat it. On proof: PropDesk, a property-management SaaS RAITHub built, collects rent through Stripe and runs 1,024 automated tests.

Why would Stripe show 200 when nothing changed?

Because Stripe only sees your status code. It retries on a non-2xx response and stops on a 2xx, so any path in your handler that returns 200 without committing the change ends the story as far as Stripe is concerned. The event is not retried, and the data stays wrong until something else touches it.

CauseWhat you seeFix
Error caught and answered with 200An error in your logs next to a 200 in StripeReturn 500 on failure so Stripe retries
Older event processed after a newer oneStatus flips back, such as active to incompleteRetrieve the current object from the API before writing
Update matched no rowNo error at all; rowCount is 0Treat 0 rows as a failure; link the customer before checkout
Event ID recorded before the work committedThe retry is skipped as a duplicateRecord the ID in the same transaction as the change
Field moved in API 2025-03-31.basilPeriod end saved as null; invoice not linked to a subscriptionRead items.data[].current_period_end and parent.subscription_details.subscription
Event type not handledA default branch returns 200Log unhandled types; subscribe only to what you handle
Wrong environmentThe right change, in a different databaseCheck which deployment the endpoint URL points at, and event.livemode
Work after the response was cut offNothing logged after "received"Commit before responding, or use a durable queue
Stale page, correct dataThe database row is right; the screen is notRevalidate the cached page or data after the write

Start with the last row. Query the account's row directly. If it is correct, the webhook worked and the page is cached; call revalidatePath or revalidateTag after the write. If the row is wrong, work up the table.

How do you find which one it is?

Follow one event ID from Stripe to your database. It takes minutes and removes the guessing.

  1. Copy the event ID (evt_...) from the Event deliveries tab, and note the response body your server returned. A body like "ok" versus "Duplicate" or "Ignored" tells you which branch ran.
  2. Search your logs for that ID. Log the event ID, type and outcome on every request. If the ID is missing, the request went to a different deployment.
  3. Check your event table. If the ID is recorded but the row did not change, the ID was saved outside the transaction that failed.
  4. Retrieve the object from Stripe and compare its status, price and period end with your row.
  5. Check the endpoint's API version. Stripe's webhooks documentation says an event is shaped by the API version in effect when it occurs, and a destination can be set to its own version. Code written for one shape misreads the other.

Why does catching the error and returning 200 lose the event?

Because Stripe retries only failures. A try/catch that logs and returns 200 turns a temporary database error into permanent wrong data. Stripe's own stripe-node App Router example returns 500 when handling fails, and so should yours.

// Wrong: the retry that would have fixed it never comes
try {
  await processEvent(event)
} catch (err) {
  console.error(err)
}
return new Response('ok', { status: 200 })

// Right: only a committed change earns a 200
try {
  await processEvent(event)
  return new Response('ok', { status: 200 })
} catch (err) {
  console.error('stripe webhook failed', event.id, event.type, err)
  return new Response('Retry', { status: 500 })
}

Stripe retries a live event for up to three days with exponential back-off, and a sandbox event three times over a few hours, so a 500 is recoverable. A 200 on failure is not. Stripe also emails you when an endpoint keeps failing, which is the alert you want.

How do out-of-order Stripe events overwrite newer data?

Stripe "doesn't guarantee the delivery of events in the order that they're generated", according to its webhooks documentation. If your handler writes whatever the payload says, a late event wins.

A typical sequence: a customer subscribes with a card that needs authentication. Stripe sends customer.subscription.created with status incomplete, then customer.subscription.updated with active once payment succeeds. If the second is delivered first and the first arrives after a retry, a payload-driven handler writes incomplete last, and a paying customer loses access. Stripe returns 200 both times.

Two rules fix it:

  • Treat the event as a signal, not the truth. Retrieve the subscription from the API and write its current state. Stripe's documentation suggests using the API to retrieve objects when events arrive out of order.
  • Do not order by timestamp. Stripe says snapshot events record created in seconds, distinct events can share a timestamp, and created should not be used to determine order or whether you have processed an event.

What changed in Stripe API 2025-03-31.basil?

Two fields that subscription handlers depend on moved. Code that reads the old location gets undefined, often writes null and still returns 200.

  • Billing period. Stripe's basil changelog says current_period_start and current_period_end "are no longer available on the subscription resource". Read items.data.current_period_end on each subscription item instead. The matching Node SDK is v18.0.0.
  • Invoice to subscription. On 2025-03-31.basil or later, Stripe's subscription webhooks guide reads an invoice's subscription from parent.subscription_details.subscription. Code that reads a top-level invoice.subscription finds nothing and cannot find the account.
  • previous_attributes on customer.subscription.updated now reports changes to the item's billing period, per the same changelog.

If a subscription has several items, each has its own period. Decide which one governs access, or take the earliest.

What does a processor that fails loudly look like?

It re-reads the subscription, writes it by subscription ID, treats zero rows as an error, and records the event ID in the same transaction. This is the processing half; verify the signature first, as in the billing guide.

import Stripe from 'stripe'
import type { PoolClient } from 'pg'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)

function subscriptionIdFrom(event: Stripe.Event): string | null {
  if (event.type.startsWith('customer.subscription.')) {
    return (event.data.object as Stripe.Subscription).id
  }
  if (event.type === 'invoice.paid' || event.type === 'invoice.payment_failed') {
    // API 2025-03-31.basil and later: the link lives under parent.
    const sub = (event.data.object as Stripe.Invoice).parent?.subscription_details?.subscription
    return typeof sub === 'string' ? sub : sub?.id ?? null
  }
  return null
}

export async function processEvent(db: PoolClient, event: Stripe.Event) {
  await db.query('BEGIN')
  try {
    const fresh = await db.query(
      'INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
      [event.id, event.type],
    )
    if (fresh.rowCount === 0) {
      await db.query('ROLLBACK') // already processed
      return
    }

    const subscriptionId = subscriptionIdFrom(event)
    if (subscriptionId) {
      // The event is a signal; the API holds the current truth.
      const sub = await stripe.subscriptions.retrieve(subscriptionId)
      const periodEnd = Math.min(...sub.items.data.map((item) => item.current_period_end))
      const accountId = sub.metadata.account_id

      const res = await db.query(
        'UPDATE accounts SET stripe_subscription_id = $1, status = $2, period_end = to_timestamp($3) WHERE id = $4',
        [sub.id, sub.status, periodEnd, accountId],
      )
      // Zero rows is a failure, not a success: throw so the route returns 500 and Stripe retries.
      if (res.rowCount !== 1) throw new Error('No account for subscription ' + sub.id)
    }

    await db.query('COMMIT')
  } catch (err) {
    await db.query('ROLLBACK')
    throw err
  }
}

The account ID comes from subscription metadata, set when the Checkout Session is created with subscription_data: { metadata: { account_id } }. That removes a common race: customer.subscription.created arriving before checkout.session.completed has linked the Stripe customer to your account, so a lookup by customer ID matches nothing.

How does the event-ID table make webhooks idempotent?

It turns "this event again" into a no-op, but only if the ID and the change commit together. Stripe says endpoints "might occasionally receive the same event more than once" and recommends logging processed event IDs and skipping them.

CREATE TABLE stripe_events (
  id          text PRIMARY KEY,           -- evt_... from Stripe
  type        text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

The trap is recording the ID first, in its own statement outside the transaction, and then doing the work. If the work fails, the ID is already saved, the retry is skipped as a duplicate, and the change never happens. In the processor above, a failure rolls back the ID with everything else, so Stripe's retry finds it absent and tries again.

Two refinements. Stripe notes that sometimes two separate Event objects are sent for one change; to catch those, it suggests using the ID of data.object together with event.type. And re-reading state from the API is idempotent on its own: applying the current subscription twice lands in the same place. The event table matters most for side effects that are not, such as sending an email, adding credits or writing to a ledger. The general principle is in what idempotency means in API design.

Should you do the work before or after returning 200?

Before, unless you have a durable queue. Stripe's documentation says to return a 2xx quickly, before complex logic that could time out, and recommends an asynchronous queue at volume. On serverless hosting, work started after the response can be cut off when the function ends, and a failure there is invisible to Stripe, so it will not retry.

  • Modest volume: verify, process in one transaction, then respond. A retrieve and an update usually fit well inside the timeout.
  • Bursty volume, such as renewals at the start of the month: store the verified event in an inbox table, return 200, and let a worker process it with its own retries. The inbox row is your durable record, so nothing depends on Stripe retrying.

Next.js has after() for work after a response, but it is not a queue: if that work fails, nothing retries it.

Could the event be updating a different environment?

Yes, and it looks exactly like a silent failure. Check these before rewriting the handler:

  • Test and live mode have separate endpoints and secrets. A sandbox event correctly processed by a staging deployment never touches production data. Compare event.livemode with the environment.
  • Preview deployments often have their own database. An endpoint pointed at a preview URL updates that database.
  • The Stripe CLI forwards to whichever local URL you gave stripe listen --forward-to, and prints its own signing secret. Events you watch arrive locally are not the ones your production endpoint receives.
  • Signature verification must run on the raw body from req.text(). A handler that skips verification to "get it working" returns 200 to anyone, including forged requests.
  • A 405 or redirect is not this problem; those show as failures in Stripe, not 200s.

How do you test it so it stays fixed?

Turn each cause in the table into a test in CI. These are the ones RAITHub writes for Stripe handlers:

  • Replay: process the same event twice; assert one change and one email.
  • Out of order: process updated (active), then a late created (incomplete); assert the account stays active.
  • Missing account: an event for a subscription with no matching account returns 500, not 200.
  • Rollback: force the update to fail; assert the event ID was not recorded.
  • Basil shape: a fixture with the period on the item and the invoice link under parent; assert the period end is not null.
  • End to end: stripe trigger through the CLI against a local database, and stripe events resend to replay a real event after a fix.

Why RAITHub for a Stripe handler that silently fails?

Because the fix is rarely one line, and it has to be proven against retries, duplicates and late events rather than one happy-path click.

  • Stripe in production. PropDesk collects rent through Stripe, in a codebase with 1,024 automated tests.
  • Data repair included. A handler that returned 200 on failure has left wrong rows behind. The fix report lists which accounts were affected and how they were re-synced from Stripe.
  • Tests that stay. Replay, ordering and rollback tests are gated in CI. See the API and backend development service.
  • Fixed scope. A free 15-minute technical audit, then a written, fixed quote. You own the code; an NDA is standard.

When you don't need us

  • The row is correct and only the page is stale. That is a cache revalidation, not a billing fix.
  • One code path returns 200 in a catch block. Change it to 500, re-send the failed events, and you are done.
  • You would rather not run billing state yourself. Stripe's hosted Billing Portal and entitlements features may cover a simple product.

If your data and Stripe still disagree, send RAITHub an event ID, your handler and the row you expected, or start from the fix one issue page.

Last reviewed: 29 September 2026. Stripe API details checked against docs.stripe.com on 29 September 2026.

Frequently asked questions

Why does my Stripe webhook return 200 but the subscription is not updated?

Usually the handler returned 200 without committing the change: it caught an error, updated zero rows, skipped the event as a duplicate after an earlier failed attempt, or read a field that moved in API 2025-03-31.basil. Follow the event ID from Stripe's delivery log to your database to find which.

Does Stripe retry a webhook that returned 200?

No. Stripe retries only non-2xx responses: for up to three days in live mode and three times over a few hours in a sandbox. Return 500 when processing fails so the retry can fix it.

How do I handle Stripe events arriving out of order?

Treat each event as a signal and retrieve the current object from the Stripe API before writing. Do not use the event's created timestamp to order events; Stripe says distinct events can share a timestamp.

Where did current_period_end go in the Stripe API?

Since API version 2025-03-31.basil it is on each subscription item, read as items.data[].current_period_end. It is no longer on the subscription object, so older code reads undefined.

Why is invoice.subscription empty in my webhook?

On API 2025-03-31.basil and later, an invoice points to its subscription through parent.subscription_details.subscription. Older versions had a top-level subscription field.

Should I record the Stripe event ID before or after processing?

In the same database transaction as the change. Recorded first on its own, a failed attempt leaves the ID behind and the retry is skipped as a duplicate.

How can I fix accounts that were left wrong?

List subscriptions from the Stripe API, compare each with your accounts table, and re-sync the ones that differ with the same processor, inside transactions. Then add the missing test so it cannot recur.

Stripe webhook 200subscription not updatedout-of-order Stripe eventsidempotencycurrent_period_endNext.jserror fix

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.