Tiago Fortunato
ProjectsOdysPayment

Stripe Webhook Verification

Stripe webhook: raw body, signature verification, event dispatch

Stripe Webhook Verification

The /api/stripe/webhook endpoint serves as the critical integration point for Odys to react to asynchronous events from Stripe. This handler is responsible for receiving notifications about significant payment lifecycle changes, such as completed checkouts, subscription updates, and cancellations. By processing these events, the application maintains an accurate and up-to-date record of user subscription statuses within its own database, ensuring that user access and features align with their payment plan.

Overview

The POST handler in src/app/api/stripe/webhook/route.ts is designed to be a robust listener for Stripe's event stream. Upon receiving a request, it first performs a crucial security check by verifying the request's authenticity using Stripe's signature mechanism. Once validated, the handler inspects the event type and dispatches the appropriate logic to update the application's data model, primarily affecting the professionals table. This table, with its 24 columns, stores vital subscription-related information such as plan, stripeCustomerId, and stripeSubscriptionId, which are directly managed by this webhook. The system is configured to listen for three specific Stripe webhook events: checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted.

Receiving and Verifying Webhook Events

The initial step in processing any incoming Stripe webhook is to ensure its legitimacy. The POST handler begins by reading the raw request body using req.text() and extracting the stripe-signature header. Stripe's signature verification process requires the raw body, not a pre-parsed JSON object, to compute the signature correctly.

const body = await req.text()
const sig = req.headers.get("stripe-signature")

If the stripe-signature header is missing, the request is immediately rejected with a 400 status, as it cannot be verified. The core of the verification lies in the call to stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!). This function uses the raw body, the signature, and a secret key (loaded from process.env.STRIPE_WEBHOOK_SECRET) to cryptographically verify that the event originated from Stripe and has not been tampered with. Any failure in this verification process results in an error being logged via logger.error("stripe_webhook_invalid_sig", { err }) and a 400 Bad Request response with the body { error: "Webhook inválido" }, preventing malicious or malformed requests from affecting the system. If verification is successful, the handler proceeds to process the event (as detailed in the 'Event Handling Logic' section) and ultimately responds with a 200 OK status and a JSON body { received: true }.

Event Handling Logic

Once an event is successfully verified, the handler proceeds to process it based on its type. The current implementation uses a series of if statements to differentiate between the three supported event types.

checkout.session.completed

This event signifies that a customer has successfully completed a checkout session, typically for a new subscription. The handler extracts the session object from the event data. Crucially, it retrieves professionalId and plan from session.metadata. These custom metadata fields are populated during the checkout session creation to link the Stripe transaction back to a specific professional within the Odys application.

if (event.type === "checkout.session.completed") {
  const session = event.data.object as Stripe.Checkout.Session
  const { professionalId, plan } = session.metadata ?? {}

  if (professionalId && plan) {
    await db.update(professionals).set({
      plan,
      stripeCustomerId: session.customer as string ?? undefined,
      stripeSubscriptionId: session.subscription as string ?? undefined,
      updatedAt: new Date(),
    }).where(eq(professionals.id, professionalId))

    capture({
      distinctId: professionalId,
      event: "subscription_started",
      properties: { plan },
    })
    setOnce({
      distinctId: professionalId,
      properties: { first_paid_subscription_at: new Date().toISOString() },
    })
  }
}

The professionals table is then updated, setting the plan to the newly purchased plan, and storing the stripeCustomerId and stripeSubscriptionId for future reference. Note that session.customer and session.subscription can sometimes be null from Stripe; the handler converts these to undefined to ensure proper database storage (typically as NULL). The updatedAt timestamp is also refreshed. Following the database update, analytics events (subscription_started) are captured and a first_paid_subscription_at property is set once for the professionalId, providing valuable insights into user behavior.

customer.subscription.updated

This event is triggered when a customer's subscription changes, often due to an upgrade or downgrade initiated through the Stripe customer portal. The handler checks if the subscription status is "active". It then attempts to match the priceId of the subscription's primary item to one of the predefined plans in the PLANS constant.

if (event.type === "customer.subscription.updated") {
  const sub = event.data.object as Stripe.Subscription
  if (sub.status === "active") {
    const priceId = sub.items.data[0]?.price.id
    const matchedPlan = priceId
      ? (Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][])
          .find(([, p]) => p.priceId === priceId)?.[0]
      : undefined

    if (matchedPlan) {
      await db.update(professionals).set({
        plan: matchedPlan,
        updatedAt: new Date(),
      }).where(eq(professionals.stripeSubscriptionId, sub.id))
    }
  }
}

If a matching plan is found, the professionals table is updated with the new plan and updatedAt timestamp, using the stripeSubscriptionId to locate the correct professional record.

customer.subscription.deleted

