Alt description missing in image
Launching on ProductHunt today!
Alt description missing in image

stripeWebhook()

Validates incoming Stripe webhook events and triggers syncStripeData() for relevant payment events to keep your DB entitlements in sync.

A webhook endpoint is automatically set up for you at /api/webhooks/stripe

Usage - Webhook

This route is already set up for you:

apps/next/app/api/webhooks/stripe/route.ts
import { createNextRouteHandler } from '@green-stack/schemas/createNextRouteHandler'
import { stripeWebhook } from '@payments/stripe/resolvers/stripeWebhooks.resolver'
 
/* --- Routes ---------------------------------------------------------------------------------- */
 
export const POST = createNextRouteHandler(stripeWebhook, false)

Implementation details

  • Validates the Stripe webhook signature using STRIPE_WEBHOOK_SECRET.
  • Extracts the event from the request body.
  • Processes relevant events asynchronously via waitUntil().
  • Calls syncStripeData() for each tracked event with the customer’s customerId.

Tracked events — The webhook listens for the following Stripe events:

stripeWebhooks.resolver.ts
export const TRACKED_EVENTS: Stripe.Event.Type[] = [
    'checkout.session.completed',
    'customer.subscription.created',
    'customer.subscription.updated',
    'customer.subscription.deleted',
    'customer.subscription.paused',
    'customer.subscription.resumed',
    'customer.subscription.pending_update_applied',
    'customer.subscription.pending_update_expired',
    'customer.subscription.trial_will_end',
    'invoice.paid',
    'invoice.payment_failed',
    'invoice.payment_action_required',
    'invoice.upcoming',
    'invoice.marked_uncollectible',
    'invoice.payment_succeeded',
    'payment_intent.succeeded',
    'payment_intent.payment_failed',
    'payment_intent.canceled',
]
stripeWebhooks.resolver.ts
export const stripeWebhook = createResolver(async ({ req, tryCatch, withDefaults }) => {
 
    const body = await req.text()
    const sig = (await headers()).get('Stripe-Signature')
 
    // Validate webhook secret exists
    if (!process.env.STRIPE_WEBHOOK_SECRET) {
        console.error('[STRIPE] -!- No webhook secret in env vars')
        return NextResponse.json({}, { status: 500 })
    }
 
    if (!sig) return NextResponse.json({}, { status: 400 })
 
    // Process event asynchronously
    const startEventProcessing = async () => {
        const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
        waitUntil(processEvent(event))
    }
 
    const { error } = await tryCatch(startEventProcessing())
    if (error) console.error('[STRIPE] -!- Webhook error:', error)
 
    return withDefaults({ received: true })
})

Input & Output

The webhook input schema is empty, as data must come in from the raw request body.

For the output though, we send a simple recieved flag:

import { StripeWebhookBridge } from '@payments/stripe/resolvers/stripeWebhooks.resolver'
export const StripeWebhookBridge = createDataBridge({
    resolverName: 'stripeWebhook',
    inputSchema: schema('StripeWebhooksInput', {}),
    outputSchema: schema('StripeWebhooksOutput', { received: z.boolean() }),
    apiPath: '/api/stripe/webhooks',
    allowedMethods: ['POST'],
    isMutation: true,
})

Testing webhooks locally

Install the stripe cli first, then run this:

stripe listen --forward-to http://localhost:3000/api/webhooks/stripe

This will forward Stripe CLI webhook events to your local development server.

Recommendations

💡

Make sure you’ve added your webhook endpoint URL (https://{your-domain}/api/webhooks/stripe) in the Stripe Dashboard settings for both test and live environments, and added the webhook signing secret to your env vars as STRIPE_WEBHOOK_SECRET.

The webhook handler processes events asynchronously, so it responds immediately to Stripe while data sync happens in the background. This prevents webhook timeouts while keeping your entitlements up to date.