Framework recipes
Create payments, handle the return and verify webhooks in Next.js, Express, Django and Laravel, without an SDK.
The API is HTTPS and JSON, and webhooks follow the Standard Webhooks spec, so there's no SDK to install. Each recipe below does the same things in Next.js, Express, Django and Laravel: create the payment and send the customer to checkout, show the right page when they come back, and fulfill the order once, on a verified webhook.
Before you start
| Environment variable | Value |
|---|---|
PAY402_SECRET_KEY | A secret key that can write payments. It stays on your server. |
PAY402_WEBHOOK_SECRET | The signing secret of your webhook endpoint, shown once when you add it. |
The recipes keep orders in a table like this one. The JavaScript recipes reach it with node-postgres, and Django and Laravel through an Order model. shipOrder, ship_order and ShipOrder stand for your own fulfillment.
create table orders ( id text primary key, status text not null default 'pending', total_cents integer not null, summary text not null, payment_id text);Create the payment
When the customer checks out, create a payment for the order and redirect them to its url.
// app/checkout/route.ts: the cart's form posts here.import { NextResponse } from "next/server";import { db } from "@/lib/db"; const API = "https://dash.402pay.co/api/v1"; export async function POST(request: Request) { const form = await request.formData(); const { rows: [order], } = await db.query("select * from orders where id = $1 and status = 'pending'", [form.get("order_id")]); if (!order) return new Response("Order not found", { status: 404 }); const response = await fetch(`${API}/payments`, { method: "POST", headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: order.total_cents, currency: "USD", reference: order.id, description: order.summary, success_url: `https://example.com/orders/${order.id}/complete`, cancel_url: "https://example.com/cart", }), }); const body = await response.json(); // 201 is a new payment; 200 is the live one this order's reference already names. if (!response.ok) { console.error("402pay", body.error.code, response.headers.get("402pay-Request-Id")); return new Response("Couldn't start checkout", { status: 502 }); } return NextResponse.redirect(body.data.url, 303);}- The order's ID is the
reference, so a double click or a retry returns the same payment with 200 instead of a new one with 201. Treat both as success. - On any other status, log the error's
codeand the402pay-Request-Idheader. Support can find the request by it. success_urlandcancel_urlmust usehttps://, except onlocalhost.
Handle the return
After paying, the customer lands on your success_url with payment_id in the query. Read that payment from your server, and show a confirmation only if it succeeded and names this order.
// app/orders/[id]/complete/page.tsxconst API = "https://dash.402pay.co/api/v1"; async function getPayment(id: string) { const response = await fetch(`${API}/payments/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` }, cache: "no-store", }); return response.ok ? (await response.json()).data : null;} // Checkout adds ?payment_id= to your success_url.export default async function OrderComplete({ params, searchParams,}: { params: Promise<{ id: string }>; searchParams: Promise<{ payment_id?: string }>;}) { const { id } = await params; const { payment_id } = await searchParams; const payment = payment_id ? await getPayment(payment_id) : null; // Anyone can edit a query string, so trust only a payment that names this order. const paid = payment?.reference === id && payment.status === "succeeded"; return <h1>{paid ? "Thanks, your order is confirmed." : "We haven't received your payment yet."}</h1>;}Receive webhooks
Add an endpoint for payment.succeeded in the dashboard, under Developers, then Webhooks, or with POST /webhooks. Each delivery is signed over its exact bytes, so every recipe verifies the raw body before it parses anything.
// app/webhooks/402pay/route.tsimport crypto from "node:crypto";import { db } from "@/lib/db";import { shipOrder } from "@/lib/fulfillment"; export async function POST(request: Request) { // The signature covers the exact bytes sent, so read the body as text and verify it first. const body = await request.text(); if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET ?? "", request.headers, body)) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(body); if (!event.test && event.type === "payment.succeeded") { const payment = event.data; // Only a pending order changes, so a repeated delivery can't ship it twice. const { rowCount } = await db.query( "update orders set status = 'paid', payment_id = $2 where id = $1 and status = 'pending'", [payment.reference, payment.id], ); if (rowCount === 1) await shipOrder(payment.reference); } return new Response(null, { status: 204 });} function verifyWebhook(secret: string, headers: Headers, body: string): boolean { const id = headers.get("webhook-id") ?? ""; const timestamp = headers.get("webhook-timestamp") ?? ""; const signatures = headers.get("webhook-signature") ?? ""; // Refuse replays: the delivery must be less than five minutes old. const sent = Number(timestamp); if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"); // During a rotation the header carries one signature per secret. return signatures.split(" ").some((entry) => { const [version, signature = ""] = entry.split(","); return ( version === "v1" && signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ); });}| Framework | Raw body | Also |
|---|---|---|
| Next.js | await request.text() | Don't call request.json() first. |
| Express | express.raw() | On the route, registered before any app-wide express.json(). |
| Django | request.body | @csrf_exempt, since deliveries carry no CSRF token. |
| Laravel | $request->getContent() | Exclude the route from CSRF checks: validateCsrfTokens in bootstrap/app.php on Laravel 11 and later, $except in VerifyCsrfToken before that. |
Answer with a 2xx within 15 seconds. Test deliveries have test: true and a made-up payment, so each recipe skips them. See responses and retries.
Fulfill once
A delivery that doesn't get a 2xx is retried with the same webhook-id, and anyone on your team can resend one, so the same event can arrive twice. Each recipe makes the change itself idempotent: it marks the order paid only while it's still pending, and ships only when that changed a row.
-- Only a pending order changes, so running this twice ships nothing twice.update orders set status = 'paid', payment_id = $2 where id = $1 and status = 'pending'; -- For a handler that does more than one thing: remember each event it finished,-- in the same transaction as the work, and skip an event whose insert changes nothing.create table processed_webhooks ( id text primary key, processed_at timestamptz not null default now());insert into processed_webhooks (id) values ($1) on conflict (id) do nothing;If your handler does several things for one event, also keep the webhook-id of each event it finished and skip any it has seen. Slow work, such as emails or calls to other services, belongs in a queue, so the delivery gets its answer in time.
Test locally
Endpoint URLs must be public https:// addresses: localhost and private addresses are refused. When deliveries go out, reach your machine through an HTTPS tunnel and add the tunnel's URL as an endpoint.
Pay a test payment, then find its delivery under Developers, then Webhooks, or with GET /webhook-deliveries. This script resends it and posts the fresh copy to your server, signed with your endpoint's secret.
// replay.mjs: node replay.mjs dlv_... http://localhost:8000/webhooks/402pay// Resending gives the delivery a fresh timestamp and signature, so it passes// your handler's five-minute check.const [deliveryId, target] = process.argv.slice(2);const response = await fetch(`https://dash.402pay.co/api/v1/webhook-deliveries/${deliveryId}/resend`, { method: "POST", headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },});const { data: delivery } = await response.json(); const local = await fetch(target, { method: "POST", headers: delivery.request_headers, // Compact, with keys in the order they arrived: the bytes that were signed. body: JSON.stringify(delivery.payload),});console.log(local.status, await local.text());To rehearse failures, point an endpoint at a host under the reserved .invalid domain, which never answers, and watch the retries. See testing for simulated payment outcomes.
Next steps
- Fulfill orders reliably Keep one payment per order, map each status to the order, and ship exactly once.
- Webhook event catalog Every event type, what its data holds, and the order a payment's events arrive in.
- Security best practices Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
- Going live Everything to check before you take real payments, and after.