When a customer cancels their subscription, Stripe sends a customer.subscription.deleted event. This event signals that the professional should revert to the "free" plan.

if (event.type === "customer.subscription.deleted") {
  const sub = event.data.object as Stripe.Subscription
  await db.update(professionals).set({
    plan: "free",
    stripeSubscriptionId: null,
    updatedAt: new Date(),
  }).where(eq(professionals.stripeSubscriptionId, sub.id))
}

The handler updates the professionals table, setting the plan to "free" and nullifying the stripeSubscriptionId, effectively revoking access to paid features. The updatedAt timestamp is also updated.

Design decisions

The design of this webhook handler reflects several deliberate choices to ensure security, data consistency, and maintainability:

  • Raw Body for Verification: The decision to read the raw request body using req.text() before any parsing is fundamental for Stripe's signature verification. This is a security requirement, as the signature is computed over the exact bytes of the payload. Parsing the body into JSON first would alter it and invalidate the signature.
  • Stripe's constructEvent: Using stripe.webhooks.constructEvent directly offloads the complexity of signature validation, timestamp checks, and event parsing to the Stripe SDK. This reduces the risk of implementation errors in cryptographic checks and ensures adherence to Stripe's recommended security practices.
  • Direct Database Updates: The handler performs direct updates to the professionals table using Drizzle ORM. This approach keeps the application's internal state synchronized with Stripe's subscription status immediately upon receiving an event. The professionals table is the central source of truth for user plans, making it a logical target for these updates.
  • Metadata for Context: Relying on session.metadata to pass professionalId and plan during checkout.session.completed is a practical way to bridge the gap between Stripe's generic checkout sessions and Odys's specific user context. It avoids the need for additional database lookups or complex mapping logic within the webhook handler itself.
  • Centralized Plan Definitions: The PLANS constant, defined in src/lib/stripe.ts, centralizes the mapping between internal plan names and Stripe priceIds. This makes the customer.subscription.updated logic cleaner and less error-prone, as it can reliably find the corresponding internal plan based on the Stripe price.
  • Analytics Integration: The inclusion of capture and setOnce calls from src/lib/analytics.ts directly within the webhook handler for checkout.session.completed events demonstrates a commitment to tracking key business metrics. This ensures that subscription start events are recorded accurately and promptly, providing immediate feedback on user acquisition and monetization.

Potential improvements

While the current webhook handler effectively manages Stripe events, several areas could be enhanced to improve its architecture, resilience, and maintainability:

  1. Modular Event Dispatching: The current structure uses a series of if (event.type === "...") statements in src/app/api/stripe/webhook/route.ts. As the number of handled Stripe events grows beyond the current three (checkout.session.completed, customer.subscription.updated, customer.subscription.deleted), this approach can become less manageable. Introducing a dedicated event dispatcher or a map of event type to handler functions would make the code more modular, easier to extend, and improve readability. Each event type could have its own dedicated function, promoting separation of concerns.
  2. Idempotency Handling: Stripe webhooks can occasionally be delivered multiple times. The POST handler in src/app/api/stripe/webhook/route.ts does not currently implement explicit idempotency checks beyond the inherent idempotency of db.update operations on unique keys. For events that might trigger more complex side effects (e.g., sending emails, provisioning resources), it would be beneficial to store the event.id in the database and check if an event has already been processed before executing its logic. This prevents duplicate processing and potential inconsistencies.
  3. Asynchronous Processing for Long-Running Tasks: The POST handler performs direct database updates and analytics calls. While these are generally fast, for scenarios where these operations might be slow, or if more complex logic (e.g., calling external APIs, sending multiple notifications) were to be added, it could lead to the webhook timing out. Offloading the actual event processing to a background job queue (e.g., using a message broker like Redis Queue or a serverless function) would allow the webhook to respond to Stripe immediately with a 200 OK, improving resilience and preventing retries from Stripe. The current implementation is synchronous, which is simpler but less fault-tolerant for complex workflows.
  4. Granular Error Handling and Alerting: The try-catch block for stripe.webhooks.constructEvent provides a general error message ("Webhook inválido") and logs stripe_webhook_invalid_sig. However, errors that occur during the processing of a valid event (e.g., database connection issues, unexpected data in session.metadata) are not explicitly caught or handled. Adding more specific try-catch blocks around each event type's processing logic would allow for more targeted error logging, potentially triggering alerts for operational teams, and providing more informative responses if a processing error occurs.
  5. Type Safety for Metadata: The session.metadata ?? {} access in the checkout.session.completed handler is functional but relies on runtime checks for professionalId and plan. Defining a TypeScript interface for the expected session.metadata structure would provide compile-time type safety, making it clearer what metadata fields are expected and reducing the chance of runtime errors due to missing or misspelled properties.

References

  • src/app/api/stripe/webhook/route.ts

On this page