Stripe Integration
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
| Property | Platform billing | Tenant BYOK |
|---|---|---|
| Who is charged | Blog owner (subscription to VeloCMS) | Blog reader (subscription to blog content) |
| Stripe account | VeloCMS UK entity (rk_live_...) | Tenant's own Stripe account |
| Keys stored in | Railway env vars | site_settings.member_stripe_secret_key (AES-256-GCM encrypted) |
| Webhook endpoint | /api/stripe/webhook | /api/member-webhook/[tenantSlug] |
| Handler file | src/lib/stripe/webhook-handlers.ts | src/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.
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
// ...
}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).
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;
}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.
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;
}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.
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);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
| File | Responsibility |
|---|---|
| src/lib/stripe/client.ts | Lazy Stripe singleton, getStripeClient(), getPriceIdForPlan() |
| src/lib/stripe/actions.ts | createCheckoutSession(), createPortalSession() server actions |
| src/lib/stripe/webhook-handlers.ts | 6 event handlers + markEventProcessed() idempotency |
| src/lib/stripe/webhook-dispatcher.ts | Event type → handler dispatch map |
| src/app/api/stripe/webhook/route.ts | POST endpoint: verify signature, dedupe, dispatch |
| src/lib/membership/stripe-setup.ts | getTenantStripe() — BYOK key decryption + Stripe instance |
| scripts/test-stripe-webhook.mjs | Local signed-payload smoke test |
| scripts/test-production-webhook.mjs | Production smoke test with STRIPE_WEBHOOK_SECRET_PROD |