---
name: talo-stack-nextjs-vercel
description: Tested code and decisions for Talo Pay bank-transfer payments through the Talo Partner Hub in a Next.js App Router app on Vercel — a server-only hub client, a checkout Server Action and order-page polling, a route handler that verifies signed webhooks on the raw body and fulfils exactly once with durable idempotency in Postgres (Neon or Supabase via the Vercel Marketplace), Vercel env vars per environment (Production, Preview, Development) set with talo-hub --vercel/--dotenv without exposing secrets, Deployment Protection and Firewall pitfalls that silently block webhooks, Vercel Cron reconciliation, and a sandbox test plan. Use when the project uses Next.js or deploys to Vercel and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---

# Talo Pay on Next.js (App Router) + Vercel

This kit adds the Next.js and Vercel layer on top of the core skills `talo-payments` (API, events, statuses) and `talo-go-live` (sandbox plan, production steps), and implements the 10-rule contract at https://talopartnerhub.vercel.app/docs/stacks.md. The TypeScript, SQL and JSON below passed `next build` (with and without `cacheComponents`) and tests against Postgres 17 and a mock hub; commands that touch the hub or Vercel follow talo-hub 0.1.4 and the Vercel CLI's `--help`. The Vercel sections (credentials, database, webhook URL, Deployment Protection, Firewall, Cron, regions, logs) apply to any framework deployed on Vercel.

## Setup

- Next.js 16.x (tested on 16.4.3; on 15.x the proxy file is `middleware.ts` exporting `middleware`), Node 24 (Vercel's default), the Vercel CLI (`npm i -g vercel`), talo-hub 0.1.4 or newer (`npx talo-hub --version`). `npm install pg @vercel/functions server-only` and `npm install -D @types/pg vitest`; add `botid` only to protect the checkout (without it, delete the `checkBotId` import and line in `app/actions.ts`).
- Files: `lib/talo.ts`, `lib/verify-webhook.ts`, `lib/db.ts`, `lib/payments.ts` (the idempotent step), `lib/payment-status.ts` (client-safe: `isPaid` = SUCCESS or OVERPAID, `isFinal` = paid or EXPIRED), `db/schema.sql`, `app/actions.ts`, `app/api/orders/[id]/route.ts`, `app/api/webhooks/talo/route.ts`, `app/api/cron/talo-reconcile/route.ts`, `proxy.ts`, `vercel.json`.
- Server-only variables: `TALO_HUB_API_KEY`, `TALO_HUB_WEBHOOK_SECRET`, `TALO_HUB_ALLOW_PRODUCTION`, `DATABASE_URL`, `CRON_SECRET`. Read them only in modules that `import "server-only"` (a client import then fails the build). Never prefix them with `NEXT_PUBLIC_`: Next.js inlines those into the browser bundle at build time. `.gitignore` contains `.env*`, `!.env.example` and `.vercel`; commit only an empty `.env.example`. Never print or read back `.env.local`, `vercel env pull` output or any secret into the conversation.

## Credentials on Vercel

| Vercel environment | `TALO_HUB_API_KEY` | Webhook endpoint (`TALO_HUB_WEBHOOK_SECRET`) | Also |
|---|---|---|---|
| Production, before go-live | its own `tph_test_` charge key | sandbox endpoint on the production domain | `CRON_SECRET` |
| Production, after go-live | `tph_live_` charge key (`--production`) | production endpoint (`--production`) | `TALO_HUB_ALLOW_PRODUCTION=true` (Config) |
| Preview | a separate `tph_test_` key, optionally one for a branch (`preview:sandbox`) | sandbox endpoint on the sandbox branch URL | never a live key |
| Development | each developer's own `tph_test_` key in `.env.local` (`--dotenv`) | none: `events listen --forward-to` | nothing in Vercel (`--vercel` refuses development: Secrets can't be pulled) |

Keys and webhook secrets are Secret type (write-only, redacted in build logs); flags are Config. `--vercel <env>[:<branch>]` runs `vercel env add <VAR> <env> [branch] --sensitive --force --yes` and writes the value to its stdin, so it is never printed; it refuses a `tph_live_` key for preview and revokes the key (or removes the endpoint) if Vercel refuses. Run these in a shell where `TALO_HUB_API_KEY` is not exported: the CLI would use it instead of the person's login and fail with `insufficient_scope`.

```bash
vercel link --yes --project <project>   # first: --vercel writes to the linked project
npx talo-hub login                      # the person authorizes the hub in Talo (sandbox); it exits when they do: wait for that, don't ask them
npx talo-hub keys create --name <project>-production --scope charge --vercel production   # before the first deploy, which is always Production
npx talo-hub keys create --name <project>-preview --scope charge --vercel preview
npx talo-hub keys create --name <project>-sandbox --scope charge --vercel preview:sandbox   # optional branch override
npx talo-hub keys create --name dev-<person> --scope charge --dotenv .env.local           # each developer
node -e "const s=require('node:crypto').randomBytes(24).toString('base64url');require('node:child_process').spawnSync('vercel',['env','add','CRON_SECRET','production','--sensitive','--force','--yes'],{input:s,stdio:['pipe','inherit','inherit'],shell:process.platform==='win32'})"
```

The last line generates `CRON_SECRET` and writes it to stdin (tested in bash and PowerShell 5.1: 32 characters, no BOM). After go-live, each step with the person (real money): `npx talo-hub login --production`, `npx talo-hub keys create --name <project>-production --scope charge --production --vercel production`, `vercel env add TALO_HUB_ALLOW_PRODUCTION production --no-sensitive --value true --yes` (not a secret), the production webhook (Webhook URL below), redeploy, then remove the sandbox endpoint that pointed at production and revoke Production's old sandbox key (`npx talo-hub webhooks remove <id> --sandbox`, `npx talo-hub keys revoke <id> --sandbox`). Sandbox orders still open in the production database make the cron log `payment_not_found` until they leave its 48-hour window.

