Next.js · App Router · Postgres

Stablecoin checkout in Next.js

Add a Pay with USDT or USDC button to a Next.js App Router app with hosted payment links: route handlers, webhook verification, late payments and refunds.

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

  1. A Valtu workspace (free on testnets) and an API key with the operate role: Developers → API keys → Create key.
  2. A webhook endpoint at https://your-app/api/valtu/webhooks with the checkout.* events, and the workspace's Ed25519 public key from Developers → Webhooks.
  3. 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 with node:crypto, which the Edge runtime doesn't have.
  • externalId makes creation idempotent: a double click returns the same checkout, not a second one.
  • Attempts: a checkout lasts an hour by default (expiresInSeconds up 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(), not req.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 COMPLETED if the rest arrives within 7 days. That's why the order is marked paid on checkout.completed whatever 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

StatusEventWhat to do
OPENWaiting for the payer.
PROCESSINGcheckout.payment_detectedSeen on-chain, not final. Show "confirming".
COMPLETEDcheckout.completedFulfil the order.
UNDERPAIDcheckout.underpaidAsk for the rest, or refund.
EXPIREDcheckout.expiredNothing was paid. Offer a new checkout.
CANCELLEDcheckout.cancelledCancelled 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

  1. Run the app with a tunnel, and point the webhook endpoint at it.
  2. Click the button, pay the test amount from a testnet wallet, and watch the order move to paid and a row appear in fulfilments.
  3. Resend the checkout.completed delivery 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_payments and 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.

More guides

Build the next piece.

Put it into practice.

Every workspace starts on testnets with free test coins: same dashboard, same API. Go live when your team is ready.