Plainform

Mobile Sidebar

Mobile Sidebar

Plainform Payment Webhooks Are Now Idempotent

30 May 2026
4 minute read
G
Gelu HorotanFullstack Engineer
Plainform Payment Webhooks Are Now Idempotent

Plainform's Stripe integration already handled the core payment flow: checkout, manual capture for one-time payments, subscription sync, customer portal access, and order status metadata.

The latest payment update tightens the part that matters when real production traffic gets messy: webhook idempotency.

In plain English, Plainform now protects the important Stripe webhook handlers from duplicate processing. If Stripe retries a webhook, or you resend the same event during testing, the app can skip work that already happened.

What changed

Plainform now stores webhook processing keys in the database using a StripeWebhookProcess model. The webhook handlers claim a key before running side effects, mark it as processed after success, and mark it as failed if the handler errors.

If you pull this update into an existing project, run the included Prisma migration before testing or deploying payment webhooks. The idempotency layer depends on the StripeWebhookProcess table.

The protected flows are the ones that can affect users or billing state:

stripe:checkout-session-completed:{session.id}
stripe:subscription-checkout-session-completed:{session.id}
stripe:subscription-created:{subscription.id}
stripe:subscription-updated:{event.id}
stripe:subscription-deleted:{subscription.id}

This protects the places where duplicate processing would be annoying or dangerous: order emails, checkout metadata updates, subscription sync, future provisioning code, seat changes, credits, and other app-specific logic you add later.

Ignored Stripe events such as charge.*, payment_intent.*, and mandate.* are not stored in this table. The goal is not to save every Stripe event forever. The goal is to guard the webhook handlers that actually do important work.

Why we added it

Stripe webhooks can be delivered more than once. That is normal webhook behavior, not a Stripe bug.

A duplicate delivery is usually harmless if your handler only updates a row to the same value. But payment webhooks often do more than that. They send emails. They provision paid access. They create onboarding records. They grant credits. They change subscription state.

Those side effects need a stronger guard than metadata.processed on a Checkout session.

Plainform still uses Stripe session metadata for the order page because it is useful UI state. The order page can read paymentStatus and show a processing state while the webhook is still finishing.

But fulfillment idempotency now lives in the database, where a unique key can act as the durable lock.

What this means for users

If you are using Plainform as a starter kit, you get safer payment defaults without changing how checkout feels.

For one-time payments, the checkout completion handler claims an idempotency key before capture, metadata updates, and emails. Resending the same checkout.session.completed event should not send another order email.

For subscriptions, the checkout-level handler and lifecycle handlers are protected separately. This matters because subscription work is split across different responsibilities:

  • handleSubscriptionCheckoutSessionCompleted() handles checkout-level subscription work.
  • handleSubscriptionCreated() handles created-only effects.
  • handleSubscriptionUpdated() handles recurring changes like upgrades, downgrades, renewals, and cancellation scheduling.
  • handleSubscriptionDeleted() handles cleanup after a subscription ends.

You can still customize these handlers. The important rule is simple: add app logic after the existing sync or cleanup succeeds, inside the idempotent handler.

What to watch when customizing

The update does not mean every custom side effect is magically correct forever. It gives you a solid default guard, but you still need to choose the right key for the behavior you add.

For example, customer.subscription.updated uses the Stripe event ID because a subscription can be updated many times. That prevents duplicate processing for the same event without blocking legitimate future updates.

If you add a side effect tied to a specific business transition, make the key match that transition. A plan-change email might be keyed by subscription ID plus the new price. A monthly usage reset might be keyed by subscription ID plus billing period. A cancellation-scheduled email might include the cancel date.

That keeps retries safe without suppressing real future changes.

The customer fallback fix

This update also documents the stale Stripe customer fallback in checkout.

If a local user row stores a stripeCustomerId that no longer exists in the current Stripe account or mode, Stripe returns a No such customer error. Plainform now handles that case by creating a new Stripe customer and saving the new ID before continuing checkout.

That makes local testing less fragile, especially when test customers get deleted or when a database is reused across Stripe test data resets.

What Changes in Practice

Plainform payments now handle common production webhook cases more safely: retries, resends, stale customer IDs, and repeated side effects.

The flow still works the same from the user's point of view. The difference is underneath: important Stripe webhook handlers now claim durable idempotency keys before doing work, so duplicate deliveries do not turn into duplicate fulfillment.

If you are building on top of Plainform, this gives you a cleaner place to add seats, credits, onboarding records, emails, and subscription-specific app logic on top of the existing payment flow.

Product
Share this article
Comments on this page

Leave comment

Stay up to date with our latest product updates. Unsubscribe anytime!

2026 © All rights reserved