- Never pass a secret as an argument (`--value tph_…`, `--token`), through `echo`, as an MCP tool argument (Vercel `create_project_env`/`edit_project_env`) or through a shell pipe into `vercel env add`: it keeps only the first stdin chunk that arrives within 500 ms (a slow `npx … |` stores an empty value), and Windows PowerShell 5.1 pipes prepend a BOM and append CRLF (measured: 35 characters for a 32-character value).
- Redeploy after every env change (`vercel redeploy <deployment-url> --target production`, or push): running deployments keep the old values, and Instant Rollback restores old ones, revoked keys included. Verify at runtime, not by reading back (Secrets can't be read; `vercel env pull` writes `[SENSITIVE]`, which `hubKey()` rejects): `vercel env ls production --format json` (names and types), `npx talo-hub webhooks test <id>` → delivered, `npx talo-hub keys list --json` → the new key's `last_used_at` moves.
- Rotate with overlap (add `--production` to every talo-hub command for live). Key: `keys create … --vercel <env>` (overwrites), redeploy, wait for its `last_used_at`, then `npx talo-hub keys revoke <old id>`. Webhook secret: `webhooks add <same url> --vercel <env>` registers endpoint B at the same URL and stores B's secret; redeploy; `webhooks test <B id>`; `webhooks remove <A id>`. Meanwhile every event goes to both endpoints (the one with the stale secret gets 400 and is retried); event-id dedupe and the conditional fulfil absorb the double delivery.

## Hub client: `lib/talo.ts` and `lib/verify-webhook.ts`

```ts
import "server-only"; // importing this from a client component fails the build

import type { PaymentStatus } from "./payment-status";

const HUB_URL = process.env.TALO_HUB_URL ?? "https://talopartnerhub.vercel.app"; // override only in tests
const KEY = /^tph_(test|live)_[A-Za-z0-9_-]{32}$/;

export interface TaloPayment {
  id: string; status: PaymentStatus; external_id: string; amount: number; currency: "ARS";
  cvu: string; alias: string; paid: number; refunded: number; expires_at: string; motive?: string;
}
export interface TaloHubEvent { id: number; type: string; created_at: string; data?: { payment?: TaloPayment } }

export class TaloHubError extends Error {
  constructor(readonly status: number, readonly code: string, message: string) { super(`Talo Partner Hub ${status} ${code}: ${message}`); }
  get retryable() { return this.status === 0 || this.status === 502; } // network error, talo_unavailable
}

/** Refuses a malformed key ("", "[SENSITIVE]", a BOM) and a live key outside Vercel Production. */
export function hubKey(): string {
  const key = process.env.TALO_HUB_API_KEY ?? "";
  if (!KEY.test(key)) throw new Error("TALO_HUB_API_KEY is missing or malformed");
  const live = process.env.VERCEL_ENV === "production" && process.env.TALO_HUB_ALLOW_PRODUCTION === "true";
  if (key.startsWith("tph_live_") && !live) throw new Error("tph_live_ key outside Vercel Production");
  return key;
}

async function hub<T>(path: string, method = "GET", body?: unknown, timeoutMs = 5_000): Promise<T> {
  const key = hubKey(); // configuration errors are not retryable
  let res: Response;
  try {
    res = await fetch(`${HUB_URL}${path}`, {
      method, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      signal: AbortSignal.timeout(timeoutMs), // reads stay short: the webhook must answer within 10 s
    });
  } catch (err) {
    throw new TaloHubError(0, "network", err instanceof Error ? err.message : "request failed");
  }
  const json = await res.json().catch(() => null); // errors: { "error": { "code", "message", "details"? } }
  if (!res.ok) throw new TaloHubError(res.status, json?.error?.code ?? "unknown", json?.error?.message ?? res.statusText);
  return json as T;
}

/** POST /api/v1/payments. Retries reuse external_id, so they get the same payment back. */
export async function createPayment(input: { amount: number; external_id: string; motive?: string }) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await hub<TaloPayment>("/api/v1/payments", "POST", input, 25_000); // Talo can take 15-20 s to create one
    } catch (err) {
      if (!(err instanceof TaloHubError && err.retryable) || attempt === 3) throw err;
      await new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
    }
  }
}

/** GET /api/v1/payments/{id}: the current status (reading it also makes the hub catch up). */
export const getPayment = (id: string) => hub<TaloPayment>(`/api/v1/payments/${encodeURIComponent(id)}`);
```

```ts
import "server-only";

import { createHmac, timingSafeEqual } from "node:crypto";

/** talo-hub-signature: t=<unix>,v1=<64 lowercase hex> = HMAC-SHA256(secret, "<t>.<raw body>"), |now - t| <= 300 s. */
export function verifyTaloHub(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => { const [k, ...v] = p.trim().split("="); return [k, v.join("=")]; }));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
```

Errors by `code`: `unauthorized` 401 (wrong or revoked key, or not redeployed), `insufficient_scope` 403 (needs `charge`), `payment_not_found` 404 (other account: sandbox vs production key), `invalid_*` 422 (fix the request; don't retry), `talo_rejected` (Talo refused, see `details.talo_message`), `talo_reconnect_required` 409 (the owner runs `npx talo-hub login`, `--production` for live; don't retry), `talo_unavailable` 502 and network errors (retry with backoff and the same `external_id`), `not_configured` 503 (hub side; later).

## Checkout: `app/actions.ts` and the order page

```ts
"use server";

import { randomUUID } from "node:crypto";
import { checkBotId } from "botid/server";
import { redirect } from "next/navigation";

import { db } from "@/lib/db";
import { PRODUCT } from "@/lib/product";
import { createPayment, getPayment } from "@/lib/talo";

export type CheckoutState = { error: string } | null;
const FAILED = { error: "No pudimos generar los datos de pago. Probá de nuevo en un momento." };
const why = (err: unknown) => (err instanceof Error ? err.message : String(err));

/** "Pagar por transferencia". The amount comes from the server; the form sends nothing. */
export async function startCheckout(_prev: CheckoutState, _form: FormData): Promise<CheckoutState> {
  if ((await checkBotId()).isBot) return FAILED; // optional: only with withBotId + initBotId on Vercel, else delete
  const orderId = `order-${randomUUID()}`;
  // The order, with external_id = its id, exists BEFORE the hub is called.
  await db().query("insert into orders (id, external_id, amount) values ($1, $1, $2)", [orderId, PRODUCT.price]);
  try {
    const payment = await createPayment({ amount: PRODUCT.price, external_id: orderId, motive: PRODUCT.name });
    await db().query("update orders set talo_payment_id = $2 where id = $1", [orderId, payment.id]);
  } catch (err) {
    console.error(JSON.stringify({ checkout: orderId, error: why(err) }));
    return FAILED;
  }
  redirect(`/pedido/${orderId}`); // outside try: redirect() throws on purpose
}
```

- The amount comes from the server; the browser gets only cvu, alias, amount, status and expires_at (or, for the widget, the payment's id and read-only `checkout_token`), and never the key.
- `renewPayment(orderId)` (same file, tested): if `getPayment` says EXPIRED, call `createPayment` FIRST with `` external_id: `${orderId}-${order.attempt + 1}` `` (a retry or double click gets that same payment back), then `update orders set attempt = $3, external_id = $2, talo_payment_id = $4, payment_status = 'PENDING', paid = 0 where id = $1 and attempt = $3 - 1`; if it isn't EXPIRED, just redirect to the order. The webhook matches orders by `external_id`, so late events of the expired payment are ignored. If the expired payment shows `paid > 0`, part of the money arrived: hand it to the person instead of charging the full amount again.
- `app/api/orders/[id]/route.ts` (display only): look up the order's `talo_payment_id` (404 if none), return `{ status, amount, paid }` from `getPayment`, 502 on hub errors. It never fulfils. The order page `/pedido/<order id>` (a Server Component reading the order and `getPayment`) renders a client component that polls that route every 4 s, doubles the wait on errors up to 30 s, pauses in hidden tabs, and stops at SUCCESS, OVERPAID, EXPIRED or 15 minutes after `expires_at`. UNDERPAID shows what is missing; EXPIRED shows a form whose action calls `renewPayment(orderId)`. With `cacheComponents`, give the page a `loading.tsx` (or `<Suspense>`): request-time reads outside one fail `next build`.
- The order page can show the hub's widget instead of polling your own route: the hub's answers to `createPayment` and `getPayment` carry `checkout_token` (add `checkout_token: string` and `payment_url?: string` to `TaloPayment`), a read-only token for that one payment's public view, valid 15 days. Pass the client component only `payment.id` and `payment.checkout_token` (never the key) and render `<Script src="https://talopartnerhub.vercel.app/widget/v1/talo-checkout.js" strategy="afterInteractive" />` (`next/script`) plus `<talo-checkout payment-id={id} token={token} />` (declare `"talo-checkout"` in `JSX.IntrinsicElements` via `declare module "react"`; attach listeners with a ref, not `on*` props). It polls the hub's public view by itself (4 s, backing off to 30 s, paused in hidden tabs, stopping at SUCCESS, OVERPAID, EXPIRED or 15 minutes after `expires_at`) and fires `talo-status`, `talo-paid`, `talo-expired`, `talo-error`: update the screen with them, never fulfil. Theme it with `--talo-accent`, `--talo-text`, `--talo-muted`, `--talo-bg`, `--talo-border`, `--talo-radius`, `--talo-font`. Docs: https://talopartnerhub.vercel.app/docs/checkout.md
- Optional BotID on the action (every click creates a real payment): `export default withBotId(nextConfig)` (`botid/next/config`) and, in `instrumentation-client.ts`, `initBotId({ protect: [{ path: "/", method: "POST" }] })` (the page whose form posts the action). In `next dev` it answers HUMAN. Keep the `checkBotId` line only with both in place and on Vercel: without the client setup it fails real buyers (BotID docs), and in production outside Vercel (`next start` in CI, self-hosting) it throws (`x-vercel-oidc-token` missing; botid 1.5.12). Never on the webhook (see Firewall).

## Starting point: the shadcn block

For a project with no Talo code yet, the hub's shadcn registry installs a minimal version of the files above:

```bash
npx shadcn@latest add https://talopartnerhub.vercel.app/r/talo-checkout-nextjs.json
```

- It adds `lib/talo-hub.ts` (server-only: `createTaloPayment({ amount, externalId, motive? })`, `getTaloPayment(id)`, `verifyTaloWebhook(rawBody, signatureHeader)`), `app/api/webhooks/talo/route.ts` (raw body, signature, `next dev` forwarding, `webhook.test`), `lib/talo-events.ts` (`handleTaloEvent` re-reads the payment and calls `markOrderPaid` for SUCCESS or OVERPAID) and the `TaloPaymentStatus` React + Tailwind component (registry item `https://talopartnerhub.vercel.app/r/talo-payment-status.json`: `<TaloPaymentStatus paymentId={payment.id} token={payment.checkout_token} />`, the widget's behavior, with `hubUrl`, `onPaid`, `onExpired`, `className`). It installs `server-only` and lists `TALO_HUB_API_KEY` and `TALO_HUB_WEBHOOK_SECRET`, which you set with `--vercel`/`--dotenv` as above.
- You write `markOrderPaid` in `lib/talo-events.ts` (it throws until you do, so the hub keeps retrying instead of a payment going unnoticed). It is only the start: before production add what this kit adds around it, the event-id table and the conditional `fulfilled_at` update (`applyPayment` below), the order stored before the hub call, the reconciliation cron, and `hubKey()`'s refusal of a `tph_live_` key outside Vercel Production.
- `onPaid` is for the screen. Fulfilment stays in the webhook and the cron.

## Webhook: `app/api/webhooks/talo/route.ts` and `lib/payments.ts`

```ts
import { after } from "next/server";

import { applyPayment, deliverOrder } from "@/lib/payments";
import type { TaloHubEvent } from "@/lib/talo";
import { verifyTaloHub } from "@/lib/verify-webhook";

// No `export const runtime`: Node.js is the default (node:crypto); Cache Components rejects the export.
export async function POST(request: Request) {
  const raw = await request.text(); // once, before any parsing: the HMAC covers these exact bytes
  // `npx talo-hub events listen --forward-to` relays unsigned events: accept them only under `next dev`.
  const forwardedInDev = process.env.NODE_ENV === "development" && request.headers.get("talo-hub-forwarded") === "1";
  if (!forwardedInDev) {
    const secret = process.env.TALO_HUB_WEBHOOK_SECRET;
    if (!secret) return Response.json({ error: "not configured" }, { status: 500 }); // the hub retries
    if (!verifyTaloHub(raw, request.headers.get("talo-hub-signature") ?? "", secret)) {
      return Response.json({ error: "invalid signature" }, { status: 400 });
    }
  }
  let event: TaloHubEvent;
  try { event = JSON.parse(raw); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); }
  const paymentId = event.data?.payment?.id;
  const log = { talo_event: event.id, type: event.type, payment_id: paymentId };
  if (event.type === "webhook.test" || !paymentId) { // pings, account.updated: ignored on purpose → 2xx
    console.log(JSON.stringify({ ...log, outcome: "ignored" }));
    return Response.json({ received: true });
  }
  try {
    const result = await applyPayment(paymentId, event); // committed when it returns
    console.log(JSON.stringify({ ...log, ...result }));
    if (result.outcome === "fulfilled") after(() => deliverOrder(result.orderId!)); // best effort; cron retries
    return Response.json({ received: true, outcome: result.outcome });
  } catch (err) {
    console.error(JSON.stringify({ ...log, outcome: "error", error: err instanceof Error ? err.message : String(err) }));
    return Response.json({ error: "not processed" }, { status: 500 }); // nothing committed: the hub retries
  }
}
```

```ts
import "server-only";

import { db, tx } from "./db";
import { isPaid } from "./payment-status";
import { getPayment, type TaloHubEvent } from "./talo";

type Result = { outcome: "fulfilled" | "updated" | "duplicate" | "ignored"; orderId?: string };

/** The one idempotent step, shared by the webhook and the cron. Throws → nothing committed → 5xx. */
export async function applyPayment(paymentId: string, event?: TaloHubEvent): Promise<Result> {
  if (event && (await db().query("select 1 from talo_hub_events where event_id = $1", [event.id])).rowCount) {
    return { outcome: "duplicate" }; // fast path; the insert below is the real guard
  }
  const payment = await getPayment(paymentId); // now, not the event's snapshot; outside the transaction
  return tx(async (client): Promise<Result> => {
    if (event) {
      const inserted = await client.query(
        "insert into talo_hub_events (event_id, type, payment_id, payment_status) values ($1, $2, $3, $4) on conflict (event_id) do nothing",
        [event.id, event.type, payment.id, payment.status]);
      if (!inserted.rowCount) return { outcome: "duplicate" };
    }
    // Match on external_id (stored before the hub was called). Never move a fulfilled order back.
    const order = await client.query<{ id: string }>(
      `update orders set talo_payment_id = $2, payment_status = $3, paid = $4
        where external_id = $1 and (fulfilled_at is null or $3 in ('SUCCESS', 'OVERPAID')) returning id`,
      [payment.external_id, payment.id, payment.status, payment.paid]);
    if (!order.rows[0]) return { outcome: "ignored" }; // not ours, or a stale read: 2xx, no retry
    if (!isPaid(payment.status)) return { outcome: "updated" }; // UNDERPAID is never paid
    const fulfilled = await client.query<{ id: string }>(
      "update orders set fulfilled_at = now() where id = $1 and fulfilled_at is null returning id", [order.rows[0].id]);
    return fulfilled.rows[0] ? { outcome: "fulfilled", orderId: fulfilled.rows[0].id } : { outcome: "updated" };
  });
}

/** Slow side effects (email, stock, shipping): at least once, so make them idempotent per order. */
export async function deliverOrder(orderId: string): Promise<void> {
  console.log(JSON.stringify({ deliver_order: orderId })); // send it here, with orderId as idempotency key
  await db().query("update orders set notified_at = now() where id = $1 and notified_at is null", [orderId]);
}
```

- Node.js runtime by default; no `runtime` export. Edge has no `node:crypto` and is deprecated (Vercel: unsupported for routes from Next.js 16.3), and with `cacheComponents` even `runtime = "nodejs"` fails the build (`Route segment config "runtime" is not compatible with nextConfig.cacheComponents`, checked on 16.4.3). `await request.text()` once, verify, then `JSON.parse`; never `request.json()` first. App Router needs no body-parser setting (`bodyParser: false` is Pages Router only).
- The transaction: event id `insert … on conflict do nothing` (bigint), the order's status, `update … set fulfilled_at = now() where … and fulfilled_at is null returning id`, commit, then 2xx. The per-order conditional update is the real guard (the hub can rarely emit two `payment.paid` with different ids for one payment); the event row is the second guard and the audit trail.
- 5xx on any failure before commit (nothing was written, so the retry starts clean); 400 only for a bad signature or body; 2xx for events ignored on purpose (`webhook.test`, `account.updated`, payments that aren't yours). The hub retries every non-2xx, 400 included, after 1 m, 5 m, 30 m, 2 h and 6 h, then gives up (~8 h 36 m) with no redelivery API.
- Slow work only for the row the conditional update returned: `after()` is best effort (it shares maxDuration and its failures never reach the hub), so `notified_at` doubles as an outbox the cron finishes; use Vercel Queues/Workflow when delivery must be guaranteed. Stay well under the hub's 10 s timeout: one hub GET (5 s timeout) plus one short transaction.
- Reachable: nothing in front of the path may redirect, challenge or require a session (matchers below tested with `unstable_doesMiddlewareMatch`). `next.config.ts` `redirects()` run before `proxy.ts`: none may match `/api/webhooks`.
- Your own `proxy.ts` (`middleware.ts` on ≤15): `export const config = { matcher: ["/((?!api/webhooks|api/cron|_next/static|_next/image|favicon.ico).*)"] }`, literal constants only.
- Clerk 7.9+: `export default clerkMiddleware()` with no `auth.protect()` in it (`createRouteMatcher` is deprecated and warns; protect inside pages and routes), and Clerk's matcher minus the two paths: `["/((?!_next|api/webhooks|api/cron|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api(?!/webhooks|/cron)|trpc)(.*)"]`.
- Auth.js v5 (`export { auth as proxy } from "@/auth"`): keep a matcher that skips `api/webhooks` and `api/cron`; its `authorized` callback otherwise redirects to the login page (Auth.js docs; not run here).

## Database on Vercel

`vercel install neon -e production` gives Production its own database; for Preview add a second resource (`vercel install neon --name <project>-preview -e preview`) or Neon's preview branching (`vercel install supabase` is the Supabase equivalent). Then redeploy: existing deployments don't get new variables. Neon injects `DATABASE_URL` (pooled) and `DATABASE_URL_UNPOOLED`; Supabase injects `POSTGRES_URL` and `POSTGRES_URL_NON_POOLING` (check names with `vercel env ls`). Apply `db/schema.sql` once per database: the person pastes it into the provider's SQL editor or runs their migration tool (a `psql "<url>"` puts the password in arguments and history; keep it out of your commands):

```sql
create table if not exists orders (
  id              text primary key,                 -- your order id
  external_id     text not null unique,             -- sent to the hub: the order id, "<id>-2" after EXPIRED
  attempt         int not null default 1,
  talo_payment_id text unique,
  amount          numeric(12,2) not null check (amount > 0),
  payment_status  text not null default 'PENDING',  -- last status read from the hub
  paid            numeric(12,2) not null default 0,
  fulfilled_at    timestamptz,                      -- set once, by the conditional update
  notified_at     timestamptz,                      -- outbox: slow work (email, shipping) done
  created_at      timestamptz not null default now()
);
create index if not exists orders_open on orders (created_at) where payment_status in ('PENDING', 'UNDERPAID');

create table if not exists talo_hub_events (
  event_id       bigint primary key,                -- the event's integer id (bigint, not int4)
  type           text not null,
  payment_id     text,
  payment_status text,
  received_at    timestamptz not null default now()
);
```

`lib/db.ts`:

```ts
import "server-only";

import { attachDatabasePool } from "@vercel/functions";
import { Pool, type PoolClient } from "pg";

const g = globalThis as unknown as { taloPool?: Pool }; // one pool per Fluid instance

export function db(): Pool {
  if (!g.taloPool) { // created on first use, not at import (`next build` imports the routes)
    const url = process.env.DATABASE_URL || process.env.POSTGRES_URL; // Neon || Supabase (both pooled)
    if (!url) throw new Error("DATABASE_URL is not set"); // fail closed: never an in-memory fallback
    g.taloPool = new Pool({ connectionString: url, max: 5, idleTimeoutMillis: 5_000, connectionTimeoutMillis: 3_000 });
    attachDatabasePool(g.taloPool); // closes idle clients before the instance suspends
  }
  return g.taloPool;
}

export async function tx<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
  const client = await db().connect();
  try {
    await client.query("begin");
    const result = await work(client);
    await client.query("commit");
    return result;
  } catch (err) {
    await client.query("rollback").catch(() => {});
    throw err; // nothing committed
  } finally { client.release(); }
}
```

- Separate data per environment: Preview's sandbox orders never land in production tables. `db()` throws without a URL everywhere, which covers the hub's own rule (fail closed when `process.env.VERCEL` is set): the webhook answers 5xx and the hub retries (tested).
- Not memory (each Fluid instance and each deployment has its own), not Global Config (formerly Edge Config; writes take seconds) and not Runtime Cache (per region, a cache): none has an atomic check-and-set. The cron's lock is `pg_try_advisory_xact_lock` (transaction-scoped) because the pooled URLs run PgBouncer/Supavisor in transaction mode, where a session lock can stay held on another backend.
- Supabase (not tested here): node-postgres treats `sslmode=require` in a URL as `verify-full` (and lets it override an `ssl` option), and Supabase's chain ends in its own root certificate, so pg will likely refuse it (`self-signed certificate in certificate chain`). Fix it with that certificate (Database Settings → SSL), never with `rejectUnauthorized: false` or `sslmode=no-verify`.

## Webhook URL on Vercel

- Register the production domain that answers without a redirect: the primary one (www or apex, whichever doesn't redirect; `<project>.vercel.app` without a custom domain), exactly `/api/webhooks/talo`. The hub uses `redirect: "manual"`, so every 3xx fails: trailing slash (Next.js 308), `//`, http→https, apex↔www.
- Probe first (bash; in Windows PowerShell use `curl.exe` and `-o NUL`): `curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "content-type: application/json" --data "{}" https://<domain>/api/webhooks/talo` must print 400 (the handler refused an unsigned body), or 500 before the first `webhooks add` (no `TALO_HUB_WEBHOOK_SECRET` yet: that command creates it). 401 with a `_vercel_sso_nonce` cookie, or 302 to `vercel.com/sso-api`, is Deployment Protection; 2xx means no verification. `npx talo-hub webhooks add` now runs this probe and refuses those cases, other 3xx, 401/403/404/429 and firewall challenges (`--skip-check` skips it). Register: `npx talo-hub webhooks add https://<domain>/api/webhooks/talo --vercel production` (plus `--production` after go-live), redeploy, `npx talo-hub webhooks test <id>` (`--production`); it names a 401, a 3xx with its Location, or a timeout.
- Deployment Protection, Standard Protection (the default for new projects): generated URLs are protected. `<project>-<hash>-<scope>.vercel.app` (one deployment, new every push), `<project>-<scope>.vercel.app` and `<project>-git-<branch>-<scope>.vercel.app` answered 401 to POSTs in our tests, even for production deployments; the production domain is public. The "All Deployments" scope protects the production domain too and breaks every production webhook. Protection runs before `proxy.ts` and the route, so code can't exempt the path. (Vercel's docs say blocked automation gets a login page or 403; we observed 401 for JSON requests, 302 otherwise, and followed that.)
- Sandbox on previews: a long-lived `sandbox` branch and its branch URL (read it from `VERCEL_BRANCH_URL` or the deployment's aliases; labels over 63 characters are truncated), or a custom domain assigned to that branch (Settings → Domains → Connect to an environment → Preview → Git Branch). Probe it; on 401 the person chooses one of:
  1. A Deployment Protection Exception for that domain: Settings → Deployment Protection → Deployment Protection Exceptions → Add Domain (they type "unprotect my domain"; free on all plans per the 2026-09 docs, which we followed over older pricing). Opens only that domain; no secret anywhere.
  2. A dedicated Protection Bypass for Automation secret (Settings → Deployment Protection → Protection Bypass for Automation → Create, labelled "talo-hub sandbox") in `https://<branch-host>/api/webhooks/talo?x-vercel-protection-bypass=<secret>`. It opens every deployment of the project and skips Bot Protection, and the hub stores the full URL (its listings and pings hide the value), so revoke or rotate it after testing. The secret is part of the `webhooks add` argument: the person runs that command themselves and never pastes the URL into the chat. Never add `x-vercel-set-bypass-cookie` (it answers with a redirect).
  Protection changes are the person's decision: show the dashboard path and wait. Then `npx talo-hub webhooks add <url> --vercel preview:sandbox`, redeploy the branch, `npx talo-hub webhooks test <id>`.

## Firewall

- The hub is not a verified bot and has no fixed IPs (System Bypass rules are IP-only). Before setting Bot Protection to Challenge or Deny, turning on OWASP rules or rate-limiting `/api/*`, add a custom rule that bypasses the webhook and order it first: Firewall → Configure → New Rule → Request Path equals `/api/webhooks/talo` AND Method equals `POST` → Bypass → Review Changes → Publish. It applies without a redeploy and can't live in `vercel.json` (no bypass there). CLI (flags checked with `--help`; bash or zsh only, Windows PowerShell 5.1 strips the JSON quotes and has no `&&`):

```bash
vercel firewall rules add "Talo Hub webhooks" --condition '{"type":"path","op":"eq","value":"/api/webhooks/talo"}' --condition '{"type":"method","op":"eq","value":"POST"}' --action bypass --yes
vercel firewall rules reorder "Talo Hub webhooks" --first --yes && vercel firewall publish --yes
```

- Attack Mode challenges everything except verified bots and your own account's traffic, and runs before custom rules: hub deliveries fail while it's on. Keep it short (`vercel firewall attack-mode disable`), then run the reconciliation. The HMAC authenticates the hub, not the firewall. Never call `checkBotId()` in the webhook or list it in `initBotId`.

## Reconciliation with Vercel Cron

`vercel.json`: `{ "$schema": "https://openapi.vercel.sh/vercel.json", "crons": [{ "path": "/api/cron/talo-reconcile", "schedule": "*/10 * * * *" }] }`. Then `app/api/cron/talo-reconcile/route.ts`:

```ts
import { timingSafeEqual } from "node:crypto";

import { db } from "@/lib/db";
import { applyPayment, deliverOrder } from "@/lib/payments";

export const maxDuration = 60;

function authorized(request: Request): boolean {
  const secret = process.env.CRON_SECRET;
  if (!secret || secret.length < 16) return false; // fail closed when it is not set
  const sent = Buffer.from(request.headers.get("authorization") ?? "");
  const expected = Buffer.from(`Bearer ${secret}`);
  return sent.length === expected.length && timingSafeEqual(sent, expected);
}

/** Vercel Cron (GET, production only): re-reads open orders in case a webhook never arrived. */
export async function GET(request: Request) {
  if (!authorized(request)) return Response.json({ error: "unauthorized" }, { status: 401 });
  const lock = await db().connect(); // transaction-scoped lock: safe behind PgBouncer, freed on commit
  try {
    await lock.query("begin");
    const { rows: [{ locked }] } = await lock.query("select pg_try_advisory_xact_lock(7349001) as locked");
    if (!locked) return Response.json({ skipped: "another run holds the lock" });
    const deadline = Date.now() + 45_000, counts: Record<string, number> = {};
    const { rows: open } = await db().query<{ talo_payment_id: string }>(
      `select talo_payment_id from orders where payment_status in ('PENDING', 'UNDERPAID') and talo_payment_id is not null
          and created_at > now() - interval '48 hours' order by created_at desc limit 100`);
    for (const { talo_payment_id } of open) {
      if (Date.now() > deadline) break;
      try {
        const result = await applyPayment(talo_payment_id); // the webhook's function: idempotent
        counts[result.outcome] = (counts[result.outcome] ?? 0) + 1;
        if (result.outcome === "fulfilled") await deliverOrder(result.orderId!);
      } catch (err) {
        counts.error = (counts.error ?? 0) + 1;
        console.error(JSON.stringify({ reconcile: talo_payment_id, error: err instanceof Error ? err.message : String(err) }));
      }
    }
    const { rows: unsent } = await db().query<{ id: string }>( // outbox: after() is best effort
      "select id from orders where notified_at is null and fulfilled_at < now() - interval '5 minutes' limit 50");
    for (const { id } of unsent) {
      if (Date.now() > deadline) break;
      await deliverOrder(id).catch((err) => console.error(JSON.stringify({ deliver_order: id, outcome: "error", error: String(err) })));
    }
    console.log(JSON.stringify({ reconcile: counts, open: open.length, unsent: unsent.length }));
    return Response.json({ ...counts, open: open.length, unsent: unsent.length });
  } finally {
    await lock.query("commit").catch(() => {});
    lock.release();
  }
}
```

- Pro: `*/10 * * * *`. Hobby allows daily only (`0 6 * * *`, ±59 min); a sub-daily schedule fails the deploy. `vercel crons run /api/cron/talo-reconcile` triggers it on production after a deploy.
- Vercel sends `GET` with `Authorization: Bearer <CRON_SECRET>` to the production deployment only, doesn't retry, doesn't follow redirects, and can skip or double a run: hence the fail-closed check, the lock, the deadline and the shared idempotent `applyPayment`. Reading a payment also makes the hub catch up and may emit webhooks; same function, same result.
- Alternative source (not in the tested code): `GET /api/v1/events?after=<stored cursor>&limit=200` (oldest first, scope read), feed each payment event to `applyPayment(event.data.payment.id, event)`, store `next_after`, and keep a small overlap (don't advance past events younger than a few minutes). Avoid wide `GET /api/v1/payments?from=&to=`: not paginated.

## Regions and runtime

- Functions default to `iad1`, where the hub runs: keep it. Only with the database in `sa-east-1` set `"regions": ["gru1"]` in `vercel.json` and redeploy; choose for the database, not for the buyers. `preferredRegion` is deprecated (Next.js warns). `maxDuration` defaults to 300 s with Fluid compute, enough for `after()`; the hub's 10 s is the deadline that matters (the cron sets 60).
- Webhooks carry no Skew Protection pin: retries (up to ~8.6 h later) and cron runs hit whatever deployment is live, also after Instant Rollback. Keep the handler backward compatible and migrations additive.

## Local development

```bash
npx talo-hub keys create --name dev-<person> --scope charge --dotenv .env.local   # mode 600, never printed
docker run -d --name shop-pg -e POSTGRES_PASSWORD=dev -p 127.0.0.1:5432:5432 postgres:17   # .env.local: DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/postgres
docker cp db/schema.sql shop-pg:schema.sql      # relative container paths: Git Bash rewrites "/schema.sql"
docker exec shop-pg psql -U postgres -f schema.sql   # once the server accepts connections
npx next dev                                                # not `vercel dev`; open http://localhost:3000
npx talo-hub events listen --forward-to http://localhost:3000/api/webhooks/talo
```

Forwarded events are unsigned and carry `talo-hub-forwarded: 1`; the route accepts them only when `NODE_ENV === "development"` (`next dev`). `VERCEL_ENV` is unset locally and Vercel previews run with `NODE_ENV=production`, so deployments reject them. Use `localhost`, not `127.0.0.1`: `next dev` blocks cross-origin dev resources unless `allowedDevOrigins` lists the host.

## Logging

- One JSON line per event: event id, type, payment id, order id and outcome (`fulfilled`, `updated`, `duplicate`, `ignored`, `error` with its message), as the route and the cron print.
- Never log keys, `TALO_HUB_WEBHOOK_SECRET`, `CRON_SECRET`, the `talo-hub-signature` or `Authorization` headers, `DATABASE_URL`, URLs with `x-vercel-protection-bypass`, or raw bodies (payments can carry the buyer's `client_data`).
- Runtime logs expire (Hobby 1 hour, Pro 1 day; a Log Drain keeps them longer): `talo_hub_events` and `orders` are the audit trail.

## Test it

Vitest needs `resolve.alias` entries for `@/` and for `server-only` → `node_modules/server-only/empty.js` (the package throws outside React Server Components), and `fileParallelism: false` when tests share one database. The verifier against the published vector:

```ts
// Test vector from https://talopartnerhub.vercel.app/docs/webhooks.md
const SECRET = "whsec_test_secret", T = 1793000000, V1 = "ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e";
const BODY = '{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}';
const at = (unix: number) => vi.useFakeTimers({ now: unix * 1000 });
afterEach(() => vi.useRealTimers());
it("accepts the test vector", () => {
  at(T);
  expect(verifyTaloHub(BODY, `t=${T},v1=${V1}`, SECRET)).toBe(true);
});
```

The route, called with a real `Request` signed at the current time (`` createHmac("sha256", secret).update(`${t}.${raw}`) ``, header `t=<now>,v1=<hex>`), a real Postgres and a mock hub (a local HTTP server answering `POST`/`GET /api/v1/payments` that can return 502 or drop the connection). `after()` needs Next's request scope, so collect its callbacks; import the route after setting the env (`lib/talo.ts` reads `TALO_HUB_URL` at import):

```ts
// after() needs Next's request scope: collect the callbacks and run them by hand.
const afterJobs = vi.hoisted(() => [] as (() => unknown)[]);
vi.mock("next/server", async (original) => ({ ...(await original<object>()), after: (job: () => unknown) => afterJobs.push(job) }));
beforeAll(async () => {
  hub = await startMockHub();
  Object.assign(process.env, { TALO_HUB_URL: hub.url, TALO_HUB_API_KEY: API_KEY, TALO_HUB_WEBHOOK_SECRET: WEBHOOK_SECRET, DATABASE_URL });
  await resetDatabase(DATABASE_URL);
  sql = new Pool({ connectionString: DATABASE_URL });
  ({ POST } = await import("@/app/api/webhooks/talo/route")); // after the env: lib/talo reads TALO_HUB_URL at import
});
```

Then `const res = await POST(signedRequest(paymentEvent(101, "payment.paid", payment)))`, assert the status and `outcome`, read the order row, and run `afterJobs` by hand. Cover at least: valid → 200 and fulfilled once; same event id → 200 duplicate; a second `payment.paid` with another id → not fulfilled again; 5 identical plus 3 different deliveries in parallel → one fulfilment; a stale UNDERPAID read that commits after the fulfilment → `ignored`, status kept; a non-ASCII body → verified on its raw text; UNDERPAID (even with a SUCCESS snapshot) → not fulfilled; bad signature, stale `t`, unsigned forwarded event outside development → 400 and nothing written; hub 502 or dropped connection → 500 with nothing committed, then the retry fulfils; `webhook.test` and foreign payments → 2xx; no `DATABASE_URL` → 5xx; cron without, with a wrong, or with no configured `CRON_SECRET` → 401, with it → the lost payment fulfilled and the outbox sent; the proxy matcher skips `/api/webhooks/talo`. Run them with `TEST_DATABASE_URL=postgres://… npx vitest run` against a throwaway `postgres:17` container, plus `npx next build`.

Sandbox on Vercel (the Preview `sandbox` branch, or Production before go-live), all with `tph_test_` keys:

1. Buy on the deployed site: the order page shows CVU, alias and amount.
2. Exact: `npx talo-hub sandbox pay <payment_id>` → SUCCESS and one `fulfilled` line in the runtime logs (`vercel logs`); the person checks `fulfilled_at` in the provider's SQL editor.
3. Less: a new order, `sandbox pay <id> --amount <amount - 1>` → UNDERPAID, not fulfilled; then `--amount 1` → SUCCESS, fulfilled.
4. More: a new order, `sandbox pay <id> --amount <amount + 500>` → OVERPAID → fulfilled.
5. Same event twice: there is no redelivery API, so the route test covers it; in the deployed database `talo_hub_events` has one row per id and each order at most one `fulfilled_at`.
6. `npx talo-hub webhooks test <id>` → delivered (HTTP 200).
7. `vercel crons run /api/cron/talo-reconcile` (Production) → 200 with counts in the logs.
8. `npx talo-hub doctor` → ready.

## Checklist

- [ ] Key, webhook secret, `DATABASE_URL` and `CRON_SECRET` read only in `server-only` modules; no `NEXT_PUBLIC_` Talo variable; `.env*` (except `.env.example`) and `.vercel` gitignored.
- [ ] One key and one webhook endpoint per Vercel environment; `tph_live_` only in Production after go-live, with `TALO_HUB_ALLOW_PRODUCTION=true`; a sandbox key in Production before the first deploy.
- [ ] Secrets set with `--vercel`/`--dotenv` or stdin from Node (never arguments, `echo`, shell pipes or MCP arguments); redeployed after every change and checked with `webhooks test` and `keys list`.
- [ ] Order stored before the hub call with `external_id` = order id; a new `external_id` after EXPIRED.
- [ ] Webhook: raw body read once, verified, no `runtime` export, nothing in front that redirects or authenticates.
- [ ] One transaction: event id `on conflict do nothing`, order status, conditional `fulfilled_at is null` update; 5xx before commit, 2xx for ignored events; UNDERPAID never fulfilled.
- [ ] Slow work only after the conditional update returned the row, via `after()` plus the outbox, or a queue.
- [ ] Marketplace Postgres, separate per environment; nothing in memory, Global Config or Runtime Cache.
- [ ] Webhook on the non-redirecting production domain; the unsigned probe answers 400; Deployment Protection is Standard, previews reached only via an Exception or a dedicated, rotated bypass secret chosen by the person.
- [ ] Firewall bypass rule for `POST /api/webhooks/talo`, ordered first, before any challenge or rate limit; no BotID on it.
- [ ] Cron with a fail-closed `CRON_SECRET` check and a lock; the tests, `next build` and `npx talo-hub doctor` pass.
