Stripe
Our fiat rail everywhere except Colombia. (Onchain payments are USDC — see
../ekx-circle-usdc/SKILL.md; for Colombia see
../ekx-mercadopago/SKILL.md.)
Authoritative: the stripe MCP server plus the bundled stripe-best-practices skill. Docs: https://docs.stripe.com
Environment
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= # pk_test_… / pk_live_…
STRIPE_SECRET_KEY= # SECRET sk_test_… / sk_live_…
STRIPE_WEBHOOK_SECRET= # SECRET whsec_…
STRIPE_PREMIUM_PRICE_ID= # price_…
The webhook secret is per endpoint. Local (Stripe CLI), preview and production each have their own. Using production's secret against a CLI-forwarded event fails signature verification — the most common Stripe bug we hit.
Checkout Session — the default
Prefer a hosted Checkout Session over a custom Payment Element unless the design genuinely requires an embedded form. It handles SCA, wallets, tax, and receipts for free.
// app/api/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { bookingId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: process.env.STRIPE_PREMIUM_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/booking/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/booking/cancelled`,
metadata: { bookingId }, // ← how you reconcile later
client_reference_id: bookingId,
});
return Response.json({ url: session.url });
}
Always set metadata. It is the only reliable link from a Stripe payment back to
a row in Supabase. Without it, reconciliation means matching on amount and timestamp.
Never trust the success_url. A user can navigate there without paying. Fulfilment happens in the webhook, never in the success page.
Webhooks
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text(); // RAW body — not req.json()
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response("bad signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object as Stripe.Checkout.Session;
await admin.from("bookings")
.update({ status: "paid", stripe_session_id: s.id })
.eq("id", s.metadata!.bookingId)
.eq("status", "pending"); // idempotency: only pending → paid
break;
}
case "customer.subscription.deleted":
/* revoke access */ break;
}
return new Response(null, { status: 200 }); // 200 fast, always
}
Three non-negotiables:
- Raw body.
req.json()re-serialises and the signature no longer matches. - Idempotent handlers. Stripe retries, and delivers at-least-once. The
.eq("status","pending")guard above makes a double-delivery a no-op. - Return 200 quickly. Do slow work (email, onchain call) after responding, or via a queue. Stripe retries anything slower than ~10s, compounding the problem.
Local testing:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
The CLI prints the whsec_… to use locally.
Subscriptions
Create products and prices in the dashboard (or via the MCP server), store the
price_id in env — never hardcode amounts in code, or test and live diverge.
Gate on the subscription status stored in your own database, synced from
customer.subscription.* webhooks. Do not call Stripe on every request to check
access; it is slow and rate-limited.
Gotchas
- Wrong webhook secret per environment. Check this first on any 400.
req.json()in the webhook breaks the signature.- Fulfilling in
success_url— free products for anyone who guesses the URL. - Amounts are integer minor units.
1000= $10.00 USD, but zero-decimal currencies (JPY, COP has 2 in Stripe) differ — check per currency, especially for our Colombian products. - Test/live key mixing produces "No such price" — the ids are environment-scoped.
stripenpm and the API version — pinapiVersionin the constructor so a dashboard-side upgrade does not change behaviour under you.