Skip to main content

Webhook Routes

Stripe webhook handler and outbound webhooks — confirm payments, activate plans, notify integrations.

Why this matters

Webhooks are the most common silent-failure surface in any payments-enabled directory. A user pays via Stripe Checkout, the user-facing flow returns success, but if your webhook handler crashes or rejects the event, your project never gets upgraded to premium and the user emails support an hour later. The Stripe webhook is a single endpoint, but it's the single endpoint that turns money into product state — treat it accordingly.

This page documents the inbound Stripe webhook (events handled, security model, local testing) and the outbound notification helper that sends events to Slack, Discord or any URL you configure. If your directory ever charges money, read the Common pitfalls section before going to production.

Not Polar

Older versions of this boilerplate targeted Polar. The current webhook is Stripe-only. If your deploy still has a /api/webhooks/polar route, it's legacy.

POST /api/webhooks/stripe

Receives events from Stripe. Confirms payments, activates premium plans, fires email + outbound webhook notifications.

Excluded from middleware so session cookies aren't touched during the signature check.

Events handled

EventWhat it does
checkout.session.completedMark payment paid, upgrade the related project / user
invoice.paidActivate recurring promotions / sponsorships
customer.subscription.updatedSync subscription status
customer.subscription.deletedRevoke promo placements when subscription ends

Security

Every request is verified with stripe.webhooks.constructEvent():

  1. Read the raw request body (important: not req.json() — signature fails)
  2. Read the stripe-signature header
  3. Verify with STRIPE_WEBHOOK_SECRET

If verification fails → respond 400. Legitimate Stripe requests respond 200.

Setup

See Payments for the Stripe webhook setup checklist.

Testing locally

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

The CLI prints a local webhook secret (whsec_...). Paste that into .env.local temporarily for local work.

Trigger a specific event:

stripe trigger checkout.session.completed

Option B — built-in simulator

pnpm webhook:simulate

Runs scripts/simulate-webhook.js — fakes a Stripe event payload locally. Useful when you just want to run the handler once without a full Stripe account.

Outbound webhooks

DirectoryKit can send webhooks too — typically to Discord or Slack. Not a route, a helper:

import { webhookEvents } from '@/lib/webhooks';
 
await webhookEvents.projectSubmitted(project);
await webhookEvents.projectApproved(project);
await webhookEvents.projectRejected(project);
await webhookEvents.userRegistered(user);

Configure the endpoints in the admin panel → Settings → Integrations (stored in the external_webhooks table). Disable the system entirely with webhooksExternal: false in features.

A real-world walkthrough: from "Pay" click to a Discord notification

Here's the chain of events when a user buys a premium listing:

  1. User completes Stripe Checkout. Stripe redirects to /checkout/success with no special params — the success page is dumb on purpose.
  2. Stripe sends checkout.session.completed to /api/webhooks/stripe (this can land before, at the same time as, or after the user sees the success page — design your UX accordingly).
  3. The handler verifies the signature, looks up the related project_id from the session metadata, sets plan = 'premium' and paid_at = now().
  4. Two outbound events fire: an email confirmation to the user (/lib/email) and a Discord webhook to your community channel via webhookEvents.projectApproved(project).
  5. The handler responds 200 to Stripe within the 10-second window. If you exceed that, Stripe retries — and a retried event with already-processed state must be idempotent.

If anything in step 3–4 throws, respond 200 anyway and queue the failed step for retry. Returning a 5xx triggers Stripe's exponential backoff retry, and on the second retry your project will get upgraded twice (or three times, if you're unlucky). Idempotency is non-negotiable.

Common pitfalls

  • Calling req.json() before signature verification. This breaks the Stripe signature check because the raw body is consumed. Always read the raw body first using await req.text(), verify, then parse the JSON yourself.
  • Letting middleware touch the webhook route. If middleware.ts runs the auth refresher on /api/webhooks/stripe, it touches cookies and changes the Set-Cookie header — Stripe's signature check fails. Confirm the matcher in middleware.ts excludes /api/webhooks/*.
  • Hardcoding the webhook secret. Use STRIPE_WEBHOOK_SECRET from env. The Stripe CLI prints a different whsec_... for local listen — keep it in .env.local and never commit it.
  • Skipping idempotency. Stripe retries on 5xx and on timeouts. If your handler runs DB updates that aren't keyed on a unique event ID, you'll double-charge in production once a quarter. Use the Stripe event.id as a row in a processed_events table or rely on the idempotency_key column already present in the payments table.

FAQ

My webhook works locally with the Stripe CLI but fails in production.

Three usual culprits: (a) the production STRIPE_WEBHOOK_SECRET is the local CLI secret, not the one Stripe shows in the dashboard for your production endpoint; (b) middleware is intercepting the route; (c) the request is being routed through Cloudflare or a CDN that buffers and changes the body. Fix in that order.

Can I add a Polar fallback alongside Stripe?

The legacy Polar route (/api/webhooks/polar) is intentionally orphaned — keep it deleted. If you must run two payment providers, route them through the same internal "process payment event" function and only differ in the verification layer. Don't double-write status logic.

How do I retry a webhook that failed silently?

Re-trigger via the Stripe dashboard (Events → click the event → "Resend"). For development, stripe trigger checkout.session.completed --add checkout_session:metadata.project_id=<id>. For full audit, every event handled writes to webhook_events table — query for status = 'failed' to backfill.

See also