---
name: talo-stack-edge
description: Talo Pay bank-transfer payments (Argentina, ARS, CVU/alias) through the Talo Partner Hub on Web-standard edge runtimes — Cloudflare Workers (plain or Hono, D1 or Postgres via Hyperdrive, Cron Triggers) and Supabase Edge Functions (Deno, Postgres RPC, pg_cron + pg_net), with notes for Deno Deploy and Bun. Covers server-side checkout with external_id = order id and a polled status, raw-body signed webhooks verified with Web Crypto, durable exactly-once handling in one transaction, reconciliation, secrets and deploy per environment (verify_jwt, Access, secrets.required gotchas) and the sandbox test plan. Use when the project uses Cloudflare Workers, Wrangler, Hono, Supabase Edge Functions, Deno or Bun and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---

# Talo Pay on edge

This kit implements the 10-rule contract of https://talopartnerhub.vercel.app/docs/stacks.md on Web-standard runtimes. Keys, webhook registration and the 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`). Main path: one Cloudflare Worker with D1; Supabase Edge Functions reuse the same runtime-agnostic `verify.ts`, `talo.ts` and `webhook.ts` with Postgres. Checked on 2026-10-28 with wrangler 4.155 (workerd, local D1), Hono 4.13, Node 24.20, Bun 1.3.14, Deno 2.9.6, Supabase CLI 2.121, `@supabase/server` 1.9.2, `@supabase/supabase-js` 2.118, pg 8.23, PostgreSQL 17 and 18, Vitest 5.

## 1. Setup

- Workers: `npm i -D wrangler` and nothing else (`hono` only if the app already uses it, `pg` only for Hyperdrive): `fetch`, `crypto.subtle` and `Request`/`Response` are built in. No SDK.
- `npx wrangler d1 create shop` (id below), `npx wrangler d1 migrations create shop talo` (→ `migrations/0001_talo.sql`, §5), `npx wrangler types` (generates `Env`; rerun after each config change).

```jsonc
// wrangler.jsonc
{
  "name": "shop",
  "main": "src/worker.ts",
  "compatibility_date": "2026-10-01",
  "secrets": { "required": ["TALO_HUB_API_KEY", "TALO_HUB_WEBHOOK_SECRET"] },
  "d1_databases": [
    { "binding": "DB", "database_name": "shop", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }
  ],
  "triggers": { "crons": ["*/10 * * * *"] }
}
```

