Stripe webhook: raw body, signature verification, event dispatch
Handling Stripe webhook events with raw body parsing, signature validation, and database updates for subscription lifecycle management.
Stripe webhook: raw body, signature verification, event dispatch
This page covers the core logic responsible for securely receiving and processing Stripe webhook events in Odys. It ensures that critical subscription lifecycle events—such as successful payments, upgrades, downgrades, and cancellations—are accurately reflected in the application state. Given that financial integrity and user plan access depend on this endpoint, reliability and correctness are paramount.
Overview
The webhook handler at /api/stripe/webhook is one of 21 API routes in Odys, specifically designed to receive POST events from Stripe. It operates within a 10-table schema where the professionals table plays a central role: storing subscription status, plan type, and Stripe identifiers like stripeCustomerId and stripeSubscriptionId. The handler listens for three key Stripe events: checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted, which together cover the full subscription lifecycle.
These events are dispatched by Stripe whenever a user completes checkout, modifies their plan via the customer portal, or cancels their subscription. The system uses the STRIPE_WEBHOOK_SECRET to cryptographically verify each payload, ensuring that only legitimate events from Stripe are processed.
Event Handling Flow
The handler begins by reading the raw request body using req.text()—a necessary step because Stripe's signature verification requires the exact raw payload. This is paired with extracting the stripe-signature header, which is passed alongside the body and secret into stripe.webhooks.constructEvent. If verification fails, the request is rejected with a 400 error.
Upon successful verification, the event type determines the next action. For checkout.session.completed, the handler reads professionalId and plan from session metadata and updates the corresponding professional’s record with the new plan, customer ID, and subscription ID. This links the payment outcome directly to the user’s account.
For customer.subscription.updated, the system inspects the subscription’s price ID and matches it against known plans (basic, pro, premium) using the PLANS configuration. If a match is found, the professional’s plan is updated—enabling seamless plan changes without requiring another checkout.
Finally, on customer.subscription.deleted, the handler resets the professional’s plan to free and clears their stripeSubscriptionId, effectively downgrading them while preserving their data.
Design decisions
The use of raw body parsing via req.text() instead of req.json() ensures byte-for-byte fidelity with Stripe’s original payload, which is essential for secure signature verification. The decision to store Stripe identifiers directly in the professionals table (rather than a separate billing table) reflects a denormalized, performance-oriented schema that allows fast lookups by stripeSubscriptionId during webhook processing.
By handling all subscription logic in a single endpoint, the system maintains coherence and reduces duplication. The reliance on metadata in the checkout session to pass professionalId and plan is a pragmatic approach to context preservation across asynchronous events.
Potential improvements
-
Add idempotency key tracking – In
src/app/api/stripe/webhook/route.ts, the handler does not yet store or check Stripe’sidempotency_key(present inevent.id). This could lead to duplicate processing if Stripe retries an already-handled event. Consider using theidof the Stripe event as a deduplication key in a temporary store. -
Validate metadata presence more robustly – In the
checkout.session.completedhandler, destructuringprofessionalIdandplanfromsession.metadataassumes they exist. A validation guard or early return would prevent silent failures if metadata is missing or malformed. -
Centralize plan lookup logic – The price ID to plan key mapping is duplicated in both
checkout.session.completedandcustomer.subscription.updatedhandlers. Extracting this into a shared utility function (e.g.,getPlanFromPriceId) would reduce redundancy and improve maintainability.
References
src/app/api/stripe/webhook/route.ts: Full webhook handler implementationprofessionalstable schema: Contains plan, Stripe IDs, and subscription statePLANSconfiguration: Maps Stripe price IDs to internal plan tiers