---
name: talo-stack-node-express
description: Talo Pay bank-transfer payments (Argentina, ARS, CVU/alias) through the Talo Partner Hub on a Node.js server — a fetch-based hub client with timeouts and safe retries, server-side checkout with external_id = order id and a polled status page, the signed webhook verified on the raw body (express.raw on that route only, a scoped Fastify parser, NestJS rawBody), durable idempotency in Postgres (unique event id + fulfil-once update in one transaction), a reconcile script for the host's cron, deploy notes for Railway, Render, Fly.io and a VPS behind nginx/Caddy, and a node:test verifier test plus the sandbox checklist. Use when the project uses Express, Fastify or NestJS (or Koa) on Node.js and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---

# Talo Pay on node-express

This kit implements the 10-rule contract of https://talopartnerhub.vercel.app/docs/stacks.md on a Node.js server: Express 5 is the main path, with exact sections for Fastify 5 and NestJS 12/11. Keys, webhook registration and the full sandbox → production plan are in the core skills `talo-payments` and `talo-go-live` (hub: https://talopartnerhub.vercel.app, agent docs `/docs/api.md`, `/docs/webhooks.md`). Checked on 2026-10-28 with Node 24.20 (22.12+ works), Express 5.3.0, pg 8.23.1, PostgreSQL 18, Fastify 5.12.7, NestJS 12.3.2 and 11.2.8, nginx 1.29 and Caddy 2.11. The code is ESM (`"type": "module"`); in CommonJS use `require`.

## 1. Setup

