Your SaaS Needs 5 Stripe Webhook Events. Wire Them to One Entitlements Table

2026-11-06来源 dev.to NodeEnglish
Your SaaS Needs 5 Stripe Webhook Events. Wire Them to One Entitlements Table

You ship the paywall on a Friday. Checkout works, checkout.session.completed lands, your handler sets is_pro = true, and the dashboard unlocks. Nobody wrote the code that sets it back. A few months later you open the Stripe Dashboard and find canceled subscriptions whose owners still use every paid feature, because the only event your app ever listened to was the one that says yes.

The other common setup fails in a different way. The endpoint subscribes to every event type, the handler grows a switch with dozens of cases nobody tests, and the server does work it never needed. Stripe's own webhook best practices say to configure endpoints to receive only the event types your integration requires, and warn that listening for all of them strains your server.

A subscription SaaS needs five events to decide who gets in. Wire those five into one entitlements table with Node.js, Express, PostgreSQL through node-postgres, and the official stripe-node SDK, then test every path locally with the Stripe CLI before a real card ever touches it.

The five events, and what each one tells you

Stripe's subscription webhooks guide lists more than twenty event types. These five are the ones that change whether a user has access:

  • checkout.session.completed links your user to Stripe's customer and subscription. The session carries the client_reference_id you set when you created it, and in subscription mode it carries the subscription ID.
  • customer.subscription.updated fires when a subscription starts or changes: renewals, plan switches, coupons, and status moves like trialing to active or active to past_due.
  • customer.subscription.deleted fires when a subscription ends. This is the one the Friday paywall never handled.
  • invoice.paid fires on a successful payment. Stripe's guide says to retrieve the subscription after this event and confirm its status is active before extending access, because a paid invoice does not always make a subscription active.
  • invoice.payment_failed fires when a charge fails. On a failed renewal, access usually stays for now; this is your cue to tell the customer their card needs attention.

The design rule that makes the rest of this work: none of these payloads is the source of truth for state. Each event is a doorbell that tells you which subscription changed. The handler then asks Stripe what that subscription looks like right now and writes that to the table.

That rule exists because of ordering. Stripe's docs state that it does not guarantee delivery of events in the order they were generated, and suggest using the API to fetch objects when an event arrives before the one you expected. If you write state from payloads, a delayed customer.subscription.updated saying active can land after customer.subscription.deleted and quietly resurrect a canceled user. If you re-fetch, the late event reads canceled from Stripe and writes canceled again.

Five small envelopes on separate wires all leading into one wooden card-catalog drawer
Five events, one table. Every event only says which subscription to look at.

Two tables: entitlements and the events you have seen

