Stripe Integration

12 min readUpdated 27 Apr 2026

VeloCMS has two Stripe contexts: platform billing (velocms.org charging blog owners for Pro/Business/Agency plans) and tenant BYOK (blog owners providing their own Stripe keys to charge their readers for paid memberships). The two contexts use separate keys, separate webhook endpoints, and separate handler chains. They must not be confused.

Platform vs BYOK Stripe

PropertyPlatform billingTenant BYOK
Who is chargedBlog owner (subscription to VeloCMS)Blog reader (subscription to blog content)
Stripe accountVeloCMS UK entity (rk_live_...)Tenant's own Stripe account
Keys stored inRailway env varssite_settings.member_stripe_secret_key (AES-256-GCM encrypted)
Webhook endpoint/api/stripe/webhook/api/member-webhook/[tenantSlug]
Handler filesrc/lib/stripe/webhook-handlers.tssrc/lib/membership/stripe-setup.ts

Webhook signature verification

Both webhook endpoints verify the Stripe signature before processing any event. The raw request body must be read as text before parsing — passing a re-serialized JSON body invalidates the signature. The STRIPE_WEBHOOK_SECRET environment variable differs between local CLI (stripe listen output, temporary whsec_...) and the production Dashboard secret (permanent whsec_... for the registered endpoint). Mixing them causes signature failures.

src/app/api/stripe/webhook/route.ts (signature check)
import { headers } from "next/headers";
import { getStripeClient } from "@/lib/stripe/client";

export async function POST(request: Request) {
  // Read raw body BEFORE any parsing — re-serialization breaks signature
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature") ?? "";

  const stripe = getStripeClient();
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook signature verification failed`, { status: 400 });
  }

  // Idempotency check — see processed_webhooks section below
  // ...
}
typescript

Idempotency via processed_webhooks

Stripe retries failed webhook deliveries up to 72 hours. To prevent double-processing, every event is inserted into the processed_webhooks collection with the Stripe event ID as the primary key. On duplicate, PocketBase returns a constraint error, and the handler returns 200 immediately. A critical detail: PocketBase's default ID pattern (^[a-z0-9]+$) rejects Stripe event IDs because they contain underscores (evt_1K2xyz_abc). The processed_webhooks collection must use the pattern ^[a-zA-Z0-9_]+$ (P-019).

src/lib/stripe/webhook-handlers.ts (idempotency)
export async function markEventProcessed(
  pb: PocketBase,
  eventId: string
): Promise<void> {
  // PK pattern must be ^[a-zA-Z0-9_]+$ to accept Stripe event IDs.
  // Default ^[a-z0-9]+$ rejects underscores — see P-019.
  await pb.collection("processed_webhooks").create({ id: eventId });
}

// In the route handler:
try {
  await markEventProcessed(pb, event.id);
} catch (err) {
  // PB unique constraint violation = already processed
  if (isUniqueConstraintError(err)) {
    return new Response("Already processed", { status: 200 });
  }
  throw err;
}
typescript

Event dispatcher pattern

Instead of a large switch statement in the route handler, VeloCMS uses a dispatch map in src/lib/stripe/webhook-dispatcher.ts. Each Stripe event type maps to a handler function. Adding a new event type means adding one line to this map — parallel development on different event types does not cause merge conflicts on the route file.

src/lib/stripe/webhook-dispatcher.ts
export const stripeWebhookHandlers: Readonly<Record<string, StripeEventHandler>> = {
  "checkout.session.completed": handleCheckoutSessionCompleted,
  "customer.subscription.updated": handleSubscriptionUpdated,
  "customer.subscription.trial_will_end": handleTrialWillEnd,
  "customer.subscription.deleted": handleSubscriptionDeleted,
  "invoice.payment_failed": handleInvoicePaymentFailed,
  "invoice.payment_succeeded": handleInvoicePaymentSucceeded,
  "charge.refunded": handleChargeRefunded,
};

export async function dispatchStripeEvent(event: Stripe.Event): Promise<boolean> {
  const handler = stripeWebhookHandlers[event.type];
  if (!handler) return false;
  await handler(event);
  return true;
}
typescript

BYOK tenant Stripe keys

Blog owners provide their own Stripe keys via Admin > Settings > Membership. VeloCMS encrypts these keys with AES-256-GCM before storing them in site_settings.member_stripe_secret_key. The enc: prefix marks an encrypted value. Never pass an enc: prefixed string directly to the Stripe SDK — it will return a 401 Invalid API key error (P-023). Always use the getTenantStripe() helper, which decrypts automatically.

Using BYOK tenant Stripe (correct pattern)
import { getTenantStripe } from "@/lib/membership/stripe-setup";

// CORRECT: helper auto-decrypts the enc: prefixed key
const stripe = await getTenantStripe(settings);
const session = await stripe.checkout.sessions.create({ ... });

// WRONG: passes the encrypted string directly — Stripe returns 401
const stripe = new Stripe(settings.member_stripe_secret_key);
typescript

Refund flow

When a refund is issued through the Stripe Dashboard, Stripe fires a charge.refunded event. The handleChargeRefunded handler in src/lib/stripe/webhook-handlers.ts immediately downgrades the tenant to the free plan, sets the subscriptions record to canceled, and sends a refund confirmation email to the blog owner. The downgrade is immediate — there is no grace period after a refund. This is consistent with the VeloCMS refund policy (30-day window documented at /refund).

Stripe API version

VeloCMS pins Stripe SDK v22 to API version 2026-03-25.dahlia. When upgrading the SDK major version, the literal in src/lib/stripe/client.ts must also be updated. Stripe's botanical codenames (acacia, clover, dahlia) introduce breaking changes — always review the changelog before bumping. One concrete change already affecting VeloCMS: invoice.subscription was removed from the current API; the correct path is invoice.parent?.subscription_details?.subscription.

Testing webhooks locally

  • Install Stripe CLI: brew install stripe/stripe-cli/stripe
  • Run stripe login to connect your account
  • In a second terminal: stripe listen --forward-to localhost:3000/api/stripe/webhook
  • Copy the whsec_ signing secret printed by stripe listen
  • Set STRIPE_WEBHOOK_SECRET=<whsec_...> in .env.local
  • Use scripts/test-stripe-webhook.mjs for a signed checkout.session.completed payload
  • For production smoke testing use scripts/test-production-webhook.mjs with STRIPE_WEBHOOK_SECRET_PROD

Reference

FileResponsibility
src/lib/stripe/client.tsLazy Stripe singleton, getStripeClient(), getPriceIdForPlan()
src/lib/stripe/actions.tscreateCheckoutSession(), createPortalSession() server actions
src/lib/stripe/webhook-handlers.ts6 event handlers + markEventProcessed() idempotency
src/lib/stripe/webhook-dispatcher.tsEvent type → handler dispatch map
src/app/api/stripe/webhook/route.tsPOST endpoint: verify signature, dedupe, dispatch
src/lib/membership/stripe-setup.tsgetTenantStripe() — BYOK key decryption + Stripe instance
scripts/test-stripe-webhook.mjsLocal signed-payload smoke test
scripts/test-production-webhook.mjsProduction smoke test with STRIPE_WEBHOOK_SECRET_PROD