Webhooks
Stripe webhook integration for Themys: handle subscription events, payments, and idempotent processing.
Overview
Themys uses Stripe webhooks to handle subscription lifecycle events. When a customer subscribes, upgrades, cancels, or a payment succeeds or fails, Stripe sends an event to the webhook endpoint.
Webhook Endpoint
POST /api/webhooks/stripe
This endpoint is called by Stripe, not by your application. It handles signature verification, event parsing, and idempotent processing automatically.
Events Handled
| Stripe Event | Trigger | Themys Action |
|---|---|---|
checkout.session.completed | Customer completes Stripe Checkout | Create or upgrade subscription, apply credit packs |
customer.subscription.created | New subscription is activated | Set user's plan tier, update daily scan limits |
customer.subscription.updated | Subscription changes (upgrade, downgrade, cancellation scheduled) | Update plan tier and limits |
customer.subscription.deleted | Subscription is cancelled or expired | Revert user to Free plan |
invoice.paid | Recurring payment succeeds | Record invoice in the invoices table |
invoice.payment_failed | Payment attempt fails | Set subscription status to past_due |
Idempotency
Stripe may deliver the same event more than once (retries, network issues). Themys handles this with the stripe_events table:
CREATE TABLE stripe_events (
id TEXT PRIMARY KEY, -- Stripe event ID
type TEXT NOT NULL,
livemode BOOLEAN,
created_at TIMESTAMP DEFAULT NOW()
);
Before processing any event:
- Insert the event ID into
stripe_events - If the insert succeeds (no duplicate), process the event
- If the insert fails with a unique constraint violation (
23505), skip processing and return200 OK
This ensures each event is processed exactly once, even if Stripe retries.
Setting Up Webhooks
Stripe Dashboard
- Go to Stripe Dashboard > Developers > Webhooks
- Click Add endpoint
- Enter your endpoint URL:
https://themys.ca/api/webhooks/stripe
- Select events to listen for:
- checkout.session.completed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
- Click Add endpoint
- Copy the Signing secret (
whsec_...) for signature verification
Environment Variables
Set the signing secret in your environment:
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here
Webhook Signature Verification
Every webhook request from Stripe includes a signature header. Themys verifies this signature before processing:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return new Response("Invalid signature", { status: 400 });
}
// Process the event...
}
Local Development
Use the Stripe CLI to forward webhook events to your local server:
Install the Stripe CLI
# macOS
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
Forward Events
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI outputs a webhook signing secret for local testing:
> Ready! Your webhook signing secret is whsec_test_...
Use this secret in your .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_...
Trigger Test Events
In a separate terminal, trigger specific events:
# Trigger a successful checkout
stripe trigger checkout.session.completed
# Trigger a subscription creation
stripe trigger customer.subscription.created
# Trigger a payment failure
stripe trigger invoice.payment_failed
Error Handling
Common Errors
| Error | Cause | Fix |
|---|---|---|
400 Invalid signature | Webhook secret doesn't match | Check STRIPE_WEBHOOK_SECRET env var |
400 Missing stripe-signature header | Request is missing the signature header | Ensure Stripe is sending to the correct URL |
500 Webhook handler failed | Database error or unhandled exception | Check server logs, verify database connection |
500 Webhook secret not configured | Missing env var | Set STRIPE_WEBHOOK_SECRET in your environment |
Important: Raw Body
Stripe signature verification requires the raw request body, not the parsed JSON. In Next.js, read the body as text:
// Correct: raw body for signature verification
const body = await request.text();
event = stripe.webhooks.constructEvent(body, signature, secret);
// Wrong: parsed JSON breaks signature verification
const body = await request.json();
Retry Behavior
Stripe retries webhook delivery with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6+ | Up to 72 hours |
Themys returns 200 OK for all successfully processed events (including idempotent duplicates). If your endpoint returns a non-2xx status, Stripe will retry.
Security Best Practices
- Always verify signatures. Never process an unverified event.
- Use HTTPS. Stripe only sends webhooks to HTTPS endpoints in production.
- Return 200 quickly. Process events asynchronously if needed; don't keep Stripe waiting.
- Log all events. Store raw events for debugging (the
stripe_eventstable). - Rotate secrets. Periodically regenerate your webhook signing secret.
Was this page helpful?