CREATE TABLE stripe_events ( id text PRIMARY KEY, -- evt_... type text NOT NULL, received_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE entitlements ( stripe_subscription_id text PRIMARY KEY, stripe_customer_id text NOT NULL, user_id text, status text NOT NULL, price_id text, current_period_end timestamptz, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX entitlements_user_id ON entitlements (user_id); 

One row per subscription, not per user. When someone cancels and subscribes again, they get a new subscription ID and a new row, so a late event about the old canceled subscription can only touch the old row. user_id is nullable because a subscription event can arrive before checkout.session.completed does; the row exists first and the user link fills in when checkout's event lands.

stripe_events is the dedupe table. Stripe's docs say endpoints occasionally receive the same event more than once and recommend logging processed event IDs. Re-fetching already makes the entitlement write safe to repeat, so the table earns its place on side effects: the "update your card" email should go out once per failed payment, not once per delivery.

The handler: verify, dedupe, lock, re-fetch, write

The server starts with a Stripe client, a Postgres pool, the signing secret, and the set of five event types the handler accepts.

// server.mjs (Node 20+) import express from "express"; import pg from "pg"; import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, }); const whsec = process.env.STRIPE_WEBHOOK_SECRET; const HANDLED = new Set([ "checkout.session.completed", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "invoice.payment_failed", ]); const app = express(); 

The webhook route needs the raw request body, because the stripe-node README is explicit that constructEvent must get the body exactly as received; a parsed JSON body fails verification. That is why express.json() is mounted after it.

// raw body: the signature covers these exact bytes app.post( "/webhook", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"], whsec, ); } catch { return res.status(400).send("invalid signature"); } if (!HANDLED.has(event.type)) return res.sendStatus(200); try { const applied = await handle(event); // if (applied && event.type === "invoice.payment_failed") // queue the "update your card" email here res.sendStatus(200); } catch (err) { console.error(event.id, err); res.sendStatus(500); // Stripe will retry } }, ); app.use(express.json()); // JSON parsing for your other routes app.listen(4242); 

The SDK's verification also checks the signature timestamp; Stripe's libraries default to a 5-minute tolerance, and the docs warn against setting it to 0, which turns the freshness check off. Events outside the five get a 200, so Stripe marks that delivery done and does not retry it; with the endpoint's enabled events set to these five, they should not arrive at all. A thrown error returns 500, and Stripe retries for up to three days in live mode (three attempts over a few hours in a sandbox).

subscriptionIdOf maps each event to the subscription it is about. A one-off payment has no subscription, so it returns null and the handler skips the event.

function subscriptionIdOf(event) { const obj = event.data.object; switch (event.type) { case "checkout.session.completed": return obj.mode === "subscription" ? obj.subscription : null; case "customer.subscription.updated": case "customer.subscription.deleted": return obj.id; case "invoice.paid": case "invoice.payment_failed": // 2025-03-31.basil moved this under parent return ( obj.parent?.subscription_details?.subscription ?? obj.subscription ?? null ); } return null; } 

handle runs everything else in one transaction. Its first half records the event ID, bails out on a duplicate, and makes sure a row exists for the subscription:

async function handle(event) { const subId = subscriptionIdOf(event); if (!subId) return false; // one-off payment, not a plan const obj = event.data.object; const userId = event.type === "checkout.session.completed" ? obj.client_reference_id : null; const db = await pool.connect(); try { await db.query("BEGIN"); const first = await db.query( `INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [event.id, event.type], ); if (first.rowCount === 0) { await db.query("ROLLBACK"); return false; // duplicate delivery } await db.query( `INSERT INTO entitlements (stripe_subscription_id, stripe_customer_id, status) VALUES ($1, $2, 'unknown') ON CONFLICT (stripe_subscription_id) DO NOTHING`, [subId, obj.customer], ); 

The second half locks that row, asks Stripe for the subscription's current state, and writes it:

 // one writer per subscription at a time await db.query( `SELECT 1 FROM entitlements WHERE stripe_subscription_id = $1 FOR UPDATE`, [subId], ); // ignore the payload; ask Stripe for the current state const sub = await stripe.subscriptions.retrieve(subId); const item = sub.items.data[0]; await db.query( `UPDATE entitlements SET user_id = COALESCE($2, user_id), status = $3, price_id = $4, current_period_end = to_timestamp($5), updated_at = now() WHERE stripe_subscription_id = $1`, [ sub.id, userId, sub.status, item?.price.id ?? null, item?.current_period_end ?? null, ], ); await db.query("COMMIT"); return true; } catch (err) { await db.query("ROLLBACK"); throw err; } finally { db.release(); } } 

The event ID goes into stripe_events inside the same transaction as the entitlement write, and that is what makes retries safe. If the Stripe call or the update throws, both roll back, the route returns 500, and the retry runs the whole thing again. Inserting the ID first and committing it separately would mark a failed event as done.

The FOR UPDATE lock makes the re-fetch trustworthy under concurrency. Without it, two deliveries for the same subscription can fetch a few milliseconds apart and commit in reverse order, writing the older snapshot last. With it, the second handler waits, then fetches after the first one commits. Holding a row lock across one HTTP call is fine at side-project volume. At higher volume, Stripe's guide recommends processing events from an asynchronous queue, since renewal spikes at the start of a month can swamp a synchronous endpoint.

The invoice branch reads two field paths on purpose. API version 2025-03-31.basil removed invoice.subscription and put the ID at invoice.parent.subscription_details.subscription. Webhook payloads are shaped by the API version of your account or endpoint, so a project that upgraded the SDK but not the endpoint can see either shape.

The period end comes from the subscription item. The same release removed current_period_end from the Subscription and added it to each SubscriptionItem. Because retrieve uses the SDK's API version, reading it from sub.items.data[0] works on a current stripe-node regardless of what the webhook payload looked like.

A figure ignoring a jumbled pile of arriving envelopes and reading one large index card through a magnifying glass
Events arrive in any order. The handler reads the subscription itself, so the access row ends up right either way.

Reading access is one query

export async function hasAccess(userId) { const { rowCount } = await pool.query( `SELECT 1 FROM entitlements WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due') LIMIT 1`, [userId], ); return rowCount > 0; } 

Keeping past_due in the allowed set is a product choice consistent with Stripe's guidance: when a subscription goes past_due, notify the customer; when it goes canceled or unpaid, revoke access. incomplete stays out, since a first payment that never succeeds within 23 hours ends as incomplete_expired. A user who cancels at period end needs no extra code either. Stripe's cancellation guide says setting cancel_at_period_end lets the subscription run out the time already paid for. customer.subscription.deleted fires when the period ends, so the row stays active until then and flips to canceled after.

Test every path before launch with the Stripe CLI

Start the server, then forward only the five events:

EVENTS=checkout.session.completed EVENTS=$EVENTS,customer.subscription.updated EVENTS=$EVENTS,customer.subscription.deleted EVENTS=$EVENTS,invoice.paid,invoice.payment_failed stripe listen --events "$EVENTS" \ --forward-to localhost:4242/webhook 

stripe listen prints a whsec_... signing secret; put it in STRIPE_WEBHOOK_SECRET and restart the server. The secret does not change between restarts of listen. In a third terminal, fire the triggers and look at the table after each one:

stripe trigger customer.subscription.updated stripe trigger customer.subscription.deleted stripe trigger checkout.session.completed stripe trigger invoice.payment_failed psql "$DATABASE_URL" -c \ "SELECT stripe_subscription_id, status, user_id FROM entitlements" 

Know what each trigger fixture builds, so you know what to expect. The customer.subscription.updated fixture creates a customer with a test Visa card, a $15/month price and a subscription, then adds metadata, so you should get an active row with no user_id. The customer.subscription.deleted fixture does the same and then cancels, so that row should end at canceled whatever order its deliveries arrive in. The checkout.session.completed fixture uses mode: "payment" and the invoice.payment_failed fixture builds a one-off invoice, so both should return 200 and write nothing. That empty result is the test for the one-off guard.

Stripe's billing testing guide says synthetic triggers do not validate the full subscription lifecycle, so finish with two real sandbox runs. Create a Checkout Session in subscription mode with client_reference_id set to a test user and pay with 4242 4242 4242 4242; the row should carry your user ID. For a failed renewal, the guide suggests setting 4000000000000341 as the customer's default payment method on a subscription with a short trial, or running a test-clock simulation from the Dashboard to jump to the renewal date.

What these five leave out

invoice.payment_action_required (3D Secure), customer.subscription.trial_will_end (sent three days before a trial ends), and the dispute and refund events Stripe lists under risk handling all matter for emails and support. Add them when the product needs them, and decide per product whether a dispute should touch the access bit. If you would rather have Stripe map products to features for you, its Entitlements feature sends entitlements.active_entitlement_summary.updated, and the same verify-dedupe-re-fetch handler shape still applies.

If this was useful

Billing is one of the pieces between localhost and production that side projects most often wire halfway. Ship It covers that whole stretch for a small team: hosting, payments, auth, CI/CD and monitoring, with the choices made for you and the trade-offs written down.

Ship It — the pragmatic startup tech stack

原文:https://www.a8q9.cn/en/article/your-saas-needs-5-stripe-webhook-events-wire-them-to-one-entitlements-table-e093f4.html

相关文章