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.
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
| Event | What it does |
|---|---|
checkout.session.completed | Mark payment paid, upgrade the related project / user |
invoice.paid | Activate recurring promotions / sponsorships |
customer.subscription.updated | Sync subscription status |
customer.subscription.deleted | Revoke promo placements when subscription ends |
Security
Every request is verified with stripe.webhooks.constructEvent():
- Read the raw request body (important: not
req.json()— signature fails) - Read the
stripe-signatureheader - 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
Option A — Stripe CLI (recommended)
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe CLI prints a local webhook secret (whsec_...). Paste that into .env.local temporarily for local work.
Trigger a specific event:
stripe trigger checkout.session.completedOption B — built-in simulator
pnpm webhook:simulateRuns 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:
- User completes Stripe Checkout. Stripe redirects to
/checkout/successwith no special params — the success page is dumb on purpose. - Stripe sends
checkout.session.completedto/api/webhooks/stripe(this can land before, at the same time as, or after the user sees the success page — design your UX accordingly). - The handler verifies the signature, looks up the related
project_idfrom the session metadata, setsplan = 'premium'andpaid_at = now(). - Two outbound events fire: an email confirmation to the user (
/lib/email) and a Discord webhook to your community channel viawebhookEvents.projectApproved(project). - 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 usingawait req.text(), verify, then parse the JSON yourself. - Letting middleware touch the webhook route. If
middleware.tsruns the auth refresher on/api/webhooks/stripe, it touches cookies and changes the Set-Cookie header — Stripe's signature check fails. Confirm the matcher inmiddleware.tsexcludes/api/webhooks/*. - Hardcoding the webhook secret. Use
STRIPE_WEBHOOK_SECRETfrom env. The Stripe CLI prints a differentwhsec_...for local listen — keep it in.env.localand 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.idas a row in aprocessed_eventstable or rely on theidempotency_keycolumn already present in thepaymentstable.
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
- Payments
- Stripe webhook docs
- API Reference Overview — request/response conventions
- Email & Notifications — what fires on payment events