> For the complete index of the 402pay docs, see [llms.txt](https://developer.402pay.co/llms.txt).

# 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.

SQL:

```
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`.

Create, Next.js:

```ts
// 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);
}
```

Create, Express:

```js
// server.js
import crypto from "node:crypto";
import express from "express";
import pg from "pg";

const API = "https://dash.402pay.co/api/v1";
const db = new pg.Pool();
const app = express();

// The cart's form posts here.
app.post("/checkout", express.urlencoded({ extended: false }), async (req, res) => {
  const {
    rows: [order],
  } = await db.query("select * from orders where id = $1 and status = 'pending'", [req.body.order_id]);
  if (!order) return res.sendStatus(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 res.status(502).send("Couldn't start checkout");
  }
  res.redirect(303, body.data.url);
});
```

Create, Django:

```python
# views.py, routed with path("checkout", views.checkout)
import logging
import os

import requests
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST

from .models import Order

API = "https://dash.402pay.co/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}"}
logger = logging.getLogger(__name__)

@require_POST
def checkout(request):
    order = get_object_or_404(Order, id=request.POST.get("order_id"), status="pending")
    response = requests.post(
        f"{API}/payments",
        headers=AUTH,
        json={
            "amount": order.total_cents,
            "currency": "USD",
            "reference": order.id,
            "description": order.summary,
            "success_url": f"https://example.com/orders/{order.id}/complete",
            "cancel_url": "https://example.com/cart",
        },
        timeout=10,
    )
    body = response.json()
    # 201 is a new payment; 200 is the live one this order's reference already names.
    if not response.ok:
        logger.error("402pay %s %s", body["error"]["code"], response.headers.get("402pay-Request-Id"))
        return HttpResponse("Couldn't start checkout", status=502)
    return redirect(body["data"]["url"])
```

Create, Laravel:

```php
// config/services.php
'pay402' => [
    'secret_key' => env('PAY402_SECRET_KEY'),
    'webhook_secret' => env('PAY402_WEBHOOK_SECRET'),
],

// routes/web.php
Route::post('/checkout', [CheckoutController::class, 'store']);
Route::get('/orders/{order}/complete', [CheckoutController::class, 'complete']);

// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class CheckoutController extends Controller
{
    public function store(Request $request)
    {
        $order = Order::where('status', 'pending')->findOrFail($request->input('order_id'));

        $response = Http::withToken(config('services.pay402.secret_key'))
            ->post('https://dash.402pay.co/api/v1/payments', [
                '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',
            ]);

        // 201 is a new payment; 200 is the live one this order's reference already names.
        if ($response->failed()) {
            Log::error('402pay', [
                'code' => $response->json('error.code'),
                'request_id' => $response->header('402pay-Request-Id'),
            ]);
            abort(502, "Couldn't start checkout");
        }

        return redirect()->away($response->json('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 `code` and the `402pay-Request-Id` header. Support can find the request by it.
- `success_url` and `cancel_url` must use `https://`, except on `localhost`.

## 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.

Return, Next.js:

```ts
// app/orders/[id]/complete/page.tsx
const 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>;
}
```

Return, Express:

```js
// server.js, continued. Checkout adds ?payment_id= to your success_url.
app.get("/orders/:id/complete", async (req, res) => {
  const paymentId = encodeURIComponent(String(req.query.payment_id ?? ""));
  const response = await fetch(`${API}/payments/${paymentId}`, {
    headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },
  });
  const payment = response.ok ? (await response.json()).data : null;

  // Anyone can edit a query string, so trust only a payment that names this order.
  const paid = payment?.reference === req.params.id && payment.status === "succeeded";
  res.send(paid ? "Thanks, your order is confirmed." : "We haven't received your payment yet.");
});
```

Return, Django:

```python
# views.py, continued; routed with path("orders/<str:order_id>/complete", views.order_complete)
from urllib.parse import quote

from django.shortcuts import render

def order_complete(request, order_id):
    # Checkout adds ?payment_id= to your success_url.
    payment_id = request.GET.get("payment_id", "")
    payment = None
    if payment_id.startswith("pmt_"):
        response = requests.get(f"{API}/payments/{quote(payment_id, safe='')}", headers=AUTH, timeout=10)
        payment = response.json()["data"] if response.ok else None

    # Anyone can edit a query string, so trust only a payment that names this order.
    paid = payment is not None and payment["reference"] == order_id and payment["status"] == "succeeded"
    return render(request, "orders/complete.html", {"paid": paid})
```

Return, Laravel:

```php
    // CheckoutController, continued. Checkout adds ?payment_id= to your success_url.
    public function complete(Request $request, Order $order)
    {
        $paymentId = (string) $request->query('payment_id', '');
        $payment = null;
        if (str_starts_with($paymentId, 'pmt_')) {
            $response = Http::withToken(config('services.pay402.secret_key'))
                ->get('https://dash.402pay.co/api/v1/payments/'.rawurlencode($paymentId));
            $payment = $response->successful() ? $response->json('data') : null;
        }

        // Anyone can edit a query string, so trust only a payment that names this order.
        $paid = $payment !== null
            && $payment['reference'] === (string) $order->id
            && $payment['status'] === 'succeeded';

        return view('orders.complete', ['paid' => $paid]);
    }
}
```

> The return page only shows the status. Fulfill on the webhook, which arrives even if the customer closes the tab before your page loads.

## Receive webhooks

Add an endpoint for `payment.succeeded` in the dashboard, under Developers, then Webhooks, or with [`POST /webhooks`](https://developer.402pay.co/api/webhooks/create.md). Each delivery is signed over its exact bytes, so every recipe verifies the raw body before it parses anything.

Webhook, Next.js:

```ts
// app/webhooks/402pay/route.ts
import 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))
    );
  });
}
```

Webhook, Express:

```js
// server.js, continued. Registered before any app-wide express.json(),
// which would parse the body before this route could verify it.
app.post("/webhooks/402pay", express.raw({ type: "application/json" }), async (req, res) => {
  // The signature covers the exact bytes sent, so verify them before parsing.
  const body = req.body.toString("utf8");
  if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET ?? "", req.headers, body)) {
    return res.sendStatus(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);
  }
  res.sendStatus(204);
});

function verifyWebhook(secret, headers, body) {
  const id = String(headers["webhook-id"] ?? "");
  const timestamp = String(headers["webhook-timestamp"] ?? "");
  const signatures = String(headers["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))
    );
  });
}
```

Webhook, Django:

```python
# views.py, continued; routed with path("webhooks/402pay", views.webhook)
import base64
import hashlib
import hmac
import json
import time

from django.views.decorators.csrf import csrf_exempt

@csrf_exempt  # Deliveries carry a signature instead of a CSRF token.
@require_POST
def webhook(request):
    # The signature covers the exact bytes sent, so verify request.body before parsing it.
    if not verify_webhook(os.environ["PAY402_WEBHOOK_SECRET"], request.headers, request.body):
        return HttpResponse("Invalid signature", status=400)

    event = json.loads(request.body)
    if not event.get("test") and event["type"] == "payment.succeeded":
        payment = event["data"]
        # Only a pending order changes, so a repeated delivery can't ship it twice.
        changed = Order.objects.filter(id=payment["reference"], status="pending").update(
            status="paid", payment_id=payment["id"]
        )
        if changed:
            ship_order(payment["reference"])
    return HttpResponse(status=204)

def verify_webhook(secret: str, headers, body: bytes) -> bool:
    msg_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")

    # Refuse replays: the delivery must be less than five minutes old.
    if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    # During a rotation the header carries one signature per secret.
    return any(
        hmac.compare_digest(entry[3:].encode(), expected)
        for entry in headers.get("webhook-signature", "").split(" ")
        if entry.startswith("v1,")
    )
```

Webhook, Laravel:

```php
// routes/web.php
Route::post('/webhooks/402pay', PaymentWebhookController::class);

// bootstrap/app.php: deliveries carry a signature instead of a CSRF token.
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/402pay']);
})

// app/Http/Controllers/PaymentWebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ShipOrder;
use App\Models\Order;
use Illuminate\Http\Request;

class PaymentWebhookController extends Controller
{
    public function __invoke(Request $request)
    {
        // The signature covers the exact bytes sent, so verify the raw body before parsing it.
        $body = $request->getContent();
        if (! $this->verify((string) config('services.pay402.webhook_secret'), $request, $body)) {
            return response('Invalid signature', 400);
        }

        $event = json_decode($body, true);
        if (empty($event['test']) && $event['type'] === 'payment.succeeded') {
            $payment = $event['data'];
            // Only a pending order changes, so a repeated delivery can't ship it twice.
            $changed = Order::where('id', $payment['reference'])
                ->where('status', 'pending')
                ->update(['status' => 'paid', 'payment_id' => $payment['id']]);
            if ($changed) {
                ShipOrder::dispatch($payment['reference']);
            }
        }

        return response()->noContent();
    }

    private function verify(string $secret, Request $request, string $body): bool
    {
        $id = (string) $request->header('webhook-id');
        $timestamp = (string) $request->header('webhook-timestamp');

        // Refuse replays: the delivery must be less than five minutes old.
        if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
            return false;
        }

        $key = base64_decode(substr($secret, strlen('whsec_')));
        $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));

        // During a rotation the header carries one signature per secret.
        foreach (explode(' ', (string) $request->header('webhook-signature')) as $entry) {
            [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
            if ($version === 'v1' && hash_equals($expected, $signature)) {
                return true;
            }
        }

        return false;
    }
}
```

| 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](https://developer.402pay.co/guides/webhooks.md#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.

SQL:

```
-- 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.

> A local 402pay sends no requests, so a tunnel receives nothing from it. Replay its deliveries to your local server instead.

Pay a test payment, then find its delivery under Developers, then Webhooks, or with [`GET /webhook-deliveries`](https://developer.402pay.co/api/webhooks/deliveries.md). This script resends it and posts the fresh copy to your server, signed with your endpoint's secret.

replay.mjs, Node.js:

```js
// 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](https://developer.402pay.co/testing.md) for simulated payment outcomes.

## Next steps

- [Fulfill orders reliably](https://developer.402pay.co/guides/order-fulfillment.md): Keep one payment per order, map each status to the order, and ship exactly once.
- [Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md): Every event type, what its data holds, and the order a payment's events arrive in.
- [Security best practices](https://developer.402pay.co/guides/security.md): Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
- [Going live](https://developer.402pay.co/going-live.md): Everything to check before you take real payments, and after.
