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

  1. Select events to listen for:

- checkout.session.completed

- customer.subscription.created

- customer.subscription.updated

- customer.subscription.deleted

- invoice.paid

- invoice.payment_failed

  1. Click Add endpoint
  2. 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?