- `npm install express@^5.3.0 pg@^8.23.1` and nothing else: global `fetch` + `AbortSignal.timeout`, `node:crypto`, `node --env-file`, `node:test`. No SDK, axios or dotenv. Express 5.3.0 requires proxy-addr ≥ 2.0.8 (IP-spoofing fix) but still allows body-parser 2.3.0: `npm ls body-parser` must show ≥ 2.4.0 (compressed-body DoS fix, used by `express.raw`) and `npm audit` must be clean.
- Server-side env vars: `TALO_HUB_API_KEY` (`tph_test_…` in dev and staging, `tph_live_…` only in production, scope `charge`), `TALO_HUB_WEBHOOK_SECRET` (the `whsec_…` of this environment's endpoint), `DATABASE_URL`, `NODE_ENV`, and `TALO_HUB_DEV_FORWARD=1` only on your machine (section 7). Locally they live in `.env`; on a host, in its variables/secrets screen. Only server modules read `process.env`: never pass the key or secret to templates, `res.locals`, a bundler `define`/`VITE_*`, logs or error bodies. The browser gets cvu, alias, amount, paid, status and expires_at.
- Write the key straight into the file, never into chat: `npx talo-hub keys create --name <project> --scope charge --dotenv .env`.
- `package.json` scripts: `"dev": "node --watch --env-file=.env src/server.js"`, `"start": "node src/server.js"`, `"reconcile": "node scripts/reconcile.js"`, `"test": "node --test"`. `.gitignore`: `node_modules/`, `.env`, `.env.*`, `!.env.example`.

## 2. Hub client (server-only)

```js
// src/talo.js — server-only. API: https://talopartnerhub.vercel.app/docs/api.md
const HUB_URL = "https://talopartnerhub.vercel.app";

export class TaloHubError extends Error {
  constructor(status, code, message, details) {
    super(`Talo Partner Hub ${status} ${code}: ${message}`);
    Object.assign(this, { name: "TaloHubError", status, code, details }); // status 0 = network error/timeout
  }
  get retryable() { return this.status === 0 || this.status === 502; } // 502 = talo_unavailable
}

export function createTaloClient({ apiKey, baseUrl = HUB_URL, timeoutMs = 8000, tries = 3 } = {}) {
  if (!/^tph_(test|live)_/.test(apiKey ?? "")) throw new Error("TALO_HUB_API_KEY is missing or not a tph_test_/tph_live_ key");

  async function once(method, path, body, ms) {
    let res;
    try {
      res = await fetch(baseUrl + path, {
        method, signal: AbortSignal.timeout(ms), body: body === undefined ? undefined : JSON.stringify(body),
        headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
      });
    } catch (err) {
      throw new TaloHubError(0, err.name === "TimeoutError" ? "timeout" : "network_error", err.message);
    }
    const json = await res.json().catch(() => null);
    if (res.ok) return json;
    const e = json?.error ?? {}; // { error: { code, message, details? } }
    throw new TaloHubError(res.status, e.code ?? "unknown", e.message ?? res.statusText, e.details);
  }

  async function call(method, path, body, { tries: max = tries, timeoutMs: ms = timeoutMs } = {}) {
    for (let i = 1; ; i++) {
      try { return await once(method, path, body, ms); } catch (err) {
        if (!err.retryable || i >= max) throw err; // 4xx: fix the request, never retry it
        await new Promise((r) => setTimeout(r, 300 * 2 ** (i - 1) + Math.random() * 200));
      }
    }
  }

  return { // same external_id => same payment, so retrying a create never charges twice
    createPayment: (input, opts) => call("POST", "/api/v1/payments", input, { timeoutMs: 25000, ...opts }), // Talo can take 15-20 s to create one
    getPayment: (id, opts) => call("GET", `/api/v1/payments/${encodeURIComponent(id)}`, undefined, opts),
  };
}
```

4xx codes need a person or a fix, not a retry: `unauthorized` (401) → missing or revoked key; `talo_reconnect_required` (409) → the account owner runs `npx talo-hub login` (`--production` for a live key); `insufficient_scope` → wrong key; `talo_rejected` → read `details.talo_message`; `invalid_*` (422) → your request.

## 3. Checkout

The order row (with its `external_id`) exists before the hub is called, so a webhook finds it even if the process dies between the create and the `UPDATE`. `CATALOG` maps sku → `priceCents` on the server. `external_id` must be unique on the whole Talo account, not just in this database: dev, CI and staging share one sandbox account, every endpoint on it gets every event, and a repeated external_id returns the other payment. Use random ids as below (or prefix a sequence with shop and environment); `startPayment` refuses a payment whose amount isn't the order's and `applyPayment` ignores it.

```js
  async function startPayment(orderId) {
    const { rows: [o] } = await pool.query("SELECT id, amount, external_id FROM orders WHERE id = $1", [orderId]);
    if (!o) return null;
    const p = await talo.createPayment({ amount: Number(o.amount), external_id: o.external_id, motive: `Pedido ${o.id}` });
    if (p.amount !== Number(o.amount)) throw new Error(`external_id ${o.external_id} already has a payment of ${p.amount}`);
    await pool.query( // only if no newer attempt replaced external_id meanwhile
      `UPDATE orders SET payment_id = $3, payment_status = COALESCE(payment_status, $4), cvu = $5, alias = $6,
         expires_at = $7, updated_at = now() WHERE id = $1 AND external_id = $2`,
      [o.id, o.external_id, p.id, p.status, p.cvu, p.alias, p.expires_at]);
    return { order_id: o.id, external_id: o.external_id, status: p.status, amount: p.amount, cvu: p.cvu, alias: p.alias, expires_at: p.expires_at };
  }

  app.post("/orders", async (req, res) => {
    const item = CATALOG[req.body?.sku];
    const qty = Number(req.body?.qty ?? 1);
    if (!item || !Number.isInteger(qty) || qty < 1 || qty > 10) return res.status(422).json({ error: "invalid item" });
    const id = `order-${randomUUID()}`;
    const amount = ((item.priceCents * qty) / 100).toFixed(2); // integer cents -> "15000.00"; never req.body.amount
    await pool.query("INSERT INTO orders (id, amount, external_id) VALUES ($1, $2, $1)", [id, amount]);
    try {
      res.status(201).json(await startPayment(id));
    } catch (err) {
      console.error("[talo] create payment:", err.message); // the buyer retries with POST /orders/:id/pay
      res.status(502).json({ order_id: id, error: "No pudimos generar los datos de pago. Probá de nuevo." });
    }
  });

  // Retries a failed create (same external_id = same payment); when the hub says EXPIRED, starts <id>-2, <id>-3...
  app.post("/orders/:id/pay", async (req, res) => {
    let started = await startPayment(req.params.id);
    if (started?.status === "EXPIRED") { // the hub's answer decides, even if the expired event hasn't arrived yet
      await pool.query(
        `UPDATE orders SET payment_attempt = payment_attempt + 1, external_id = id || '-' || (payment_attempt + 1),
           payment_id = NULL, payment_status = NULL, paid = NULL, cvu = NULL, alias = NULL, expires_at = NULL
         WHERE id = $1 AND external_id = $2 AND fulfilled_at IS NULL`, [req.params.id, started.external_id]);
      started = await startPayment(req.params.id); // parallel clicks: only one UPDATE matches, all get <id>-2
    }
    started ? res.json(started) : res.status(404).json({ error: "not found" });
  });

  // What the order page polls. Read-only: fulfilment happens only in the webhook and the reconcile job.
  app.get("/orders/:id/status", async (req, res) => {
    const { rows: [o] } = await pool.query(
      "SELECT payment_id, payment_status, paid, amount, cvu, alias, expires_at FROM orders WHERE id = $1", [req.params.id]);
    if (!o) return res.status(404).json({ error: "not found" });
    let { payment_status: status, paid } = o;
    if (o.payment_id && !isFinal(status)) { // live status for the screen; keep the stored one if the hub is slow
      ({ status, paid } = await talo.getPayment(o.payment_id, { tries: 1, timeoutMs: 4000 }).catch(() => ({ status, paid })));
    }
    res.json({ status, paid: Number(paid ?? 0), amount: Number(o.amount), cvu: o.cvu, alias: o.alias, expires_at: o.expires_at });
  });
```

The order page shows CVU and alias (with copy buttons), the exact amount (`Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS" })`) and the expiry, then polls. Order ids are random UUIDs, so the status URL reveals nothing else; with sequential ids, authorize it by session.

```js
// public/order-status.js — browser module: <script type="module"> import { pollOrderStatus } from "/order-status.js"
export function pollOrderStatus(url, onStatus, { min = 3000, max = 10000 } = {}) {
  let delay = min, timer, stopped = false;
  const tick = async () => {
    try {
      const res = await fetch(url, { headers: { accept: "application/json" } });
      const order = res.ok ? await res.json() : null;
      if (stopped) return; // stop() was called while this request was in flight
      if (order) onStatus(order); // UNDERPAID: "transferí el resto" (amount - paid); EXPIRED: offer a new payment
      if (["SUCCESS", "OVERPAID", "EXPIRED"].includes(order?.status)) return; // final: stop
    } catch { /* offline for a moment: keep trying */ }
    if (stopped) return;
    timer = setTimeout(tick, delay);
    delay = Math.min(Math.round(delay * 1.5), max); // 3 s, 4.5 s, 6.8 s, 10 s, 10 s...
  };
  timer = setTimeout(tick, 0);
  return () => { stopped = true; clearTimeout(timer); };
}
```

## 4. Webhook endpoint

**Raw body in Express 5:** `express.raw({ type: "application/json" })` on the webhook route only, registered **before** `app.use(express.json())`. Tested: with a global `express.json()` first, `express.raw` finds the body already consumed and `req.body` is an object, so nothing can be verified (re-serializing doesn't reproduce the bytes). Put CSRF (csrf-csrf, csrf-sync, lusca), `express-session`/passport login gates, `requiresAuth()`, IP or basic-auth filters and tight rate limiters after this route, or exclude `/webhooks/talo`. Don't redirect it (http→https, trailing slash, `www`): register the exact final https URL.

```js
// src/verify-webhook.js — https://talopartnerhub.vercel.app/docs/webhooks.md
import { createHmac, timingSafeEqual } from "node:crypto";

/** rawBody: the exact request bytes (Buffer). header: talo-hub-signature ("t=<unix>,v1=<hex>"). */
export function verifyTaloSignature(rawBody, header, secret, { now = Date.now() / 1000, tolerance = 300 } = {}) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const parts = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    const key = part.slice(0, i).trim();
    if (i < 1 || key in parts) return false; // malformed or repeated t/v1
    parts[key] = part.slice(i + 1).trim();
  }
  if (!/^\d{1,12}$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(now - Number(parts.t)) > tolerance) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex")); // both 32 bytes
}
```

```js
  // 1. The webhook FIRST, with its own raw parser: req.body is the exact bytes (a Buffer).
  app.post("/webhooks/talo", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
    if (req.body !== undefined && !Buffer.isBuffer(req.body)) { // another parser consumed the body first
      console.error("[talo] webhook body already parsed: mount this route before express.json()");
      return res.status(500).json({ error: "raw body unavailable" });
    }
    const devRelay = acceptForwarded && req.get("talo-hub-forwarded") === "1" && !req.get("talo-hub-signature");
    if (!devRelay && !webhookSecret) return res.status(503).json({ error: "webhook secret not set" }); // the hub retries
    if (!devRelay && !verifyTaloSignature(req.body, req.get("talo-hub-signature"), webhookSecret)) {
      return res.status(400).json({ error: "invalid signature" });
    }
    let event;
    try { event = JSON.parse(req.body.toString("utf8")); } catch { return res.status(400).json({ error: "invalid JSON" }); }

    const paymentId = event?.data?.payment?.id;
    if (!event?.type?.startsWith("payment.") || !paymentId || !Number.isSafeInteger(event.id)) {
      return res.json({ received: true }); // webhook.test, account.updated: 2xx and nothing else
    }
    let payment;
    try {
      payment = await talo.getPayment(paymentId, { tries: 1, timeoutMs: 5000 }); // the hub's status, not the snapshot
    } catch (err) {
      console.error(`[talo] event ${event.id}: re-read failed: ${err.message}`);
      return res.status(503).json({ error: "retry later" }); // non-2xx: the hub retries in 1 min
    }
    // Express 5: if this rejects (database down) the request ends in a 500 and the hub retries.
    const result = await applyPayment(pool, payment, { id: event.id, type: event.type });
    res.json({ received: true, duplicate: result.duplicate });
    if (result.fulfilledNow) {
      setImmediate(() => onFulfilled(result.orderId).catch((err) => console.error("[talo] fulfil", err)));
    }
  });

  // 2. Everything else: JSON, static files, and your session/CSRF/auth middleware AFTER the webhook.
  app.use(express.json());
```

- The event id is recorded only after the re-read succeeds, so a 503 leaves it unconsumed and the hub's retry processes it. Until `TALO_HUB_WEBHOOK_SECRET` is set the route answers 503 (retried) instead of crashing the app, because `webhooks add` probes the deployed URL before the secret exists. Worst case stays inside the 10 s budget (5 s GET, ≤ 3 s waiting for a pool connection, one short transaction). `onFulfilled` (email, stock, invoice) runs once, after the answer. If it must survive a crash, enqueue a job in the same transaction (pg-boss, or a `notified_at` column a worker picks up) instead of `setImmediate`.
- If a global `express.json()` can't move, keep the bytes with its verify callback and verify `req.rawBody`: `app.use(express.json({ verify: (req, _res, buf) => { if (req.originalUrl.startsWith("/webhooks/talo")) req.rawBody = buf; } }))`. Koa 3 with `@koa/bodyparser` 6 keeps the bytes as a string in `ctx.request.rawBody`: verify `Buffer.from(ctx.request.rawBody, "utf8")`.

**Fastify 5** (tested 5.12.7; use ≥ 5.12.6, older 5.x have trustProxy, validation and HTTP/2 advisories). Register this plugin **without** `fastify-plugin` so the buffer parser stays in its scope and other routes keep parsing JSON: `app.register(taloWebhook, { secret, handle })`. Keep `@fastify/csrf-protection` and auth hooks out of this scope.

```js
import { verifyTaloSignature } from "./verify-webhook.js";

export async function taloWebhook(app, { secret, handle }) {
  app.addContentTypeParser("application/json", { parseAs: "buffer", bodyLimit: 1_048_576 }, (_req, body, done) => done(null, body));
  app.post("/webhooks/talo", async (req, reply) => {
    if (!verifyTaloSignature(req.body, req.headers["talo-hub-signature"], secret)) {
      return reply.code(400).send({ error: "invalid signature" });
    }
    return handle(JSON.parse(req.body.toString("utf8")), reply); // same steps as the Express route
  });
}
```

**NestJS 12 / 11:** `NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true })` keeps the built-in parsers and adds `req.rawBody` (don't pass `bodyParser: false`); `RawBody`, `Headers`, `HttpCode`, `BadRequestException` come from `@nestjs/common`; with Fastify, `NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { rawBody: true })` (not `trustProxy: true`, which trusts any `X-Forwarded-For`). A global auth guard (`APP_GUARD`) also guards this controller: mark it with your public/skip-auth decorator. Prefer Nest 12.3+: 11.2.8 still pulls fastify ≤ 5.12.4 and multer 2.4.0 with high advisories (`npm audit`).

```ts
@Controller("webhooks")
export class TaloWebhookController {
  @Post("talo")
  @HttpCode(200) // Nest answers 201 to POST by default; the hub only needs 2xx, 200 is clearer
  async receive(@RawBody() raw: Buffer | undefined, @Headers("talo-hub-signature") signature?: string) {
    if (!verifyTaloSignature(raw, signature, process.env.TALO_HUB_WEBHOOK_SECRET)) {
      throw new BadRequestException("invalid signature");
    }
    const event = JSON.parse(raw!.toString("utf8"));
    // Same steps as the Express route: ignore non-payment events, getPayment, applyPayment (one transaction).
    return { received: true, id: event.id };
  }
}
```

## 5. Durable storage (Postgres + pg)

```sql
-- migrations/001_talo.sql (PostgreSQL 14+)
CREATE TABLE IF NOT EXISTS orders (
  id              text PRIMARY KEY,                 -- your order id, e.g. 'order-<uuid>'
  amount          numeric(12,2) NOT NULL CHECK (amount > 0),  -- ARS, never float
  payment_attempt integer NOT NULL DEFAULT 1,
  external_id     text NOT NULL UNIQUE,             -- id, then id-2, id-3 after EXPIRED
  payment_id      text UNIQUE,
  payment_status  text,                             -- PENDING|UNDERPAID|SUCCESS|OVERPAID|EXPIRED
  paid            numeric(12,2),
  cvu text, alias text, expires_at timestamptz,     -- shown to the buyer
  fulfilled_at    timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS orders_open_payments ON orders (created_at)
  WHERE payment_status IN ('PENDING', 'UNDERPAID');

CREATE TABLE IF NOT EXISTS talo_events (
  event_id    bigint PRIMARY KEY,                   -- the event's integer id: handled once
  type        text NOT NULL,
  payment_id  text,
  received_at timestamptz NOT NULL DEFAULT now()
);
```

`src/db.js` exports `new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10, connectionTimeoutMillis: 3000 })` and sets `pool.on("error", (err) => console.error("[db] idle client:", err.message))`: without that listener, an idle connection dropped by a database restart or failover crashes the process (tested).

```js
// src/payments.js — the one place that turns a payment (re-read from the hub) into order state.
export const isPaid = (status) => status === "SUCCESS" || status === "OVERPAID";
export const isFinal = (status) => isPaid(status) || status === "EXPIRED";
const RANK = { PENDING: 0, UNDERPAID: 1, EXPIRED: 2, SUCCESS: 2, OVERPAID: 3 }; // statuses only move forward

// One transaction: record the event id (if any), move the status forward, fulfil once.
export async function applyPayment(pool, payment, event) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    if (event) {
      const ins = await client.query(
        "INSERT INTO talo_events (event_id, type, payment_id) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING",
        [event.id, event.type, payment.id]);
      if (ins.rowCount === 0) { await client.query("COMMIT"); return { duplicate: true, fulfilledNow: false }; }
    }
    const { rows: [order] } = await client.query(
      "SELECT id, amount, payment_status FROM orders WHERE external_id = $1 FOR UPDATE", [payment.external_id]);
    if (!order || Number(order.amount) !== payment.amount) { // not ours: another shop or environment on the account
      await client.query("COMMIT"); return { duplicate: false, fulfilledNow: false };
    }
    if (RANK[payment.status] >= (RANK[order.payment_status] ?? 0)) {
      await client.query(
        "UPDATE orders SET payment_id = $2, payment_status = $3, paid = $4, updated_at = now() WHERE id = $1",
        [order.id, payment.id, payment.status, payment.paid ?? 0]);
    }
    let fulfilledNow = false;
    if (isPaid(payment.status)) { // UNDERPAID never gets here
      const upd = await client.query(
        "UPDATE orders SET fulfilled_at = now() WHERE id = $1 AND fulfilled_at IS NULL", [order.id]);
      fulfilledNow = upd.rowCount === 1;
    }
    await client.query("COMMIT");
    return { duplicate: false, orderId: order.id, fulfilledNow };
  } catch (err) {
    await client.query("ROLLBACK").catch(() => {});
    throw err;
  } finally { client.release(); }
}
```

- `pg` returns `numeric` as a string (`"15000.00"`): keep money that way; `Number()` only at the hub/JSON boundary. If you change a payment's price (`PUT /api/v1/payments/{id}/price`), update `orders.amount` in the same step, or the amount check skips it.
- Concurrent deliveries of one event block on the primary key and then see `rowCount 0`; different events for one order serialize on `FOR UPDATE`; `fulfilled_at IS NULL` makes fulfilment exactly-once (tested: 20 parallel copies of one event, 10 other events and 3 reconcile runs fulfil once).
- No `Map`/`Set`/LRU of handled events: a deploy, restart or second instance forgets it and a retried event fulfils again. With Prisma, Drizzle or Knex keep the same statements inside its transaction API; apply the SQL with your migration tool.

## 6. Reconciliation

```js
// src/reconcile.js — re-read open payments in case a webhook never arrived (rule 8).
import { applyPayment } from "./payments.js";

export async function reconcile({ pool, talo, onFulfilled, days = 3 }) {
  const { rows } = await pool.query(
    `SELECT payment_id FROM orders
      WHERE payment_status IN ('PENDING', 'UNDERPAID') AND payment_id IS NOT NULL
        AND created_at > now() - make_interval(days => $1)
      ORDER BY created_at LIMIT 500`, [days]);
  let failed = 0;
  for (const { payment_id } of rows) {
    try {
      const result = await applyPayment(pool, await talo.getPayment(payment_id)); // same logic, no event id
      if (result.fulfilledNow) await onFulfilled(result.orderId);
    } catch (err) { failed++; console.error(`[reconcile] ${payment_id}: ${err.message}`); }
  }
  return { checked: rows.length, failed };
}
```

`scripts/reconcile.js` creates the client with `createTaloClient({ apiKey: process.env.TALO_HUB_API_KEY })`, awaits `reconcile({ pool, talo, onFulfilled })`, logs the result, calls `await pool.end()` so the process exits, and sets `process.exitCode = 1` when `failed > 0`. Run it every 10 minutes (`*/10 * * * *`) with the host's scheduler. A separate one-off process beats `setInterval` in the web server: every replica would run its own copy, it stops whenever an instance sleeps or redeploys, a slow run competes with webhook answers, and the host's cron gives one run at a time with logs and an exit status.

## 7. Local development

```sh
cp .env.example .env   # tph_test_ key, DATABASE_URL, NODE_ENV=development, TALO_HUB_DEV_FORWARD=1
npm run dev
npx talo-hub events listen --forward-to http://localhost:3000/webhooks/talo
```

Relayed events carry `talo-hub-forwarded: 1` and no signature. The route accepts them only when the app was built with `acceptForwarded`, and `src/server.js` refuses to start with the flag unless `NODE_ENV=development` and the key is `tph_test_`, so a host that leaves NODE_ENV unset or gets a pasted `.env` can't enable it:

```js
const acceptForwarded = env.TALO_HUB_DEV_FORWARD === "1"; // local dev only, see "Local development"
if (acceptForwarded && (env.NODE_ENV !== "development" || !env.TALO_HUB_API_KEY?.startsWith("tph_test_"))) {
  throw new Error("TALO_HUB_DEV_FORWARD=1 needs NODE_ENV=development and a tph_test_ key");
}
// Not fatal: `webhooks add` probes the deployed URL before the secret exists; until it's set the route answers 503.
if (!env.TALO_HUB_WEBHOOK_SECRET) console.error("[talo] TALO_HUB_WEBHOOK_SECRET is not set: the webhook answers 503");
```

Because the handler re-reads every payment from the hub, a forged relay can at worst trigger a re-read. Signed deliveries need a public https URL (a tunnel or a deployment).

## 8. Deploy

- **Every host:** one environment = one key + one webhook endpoint + one secret. Order: deploy without `TALO_HUB_WEBHOOK_SECRET` (the route answers 503, which the probe accepts); `npx talo-hub webhooks add https://<host>/webhooks/talo --dotenv .env.<env>` (the secret goes to that gitignored file, not the screen; `webhooks add` probes the URL and refuses redirects); copy it into the host's secrets (`fly secrets import < .env.<env>`, or the variables screen) and delete the file; redeploy; `npx talo-hub webhooks test <id>`. Staging uses a `tph_test_` key; production repeats it after `npx talo-hub login --production`, with `--production` on `keys create`, `webhooks add` and `webhooks test`, and that secret only in production. `NODE_ENV=production` on every deployed environment, never `TALO_HUB_DEV_FORWARD`. Nothing in front of `/webhooks/talo` may redirect, challenge or ask for a login (Cloudflare challenges/Access, basic auth, IP allowlists). `src/server.js` sets `app.set("trust proxy", 1)` when `NODE_ENV=production` (one hop: the platform edge, nginx or Caddy; it fixes `req.ip`/`req.protocol`, the signature doesn't depend on it) and `process.on("SIGTERM", () => server.close(() => pool.end()))` so redeploys finish in-flight webhooks.
- **Railway:** web service `npm start` (PORT is injected), `DATABASE_URL=${{Postgres.DATABASE_URL}}`, public domain in Settings → Networking. Reconcile: a second service from the same repo with start command `node scripts/reconcile.js` and Settings → Cron Schedule `*/10 * * * *` (UTC, ≥ 5 min apart; a run is skipped while the previous one is active, so it must exit). Staging is a Railway environment with its own variables.
- **Render:** a Web Service plus a Cron Job (`node scripts/reconcile.js`, schedule `*/10 * * * *`, UTC, at most one active run), sharing variables through an Environment Group; use the internal database URL.
- **Fly.io:** one app per environment with `fly secrets set`; for the job, install supercronic in the Dockerfile and add a process group (`[processes] app = "node src/server.js"`, `cron = "supercronic /app/crontab"`, crontab line `*/10 * * * * node /app/scripts/reconcile.js`) and `fly scale count app=2 cron=1`: exactly one cron machine. Scheduled Machines only run hourly or less often.
- **VPS:** a systemd service with `EnvironmentFile=` (mode 600) and a systemd timer or crontab running `node --env-file=/etc/<app>.env scripts/reconcile.js`. nginx `proxy_pass http://127.0.0.1:3000;` (plus `X-Forwarded-For`/`X-Forwarded-Proto`) and Caddy `reverse_proxy 127.0.0.1:3000` pass the body byte for byte (tested); no `auth_basic`, rewrite or body filter on that path. With the proxy on the same host, `app.set("trust proxy", "loopback")` is tighter.

## 9. Test it

```js
// test/verify-webhook.test.js — run with: node --test
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { test } from "node:test";
import { verifyTaloSignature } from "../src/verify-webhook.js";

const secret = "whsec_test_secret", t = 1793000000;
const body = Buffer.from('{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}');
const v1 = "ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e";
const ok = `t=${t},v1=${v1}`, at = { now: t }; // fixed clock: the 5-minute check is off for the vector

test("accepts the hub's test vector and a fresh signature", () => {
  assert.equal(verifyTaloSignature(body, ok, secret, at), true);
  const now = Math.floor(Date.now() / 1000);
  const fresh = createHmac("sha256", secret).update(`${now}.`).update(body).digest("hex");
  assert.equal(verifyTaloSignature(body, `t=${now},v1=${fresh}`, secret), true);
});

test("rejects tampered body, wrong secret, stale t, bad v1, missing header", () => {
  assert.equal(verifyTaloSignature(Buffer.from(body.toString().replace('"id":1', '"id":2')), ok, secret, at), false);
  assert.equal(verifyTaloSignature(body, ok, "whsec_other", at), false);
  assert.equal(verifyTaloSignature(body, ok, secret, { now: t + 301 }), false);
  assert.equal(verifyTaloSignature(body, ok, secret), false); // real clock: t is stale
  for (const bad of [`t=${t},v1=${v1.slice(0, 62)}`, `t=${t},v1=${"z".repeat(64)}`, `t=${t},v1=${v1.toUpperCase()}`,
    `t=${t},v1=${v1},v1=${v1}`, `t=abc,v1=${v1}`, `v1=${v1}`, "", undefined]) {
    assert.equal(verifyTaloSignature(body, bad, secret, at), false, String(bad));
  }
  assert.equal(verifyTaloSignature(body.toString(), ok, secret, at), false); // only raw bytes (Buffer)
});
```

Route test (`node --test`): `createApp(...)` on `app.listen(0)`, `createTaloClient({ apiKey: "tph_test_…", baseUrl })` pointed at a local `node:http` mock of `GET /api/v1/payments/:id` (never the real hub), a real Postgres, and bodies you sign yourself with the current `t`. Assert: valid → 200 and fulfilled once; same event again → 200 `duplicate: true`; bad signature, stale `t` or no header → 400; UNDERPAID → not fulfilled; mock GET 502 → 503, then the retry fulfils; parallel copies of one event → fulfilled once; a mock payment with another amount for the same external_id → never shown or fulfilled.

Sandbox checklist (staging deploy or tunnel):
1. Check out an order, `npx talo-hub sandbox pay <payment_id>` → SUCCESS in ~10–25 s, fulfilled once.
2. New order, `npx talo-hub sandbox pay <id> --amount <amount - 1>` → UNDERPAID, page asks for the rest, not fulfilled; pay the rest → fulfilled. Another with `--amount <amount + 100>` → OVERPAID, fulfilled.
3. The same event twice: re-sign a delivered event (`npx talo-hub events list --payment <id> --json`) with your secret and the current `t` and POST it again, or, with a tunnel to your local dev app, run `events listen --forward-to` next to the signed delivery → 200 `duplicate: true`, fulfilled once.
4. `npx talo-hub webhooks test <endpoint_id>` → delivered; `npx talo-hub doctor` until ready.

## Checklist

- [ ] Key and webhook secret only in server env; `.env*` gitignored; sandbox and live keys in different environments.
- [ ] Amount computed on the server; `external_id` = order id, unique on the Talo account, stored before the create; payment id, cvu, alias, expires_at on the order; a payment whose amount differs is never shown or fulfilled.
- [ ] Hub calls have a timeout; only network errors and 502 are retried, with the same external_id.
- [ ] Webhook route before `express.json()` (or verify callback / scoped Fastify parser / Nest `rawBody: true`), outside CSRF, session and auth.
- [ ] Verifier passes the test vector and rejects tampered, stale, non-hex, short and missing signatures with 400.
- [ ] Event id unique, status update and `fulfilled_at IS NULL` update in one transaction; duplicates → 2xx; slow work only when `fulfilledNow`, after the response or in a queue.
- [ ] Payment re-read with GET before acting; failed re-read → 5xx; UNDERPAID never fulfilled.
- [ ] Order page polls 3–10 s, stops at SUCCESS, OVERPAID or EXPIRED; EXPIRED gets a new external_id.
- [ ] Reconcile on the host's cron every 10 min, exiting when done.
- [ ] `pool.on("error")` set; unsigned forwarded events only with `TALO_HUB_DEV_FORWARD=1` and `NODE_ENV=development`; public https URL, no redirect or protection in front; sandbox checklist passed; `npx talo-hub doctor` ready.
