In this guide you'll add a "Pay with USDT or USDC" button to a Next.js App Router app. Valtu hosts the payment page: the payer picks a coin and network, sees the exact amount, address and QR code, and the page follows the payment until it's confirmed. Your app needs two route handlers and a button.
We built and ran this code on Next.js 16 against Postgres, including late payments and duplicate deliveries.
Before you start
- A Valtu workspace (free on testnets) and an API key with the
operaterole: Developers → API keys → Create key. - A webhook endpoint at
https://your-app/api/valtu/webhookswith thecheckout.*events, and the workspace's Ed25519 public key from Developers → Webhooks. - Settings, server-side only (never
NEXT_PUBLIC_):
VALTU_PRIVATE_KEY=…
VALTU_WEBHOOK_PUBLIC_KEY=…
DATABASE_URL=postgres://…
APP_URL=https://your-app.example
npm install @valtu/wallet-kit pg
npm install -D @types/pg
1. Tables
CREATE TABLE orders (
id text PRIMARY KEY,
total_usd numeric(12, 2) NOT NULL,
status text NOT NULL DEFAULT 'unpaid', -- unpaid · paid · underpaid
checkout_attempt int NOT NULL DEFAULT 1,
paid_checkout_id text,
paid_at timestamptz
);
CREATE TABLE valtu_events (id text PRIMARY KEY, type text NOT NULL, received_at timestamptz NOT NULL DEFAULT now());
-- Paid orders waiting to be fulfilled (a worker picks them up).
CREATE TABLE fulfilments (order_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
-- Payments that arrived for an order already paid: refund them, or credit the customer.
CREATE TABLE extra_payments (checkout_id text PRIMARY KEY, order_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
2. The client
import { Valtu } from "@valtu/wallet-kit";
import pg from "pg";
let client: Valtu | undefined;
/** The API client, created on first use: `next build` loads this file without your keys. */
export const valtu = () => (client ??= Valtu.fromEnv()); // VALTU_PRIVATE_KEY, server-side only
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
Why a function and not a constant: next build loads every route module to collect page data, usually on a build machine without your keys. A client created at import time would fail the build. Created on first use, it only needs the key at runtime.
3. Start a payment
import { NextResponse } from "next/server";
import { db, valtu } from "@/lib/valtu";
export const runtime = "nodejs"; // the wallet kit signs requests with node:crypto
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Check here that the signed-in user owns this order.
const { rows } = await db.query("SELECT id, total_usd::text AS total, status, checkout_attempt FROM orders WHERE id = $1", [id]);
const order = rows[0];
if (!order || order.status === "paid") return NextResponse.json({ error: "This order can't be paid." }, { status: 409 });
const open = (attempt: number) =>
valtu().checkouts.create({
amount: order.total, // "49.00", in USD
externalId: `${order.id}:${attempt}`, // the same id returns the same checkout
description: `Order ${order.id}`,
successUrl: `${process.env.APP_URL}/orders/${order.id}`,
cancelUrl: `${process.env.APP_URL}/orders/${order.id}`,
metadata: { orderId: order.id }, // comes back in every checkout.* event
});
let checkout = await open(order.checkout_attempt);
if (checkout.status === "EXPIRED" || checkout.status === "CANCELLED") {
// The last one ran out of time: start a fresh checkout for the same order.
await db.query("UPDATE orders SET checkout_attempt = checkout_attempt + 1 WHERE id = $1", [order.id]);
checkout = await open(order.checkout_attempt + 1);
}
return NextResponse.json({ url: checkout.url });
}
runtime = "nodejs": the wallet kit signs every request withnode:crypto, which the Edge runtime doesn't have.externalIdmakes creation idempotent: a double click returns the same checkout, not a second one.- Attempts: a checkout lasts an hour by default (
expiresInSecondsup to 7 days). If the customer comes back after it expired, the order gets a new checkout with the next attempt number.
4. The button
"use client";
import { useState } from "react";
export function PayButton({ orderId }: { orderId: string }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function pay() {
setBusy(true);
const r = await fetch(`/api/orders/${orderId}/pay`, { method: "POST" });
const body = await r.json();
if (r.ok) window.location.href = body.url; // Valtu's hosted payment page
else { setError(body.error); setBusy(false); }
}
return (
<>
<button onClick={pay} disabled={busy}>{busy ? "Opening…" : "Pay with USDT or USDC"}</button>
{error && <p role="alert">{error}</p>}
</>
);
}
5. The webhook
import { constructEvent, WebhookVerificationError, type WebhookEvent } from "@valtu/wallet-kit";
import { db } from "@/lib/valtu";
export const runtime = "nodejs";
export async function POST(req: Request) {
const raw = await req.text(); // the exact bytes that were signed: don't use req.json() here
let event: WebhookEvent;
try {
event = constructEvent(raw, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
} catch (e) {
if (e instanceof WebhookVerificationError) return new Response("bad signature", { status: 400 });
throw e;
}
const client = await db.connect();
try {
await client.query("BEGIN");
const first = await client.query("INSERT INTO valtu_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING", [event.id, event.type]);
if (first.rowCount === 1) {
if (event.type === "checkout.completed") {
const orderId = String(event.data.metadata?.orderId);
// Paid in full, even after an underpaid or expired notice (late payments still complete).
const paid = await client.query(
"UPDATE orders SET status = 'paid', paid_checkout_id = $2, paid_at = now() WHERE id = $1 AND status <> 'paid'",
[orderId, event.data.checkoutId],
);
if (paid.rowCount === 1) await client.query("INSERT INTO fulfilments (order_id) VALUES ($1)", [orderId]); // a worker ships it
else await client.query("INSERT INTO extra_payments (checkout_id, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", [event.data.checkoutId, orderId]);
} else if (event.type === "checkout.underpaid") {
await client.query("UPDATE orders SET status = 'underpaid' WHERE id = $1 AND status = 'unpaid'", [String(event.data.metadata?.orderId)]);
}
}
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e; // answers 500: Valtu delivers the event again later
} finally {
client.release();
}
return new Response(null, { status: 200 });
}
req.text(), notreq.json(): the signature covers the exact bytes. Parsing first would change them.- Late payments complete. A checkout that expired or was underpaid can still become
COMPLETEDif the rest arrives within 7 days. That's why the order is marked paid oncheckout.completedwhatever it was before. - Paid twice? If a customer pays an old checkout after paying a new one, the second payment lands in
extra_payments, for a refund or a credit. Nothing is lost and nothing is fulfilled twice. - One transaction records the event id and the order change together, so a redelivery can't fulfil an order twice, and a failure is retried.
The checkout statuses
| Status | Event | What to do |
|---|---|---|
OPEN | Waiting for the payer. | |
PROCESSING | checkout.payment_detected | Seen on-chain, not final. Show "confirming". |
COMPLETED | checkout.completed | Fulfil the order. |
UNDERPAID | checkout.underpaid | Ask for the rest, or refund. |
EXPIRED | checkout.expired | Nothing was paid. Offer a new checkout. |
CANCELLED | checkout.cancelled | Cancelled before any payment. |
US dollar stablecoins count one-for-one. A checkout is complete when the confirmed payments add up to the quote, within 0.5%.
Try it
- Run the app with a tunnel, and point the webhook endpoint at it.
- Click the button, pay the test amount from a testnet wallet, and watch the order move to
paidand a row appear infulfilments. - Resend the
checkout.completeddelivery from the dashboard's delivery log: the order is fulfilled once.
Going live
- A live workspace, its own API key, endpoint and public key.
- Sweeps to move checkout payments into your treasury, and a funded gas wallet so token sweeps can pay their fees.
- A routine for
extra_paymentsand underpaid orders: refunds are payouts under your approval rules.
Prefer your own payment screen and permanent addresses? See crypto deposits with Express, and the payment acceptance guide.