# Accept a payment from a Next.js app

> A complete Next.js App Router integration: a single-use payment link per order made on the server, a redirect to it, and a verified webhook that marks it paid.

Source: https://anytime-capital.com/docs/nextjs-checkout · Markdown: https://anytime-capital.com/docs/nextjs-checkout.md · All docs: https://anytime-capital.com/docs/llms-full.txt

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](https://anytime-capital.com/docs/payment-links.md) for it, the customer pays on Anytime Capital's hosted checkout, and a verified [webhook](https://anytime-capital.com/docs/webhooks.md) marks the order paid. Every file is complete: paste them into a new app and it runs.

1. Browser → `POST /api/checkout` (your server) → `POST /api/v1/payment-links` with the order id in `metadata` and the order's Idempotency-Key.
2. 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.
3. We send `payment.succeeded` to `/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

```bash
npx create-next-app@latest shop --ts --app --eslint --no-src-dir --import-alias "@/*" --yes
cd shop
```

In **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](https://anytime-capital.com/docs/authentication.md).

**.env.local**

```bash
# .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 paying
```

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

```typescript
// 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**

```typescript
// 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**

```typescript
// 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**

```typescript
// 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**

```typescript
// 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**

```typescript
// 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](https://anytime-capital.com/docs/webhook-endpoints.md). 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.

**Or register it with the API**

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

1. `npm run dev`, open the site, and press **Buy**. You land on the hosted checkout.
2. With a test key (`ak_test_…`) and **Test mode** on, pay with a [test card](https://anytime-capital.com/docs/test-mode.md#test-cards): no real money moves. With a live key, pay a small amount with your own card and refund it from **Orders**.
3. With your tunnel's `https://` address as `APP_URL`, you come back to `/orders/<id>` after paying, and it reads “Paid” once the webhook has arrived.
4. Refund it from **Orders** in the dashboard, and the `payment.refunded` event marks it refunded.

## Going to production

- Swap `lib/orders.ts` for your database, with a unique index on the event ids you've handled.
- Keep `ANYTIME_API_KEY` and `ANYTIME_WEBHOOK_SECRET` in 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](https://anytime-capital.com/docs/checkout.md).
- Bank payments sit at `pending` for days: show “confirming” until `payment.succeeded` arrives, and handle `payment.failed`.

---

Anytime Capital is a fintech company, not a bank. Payment processing, banking and payout services are provided by Anytime Capital's licensed partners. Rates shown are for US card payments; international cards, currency conversion and disputes may carry additional fees shown in your dashboard before you accept a payment.
