ThemysThemys/Docs

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

code
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 EventTriggerThemys Action ------------------------------------- checkout.session.completedCustomer completes Stripe CheckoutCreate or upgrade subscription, apply credit packs customer.subscription.createdNew subscription is activatedSet user's plan tier, update daily scan limits customer.subscription.updatedSubscription changes (upgrade, downgrade, cancellation scheduled)Update plan tier and limits customer.subscription.deletedSubscription is cancelled or expiredRevert user to Free plan invoice.paidRecurring payment succeedsRecord invoice in the invoices table invoice.payment_failedPayment attempt failsSet 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:

sql
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:

1. Insert the event ID into stripe_events

2. If the insert succeeds (no duplicate), process the event

3. If the insert fails with a unique constraint violation (23505), skip processing and return 200 OK

This ensures each event is processed exactly once, even if Stripe retries.

Setting Up Webhooks

Stripe Dashboard

1. Go to Stripe Dashboard > Developers > Webhooks

2. Click Add endpoint

3. Enter your endpoint URL:

code
https://themys.ca/api/webhooks/stripe

4. Select events to listen for:

- checkout.session.completed

- customer.subscription.created

- customer.subscription.updated

- customer.subscription.deleted

- invoice.paid

- invoice.payment_failed

5. Click Add endpoint

6. Copy the Signing secret (whsec_...) for signature verification

Environment Variables

Set the signing secret in your environment:

bash
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:

javascript
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...

}

Never skip signature verification. Without it, anyone could send fake events to your endpoint.

Local Development

Use the Stripe CLI to forward webhook events to your local server:

Install the Stripe CLI

bash
# macOS

brew install stripe/stripe-cli/stripe

# Login to your Stripe account

stripe login

Forward Events

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

The CLI outputs a webhook signing secret for local testing:

code
> Ready! Your webhook signing secret is whsec_test_...

Use this secret in your .env.local:

bash
STRIPE_WEBHOOK_SECRET=whsec_test_...

Trigger Test Events

In a separate terminal, trigger specific events:

bash
# 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

ErrorCauseFix ------------------- 400 Invalid signatureWebhook secret doesn't matchCheck STRIPE_WEBHOOK_SECRET env var 400 Missing stripe-signature headerRequest is missing the signature headerEnsure Stripe is sending to the correct URL 500 Webhook handler failedDatabase error or unhandled exceptionCheck server logs, verify database connection 500 Webhook secret not configuredMissing env varSet 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:

javascript
// 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:

AttemptDelay ---------------- 1Immediate 21 minute 35 minutes 430 minutes 52 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

1. Always verify signatures. Never process an unverified event.

2. Use HTTPS. Stripe only sends webhooks to HTTPS endpoints in production.

3. Return 200 quickly. Process events asynchronously if needed; don't keep Stripe waiting.

4. Log all events. Store raw events for debugging (the stripe_events table).

5. Rotate secrets. Periodically regenerate your webhook signing secret.

Was this page helpful?