- Secrets, never on screen or in `vars` (plain config): locally `npx talo-hub keys create --name shop-dev --scope charge --dotenv .dev.vars` writes `TALO_HUB_API_KEY=tph_test_…` into `.dev.vars` next to the config (`.dev.vars` or `.env`, not both; add `TALO_HUB_WEBHOOK_SECRET=whsec_…` only for signed local tests); deployed environments in §8. `secrets.required` makes `wrangler deploy` fail while one is missing, and it isn't inherited: repeat it in every `env`. Gotcha (tested): with `secrets.required`, any other key in `.dev.vars` is silently dropped, so the dev flag of §7 goes on the command line.
- Supabase: `supabase secrets set --env-file <gitignored file>` (names can't start with `SUPABASE_`; no redeploy needed); locally `supabase/functions/.env`. `SUPABASE_URL` and the project keys are injected. `supabase/functions/_shared/` holds copies of `verify.ts`, `talo.ts`, `webhook.ts` and the store of §4; each function's `deno.json` is `{ "imports": { "@supabase/server": "npm:@supabase/server@1", "@supabase/supabase-js": "npm:@supabase/supabase-js@2" } }`.
- Never in the client: the browser bundle doesn't import `talo.ts` and gets only `status, cvu, alias, amount, expires_at` (plus `paid`, to ask for the rest when UNDERPAID). `.gitignore`: `.dev.vars*`, `.env*`, `supabase/functions/.env`, `.wrangler/`.

## 2. Hub client (server-only)

```ts
// src/talo.ts — server-only Talo Partner Hub client (https://talopartnerhub.vercel.app/docs/api.md); only fetch, any runtime
const HUB_URL = "https://talopartnerhub.vercel.app";
export type PaymentStatus = "PENDING" | "SUCCESS" | "OVERPAID" | "UNDERPAID" | "EXPIRED";
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; created_at?: string; motive?: string; payment_url?: string;
}
export class TaloHubError extends Error { // from { "error": { "code", "message", "details"? } }
  status: number; code: string; details: unknown;
  constructor(status: number, code: string, message: string, details?: unknown) {
    super(`Talo Partner Hub ${status} ${code}: ${message}`);
    this.name = "TaloHubError"; this.status = status; this.code = code; this.details = details;
  }
}
type Opts = { method?: "GET" | "POST"; body?: unknown; timeoutMs?: number; attempts?: number };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function hub<T>(apiKey: string | undefined, path: string, o: Opts = {}): Promise<T> {
  if (!apiKey || !/^tph_(test|live)_/.test(apiKey)) throw new Error("TALO_HUB_API_KEY is missing or not a tph_test_/tph_live_ key");
  for (let attempt = 1; ; attempt++) {
    const last = attempt >= (o.attempts ?? 3);
    let res: Response;
    try {
      res = await fetch(HUB_URL + path, { method: o.method ?? "GET", signal: AbortSignal.timeout(o.timeoutMs ?? 8_000),
        headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
        body: o.body === undefined ? undefined : JSON.stringify(o.body) });
    } catch (err) { if (last) throw err; await sleep(400 * attempt); continue; } // network error/timeout: retry, same body
    const json = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string; details?: unknown } } | null;
    if (res.ok) return json as T;
    if (res.status === 502 && !last) { await sleep(400 * attempt); continue; } // talo_unavailable: retry with backoff
    const e = json?.error ?? {};
    throw new TaloHubError(res.status, e.code ?? "unknown", e.message ?? res.statusText, e.details); // 4xx: never retried
  }
}
export const createPayment = (apiKey: string | undefined, body: { amount: number; external_id: string; motive?: string; redirect_url?: string }) =>
  hub<TaloPayment>(apiKey, "/api/v1/payments", { method: "POST", body, timeoutMs: 25_000 }); // Talo can take 15-20 s to create one
export const getPayment = (apiKey: string | undefined, id: string, o?: Opts) =>
  hub<TaloPayment>(apiKey, `/api/v1/payments/${encodeURIComponent(id)}`, o);
```

## 3. Checkout

The browser sends only the order id. The server reads the amount from the order row (computed by your cart code, never from the request), creates the payment with `external_id` = order id, stores `payment_id`, status, CVU, alias and `expires_at`, and answers what the buyer needs; after EXPIRED the next attempt uses `<order>-2`, `<order>-3`. Order ids 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 reset database restarts a sequence, while a reused `external_id` returns the old payment, maybe already SUCCESS. Use `crypto.randomUUID()` ids, never an autoincrement; `payOrder` also refuses a payment whose amount isn't the order's, and an order's amount is fixed once it has a payment (a changed cart is a new order). Put your session auth on `/orders/*`, never on `/webhooks/talo`. The order page shows CVU, alias, the exact amount and the expiry and polls `GET /orders/<id>/status` with backoff (3 s, ×1.5, capped at 10 s) until SUCCESS, OVERPAID or EXPIRED (then a button calls `/pay` again); format the amount with `Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS" })`. On Supabase, run the same logic in a function with `verify_jwt` on and `withSupabase({ auth: "user" })`, checking the order belongs to `ctx.userClaims.id`.

```ts
// src/app.ts — the Worker's checkout, status, webhook and cron glue
import { applyPayment } from "./store-d1.ts";
import { createPayment, getPayment, TaloHubError, type TaloPayment } from "./talo.ts";
import { handleTaloWebhook, reconcilePayments } from "./webhook.ts";
export type AppEnv = Env & { TALO_HUB_DEV_FORWARD?: string }; // Env comes from `wrangler types`
type Ctx = Pick<ExecutionContext, "waitUntil">;
type Order = { id: string; amount_cents: number; payment_attempt: number; payment_status: string | null; fulfilled_at: string | null };
export async function payOrder(env: AppEnv, orderId: string): Promise<Response> {
  const order = await env.DB.prepare("SELECT id, amount_cents, payment_attempt, payment_status, fulfilled_at FROM orders WHERE id = ?1")
    .bind(orderId).first<Order>();
  if (!order) return Response.json({ error: "order not found" }, { status: 404 });
  if (order.fulfilled_at) return Response.json({ error: "order already paid" }, { status: 409 });
  const attempt = order.payment_status === "EXPIRED" ? order.payment_attempt + 1 : order.payment_attempt;
  const externalId = attempt === 1 ? order.id : `${order.id}-${attempt}`; // new external_id after EXPIRED
  let p: TaloPayment;
  try {
    p = await createPayment(env.TALO_HUB_API_KEY, { amount: order.amount_cents / 100, external_id: externalId, motive: `Pedido ${order.id}` });
  } catch (err) {
    console.error("[checkout]", err instanceof TaloHubError ? `${err.status} ${err.code}` : err);
    return Response.json({ error: "No pudimos generar los datos de pago. Probá de nuevo." }, { status: 502 });
  }
  if (Math.round(p.amount * 100) !== order.amount_cents) { // that external_id already had a payment: reused id or changed cart
    console.error(`[checkout] ${externalId} already has a payment of ${p.amount}; never show or store it`);
    return Response.json({ error: "Este pedido cambió: generá uno nuevo." }, { status: 409 });
  }
  await env.DB.prepare(`UPDATE orders SET payment_id = ?1, payment_status = ?2, payment_attempt = ?3, cvu = ?4, alias = ?5,
    expires_at = ?6 WHERE id = ?7 AND fulfilled_at IS NULL`).bind(p.id, p.status, attempt, p.cvu, p.alias, p.expires_at, order.id).run();
  return Response.json({ status: p.status, cvu: p.cvu, alias: p.alias, amount: p.amount, expires_at: p.expires_at });
}
export async function orderStatus(env: AppEnv, orderId: string): Promise<Response> { // the buyer's screen only, never fulfils
  const row = await env.DB.prepare("SELECT payment_id FROM orders WHERE id = ?1").bind(orderId).first<{ payment_id: string | null }>();
  if (!row?.payment_id) return Response.json({ error: "order not found" }, { status: 404 });
  const p = await getPayment(env.TALO_HUB_API_KEY, row.payment_id, { attempts: 1, timeoutMs: 5_000 }).catch(() => null);
  if (!p) return Response.json({ error: "try again" }, { status: 503 });
  return Response.json({ status: p.status, amount: p.amount, paid: p.paid, expires_at: p.expires_at });
}
export const taloWebhook = (request: Request, env: AppEnv, ctx: Ctx) => handleTaloWebhook(request, {
  secret: env.TALO_HUB_WEBHOOK_SECRET, apiKey: env.TALO_HUB_API_KEY,
  acceptForwarded: env.TALO_HUB_DEV_FORWARD === "1" && env.TALO_HUB_API_KEY?.startsWith("tph_test_") === true, // never with a live key
  apply: (p, event) => applyPayment(env.DB, p, event),
  fulfil: (orderId) => ctx.waitUntil(fulfilOrder(env, orderId)), // ~30 s after the response; use a Queue for must-not-lose work
});
export async function fulfilOrder(_env: AppEnv, orderId: string): Promise<void> { // runs once per order
  console.log(`[fulfil] order ${orderId} is paid: send the email, reserve stock, ship`);
}
export async function reconcile(env: AppEnv, ctx: Ctx): Promise<void> { // Cron Trigger
  const { results } = await env.DB.prepare(`SELECT payment_id FROM orders WHERE fulfilled_at IS NULL AND payment_id IS NOT NULL
    AND payment_status IN ('PENDING', 'UNDERPAID', 'SUCCESS', 'OVERPAID') -- SUCCESS here: stored by /pay, not yet fulfilled
    AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 days') -- payments can last up to 14 days
    ORDER BY random() LIMIT 40`).all<{ payment_id: string }>(); // Free plan: 50 subrequests per run; random(): none starves
  await reconcilePayments(results.map((r) => r.payment_id), {
    apiKey: env.TALO_HUB_API_KEY, apply: (p) => applyPayment(env.DB, p), fulfil: (orderId) => ctx.waitUntil(fulfilOrder(env, orderId)),
  });
}
// src/worker.ts — exact paths; POST only for the webhook
import { orderStatus, payOrder, reconcile, taloWebhook, type AppEnv } from "./app.ts";
export default {
  async fetch(request, env, ctx) {
    const { pathname } = new URL(request.url), m = pathname.match(/^\/orders\/([\w-]{1,64})\/(pay|status)$/);
    if (request.method === "POST" && pathname === "/webhooks/talo") return taloWebhook(request, env, ctx);
    if (m?.[2] === "pay" && request.method === "POST") return payOrder(env, m[1]);
    if (m?.[2] === "status" && request.method === "GET") return orderStatus(env, m[1]);
    return new Response("Not found", { status: 404 });
  },
  async scheduled(_controller, env, ctx) { await reconcile(env, ctx); },
} satisfies ExportedHandler<AppEnv>;
```

## 4. Webhook endpoint

```ts
// src/verify.ts — talo-hub-signature: t=<unix s>,v1=<64 lowercase hex>, v1 = hex(HMAC-SHA256(secret, "<t>." + raw body bytes))
const enc = new TextEncoder();
export async function verifyTaloSignature(rawBody: ArrayBuffer, header: string | null, secret: string,
  now = Math.floor(Date.now() / 1000)): Promise<boolean> { // tests pass the vector's t as `now`
  if (!header || !secret) return false;
  const fields = header.split(",").map((p) => p.trim().split("="));
  const t = fields.filter(([k]) => k === "t"), v1 = fields.filter(([k]) => k === "v1");
  if (fields.length !== 2 || t.length !== 1 || v1.length !== 1) return false; // exactly one t and one v1
  const ts = t[0][1] ?? "", hex = v1[0][1] ?? "";
  if (!/^\d{1,12}$/.test(ts) || !/^[0-9a-f]{64}$/.test(hex)) return false;
  if (Math.abs(now - Number(ts)) > 300) return false;
  const signature = new Uint8Array(32);
  for (let i = 0; i < 32; i++) signature[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  const prefix = enc.encode(`${ts}.`), message = new Uint8Array(prefix.length + rawBody.byteLength);
  message.set(prefix); message.set(new Uint8Array(rawBody), prefix.length);
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  return crypto.subtle.verify("HMAC", key, signature, message); // constant-time comparison
}
// src/webhook.ts — runtime-agnostic (Workers, Supabase/Deno, Bun): only `apply` (the store) and `fulfil` differ
import { getPayment, type TaloPayment } from "./talo.ts";
import { verifyTaloSignature } from "./verify.ts";
export type Applied = { duplicate: boolean; fulfilledOrderId: string | null };
export type WebhookDeps = {
  secret: string | undefined; apiKey: string | undefined;
  acceptForwarded: boolean; // explicit dev flag for unsigned `talo-hub-forwarded: 1` events; never true in production
  apply(p: TaloPayment, event: { id: number; type: string }): Promise<Applied>; // ONE database transaction
  fulfil(orderId: string): void; // schedule slow work after the response (waitUntil / queue)
};
export async function handleTaloWebhook(request: Request, d: WebhookDeps): Promise<Response> {
  const raw = await request.arrayBuffer(); // read the body ONCE, as bytes, before anything parses it
  if (!(d.acceptForwarded && request.headers.get("talo-hub-forwarded") === "1")) {
    if (!d.secret?.startsWith("whsec_")) return Response.json({ error: "webhook secret not set yet" }, { status: 503 }); // retried
    if (!(await verifyTaloSignature(raw, request.headers.get("talo-hub-signature"), d.secret))) {
      return Response.json({ error: "invalid signature" }, { status: 400 });
    }
  }
  let event: { id: number; type: string; data?: { payment?: { id?: string } } };
  try { event = JSON.parse(new TextDecoder().decode(raw)); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); }
  const paymentId = String(event?.type).startsWith("payment.") ? event.data?.payment?.id : undefined;
  if (!paymentId || !Number.isSafeInteger(event.id)) return Response.json({ received: true }); // webhook.test, account.updated
  let applied: Applied;
  try {
    const payment = await getPayment(d.apiKey, paymentId, { attempts: 1, timeoutMs: 5_000 }); // never trust the snapshot
    applied = await d.apply(payment, { id: event.id, type: event.type });
  } catch (err) {
    console.error("[webhook] not handled, the hub will retry:", err);
    return Response.json({ error: "try again" }, { status: 503 });
  }
  if (applied.fulfilledOrderId) d.fulfil(applied.fulfilledOrderId);
  return Response.json({ received: true, duplicate: applied.duplicate });
}
// Reconciliation: the same re-read and transaction for payments whose webhook never arrived.
export async function reconcilePayments(ids: string[], d: Pick<WebhookDeps, "apiKey" | "fulfil"> & { apply(p: TaloPayment): Promise<Applied> }) {
  let fulfilled = 0;
  for (const id of ids) {
    try {
      const { fulfilledOrderId } = await d.apply(await getPayment(d.apiKey, id));
      if (fulfilledOrderId) { fulfilled++; d.fulfil(fulfilledOrderId); }
    } catch (err) { console.error(`[reconcile] ${id}:`, err); }
  }
  console.log(`[reconcile] checked ${ids.length}, fulfilled ${fulfilled}`);
}
```

What breaks it:
- Reading the body first. A `Request` body is a one-shot stream: a middleware that called `request.json()`/`text()` makes `arrayBuffer()` throw "Body has already been read" (tested with Hono 4; → 503 forever). Hono 4: `app.post("/webhooks/talo", (c) => taloWebhook(c.req.raw, c.env, c.executionCtx))` with no `c.req.json()`, `validator("json")` or `zValidator` middleware on that path.
- Auth or challenges in front: Hono `jwt`/`basicAuth`/`bearerAuth`, Cloudflare Access, WAF managed challenges, Under Attack mode, Bot Fight Mode (it can't be skipped per path: turn it off on that zone, or use Super Bot Fight Mode with a Skip rule). The hub gets 401/403/a challenge page, retries after 1 m, 5 m, 30 m, 2 h, 6 h, then drops the event. Hono `csrf()` only checks form content types: the hub's `application/json` POST passes and a `text/plain` one gets 403 (tested).
- Supabase: `verify_jwt` is on by default (the platform answers 401 before your code) and `withSupabase()` defaults to `auth: "user"` (401 too; both tested). The webhook needs `verify_jwt = false` and `auth: "none"`.
- 10 s budget: one GET (5 s timeout, no retry) + one transaction. Slow work goes to `ctx.waitUntil` (≤ 30 s after the response), `EdgeRuntime.waitUntil` on Supabase, or a Queue.
- Path and answers: `/webhooks/talo/` is a 404 here (tested in `wrangler dev`), so register the exact https URL with no http→https, apex→www or trailing-slash redirect in front. Bad signature 400; `webhook.test`/`account.updated` 200 with no work; secret not set yet (not a `whsec_`), hub or database failure 503 (the hub retries); repeated event 200.

```toml
# supabase/config.toml — add these two sections; keep the rest of what `supabase init` wrote
[functions.talo-webhook]
verify_jwt = false # the hub sends no Supabase JWT; the handler verifies talo-hub-signature
[functions.talo-reconcile]
verify_jwt = false # called by pg_cron with the secret key on `apikey`; checked by withSupabase({ auth: "secret" })
```

```ts
// supabase/functions/_shared/store-supabase.ts — talo_apply_payment runs as one transaction (see the migration)
import type { SupabaseClient } from "@supabase/supabase-js";
import type { TaloPayment } from "./talo.ts";
import type { Applied } from "./webhook.ts";
export async function applyPaymentSupabase(db: SupabaseClient, p: TaloPayment, e?: { id: number; type: string }): Promise<Applied> {
  const { data, error } = await db.rpc("talo_apply_payment",
    { p_payment_id: p.id, p_status: p.status, p_paid: p.paid, p_event_id: e?.id ?? null, p_event_type: e?.type ?? null });
  if (error) throw new Error(`talo_apply_payment: ${error.message}`);
  return { duplicate: data.duplicate, fulfilledOrderId: data.fulfilled_order };
}
export async function fulfilOrder(orderId: string) { // once per order, after the response
  console.log(`[fulfil] order ${orderId} is paid: send the email, reserve stock, ship`);
}
// supabase/functions/talo-webhook/index.ts
import { withSupabase } from "@supabase/server";
import { applyPaymentSupabase, fulfilOrder } from "../_shared/store-supabase.ts";
import { handleTaloWebhook } from "../_shared/webhook.ts";
declare const EdgeRuntime: { waitUntil(p: Promise<unknown>): void };
export default { // auth "none": the hub sends no Supabase credentials; talo-hub-signature is the authentication
  fetch: withSupabase({ auth: "none", cors: "disabled" }, async (req, ctx) => {
    if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const apiKey = Deno.env.get("TALO_HUB_API_KEY");
    return handleTaloWebhook(req, {
      secret: Deno.env.get("TALO_HUB_WEBHOOK_SECRET"), apiKey,
      acceptForwarded: Deno.env.get("TALO_HUB_DEV_FORWARD") === "1" && apiKey?.startsWith("tph_test_") === true, // only in supabase/functions/.env
      apply: (p, event) => applyPaymentSupabase(ctx.supabaseAdmin, p, event),
      fulfil: (orderId) => EdgeRuntime.waitUntil(fulfilOrder(orderId)),
    });
  }),
};
```

## 5. Durable storage

Isolates and function instances are many, short-lived and share no memory: an in-memory "seen" set is per isolate and gone on each deploy, so two deliveries of one event would both fulfil. Event ids and `fulfilled_at` live in the database under unique constraints; money is integer cents in SQLite and `numeric(12,2)` in Postgres, never float. D1 facts (checked on local D1): `BEGIN TRANSACTION` is refused; `db.batch()` runs its statements in order as one transaction and rolls all back if one fails; `ON CONFLICT DO NOTHING` gives `meta.changes = 0`; `UPDATE … RETURNING` works. A batch can't branch, so a duplicate doesn't skip the updates: they are idempotent (forward-only status, `fulfilled_at IS NULL`).

```sql
-- migrations/0001_talo.sql — D1 is SQLite: no decimal type, so ARS amounts are integer cents
CREATE TABLE IF NOT EXISTS orders (
  id TEXT PRIMARY KEY, amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), payment_attempt INTEGER NOT NULL DEFAULT 1,
  payment_id TEXT UNIQUE, payment_status TEXT CHECK (payment_status IN ('PENDING','SUCCESS','OVERPAID','UNDERPAID','EXPIRED')),
  paid_cents INTEGER NOT NULL DEFAULT 0, cvu TEXT, alias TEXT, expires_at TEXT, fulfilled_at TEXT,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS orders_reconcile ON orders (payment_status, created_at);
CREATE TABLE IF NOT EXISTS talo_events ( -- one row per hub event id, ever
  id INTEGER PRIMARY KEY, type TEXT NOT NULL, payment_id TEXT, received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')));
```

```ts
// src/store-d1.ts — one db.batch() = one transaction; it can't branch, so both updates are idempotent
import type { TaloPayment } from "./talo.ts";
const RANK = (x: string) => `CASE ${x} WHEN 'PENDING' THEN 0 WHEN 'UNDERPAID' THEN 1 WHEN 'OVERPAID' THEN 3 ELSE 2 END`;
export async function applyPayment(db: D1Database, p: TaloPayment, event?: { id: number; type: string }) {
  const stmts = [
    db.prepare(`UPDATE orders SET payment_status = ?1, paid_cents = MAX(paid_cents, ?2)
      WHERE payment_id = ?3 AND (payment_status IS NULL OR ${RANK("payment_status")} <= ${RANK("?1")})`)
      .bind(p.status, Math.round(p.paid * 100), p.id), // statuses only move forward
    db.prepare(`UPDATE orders SET fulfilled_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
      WHERE payment_id = ?1 AND fulfilled_at IS NULL AND ?2 IN ('SUCCESS', 'OVERPAID') RETURNING id`)
      .bind(p.id, p.status), // fulfil once
  ];
  if (event) stmts.unshift(db.prepare("INSERT INTO talo_events (id, type, payment_id) VALUES (?1, ?2, ?3) ON CONFLICT (id) DO NOTHING")
    .bind(event.id, event.type, p.id));
  const results = await db.batch<{ id: string }>(stmts);
  return { duplicate: event !== undefined && results[0].meta.changes === 0,
    fulfilledOrderId: results[results.length - 1].results[0]?.id ?? null }; // set only on the delivery that fulfilled
}
```

```sql
-- supabase/migrations/20261028000000_talo.sql — Postgres (Supabase, or Workers + Hyperdrive): numeric money; one function = one transaction
create table public.orders (
  id text primary key, amount numeric(12,2) not null check (amount > 0), payment_attempt int not null default 1,
  payment_id text unique, payment_status text check (payment_status in ('PENDING','SUCCESS','OVERPAID','UNDERPAID','EXPIRED')),
  paid numeric(12,2) not null default 0, cvu text, alias text, expires_at timestamptz, fulfilled_at timestamptz,
  created_at timestamptz not null default now()
);
create index orders_reconcile on public.orders (payment_status, created_at);
create table public.talo_events (id bigint primary key, type text not null, payment_id text, received_at timestamptz not null default now());
alter table public.orders enable row level security; -- no policies: only the secret key / service role reaches them
alter table public.talo_events enable row level security;
create function public.talo_status_rank(s text) returns int language sql immutable as $$
  select case s when 'PENDING' then 0 when 'UNDERPAID' then 1 when 'OVERPAID' then 3 else 2 end $$;
-- One call = one transaction: record the event (or stop: duplicate), move the status forward, fulfil once.
create function public.talo_apply_payment(p_payment_id text, p_status text, p_paid numeric,
  p_event_id bigint default null, p_event_type text default null) returns jsonb language plpgsql set search_path = '' as $$
declare v_fulfilled text;
begin
  if p_event_id is not null then
    insert into public.talo_events (id, type, payment_id) values (p_event_id, p_event_type, p_payment_id) on conflict (id) do nothing;
    if not found then return jsonb_build_object('duplicate', true, 'fulfilled_order', null); end if;
  end if;
  update public.orders set payment_status = p_status, paid = greatest(paid, p_paid) where payment_id = p_payment_id
    and (payment_status is null or public.talo_status_rank(payment_status) <= public.talo_status_rank(p_status));
  update public.orders set fulfilled_at = now()
    where payment_id = p_payment_id and fulfilled_at is null and p_status in ('SUCCESS', 'OVERPAID') returning id into v_fulfilled;
  return jsonb_build_object('duplicate', false, 'fulfilled_order', v_fulfilled);
end $$;
revoke execute on function public.talo_apply_payment from public, anon, authenticated; -- plain Postgres: `from public` only
grant execute on function public.talo_apply_payment to service_role;
```

Workers + Hyperdrive: add `"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<id>", "localConnectionString": "postgres://…" }]`, `npm i pg`, and make `apply` run `select public.talo_apply_payment($1, $2, $3, $4, $5) as r` with `[p.id, p.status, p.paid, event?.id ?? null, event?.type ?? null]` on `new Client({ connectionString: env.HYPERDRIVE.connectionString })` (`nodejs_compat` is on by default from compatibility date 2026-08-04). Outside Supabase drop the `anon`/`authenticated`/`service_role` grants.

## 6. Reconciliation

Workers: `triggers.crons` (UTC) runs `scheduled` → `reconcile` (§3) every 10 minutes: unfulfilled orders whose payment is PENDING, UNDERPAID, or SUCCESS/OVERPAID as stored by a repeated `/pay` before any webhook, from the last 15 days (cancellation periods go up to 14 days), re-read, same `applyPayment`. Free plan: 50 subrequests and 10 ms CPU per cron run (hence 40 per run, picked at random so none starves behind newer abandoned checkouts; Paid allows 30 s CPU). Every 10 minutes is enough: webhooks do the real-time work, this catches deliveries that never arrived. Crons don't fire locally: `curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*/10+*+*+*+*"`. Supabase: a second function called by pg_cron (Supabase Cron, Dashboard → Integrations → Cron) through pg_net with the project's `default` secret key (`auth: "secret:<name>"` for another key):

```ts
// supabase/functions/talo-reconcile/index.ts — pg_cron calls it with the `default` secret key on `apikey`; others get 401
import { withSupabase } from "@supabase/server";
import { applyPaymentSupabase, fulfilOrder } from "../_shared/store-supabase.ts";
import { reconcilePayments } from "../_shared/webhook.ts";
declare const EdgeRuntime: { waitUntil(p: Promise<unknown>): void };
export default {
  fetch: withSupabase({ auth: "secret", cors: "disabled" }, async (_req, ctx) => {
    const db = ctx.supabaseAdmin; // same rows as the Worker's cron: unfulfilled, not EXPIRED, last 15 days
    const { data, error } = await db.from("orders").select("payment_id").is("fulfilled_at", null).not("payment_id", "is", null)
      .in("payment_status", ["PENDING", "UNDERPAID", "SUCCESS", "OVERPAID"]).gte("created_at", new Date(Date.now() - 15 * 86_400_000).toISOString())
      .order("created_at", { ascending: false }).limit(200);
    if (error) return Response.json({ error: error.message }, { status: 500 });
    EdgeRuntime.waitUntil(reconcilePayments(data.map((o) => o.payment_id), { // answer now: pg_net stops waiting after 2 s
      apiKey: Deno.env.get("TALO_HUB_API_KEY"), apply: (p) => applyPaymentSupabase(db, p),
      fulfil: (orderId) => EdgeRuntime.waitUntil(fulfilOrder(orderId)),
    }));
    return Response.json({ started: data.length }, { status: 202 });
  }),
};
```

```sql
-- once per project in the SQL editor (never in a migration: the key would land in git); pg_cron and pg_net enabled
select vault.create_secret('https://<project-ref>.supabase.co', 'project_url');
select vault.create_secret('<sb_secret_...>', 'secret_key');
select cron.schedule('talo-reconcile', '*/10 * * * *', $$
  select net.http_post(
    url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url') || '/functions/v1/talo-reconcile',
    headers := jsonb_build_object('Content-Type', 'application/json',
      'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')),
    body := '{}'::jsonb
  );
$$);
```

## 7. Local development

- Workers: `npx wrangler d1 migrations apply DB --local`, `npx wrangler dev --var TALO_HUB_DEV_FORWARD:1` (http://localhost:8787), then `npx talo-hub events listen --forward-to http://localhost:8787/webhooks/talo`.
- Supabase: `supabase start`, `supabase db reset` (applies migrations), `TALO_HUB_DEV_FORWARD=1` only in `supabase/functions/.env`, `supabase functions serve` (keep `[edge_runtime] policy = "per_worker"`, the `supabase init` default: with `oneshot` the CLI kills `EdgeRuntime.waitUntil` work), then `npx talo-hub events listen --forward-to http://127.0.0.1:54321/functions/v1/talo-webhook`. Forwarded events are unsigned and carry `talo-hub-forwarded: 1`; they pass only with that flag and a `tph_test_` key (both handlers check), and the flag never goes into `wrangler secret`, `vars`, `wrangler deploy --var` or `supabase secrets`. A forged one can at worst trigger a re-read: the handler acts on the GET, not on the body.

## 8. Deploy

- Workers: one Worker per environment (`"env": { "staging": { … its own d1_databases, triggers and secrets } }`, `--env staging` on every wrangler command): `tph_test_` in dev/staging, `tph_live_` only in production (`--production` on the talo-hub commands, after `npx talo-hub login --production`). Webhook URL `https://<worker>.<account>.workers.dev/webhooks/talo` or a custom domain; no Access application, WAF challenge, Bot Fight Mode or Under Attack mode on that path. Crons run only on the deployed Worker. Order per environment, through the gitignored file `.dev.vars.<env>` so no value reaches the screen:
  1. `npx wrangler d1 migrations apply DB --remote`; `npx talo-hub keys create --name shop-<env> --scope charge --dotenv .dev.vars.<env>`, then add the line `TALO_HUB_WEBHOOK_SECRET=unset` to that file (`secrets.required` refuses a first deploy without it; the route answers 503 until it is a `whsec_`).
  2. `npx wrangler deploy --secrets-file .dev.vars.<env>`.
  3. `npx talo-hub webhooks add https://<host>/webhooks/talo --dotenv .dev.vars.<env>` (probes the URL, then writes the `whsec_…` over the placeholder), `npx wrangler secret bulk .dev.vars.<env>` (deploys it), delete the file.
  4. `npx talo-hub webhooks test <id>` must report delivered.
- Supabase: one project per environment. `supabase db push`, `supabase functions deploy talo-webhook` and `talo-reconcile` (`verify_jwt = false` comes from config.toml; `--no-verify-jwt` does the same per deploy; `--use-api` bundles without Docker). URL `https://<ref>.supabase.co/functions/v1/talo-webhook`. Then `keys create` and `webhooks add` with `--dotenv supabase/.env.<env>`, `supabase secrets set --env-file supabase/.env.<env>`, delete the file, `webhooks test <id>`.
- Deno Deploy / Bun: `verify.ts`, `talo.ts`, `webhook.ts` run unchanged with the Postgres store; serve `export default { fetch }` / `Deno.serve` or `Bun.serve({ fetch })`; schedule with `Deno.cron("talo-reconcile", "*/10 * * * *", fn)` (Deno Deploy; `--unstable-cron` locally) or `Bun.cron("*/10 * * * *", fn)` (Bun ≥ 1.3.12, in-process: it dies with the process).

## 9. Test it

```ts
// test/verify.test.ts — test vector from https://talopartnerhub.vercel.app/docs/webhooks.md (its t is passed as "now")
import { expect, it } from "vitest";
import { verifyTaloSignature } from "../src/verify.ts";
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 check = (b: string, h: string | null, now = t, s = secret) => verifyTaloSignature(new TextEncoder().encode(b).buffer, h, s, now);
const ok = `t=${t},v1=${v1}`;
it("accepts the test vector", async () => expect(await check(body, ok)).toBe(true));
it("rejects a tampered body, another secret, a t more than 300 s away", async () => {
  expect(await check(body.replace("sandbox", "production"), ok)).toBe(false);
  expect(await check(body, ok, t, "whsec_other")).toBe(false);
  expect(await check(body, ok, t + 301)).toBe(false);
  expect(await check(body, ok, Math.floor(Date.now() / 1000))).toBe(false);
});
it("rejects a malformed or missing header", async () => {
  for (const h of [`t=${t},v1=${v1.toUpperCase()}`, `t=${t},v1=${v1.slice(1)}`, `t=${t},v1=${"z".repeat(64)}`,
    `t=${t}`, `v1=${v1}`, `${ok},v1=${v1}`, "", null]) expect(await check(body, h)).toBe(false);
});
```

`npx vitest run` and `bun test` run it as is; for `deno test` replace the vitest import with `import { expect } from "jsr:@std/expect@1"; const it = Deno.test;`. Route test: call `worker.fetch(request, env, ctx)` in-process with a body signed at the current time (`crypto.subtle.sign`), `globalThis.fetch` stubbed for the hub (never the real one) and a real D1 (Miniflare's `getD1Database`); assert valid → 200 and fulfilled once, same event → 200 `duplicate: true`, bad signature → 400, UNDERPAID → not fulfilled, hub 502 → 503 with nothing stored, parallel copies of one event → fulfilled once, a hub payment with another amount for that `external_id` → 409 and nothing stored. Sandbox, on the deployed sandbox URL (`npx wrangler tail` or the function logs show `[fulfil]`):
1. Check out an order, `npx talo-hub sandbox pay <payment_id>` → SUCCESS, fulfilled once.
2. New order, `--amount <amount - 1>` → UNDERPAID, not fulfilled; `--amount 1` → SUCCESS, fulfilled once (or wait for the cron). A third order with `--amount <more>` → OVERPAID, fulfilled.
3. Same event twice, locally (§7): pay an order created through `wrangler dev` while `events listen --forward-to` runs, then POST one object of the `events` array that `npx talo-hub events list --payment <id>` prints to the local route with `talo-hub-forwarded: 1` → `{"received":true,"duplicate":true}`, no second `[fulfil]`.
4. `npx talo-hub webhooks test <id>` → delivered; then `npx talo-hub doctor` until every check passes.

## Checklist

- [ ] Key and webhook secret only as Worker / Supabase secrets, written by `talo-hub … --dotenv`, never on screen; `.dev.vars*` and `.env*` gitignored; the dev flag only via `wrangler dev --var` or `supabase/functions/.env`, and ignored without a `tph_test_` key.
- [ ] Amount read from the order row; random order ids; `external_id` = order id, `-2`, `-3` after EXPIRED; a payment with another amount is never shown or stored.
- [ ] Webhook reads `request.arrayBuffer()` once; nothing parses or gates it first (Supabase: `verify_jwt = false` + `auth: "none"`).
- [ ] Verifier passes the test vector and rejects tampered, stale, non-hex/short `v1` and missing headers.
- [ ] Payment re-read with GET before acting; hub or DB failure answers 503.
- [ ] Event id + status + fulfil-once in one D1 batch or one `talo_apply_payment` call; slow work only when `fulfilledOrderId` is set.
- [ ] Reconciliation every 10 min (Cron Trigger or pg_cron + pg_net) over every unfulfilled, non-EXPIRED order of the last 15 days.
- [ ] One webhook endpoint + secret per environment; `webhooks test` delivered; `doctor` ready.
