This guide builds the whole flow in a Next.js App Router app: a Buy button posts to your server, your server creates the order and a single-use payment link for it, the customer pays on Anytime Capital's hosted checkout, and a verified webhook marks the order paid. Every file is complete: paste them into a new app and it runs.
- Browser →
POST /api/checkout(your server) →POST /api/v1/payment-linkswith the order id inmetadataand the order's Idempotency-Key. - Your server redirects to the link's
url; the customer pays by card, Apple Pay, Google Pay, or bank or stablecoin where you've turned them on. - We send
payment.succeededto/api/webhooks/anytime; you verify it and mark the order paid. Never fulfil on the redirect alone: bank payments take days, and customers close tabs.
1. Create the app and your keys
npx create-next-app@latest shop --ts --app --eslint --no-src-dir --import-alias "@/*" --yes
cd shopIn Settings → Developers, create an API key for this site. A restricted key with payment_links write is all this guide needs; add payments read if you'll look payments up. See Authentication.
# .env.local — never commit this file
ANYTIME_API_KEY=ak_live_… # Settings → Developers → Create key
ANYTIME_WEBHOOK_SECRET=whsec_… # shown when you add the webhook endpoint
APP_URL=https://shop.example.com # your site; https:// to bring customers back after paying2. Orders
Each order gets an Idempotency-Key when it's created and keeps it. If creating the link times out and you try again, the same key returns the first link instead of making a second one.
// lib/orders.ts — a stand-in for your database: swap it for a real table.
import { randomUUID } from "node:crypto";
export type OrderStatus = "pending" | "paid" | "refunded";
export interface Order {
id: string;
title: string;
amountCents: number;
status: OrderStatus;
/** Made once per order and reused on every retry, so a retry never makes a second link. */
idempotencyKey: string;
paymentLinkId: string | null;
paymentId: string | null;
}
// Kept on globalThis so the dev server's reloads don't empty it. One process only.
const store = globalThis as typeof globalThis & {
anytimeOrders?: Map<string, Order>;
anytimeEvents?: Set<string>;
};
const orders = (store.anytimeOrders ??= new Map<string, Order>());
/** Event ids already handled: webhooks can arrive more than once. */
export const handledEvents = (store.anytimeEvents ??= new Set<string>());
export function createOrder(input: { title: string; amountCents: number }): Order {
const order: Order = {
id: `order_${randomUUID().slice(0, 8)}`,
title: input.title,
amountCents: input.amountCents,
status: "pending",
idempotencyKey: randomUUID(),
paymentLinkId: null,
paymentId: null,
};
orders.set(order.id, order);
return order;
}
export function saveOrder(order: Order): void {
orders.set(order.id, order);
}
export function findOrder(id: string): Order | null {
return orders.get(id) ?? null;
}3. Create the payment link
// lib/anytime.ts — calls the Anytime Capital Payments API. Import it from server code only:
// the key must never reach the browser.
const API = "https://anytime-capital.com/api/v1";
export interface PaymentLink {
id: string;
url: string;
}
export async function createPaymentLink(input: {
title: string;
amountCents: number;
orderId: string;
idempotencyKey: string;
successUrl?: string;
}): Promise<PaymentLink> {
const res = await fetch(`${API}/payment-links`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANYTIME_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": input.idempotencyKey,
},
body: JSON.stringify({
title: input.title,
amountCents: input.amountCents,
singleUse: true, // closes after its first payment: one link per order
metadata: { orderId: input.orderId },
...(input.successUrl ? { successUrl: input.successUrl } : {}),
}),
cache: "no-store",
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return { id: body.id, url: body.url };
}// app/api/checkout/route.ts — makes the order and its payment link, then sends the customer to pay.
import { createPaymentLink } from "@/lib/anytime";
import { createOrder, saveOrder } from "@/lib/orders";
export const runtime = "nodejs";
// Prices live on the server: never take an amount from the browser.
const PRODUCTS = new Map([["consulting-hour", { title: "Consulting — 1 hour", amountCents: 12500 }]]);
export async function POST(request: Request) {
const form = await request.formData();
const product = PRODUCTS.get(String(form.get("productId")));
if (!product) return new Response("Unknown product", { status: 400 });
const order = createOrder(product);
const appUrl = process.env.APP_URL ?? "";
const link = await createPaymentLink({
title: order.title,
amountCents: order.amountCents,
orderId: order.id,
idempotencyKey: order.idempotencyKey,
// Success URLs must be https://; on http://localhost the customer stays on our confirmation page.
successUrl: appUrl.startsWith("https://") ? `${appUrl}/orders/${order.id}` : undefined,
});
saveOrder({ ...order, paymentLinkId: link.id });
return Response.redirect(link.url, 303);
}// app/page.tsx — a Buy button. The form posts to your server, not to us.
export default function Home() {
return (
<main>
<h1>Consulting</h1>
<form action="/api/checkout" method="post">
<input type="hidden" name="productId" value="consulting-hour" />
<button type="submit">Buy one hour, $125.00</button>
</form>
</main>
);
}// app/orders/[id]/page.tsx — where customers land after paying (with an https APP_URL).
import { notFound } from "next/navigation";
import { findOrder } from "@/lib/orders";
export const dynamic = "force-dynamic";
export default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = findOrder(id);
if (!order) notFound();
return (
<main>
<h1>{order.title}</h1>
<p>{order.status === "paid" ? "Paid. Thank you!" : "Confirming your payment: refresh in a moment."}</p>
</main>
);
}4. Confirm payment by webhook
// app/api/webhooks/anytime/route.ts — marks orders paid once the payment really went through.
import crypto from "node:crypto";
import { findOrder, handledEvents, saveOrder } from "@/lib/orders";
export const runtime = "nodejs";
function verified(body: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
const signed =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
return signed && Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
}
export async function POST(request: Request) {
const body = await request.text(); // the raw body: the signature covers these exact bytes
const header = request.headers.get("Anytime-Signature") ?? "";
if (!verified(body, header, process.env.ANYTIME_WEBHOOK_SECRET ?? "")) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(body);
if (handledEvents.has(event.id)) return new Response(null, { status: 200 });
const payment = event.data;
const order = typeof payment?.metadata?.orderId === "string" ? findOrder(payment.metadata.orderId) : null;
// Only trust a payment made on this order's own link, for this order's amount.
if (order && payment.paymentLinkId === order.paymentLinkId) {
if (event.type === "payment.succeeded" && payment.amountCents === order.amountCents) {
saveOrder({ ...order, status: "paid", paymentId: payment.id });
}
if (event.type === "payment.refunded" && payment.amountRefundedCents >= payment.amountCents) {
saveOrder({ ...order, status: "refunded" });
}
}
handledEvents.add(event.id);
return new Response(null, { status: 200 });
}Register the endpoint in Settings → Developers → Add endpoint with https://your-site/api/webhooks/anytime and the events payment.succeeded and payment.refunded, or from code with the webhook endpoints API. Copy its signing secret into ANYTIME_WEBHOOK_SECRET. Webhook URLs must be public https:// addresses, so while you develop on your own machine, expose it with a tunnel such as ngrok or Cloudflare Tunnel.
curl -X POST https://anytime-capital.com/api/v1/webhook-endpoints \
-H "Authorization: Bearer $ANYTIME_API_KEY" \
-H "Idempotency-Key: shop-webhook-endpoint" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example.com/api/webhooks/anytime","events":["payment.succeeded","payment.refunded"]}'5. Try it
npm run dev, open the site, and press Buy. You land on the hosted checkout.- With a test key (
ak_test_…) and Test mode on, pay with a test card: no real money moves. With a live key, pay a small amount with your own card and refund it from Orders. - With your tunnel's
https://address asAPP_URL, you come back to/orders/<id>after paying, and it reads “Paid” once the webhook has arrived. - Refund it from Orders in the dashboard, and the
payment.refundedevent marks it refunded.
Going to production
- Swap
lib/orders.tsfor your database, with a unique index on the event ids you've handled. - Keep
ANYTIME_API_KEYandANYTIME_WEBHOOK_SECRETin your host's secret settings, never in code. - Want customers to stay on your page? Open the same link as an overlay with the Pay button.
- Bank payments sit at
pendingfor days: show “confirming” untilpayment.succeededarrives, and handlepayment.failed.