On this page
Payments are the one feature where a bug costs real money in both directions. Grant access without charging and you give away the product; charge without granting and you have an angry customer and a chargeback. The danger is that the happy path looks trivial: redirect to a checkout, the user pays, you flip a flag. The traps are all in the parts you cannot see in a demo, the duplicate webhook, the spoofed payload, the payment that succeeds while your server is down. This page is the billing flow that survives them, built around a provider webhook as the single source of truth.
The model that holds up: checkout collects the money on the provider's hosted page, the provider sends a webhook when payment settles, and your webhook handler is the only place that grants access. Never grant access from the browser redirect, because the browser can be closed, replayed, or forged. The webhook is signed, idempotent, and trusts the provider's API over its own request body.
Why the webhook is the source of truth
The checkout redirect tells the browser "you paid." It does not tell your server anything trustworthy: the user can close the tab before the redirect, hit it twice, or hand-craft the success URL. The webhook is a server-to-server call the provider signs with a secret only you and they share. That signature is what makes it trustworthy, so verification is the first thing the handler does, before it parses the body.
export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // never cache a webhook
export async function POST(req: Request) {
const raw = await req.text(); // raw body, NOT req.json(), signature is over bytes
const sig = req.headers.get("paymongo-signature");
if (!verifySignature(raw, sig)) {
log.error("payment.webhook.rejected", { reason: "bad_signature" });
return Response.json({ ok: false }, { status: 401 });
}
// ...only now is it safe to parse and act on `raw`.
}The rules
Whenyou receive a payment webhook
Doverify the signature against the raw request body before parsing or trusting anything. Read req.text(), not req.json(), because the signature is computed over the exact bytes.
const raw = await req.text();
if (!verifySignature(raw, req.headers.get("provider-signature"))) {
return Response.json({ ok: false }, { status: 401 });
}
const body = JSON.parse(raw);Whenyou read the amount, currency, or status from a webhook
Dore-fetch the payment from the provider's API by id and trust that, not the numbers in the webhook body. The body is a hint; the API is the truth.
let { amount, currency, status } = bodyHints;
const fresh = await provider.fetchPayment(paymentId); // authoritative
amount = fresh.amount; currency = fresh.currency; status = fresh.status;Whenyou write the webhook handler
Domake it idempotent. Insert the payment keyed by a unique constraint and treat a 23505 unique-violation as 'already processed, no-op'. Providers retry and often fire more than one event per payment.
const { error } = await admin.from("payments").insert({
provider, provider_event_id: eventId, provider_payment_id: paymentId, /* ... */
});
if (error?.code === "23505") return Response.json({ ok: true, replay: true });Whena provider fires multiple event types for one payment
Dokey idempotency on the PAYMENT id, not just the event id. e.g. payment.paid and checkout.paid are two events with two event ids for the same money; the payment id collapses them to one grant.
-- Two guards: one stops duplicate events, one stops sibling events.
CREATE UNIQUE INDEX ux_pay_event ON payments (provider, provider_event_id);
CREATE UNIQUE INDEX ux_pay_payment ON payments (provider, provider_payment_id);Whenpayment is confirmed and you grant access
Dogrant from the webhook only, after the payment row commits, and only for status paid. Carry the user id in the checkout session metadata so the webhook knows who paid.
if (status !== "paid") return Response.json({ ok: true });
const userId = session.metadata?.userId;
await admin.from("entitlements").insert({ user_id: userId, plan, expires_at });Whenyou decide what a user can access
Docheck a live entitlement (active and not expired), not a boolean you flipped once. Expiry, refunds, and downgrades all need a queryable source of truth, not a stale flag.
const { data } = await admin.from("entitlements")
.select("id").eq("user_id", userId).eq("active", true)
.gt("expires_at", new Date().toISOString()).maybeSingle();
const isPaid = !!data;Whenyou create the checkout session
Doset amounts and the SKU on the server from your own pricing table, never from a price the client sent. A client-supplied amount is a free-product exploit.
"use server";
const plan = getPlanBySku(sku); // server-side price source of truth
const session = await provider.createCheckout({
amount: plan.amountCents, // never formData.get("amount")
metadata: { userId: session.userId, sku },
});