Skills
Instrucciones que tu agente carga cuando las necesita: le enseñan a integrar y operar Talo con buenas prácticas, sin que se las expliques.
Instalar
npx talo-hub skills install # skills base + el kit de tu stack, en ./.claude/skills
npx talo-hub skills install --stack python # elegir el kit
npx talo-hub skills install --global # todas, en ~/.claude/skills.claude/skills) y con cualquier agente que lea archivos SKILL.md. El CLI reconoce el stack por package.json, composer.json, requirements.txt o pyproject.toml. Combinalas con el servidor MCP para que además pueda operar la cuenta.Cobrar con Talo en un proyecto
El agente integra el checkout por transferencia: crea el pago, muestra el CVU, verifica webhooks firmados y prueba todo en sandbox.
---
name: talo-payments
description: Add bank-transfer payments (Argentina, CVU/alias) with Talo Pay to a project through the Talo Partner Hub — create a payment at checkout, show the CVU (Talo's payment page, the <talo-checkout> widget, or a reusable payment link with a QR), confirm it via signed webhooks, refund, and test end to end in sandbox with simulated transfers. Use when the user wants to charge by transfer, integrate Talo or Talo Pay, accept payments in ARS by CVU, or share a payment link.
---
# Talo payments through the Talo Partner Hub
The hub (`https://talopartnerhub.vercel.app`) wraps Talo's API: one API key per account and
environment, payments attributed to the partner, and signed webhooks. Money moves only in
**ARS by bank transfer**: each payment gets a CVU and alias that the buyer transfers to.
Stack kits: if this project has a `talo-stack-*` skill (Next.js + Vercel, Node, Python, Laravel,
edge), follow it for the code; `npx talo-hub skills install` adds the one that matches. Any other
stack: apply the 10 rules at https://talopartnerhub.vercel.app/docs/stacks.md.
## 1. Get a key
- Sandbox first: `npx talo-hub login` (opens the browser to authorize the hub in Talo; it exits by
itself when the person authorizes, so wait for it instead of asking them whether they did; see
talo-operations for running it from an agent) and then
`npx talo-hub keys create --name "<project>" --scope charge --dotenv .env.local` for the project's server
(`--vercel production|preview` on Vercel: the key goes straight there, never on screen). Keys look
like `tph_test_…` (sandbox) or `tph_live_…` (production, real money). `charge` creates payments
but can't refund; that's all a checkout needs.
- Write it straight into the project's server-side env as `TALO_HUB_API_KEY`. **Never** in client
code, git, logs or the conversation. Check `.env*` is gitignored.
- If the MCP server is connected (tools `create_payment`, `simulate_transfer`, `create_api_key`…),
use the tools to explore and test; the project's code still calls the HTTP API.
- For the full test plan and the move to production, see the `talo-go-live` skill.
## 2. Create the payment on the server, at checkout
```http
POST https://talopartnerhub.vercel.app/api/v1/payments
Authorization: Bearer $TALO_HUB_API_KEY
Content-Type: application/json
{ "amount": 15000, "external_id": "<order id>", "motive": "Pedido #1001" }
```
The answer has `id`, `status` (`PENDING`), `cvu`, `alias`, `amount`, `expires_at`, `payment_url`
and `checkout_token`. Show the buyer the CVU and alias with the exact amount, and keep `id` with the
order. Reusing the order's `external_id` returns the same payment, so retries never charge twice.
The amount always comes from your server (the order), never from the browser.
## 3. Show the payment to the buyer
Pick one (details: https://talopartnerhub.vercel.app/docs/checkout.md):
| The project | Use |
|---|---|
| Just needs somewhere to send the buyer | Redirect to `payment_url`: Talo's own page with amount, alias and CVU (copy buttons), countdown, account holder and CUIT. No UI to build. |
| Wants the buyer to stay on its page | The widget. Pass the page only `id` and `checkout_token`, never the API key: `<script src="https://talopartnerhub.vercel.app/widget/v1/talo-checkout.js" defer></script>` and `<talo-checkout payment-id="…" token="…"></talo-checkout>`. React/Next.js: `npx shadcn@latest add https://talopartnerhub.vercel.app/r/talo-payment-status.json` (component) or `…/r/talo-checkout-nextjs.json` (Next.js block with hub client and webhook route). |
| Has no checkout at all (sells by chat, Instagram, a QR at the counter) | A payment link: `npx talo-hub links create --title "<what>" --amount <ARS>` (or `--min`/`--max` for an amount the buyer types), `POST /api/v1/payment-links`, or the MCP tool `create_payment_link`. Share its `url` or print its `qr_url`. Each buyer gets a new payment whose `external_id` is `link_<link id>_<random>` and whose `motive` is the title. |
- `checkout_token` is read-only and opens only that payment's public view
(`GET /api/public/payments/{id}?token=…`) for 15 days: it may go in the page or a URL. It comes
in the answers to create and `GET /api/v1/payments/{id}`, not in lists or webhook events.
- The widget polls by itself (every 4 s, backing off on errors, stopping when paid or expired) and
fires `talo-status`, `talo-paid`, `talo-expired` and `talo-error`. Use them to update the screen
only: **never fulfil on `talo-paid`, `onPaid` or the buyer coming back from Talo's page** —
anyone can fire a browser event. Fulfil in the webhook (step 4).
- If the site sends a Content-Security-Policy, add `https://talopartnerhub.vercel.app` to
`script-src` and `connect-src`. `style-src` needs no change: the widget uses a constructed
stylesheet, not inline styles.
- Payment links allow up to 20 payments per hour from the same network (IP) per link, and a breaker
of 200 per link per 10 minutes; a buyer who comes back to the link gets the payment they already
started (also after a partial transfer), which doesn't count. Buyers on the same Wi-Fi count
together. Disabling one (`links disable <id>`) is permanent; payments already started can still
be paid.
- A link's payments have `external_id` `link_<12-char link id>_<random>`: the link id is
`external_id.slice(5, 17)` (link ids are letters and digits; the random part may hold `-` or `_`).
## 4. Confirm with webhooks, never with the browser
Register the endpoint once: `npx talo-hub webhooks add https://<site>/api/webhooks/talo --dotenv .env.local`
(it checks the URL first; `--vercel <env>` on Vercel), and store
the `whsec_…` secret it prints as `TALO_HUB_WEBHOOK_SECRET`.
Each event is a POST with `Talo-Hub-Event` and `Talo-Hub-Signature: t=<unix>,v1=<hex>`, where
`v1 = HMAC-SHA256(secret, "<t>.<raw body>")`. Verify it on the **raw** body, reject a `t` older
than 5 minutes, and answer 2xx quickly. Failed deliveries are retried for about 9 hours.
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
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"));
}
```
Test vector (https://talopartnerhub.vercel.app/docs/webhooks.md): secret `whsec_test_secret`, t `1793000000`, the body shown there → v1 `ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e`.
Before acting on an event, re-read the payment with `GET /api/v1/payments/{id}`: deliveries are
retried independently and can arrive out of order.
Body: `{ id, type, created_at, data: { payment, previous_status } }`. Types: `payment.created`,
`payment.updated`, `payment.paid` (SUCCESS or OVERPAID: fulfil the order), `payment.expired`,
`account.updated`. Statuses: `PENDING`, `SUCCESS`, `OVERPAID`, `UNDERPAID` (paid less: ask for
the rest, don't fulfil), `EXPIRED`. Handle each event idempotently by `id`.
## 5. Test end to end in sandbox
1. Create a payment (step 2).
2. `npx talo-hub sandbox pay <payment_id>` (or the `simulate_transfer` tool, or
`POST /api/v1/sandbox/transfers { payment_id, amount? }`). Pass a smaller `--amount` to test
UNDERPAID, a larger one for OVERPAID.
3. The webhook gets `payment.updated` and `payment.paid`. During local development,
`npx talo-hub events listen --forward-to http://localhost:3000/api/webhooks/talo` relays events
to localhost. They are unsigned and carry `talo-hub-forwarded: 1`: accept those only when
`NODE_ENV=development`.
## Other operations
| | |
|---|---|
| Refund | `POST /api/v1/payments/{id}/refunds` with `{ amount }` (partial) or no body (full). Asynchronous |
| Change amount | `PUT /api/v1/payments/{id}/price { amount }` |
| Extend | `PUT /api/v1/payments/{id}/expiration { expires_at }` (only PENDING) |
| Look up | `GET /api/v1/payments/{id}`, `GET /api/v1/payments?from=&to=` |
| Payment links | `GET /api/v1/payment-links`, `GET /api/v1/payment-links/{id}` (with its payments), `DELETE /api/v1/payment-links/{id}` (for good) |
| Balance, movements | `GET /api/v1/balance`, `GET /api/v1/transactions` (501 `talo_partner_unsupported` until Talo enables them for partners) |
Errors are `{ "error": { "code", "message" } }`. `talo_reconnect_required` means the account
owner has to run `npx talo-hub login` again (`--production` for a `tph_live_` key).
Before going live: switch to a `tph_live_` key, register the production webhook, and make one
small real transfer.
Probar en sandbox y pasar a producción
El agente prueba el flujo completo en sandbox, maneja las credenciales de los dos entornos con seguridad y lleva la integración a producción.
---
name: talo-go-live
description: Take a Talo Pay integration from zero to production through the Talo Partner Hub — test the whole flow in sandbox autonomously (payments, simulated transfers, underpaid and overpaid cases, signed webhooks, pings), handle sandbox and production credentials safely (scoped keys, env vars, rotation), and run the go-live checklist before charging real money. Use when the user wants to test Talo payments, set up Talo keys or webhooks, or launch Talo in production.
---
# From sandbox to production with the Talo Partner Hub
Hub: `https://talopartnerhub.vercel.app` · docs for agents: `https://talopartnerhub.vercel.app/docs/index.md`
(every page also as Markdown: `/docs/<page>.md`, or the MCP tool `read_docs`).
## 0. Credentials, before anything
- Two environments, two kinds of key: `tph_test_…` (sandbox, test money) and `tph_live_…` (production, real money).
- Three scopes: `read` (look), `charge` (read + create payments + sandbox transfers + webhook pings), `full` (also refunds, price changes, keys, webhooks). Use the narrowest: **checkout servers get `charge`**.
- Best way to connect yourself: the MCP server with OAuth. `claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp`, then authenticate; the person picks the environment and scope in the browser, and the key never passes through the conversation.
- Otherwise `npx talo-hub login` (sandbox) / `npx talo-hub login --production`. The person must authorize Talo once per environment; you can't do that step for them. Tell them exactly what to click, then wait for the command. `npx talo-hub login` waits until the person authorizes and then exits by itself (0 = connected): that exit is your signal, so don't ask them whether they authorized. Show them the link and code it prints; they compare the code in their browser. If your tool notifies you when a background command exits (Claude Code's background commands do), run it in the background and continue when that notice arrives; otherwise run it in the foreground with a timeout of at least 10 minutes. If it gets cut off, run the same command again: it picks up the same login, with the same link and code. If it expired, running it again checks once whether the person finished in Talo anyway (and collects the key), and otherwise starts a new login with a new link and code.
- Project keys: `npx talo-hub keys create --name <project> --scope charge --dotenv .env.local` writes `TALO_HUB_API_KEY` into that file, and `--vercel production|preview[:<branch>]` stores it as a Vercel Secret; either way it never appears on screen. (MCP `create_api_key` returns it: write it straight to the env file.) Never into code, git, logs or the chat. Check `.env*` is gitignored.
- Sandbox key in development/preview environments, production key only in production. Never mix them.
- Rotation: create the new key, deploy it, then revoke the old one (`npx talo-hub keys revoke <id>`). Revoke immediately if one leaks.
## 1. Test everything in sandbox yourself
No real money moves in sandbox: run these without asking.
1. `create_payment { amount: 15000, external_id: "test-<n>" }` (or `POST /api/v1/payments`).
2. `simulate_transfer { payment_id, amount: 14999 }` → `get_payment` shows **UNDERPAID**: the code must not fulfil.
3. `simulate_transfer { payment_id, amount: 1 }` → **SUCCESS**, events `payment.updated` + `payment.paid` (`list_events`).
4. New payment, `simulate_transfer` with more than the price → **OVERPAID** (counts as paid).
5. Webhooks: register the project's https endpoint (`create_webhook_endpoint`, or `npx talo-hub webhooks add <url> --dotenv <file> | --vercel <env>`, which first checks the URL answers without redirects or Vercel's login), store the `whsec_` secret as `TALO_HUB_WEBHOOK_SECRET`, then `test_webhook { endpoint_id }`; it must report `delivered: true`. If it fails, fix the handler (raw-body HMAC verification, 2xx answer) and ping again.
6. Locally, relay events with `npx talo-hub events listen --forward-to http://localhost:3000/api/webhooks/talo`. Relayed events are unsigned and carry `talo-hub-forwarded: 1`: accept those only in development.
7. Refund (needs `full`): `refund_payment { payment_id }` and check it in `get_payment`.
8. `check_go_live` (or `npx talo-hub doctor`, `GET /api/v1/readiness`) until `ready: true`. Fix each failed check with its `fix` text.
## 2. Go to production
Ask the person before each production step, saying what will happen.
1. Production authorization: they run `npx talo-hub login --production` or re-authorize the MCP choosing **Producción**. New Talo accounts can take up to 36 h to be ACTIVE; until then no CVUs.
2. Production key with scope `charge` for the server, into the production environment only.
3. Register the production webhook URL, store its new secret in production, ping it with `test_webhook` / `npx talo-hub webhooks test <id> --production`.
4. One small real payment: create it, the person transfers from their bank, confirm SUCCESS and that the order was fulfilled once.
5. `check_go_live` with the production key → ready. Follow its `recommendations` (for example, replace full keys with charge).
## Rules in production
- Before `refund_payment`, `update_payment_price` or creating a production key: confirm with the person, naming the payment and the amount.
- Never treat UNDERPAID as paid. EXPIRED can't be paid: create a new payment.
- `talo_reconnect_required` means the person must authorize again; `insufficient_scope` means this key can't do that, so ask for a key with the needed scope instead of working around it.
Operar la cuenta Talo
El agente consulta pagos, reembolsa, revisa saldo y movimientos, y prueba flujos en sandbox con el CLI o el MCP.
---
name: talo-operations
description: Operate a Talo Pay account through the Talo Partner Hub — look up and list payments, refund (full or partial), change amounts or extend expirations, check balance and bank movements, follow events, and run sandbox payment tests with simulated transfers. Use when the user asks about their Talo payments, a buyer's transfer, a refund, their Talo balance, or wants to test a payment flow.
---
# Operating a Talo account through the Talo Partner Hub
Two ways in, same operations. Prefer the MCP tools when they are connected; otherwise use the
`talo-hub` CLI (`npx talo-hub …`). Both act on the account the key belongs to:
- `tph_test_…` → Talo **sandbox**: test money, transfers can be simulated.
- `tph_live_…` → Talo **production**: real money. Confirm with the user before any refund or
amount change, stating the payment and the amount.
Never run `npx talo-hub disconnect` or `DELETE /api/v1/account` unless the user asks for it in
this conversation: it revokes every key and webhook of that environment and ends its dashboard
sessions.
## Connect
- MCP with OAuth (best): `claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp`,
then authenticate. The user picks sandbox or production and the permissions in the browser; no
key goes through the conversation. The tools you see are the ones that key may use.
- CLI: `npx talo-hub login` (sandbox) or `npx talo-hub login --production`. It opens the browser;
the user authorizes in Talo and confirms the code shown in the terminal. `npx talo-hub login` waits until the person authorizes and then exits by itself (0 = connected): that exit is your signal, so don't ask them whether they authorized. Show them the link and code it prints; they compare the code in their browser. If your tool notifies you when a background command exits (Claude Code's background commands do), run it in the background and continue when that notice arrives; otherwise run it in the foreground with a timeout of at least 10 minutes. If it gets cut off, run the same command again: it picks up the same login, with the same link and code. If it expired, running it again checks once whether the person finished in Talo anyway (and collects the key), and otherwise starts a new login with a new link and code. `npx talo-hub mcp`
prints the MCP setup (OAuth). `--with-key` is for clients without OAuth: for Claude Code it
prints a command that reads `$TALO_HUB_API_KEY` (PowerShell: `$env:TALO_HUB_API_KEY`), so set it
first; for `--client cursor` / `desktop` it embeds the active key.
- `get_account` / `npx talo-hub status` shows the account, environment and the key's scope
(`read`, `charge` or `full`). `insufficient_scope` means ask for a key with more permissions.
- Docs from inside the conversation: `read_docs { page }`.
## Tasks
| The user wants | MCP tool | CLI |
|---|---|---|
| Charge someone | `create_payment` | `payments create --amount <ARS> --motive "…"` |
| A link to share (chat, Instagram, a QR), each buyer pays their own | `create_payment_link` | `links create --title "…" --amount <ARS>` (or `--min`/`--max` for an amount the buyer types) |
| See or stop links | `list_payment_links`, `get_payment_link`, `disable_payment_link` | `links list`, `links get <id>`, `links disable <id>` |
| Know whether a buyer paid | `get_payment` | `payments get <id>` |
| See recent payments | `list_payments` | `payments list --from <date>` |
| Refund | `refund_payment` (omit amount for full) | `payments refund <id> [--amount <ARS>]` |
| Fix the amount | `update_payment_price` | API `PUT /api/v1/payments/{id}/price` |
| Give more time | `extend_payment` | API `PUT /api/v1/payments/{id}/expiration` |
| Balance and movements | `get_balance`, `list_transactions` | `balance`, `transactions` |
| What happened lately | `list_events` | `events list`, `events listen` |
| Test a payment (sandbox) | `simulate_transfer` | `sandbox pay <id> [--amount <ARS>]` |
Give the buyer the **CVU or alias and the exact amount**. A payment is done when its status is
`SUCCESS` (or `OVERPAID`). `UNDERPAID` means the buyer sent less: tell the user how much is
missing (`amount - paid`) instead of treating it as paid. `EXPIRED` can't be paid any more:
create a new payment.
Refunds are asynchronous: they start `PENDING`; check again with `get_payment` after a minute.
Only one refund per payment can be pending at a time.
## Testing a flow in sandbox
1. `create_payment` with an amount (for example 15000).
2. `simulate_transfer` with the payment ID: the full price pays it; pass `amount` lower to get
`UNDERPAID`, higher for `OVERPAID`.
3. `get_payment` shows the new status a few seconds later, and `list_events` shows
`payment.updated` and `payment.paid`.
## Errors
Errors are `{ "error": { "code", "message" } }`.
- `talo_reconnect_required`: Talo no longer accepts the account's authorization; the user runs
`npx talo-hub login` again (`npx talo-hub login --production` for a `tph_live_` key), or
re-authenticates the MCP server.
- `sandbox_only`: transfers can only be simulated with a `tph_test_` key.
- `talo_rejected`: Talo refused the request; its reason is in `details.talo_message`
(for example `refund.already.pending`, `refund.amount.exceeds.payment.amount`).
Kits por stack
Mejores prácticas y código probado para cada stack. ¿El tuyo no está? Las reglas comunes alcanzan para que tu agente lo adapte.
Next.js + Vercel
Route handlers y Server Actions, webhook con body crudo, variables por entorno de Vercel, Deployment Protection, Firewall y Cron para conciliar.
Ver talo-stack-nextjs-vercel/SKILL.md
---
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.
Node.js (Express, Fastify, NestJS, Koa)
Cliente del hub, body crudo solo en la ruta del webhook, idempotencia en Postgres y un job de conciliación por cron. También Fastify, NestJS y Koa.
Ver talo-stack-node-express/SKILL.md
---
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.
Python (FastAPI, Django, Flask)
Cliente httpx2, el body crudo en FastAPI y Django, csrf_exempt, hmac.compare_digest, una tabla de eventos procesados y un comando de conciliación.
Ver talo-stack-python/SKILL.md
---
name: talo-stack-python
description: Talo Pay bank-transfer payments (Argentina, ARS, CVU/alias) through the Talo Partner Hub in Python 3.12+, with FastAPI as the main path, Django 5.2 LTS / 6.x as the second and a Flask note. Covers an httpx2 hub client with typed errors and safe retries, server-side checkout with external_id = order id and a polling order page, raw-body HMAC-signed webhooks (await request.body(), request.body + csrf_exempt, request.get_data()), durable exactly-once handling in Postgres (unique event ids and a fulfil-once conditional update in one transaction), reconciliation by cron (script or management command), deploy notes for Railway, Render, Fly.io and a VPS, and pytest tests plus the sandbox checklist. Use when the project uses FastAPI, Django or Flask and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---
# Talo Pay on python
This kit implements the 10-rule contract at https://talopartnerhub.vercel.app/docs/stacks.md in Python, on top of the core skills `talo-payments` (API, events) and `talo-go-live` (keys, sandbox plan, production). Hub: https://talopartnerhub.vercel.app (API facts: /docs/api.md, /docs/webhooks.md).
All code below ran (pytest, Django's test runner, a live uvicorn + mock hub) on Python 3.12 and 3.13, FastAPI 0.162 / Starlette 1.10, httpx2 2.16 (Pydantic's maintained continuation of httpx, same API; Starlette's TestClient prefers it), psycopg 3.3 + psycopg-pool 3.3, Django 6.1 and 5.2 LTS, django-tasks-db 0.13, Flask 3.1, Postgres 18.
## 1. Setup
```bash
pip install "fastapi[standard]" "psycopg[binary,pool]" httpx2 # FastAPI (brings uvicorn, python-dotenv)
pip install "django>=5.2" "psycopg[binary]" httpx2 dj-database-url django-tasks-db gunicorn # Django; on 5.2 also django-tasks
```
Django reads the URL with `DATABASES = {"default": dj_database_url.config(conn_max_age=600, conn_health_checks=True)}`.
```bash
TALO_HUB_API_KEY=tph_test_... # npx talo-hub keys create --name <project> --scope charge --dotenv .env
TALO_HUB_WEBHOOK_SECRET=whsec_... # printed once by: npx talo-hub webhooks add https://<host>/webhooks/talo
DATABASE_URL=postgresql://user:password@host:5432/shop
TALO_HUB_DEV_FORWARD=1 # local .env only (section 7)
```
Server-side only: `.env` locally (gitignore `.env` and `.env.*`; commit a value-less `.env.example`), the host's env vars when deployed; read with `os.environ`, never in templates, JS or logs. The browser only gets `FIELDS` (status, cvu, alias, amount, paid, expires_at).
## 2. Hub client (server-only)
`shop/talo_hub.py` (Django: `payments/talo_hub.py`; one file for every framework):
```python
"""Talo Partner Hub client + webhook verifier. Server-side only."""
import asyncio, hashlib, hmac, json, os, re, time
from decimal import Decimal
from urllib.parse import quote
import httpx2
HUB_URL = "https://talopartnerhub.vercel.app"
PAID = frozenset({"SUCCESS", "OVERPAID"}) # fulfil once; UNDERPAID is NOT paid
RANK = {"PENDING": 0, "UNDERPAID": 1, "EXPIRED": 2, "SUCCESS": 3, "OVERPAID": 4} # statuses only move forward
TIMEOUT = httpx2.Timeout(8.0, connect=3.0)
CREATE_TIMEOUT = httpx2.Timeout(25.0, connect=3.0) # Talo can take 15-20 s to create a payment
class TaloHubError(Exception): # the hub answered { "error": { code, message, details? } }
def __init__(self, status: int, code: str, message: str, details: dict | None = None):
super().__init__(f"Talo Partner Hub {status} {code}: {message}")
self.status, self.code, self.details = status, code, details or {}
def _request(method: str, path: str, body: dict | None = None, timeout: httpx2.Timeout = TIMEOUT) -> dict: # KeyError if no key
return {"method": method, "url": path, "json": body, "timeout": timeout, "headers": {"Authorization": f"Bearer {os.environ['TALO_HUB_API_KEY']}"}}
def _parse(res: httpx2.Response) -> dict:
try:
body = res.json()
except ValueError:
body = None
if res.is_success and isinstance(body, dict):
return body
err = (body.get("error") if isinstance(body, dict) else None) or {}
raise TaloHubError(res.status_code, err.get("code", "unknown"), err.get("message", res.reason_phrase), err.get("details"))
def _backoff(exc: Exception, attempt: int, attempts: int) -> float: # retry network errors and 502 only
if attempt == attempts or not (isinstance(exc, httpx2.TransportError) or getattr(exc, "status", 0) == 502):
raise exc
return 0.5 * 2 ** (attempt - 1)
class TaloHub: # Django, Flask, scripts: blocking
client = httpx2.Client
def __init__(self, transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None):
self.http = self.client(base_url=HUB_URL, timeout=TIMEOUT, transport=transport)
def _call(self, method: str, path: str, body: dict | None = None, attempts: int = 3, timeout: httpx2.Timeout = TIMEOUT) -> dict:
for attempt in range(1, attempts + 1):
try:
return _parse(self.http.request(**_request(method, path, body, timeout)))
except (httpx2.TransportError, TaloHubError) as exc:
time.sleep(_backoff(exc, attempt, attempts))
def create_payment(self, amount: Decimal, external_id: str, motive: str | None = None):
body = {"amount": float(amount.quantize(Decimal("0.01"))), "external_id": external_id} # same id on retry
return self._call("POST", "/api/v1/payments", body | {"motive": motive} if motive else body, timeout=CREATE_TIMEOUT)
def get_payment(self, payment_id: str, attempts: int = 3):
return self._call("GET", f"/api/v1/payments/{quote(payment_id, safe='')}", attempts=attempts)
class AsyncTaloHub(TaloHub): # FastAPI: same methods, awaited (`await hub.get_payment(...)`)
client = httpx2.AsyncClient
async def _call(self, method: str, path: str, body: dict | None = None, attempts: int = 3, timeout: httpx2.Timeout = TIMEOUT) -> dict:
for attempt in range(1, attempts + 1):
try:
return _parse(await self.http.request(**_request(method, path, body, timeout)))
except (httpx2.TransportError, TaloHubError) as exc:
await asyncio.sleep(_backoff(exc, attempt, attempts))
```
Retries only network errors (`httpx2.TransportError`, timeouts included) and `502 talo_unavailable`, with the same external_id, so a retried create returns the same payment. Anything else raises `TaloHubError` with `.status` / `.code` (`unauthorized`, `insufficient_scope`, `invalid_amount`, `talo_rejected` + `.details["talo_message"]`, `talo_reconnect_required`: the account owner runs `npx talo-hub login` again): log it, show the buyer a generic "try again". The webhook re-read uses `attempts=1` to answer within the hub's 10 s.
## 3. Checkout (FastAPI)
```python
import logging, os
from contextlib import asynccontextmanager
import httpx2
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from psycopg_pool import AsyncConnectionPool
from .payments import apply_payment, send_receipt
from .talo_hub import AsyncTaloHub, TaloHubError, read_event
log = logging.getLogger("talo")
FIELDS = ("status", "cvu", "alias", "amount", "paid", "expires_at") # all the browser ever gets
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncConnectionPool(os.environ["DATABASE_URL"], kwargs={"autocommit": True}) as pool:
app.state.pool, app.state.hub = pool, AsyncTaloHub()
yield
await app.state.hub.http.aclose()
app = FastAPI(lifespan=lifespan)
def external_id(order_id: int, attempt: int) -> str: # order-1001, then order-1001-2 after EXPIRED ...
return f"order-{order_id}" if attempt == 1 else f"order-{order_id}-{attempt}"
@app.post("/orders/{order_id}/pay")
async def pay(order_id: int, request: Request): # also check the order belongs to this session/user
pool, hub = request.app.state.pool, request.app.state.hub
async with pool.connection() as conn:
cur = await conn.execute("SELECT amount, payment_status, payment_attempt FROM orders WHERE id = %s", (order_id,))
if (order := await cur.fetchone()) is None:
raise HTTPException(404, "order not found")
amount, status, attempt = order # the amount comes from YOUR database, never from the request
attempt += status == "EXPIRED" # an expired payment can't be paid: new payment, new external_id
try:
p = await hub.create_payment(amount, external_id(order_id, attempt), motive=f"Pedido #{order_id}")
if p["status"] == "EXPIRED": # the hub knew before its webhook reached us: next external_id now
attempt += 1
p = await hub.create_payment(amount, external_id(order_id, attempt), motive=f"Pedido #{order_id}")
except (TaloHubError, httpx2.HTTPError):
log.exception("could not create the Talo payment for order %s", order_id)
raise HTTPException(503, "No pudimos generar los datos de pago. Probá de nuevo en un momento.")
async with pool.connection() as conn:
await conn.execute(
"UPDATE orders SET payment_id = %s, payment_status = %s, payment_attempt = %s, cvu = %s, alias = %s,"
" expires_at = %s WHERE id = %s",
(p["id"], p["status"], attempt, p["cvu"], p["alias"], p["expires_at"], order_id),
)
return {k: p.get(k) for k in FIELDS}
@app.get("/orders/{order_id}/payment") # polled by the order page (same ownership check); never fulfils
async def payment_status(order_id: int, request: Request):
async with request.app.state.pool.connection() as conn:
cur = await conn.execute("SELECT payment_id FROM orders WHERE id = %s AND payment_id IS NOT NULL", (order_id,))
if (row := await cur.fetchone()) is None:
raise HTTPException(404, "no payment for this order")
try:
p = await request.app.state.hub.get_payment(row[0], attempts=1)
except (TaloHubError, httpx2.HTTPError):
raise HTTPException(503, "status unavailable, try again")
return {k: p.get(k) for k in FIELDS}
```
Order page (any template engine), polling every 3 s and backing off to 10 s, stopping at a final status:
```html
<p>Transferí <strong>{{ amount }}</strong> al CVU <code>{{ cvu }}</code> (alias <code>{{ alias }}</code>) antes del {{ expires_at }}.</p>
<p id="status">Esperando la transferencia…</p>
<script>
const ars = new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS" });
const show = (text) => (document.getElementById("status").textContent = text);
async function poll(orderId, delay = 3000) {
try {
const p = await (await fetch(`/orders/${orderId}/payment`, { cache: "no-store" })).json();
if (p.status === "UNDERPAID") show(`Recibimos ${ars.format(p.paid)}: faltan ${ars.format(p.amount - p.paid)}.`);
if (p.status === "SUCCESS" || p.status === "OVERPAID") return show("¡Pago recibido! Ya preparamos tu pedido.");
if (p.status === "EXPIRED") return show("El pago venció: generá uno nuevo."); // POST /orders/<id>/pay again
} catch {} // network blip: keep polling
setTimeout(() => poll(orderId, Math.min(delay * 1.5, 10000)), delay);
}
poll({{ order.id }});
</script>
```
After EXPIRED a "generar nuevo pago" button POSTs `/orders/<id>/pay` again: `payment_attempt` goes up and the external_id becomes `order-<id>-2`, also when the hub answers EXPIRED before its webhook arrived. If the expired payment had `paid > 0`, that money arrived: refund it (`POST /api/v1/payments/{id}/refunds`) or charge only `amount - paid`, never the full amount twice. The page only displays; the webhook and the reconciliation job fulfil, never the browser.
## 4. Webhook endpoint
End of `talo_hub.py`: the verifier and the framework-neutral first steps (verify, parse, ack non-payment events):
```python
_T, _V1 = re.compile(r"[0-9]{1,12}"), re.compile(r"[0-9a-f]{64}")
def verify_signature(raw_body: bytes, header: str | None, secret: str, *, now: float | None = None) -> bool:
"""talo-hub-signature: t=<unix>,v1=hex(HMAC-SHA256(secret, b"<t>." + raw body)); |now - t| <= 300 s."""
pairs = [part.strip().partition("=") for part in (header or "").split(",")]
fields = {key: value for key, _, value in pairs}
if len(fields) != len(pairs) or not _T.fullmatch(fields.get("t", "")) or not _V1.fullmatch(fields.get("v1", "")):
return False # missing, repeated or malformed t / v1 (v1 = 64 lowercase hex)
if abs((time.time() if now is None else now) - int(fields["t"])) > 300:
return False
expected = hmac.new(secret.encode(), fields["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, fields["v1"])
def accept_unsigned_forward(headers) -> bool: # `events listen --forward-to` doesn't sign: dev flag + sandbox key only
return (os.environ.get("TALO_HUB_DEV_FORWARD") == "1" and headers.get("talo-hub-forwarded") == "1"
and os.environ.get("TALO_HUB_API_KEY", "").startswith("tph_test_"))
def read_event(raw: bytes, headers) -> tuple[int, int | None, str | None]:
"""Verify + parse, for any framework. Returns (status, event_id, payment_id): answer `status` now unless it is 0."""
if not accept_unsigned_forward(headers):
if not (secret := os.environ.get("TALO_HUB_WEBHOOK_SECRET")):
return 500, None, None # misconfigured: a 5xx makes the hub retry later
if not verify_signature(raw, headers.get("talo-hub-signature"), secret):
return 400, None, None
try:
event = json.loads(raw)
event_id, event_type = int(event["id"]), str(event["type"])
payment_id = event["data"]["payment"]["id"] if event_type.startswith("payment.") else None
except (ValueError, KeyError, TypeError):
return 400, None, None
return (0 if payment_id else 200), event_id, payment_id # 200 now: webhook.test, account.updated
```
FastAPI: raw bytes → `read_event` → re-read with GET (503 on failure: the hub retries) → one transaction → 2xx → slow work after the response (`BackgroundTasks` is in-process; work that must survive a crash goes to a queue such as Celery, RQ or arq):
```python
@app.post("/webhooks/talo") # the exact path you register: no trailing slash, no auth dependency
async def talo_webhook(request: Request, background: BackgroundTasks):
raw = await request.body() # exact bytes: no Pydantic body model, no request.json() before this
status, event_id, payment_id = read_event(raw, request.headers)
if status: # 400 bad signature/body, 500 secret missing, 200 non-payment event
return JSONResponse({"received": status == 200}, status_code=status)
try:
payment = await request.app.state.hub.get_payment(payment_id, attempts=1) # fresh data, not the snapshot
except (TaloHubError, httpx2.HTTPError):
log.exception("re-read of %s failed; the hub will retry event %s", payment_id, event_id)
return JSONResponse({"received": False}, status_code=503)
async with request.app.state.pool.connection() as conn:
order_id = await apply_payment(conn, payment, event_id)
if order_id is not None: # fulfilled by THIS delivery
background.add_task(send_receipt, order_id) # runs after the response is sent
return {"received": True}
```
What breaks it in FastAPI:
- A Pydantic body model or `await request.json()` instead of `await request.body()` (the HMAC covers the exact bytes), or a middleware that rewrites/decompresses bodies.
- Auth in front of the path: router-level `dependencies=[Depends(...)]`, session/auth middleware. Declare the route on the app itself.
- `HTTPSRedirectMiddleware`: uvicorn trusts `X-Forwarded-Proto` only from `--forwarded-allow-ips` (default 127.0.0.1), so behind Railway/Render/Fly it sees http and answers 307. Leave it out (the platform already forces https) or set `--forwarded-allow-ips` to the proxy.
- `redirect_slashes` (default on): POST `/webhooks/talo/` gets a 307. Register exactly the declared path.
Django, `payments/views.py`. Below the webhook, `pay` and `payment_status` port the FastAPI routes 1:1, EXPIRED re-check included (`get_object_or_404`, `services.hub.create_payment(order.amount, ...)`, `Order.objects.filter(pk=order.pk).update(..., expires_at=parse_datetime(p["expires_at"]))`), and keep CSRF on. URLs: `path("webhooks/talo", views.talo_webhook)` (register exactly that, no trailing slash), `path("orders/<int:order_id>/pay", views.pay)`, `path("orders/<int:order_id>/payment", views.payment_status)`.
```python
import logging
import httpx2
from django.contrib.auth.decorators import login_not_required
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST
from . import services
from .models import Order
from .talo_hub import TaloHubError, read_event
log = logging.getLogger("talo")
FIELDS = ("status", "cvu", "alias", "amount", "paid", "expires_at") # all the browser ever gets
@csrf_exempt # the hub has no CSRF token: the HMAC signature is the authentication
@login_not_required # Django 5.1+: needed when LoginRequiredMiddleware is on
@require_POST
def talo_webhook(request):
status, event_id, payment_id = read_event(request.body, request.headers) # request.body = the raw bytes
if status: # 400 bad signature/body, 500 secret missing, 200 non-payment event
return JsonResponse({"received": status == 200}, status=status)
try:
payment = services.hub.get_payment(payment_id, attempts=1) # fresh data, not the snapshot
except (TaloHubError, httpx2.HTTPError):
log.exception("re-read of %s failed; the hub will retry event %s", payment_id, event_id)
return JsonResponse({"received": False}, status=503)
services.apply_payment(payment, event_id) # commits; the receipt task is enqueued on commit
return JsonResponse({"received": True})
```
What breaks it in Django:
- No `@csrf_exempt` → 403 (`CsrfViewMiddleware`); with `LoginRequiredMiddleware` (5.1+) and no `@login_not_required` → 302 to login.
- `APPEND_SLASH`: pattern `webhooks/talo/` hit at `/webhooks/talo` raises RuntimeError under DEBUG and answers 301 in production, losing the POST. Pattern and registered URL must be identical.
- Anything that reads the stream first (`request.read()` in a middleware) makes `request.body` raise `RawPostDataException`.
- Deploy settings: the host in `ALLOWED_HOSTS` (else 400 `DisallowedHost`); with `SECURE_SSL_REDIRECT = True` also `SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")`, or every delivery gets a 301.
Flask 3.1: in a `@app.post("/webhooks/talo")` view call `read_event(request.get_data(), request.headers)` (`get_data()` is the raw body), decorate it with `@csrf.exempt` if Flask-WTF's `CSRFProtect` is on, then follow the Django view with the blocking `TaloHub` and a sync copy of `apply_payment` (psycopg `Connection`, same SQL, `with conn.transaction():`).
## 5. Durable storage
`schema.sql` (psycopg path; run it from your migration tool). Money is `numeric(12, 2)` → `Decimal`, never float:
```sql
CREATE TABLE IF NOT EXISTS orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount numeric(12, 2) NOT NULL CHECK (amount > 0), -- ARS, set by your server from the cart
payment_id text UNIQUE, -- "PAR-talo-hub-<external_id>"
payment_status text, -- PENDING | UNDERPAID | SUCCESS | OVERPAID | EXPIRED
payment_attempt integer NOT NULL DEFAULT 1, -- external_id = order-<id>, then order-<id>-2 ...
cvu text,
alias text,
expires_at timestamptz,
fulfilled_at timestamptz, -- set exactly once
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS orders_unfulfilled ON orders (created_at) WHERE fulfilled_at IS NULL;
CREATE TABLE IF NOT EXISTS talo_events (
event_id bigint PRIMARY KEY, -- the webhook body's integer "id"
received_at timestamptz NOT NULL DEFAULT now()
);
```
`shop/payments.py`, shared by the webhook and the reconciliation job:
```python
import logging
from psycopg import AsyncConnection
from .talo_hub import PAID, RANK
log = logging.getLogger("talo")
async def apply_payment(conn: AsyncConnection, payment: dict, event_id: int | None = None) -> int | None:
"""Webhook + reconciliation; `payment` was just re-read with GET. Returns the order id if fulfilled NOW."""
async with conn.transaction(): # ONE transaction: event id + status + fulfilment
if event_id is not None:
cur = await conn.execute("INSERT INTO talo_events (event_id) VALUES (%s) ON CONFLICT DO NOTHING", (event_id,))
if cur.rowcount == 0:
return None # already handled: commit, answer 2xx
status = payment["status"]
await conn.execute( # forward only: a slow, older re-read can't move SUCCESS back to PENDING
"UPDATE orders SET payment_status = %s WHERE payment_id = %s AND payment_status = ANY(%s)",
(status, payment["id"], [s for s, rank in RANK.items() if rank < RANK.get(status, -1)]),
)
if status not in PAID:
return None # UNDERPAID (the buyer transfers the rest), PENDING, EXPIRED: never fulfil
cur = await conn.execute(
"UPDATE orders SET fulfilled_at = now() WHERE payment_id = %s AND fulfilled_at IS NULL RETURNING id",
(payment["id"],),
)
return row[0] if (row := await cur.fetchone()) else None
async def send_receipt(order_id: int) -> None: # slow side effects: email, stock, shipping
log.info("order %s paid: email the buyer, reserve stock, ship", order_id)
```
Django, same logic with the ORM: `get_or_create` on the event's primary key absorbs the concurrent-duplicate IntegrityError, and `transaction.on_commit` enqueues the receipt only if the commit happened. `payments/models.py` mirrors `schema.sql`: `Order` with `amount = DecimalField(max_digits=12, decimal_places=2)` (never FloatField), `payment_id = CharField(max_length=150, unique=True, null=True)`, `payment_status` (`max_length=10`, default `""`), `payment_attempt` (default 1), `cvu`, `alias`, `expires_at`, `fulfilled_at` (null), `created_at` (`auto_now_add`); and `TaloEvent` with `event_id = BigIntegerField(primary_key=True)`: that constraint IS the dedupe. Add `"django_tasks_db"` (on 5.2 also `"django_tasks"`) and the app to `INSTALLED_APPS`, set `TASKS = {"default": {"BACKEND": "django_tasks_db.DatabaseBackend"}}`, `makemigrations` + `migrate`. `payments/services.py`:
```python
import logging
from functools import partial
from django.db import transaction
from django.utils import timezone
try:
from django.tasks import task # Django 6.x
except ImportError: # Django 5.2 LTS: pip install django-tasks
from django_tasks import task
from .models import Order, TaloEvent
from .talo_hub import PAID, RANK, TaloHub
hub = TaloHub() # one per process
@task
def send_receipt(order_id: int) -> None: # slow side effects run in the worker: email, stock, shipping
logging.getLogger("talo").info("order %s paid: email the buyer, reserve stock, ship", order_id)
def apply_payment(payment: dict, event_id: int | None = None) -> int | None:
"""Webhook + reconciliation; `payment` was just re-read with GET. Returns the order id if fulfilled NOW."""
with transaction.atomic(): # ONE transaction: event id + status + fulfilment
if event_id is not None:
_, created = TaloEvent.objects.get_or_create(event_id=event_id) # handles the IntegrityError race
if not created:
return None # already handled: commit, answer 2xx
status, orders = payment["status"], Order.objects.filter(payment_id=payment["id"])
orders.filter(payment_status__in=[s for s, r in RANK.items() if r < RANK.get(status, -1)]).update(payment_status=status)
if status not in PAID or not orders.filter(fulfilled_at__isnull=True).update(fulfilled_at=timezone.now()):
return None # UNDERPAID (never fulfil), or fulfilled before
order_id = orders.values_list("pk", flat=True).get()
transaction.on_commit(partial(send_receipt.enqueue, order_id)) # enqueued only if the COMMIT happens
return order_id
```
Never keep handled event ids or "fulfilled" flags in a set, dict, `lru_cache` or module global: each gunicorn/uvicorn worker, restart and machine has its own copy, so a retried event is processed twice or a fulfilment is lost. SQLite: tests only.
## 6. Reconciliation
FastAPI, `shop/reconcile.py`:
```python
async def reconcile(hub: AsyncTaloHub, conn: psycopg.AsyncConnection) -> int:
cur = await conn.execute( # PENDING, UNDERPAID, or paid but not fulfilled; 15 days > the longest expiry (14)
"SELECT payment_id FROM orders WHERE payment_id IS NOT NULL AND fulfilled_at IS NULL AND payment_status <> 'EXPIRED'"
" AND created_at > now() - interval '15 days'"
)
fulfilled = 0
for (payment_id,) in await cur.fetchall():
try:
payment = await hub.get_payment(payment_id)
except (TaloHubError, httpx2.HTTPError):
logging.exception("reconcile: could not read %s", payment_id)
continue
if (order_id := await apply_payment(conn, payment)) is not None: # same logic as the webhook
await send_receipt(order_id)
fulfilled += 1
return fulfilled
async def main() -> None:
hub = AsyncTaloHub()
async with await psycopg.AsyncConnection.connect(os.environ["DATABASE_URL"], autocommit=True) as conn:
logging.info("reconcile: fulfilled %s orders", await reconcile(hub, conn))
await hub.http.aclose()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop) # psycopg async needs a selector loop (Windows)
```
Django, `payments/management/commands/talo_reconcile.py`: a `BaseCommand` whose `handle` takes the same orders (`Order.objects.filter(payment_id__isnull=False, fulfilled_at__isnull=True, created_at__gte=timezone.now() - timedelta(days=15)).exclude(payment_status="EXPIRED")`) and runs `services.apply_payment(services.hub.get_payment(pid))` for each inside `try/except (TaloHubError, httpx2.HTTPError)`, so one failing read doesn't stop the rest; receipts go through `on_commit` + `db_worker` as in the webhook. Run it as `python manage.py talo_reconcile`.
Every 10–15 min (`*/10 * * * *`, UTC), one run at a time:
- Railway: a second service from the repo with Settings → Cron Schedule; start command `python -m shop.reconcile` or `python manage.py talo_reconcile` (it must exit; runs ≥ 5 min apart).
- Render: a Cron Job with that command and the web service's environment group.
- Fly.io: a process group `cron = "supercronic /app/crontab"`, then `fly scale count cron=1`.
- VPS: crontab or a systemd timer with the app's environment.
## 7. Local development
```bash
uvicorn shop.main:app --env-file .env --port 8000 # Windows: add --loop asyncio:SelectorEventLoop (psycopg async)
python -m dotenv -f .env run -- python manage.py runserver 8000 # Django, needs "python-dotenv[cli]"; plus manage.py db_worker
npx talo-hub events listen --forward-to http://localhost:8000/webhooks/talo
```
Relayed events are unsigned and carry `talo-hub-forwarded: 1`; `accept_unsigned_forward` takes them only with `TALO_HUB_DEV_FORWARD=1` **and** a `tph_test_` key. Never set the flag when deployed (a forged event can at worst trigger a re-read).
## 8. Deploy
- Start: `uvicorn shop.main:app --host 0.0.0.0 --port $PORT --workers 2` (Fly: `PORT` must equal fly.toml's `internal_port`; set it in `[env]`). Django: `gunicorn shop.wsgi --bind 0.0.0.0:$PORT --workers 3`, `python manage.py migrate` as the pre-deploy/release command, `python manage.py db_worker` as its own worker service or process.
- Railway, Render and Fly.io give a public https URL. Register the final one (custom domain, no www / http→https / slash redirect) with no basic auth, IP allowlist, bot challenge or preview protection in front of `/webhooks/talo`.
- VPS: app on 127.0.0.1 behind nginx with a valid certificate; `location = /webhooks/talo { proxy_pass http://127.0.0.1:8000; }` without `auth_basic` or `return` (uvicorn trusts nginx's `X-Forwarded-*` from 127.0.0.1 by default).
- Per environment: dev/staging get the `tph_test_` key and their own endpoint + `whsec_`; only production gets the `tph_live_` key and its endpoint's secret (`npx talo-hub login --production`, then `keys create ... --production` and `webhooks add <url> --production`). Never share a secret between environments.
## 9. Test it
`tests/test_verify.py` (pytest):
```python
import hashlib, hmac, time
import pytest
from shop.talo_hub import verify_signature # Django: from payments.talo_hub import verify_signature
SECRET, T = "whsec_test_secret", 1793000000
BODY = b'{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}'
V1 = "ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e"
def test_vector():
assert verify_signature(BODY, f"t={T},v1={V1}", SECRET, now=T) # now= pins the clock for the fixed vector
assert verify_signature(BODY, f"t={T},v1={V1}", SECRET, now=T + 300)
@pytest.mark.parametrize("body,header,now", [
(BODY + b" ", f"t={T},v1={V1}", T), # tampered body
(BODY, f"t={T},v1={V1}", T + 301), (BODY, f"t={T},v1={V1}", T - 301), (BODY, f"t={T},v1={V1}", None), # stale t
(BODY, f"t={T},v1={V1[:-2]}", T), (BODY, f"t={T},v1={V1.upper()}", T), (BODY, f"t={T},v1={'z' * 64}", T), # bad v1
(BODY, f"t={T},v1={V1},v1={V1}", T), (BODY, f"v1={V1}", T), (BODY, "", T), (BODY, None, T), # bad/missing header
])
def test_rejects(body, header, now):
assert not verify_signature(body, header, SECRET, now=now)
def test_current_time_and_wrong_secret():
t = int(time.time())
v1 = hmac.new(SECRET.encode(), f"{t}.".encode() + BODY, hashlib.sha256).hexdigest()
assert verify_signature(BODY, f"t={t},v1={v1}", SECRET)
assert not verify_signature(BODY, f"t={t},v1={v1}", "whsec_other")
```
Route tests: inject `AsyncTaloHub(transport=httpx2.MockTransport(handler))` (Django: patch `services.hub` with `TaloHub(transport=...)`), sign bodies with the current time, use a real database, and assert: valid → 200 and fulfilled once; same event again → 200, not fulfilled twice; bad signature → 400, nothing stored; UNDERPAID → not fulfilled; failed re-read → 503 and the event id not stored. Tests never call the real hub.
Sandbox (`tph_test_` key, deployed or tunnelled https URL registered):
1. POST `/orders/<id>/pay`, then `npx talo-hub sandbox pay <payment_id>` → SUCCESS in ~10–25 s, fulfilled once, the page shows it.
2. New order, `npx talo-hub sandbox pay <id> --amount <less>` → UNDERPAID, not fulfilled, the page asks for the rest; pay the rest → fulfilled. Another with `--amount <more>` → OVERPAID, fulfilled.
3. The same event twice: locally (dev flag on), POST one event's body from `npx talo-hub events list --payment <id> --json` twice with `talo-hub-forwarded: 1` → 2xx both times, one fulfilment; signed duplicates are covered by the route tests.
4. `npx talo-hub webhooks test <id>` → delivered; `npx talo-hub doctor` → ready.
## Checklist
- [ ] `TALO_HUB_API_KEY` (scope charge) and `TALO_HUB_WEBHOOK_SECRET` only in server env; `.env*` gitignored
- [ ] Payment created on the server from the order's `Decimal`; `external_id` = order id, `-2`, `-3`… after EXPIRED
- [ ] Webhook verifies the raw bytes (`await request.body()` / `request.body` / `request.get_data()`): 300 s, 64-hex v1, `compare_digest`, 400
- [ ] Webhook path outside CSRF, login and auth dependencies, no redirect; exact https URL registered
- [ ] Unsigned forwarded events only with `TALO_HUB_DEV_FORWARD=1` and a `tph_test_` key
- [ ] Payment re-read with GET before acting; 503 when that fails
- [ ] One transaction: unique event id, forward-only status, `fulfilled_at IS NULL` conditional update
- [ ] Slow work after the response (BackgroundTasks, `django.tasks` + `on_commit`, a queue)
- [ ] Reconciliation scheduled on the host, one instance
- [ ] Verifier test vector green; sandbox exact / less / more / duplicate / ping / doctor done
- [ ] `tph_live_` key and production secret only in production
PHP + Laravel
Cliente Http, $request->getContent(), el webhook fuera del CSRF, hash_equals, colas y el scheduler para conciliar.
Ver talo-stack-laravel/SKILL.md
---
name: talo-stack-laravel
description: Laravel kit for Talo Pay bank-transfer payments (Argentina, CVU/alias, ARS) through the Talo Partner Hub, tested on Laravel 13 and 12 with PHP 8.3+. Covers a server-only Http-facade client with typed errors and retries only on network errors and 502, checkout that creates the payment with external_id = order id and shows CVU, alias, amount and expiry with status polling, a raw-body signed webhook ($request->getContent(), hash_hmac + hash_equals) in the stateless api routes outside CSRF, durable idempotency (unique event ids with insertOrIgnore and a fulfil-once conditional update in one DB::transaction, a queued job), reconciliation with an Artisan command on the scheduler, deploy notes for Forge, Laravel Cloud, a VPS and Railway, and the sandbox test. Use when the project uses Laravel (PHP) and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---
# Talo Pay on Laravel
The Laravel code for the 10-rule contract at https://talopartnerhub.vercel.app/docs/stacks.md (hub: `https://talopartnerhub.vercel.app`, API `/docs/api.md`, webhooks `/docs/webhooks.md`). Keys, the full sandbox plan and going live: core skills `talo-payments` and `talo-go-live`. Everything below ran on Laravel 13.39 (current; PHP 8.3 and 8.5, SQLite and PostgreSQL 17) and 12.70 (PHP 8.3). Laravel 11 runs it too (CSRF syntax below) but is end of life since 2026-03-12.
## Setup
No Composer package: the `Http` facade, `hash_hmac`/`hash_equals`, queues and the scheduler ship with Laravel.
```bash
php artisan install:api # only if routes/api.php is missing (Laravel 11+); also installs Sanctum
npx talo-hub login # sandbox; the person authorizes Talo in the browser and it exits when they do
npx talo-hub keys create --name "<project>" --scope charge --dotenv .env # writes TALO_HUB_API_KEY, never printed
```
`.env` stays gitignored (Laravel's default); `.env.example` gets empty `TALO_HUB_API_KEY=`, `TALO_HUB_WEBHOOK_SECRET=`, `TALO_HUB_DEV_FORWARD=false`. Read them only in `config/services.php`: after `php artisan config:cache` (every production deploy) `env()` returns null anywhere else. Never pass them to Blade, Inertia/Livewire props, `VITE_*` or logs.
```php
'talo' => [
'key' => env('TALO_HUB_API_KEY'), // tph_test_… (sandbox) / tph_live_… (production)
'webhook_secret' => env('TALO_HUB_WEBHOOK_SECRET'), // whsec_… of THIS environment's endpoint
'accept_forwarded' => env('TALO_HUB_DEV_FORWARD', false), // local dev only
],
```
## Hub client (server only)
`app/Talo/TaloHubException.php`:
```php
namespace App\Talo;
class TaloHubException extends \RuntimeException
{
// $status: HTTP status, 0 = network error. $errorCode: error.code (invalid_amount, talo_unavailable…).
public function __construct(public readonly int $status, public readonly string $errorCode, string $message,
public readonly array $details = [], ?\Throwable $previous = null)
{
parent::__construct("Talo Partner Hub {$status} {$errorCode}: {$message}", 0, $previous);
}
}
```
`app/Talo/TaloHub.php`:
```php
namespace App\Talo;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class TaloHub // https://talopartnerhub.vercel.app/docs/api.md
{
/** The same external_id returns the same payment, so retrying never charges twice. */
public function createPayment(array $body): array
{
return $this->send(fn () => $this->http(tries: 3, timeout: 25)->post('/payments', $body)); // Talo can take 15-20 s
}
/** One attempt: a webhook must answer within 10 s, and the hub retries the delivery. */
public function getPayment(string $id): array
{
return $this->send(fn () => $this->http(tries: 1)->get('/payments/'.rawurlencode($id)));
}
private function http(int $tries, int $timeout = 8): PendingRequest
{
$key = (string) config('services.talo.key');
throw_unless(str_starts_with($key, 'tph_test_') || str_starts_with($key, 'tph_live_'),
new TaloHubException(0, 'not_configured', 'TALO_HUB_API_KEY is missing or not a tph_ key'));
return Http::baseUrl('https://talopartnerhub.vercel.app/api/v1')->withToken($key)->acceptJson()
->connectTimeout(3)->timeout($timeout)
// Retry only network errors and 502 talo_unavailable; a 4xx is final.
->retry($tries, fn (int $attempt) => 400 * $attempt, fn (\Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->status() === 502), throw: false);
}
private function send(callable $request): array
{
try {
$response = $request();
} catch (ConnectionException $e) {
throw new TaloHubException(0, 'network_error', $e->getMessage(), previous: $e);
}
if ($response->failed()) { // { "error": { "code", "message", "details"? } }
throw new TaloHubException($response->status(), (string) $response->json('error.code', 'unknown'),
(string) $response->json('error.message', $response->reason()), (array) $response->json('error.details', []));
}
return $response->json();
}
}
```
By `errorCode`: `unauthorized` (401), `insufficient_scope`/`sandbox_only` (403) mean a wrong key, never retried; `talo_reconnect_required` (409): the account owner runs `npx talo-hub login` again (`--production` for a live key); `invalid_amount`/`invalid_field` (422) and `talo_rejected` (`details['talo_message']`) are your request; `payment_not_found` (404) on a read usually means a key from the other environment. Only `network_error` (0) and `talo_unavailable` (502) retry, with the same external_id.
## Checkout
`routes/web.php` (normal web routes; the buyer's form keeps its CSRF token):
```php
Route::post('/orders/{order}/pay', [TaloCheckoutController::class, 'store'])->name('orders.pay');
Route::get('/orders/{order}/payment', fn (Order $order) => view('orders.payment', ['order' => $order]))->name('orders.payment');
Route::get('/orders/{order}/payment-status', [TaloCheckoutController::class, 'status'])->name('orders.payment.status');
```
`app/Http/Controllers/TaloCheckoutController.php`: `external_id` is `order-<id>`, then `order-<id>-2`… only after EXPIRED; the amount comes from the order row, minus what an expired payment already received (that money arrived: never ask for the total twice).
```php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Talo\PaymentSync;
use App\Talo\TaloHub;
use App\Talo\TaloHubException;
use Illuminate\Support\Facades\DB;
class TaloCheckoutController // authorize all three like your other order pages (policy, signed URL…)
{
public function store(Order $order, TaloHub $hub)
{
// Under a row lock: a double click reuses the external_id; after EXPIRED a new one is used.
[$externalId, $amount] = DB::transaction(function () use ($order) {
$o = Order::whereKey($order->getKey())->lockForUpdate()->firstOrFail();
if ($o->payment_external_id === null || $o->payment_status === 'EXPIRED') {
$attempt = $o->payment_attempt + 1;
// Money sent to the expired payment did arrive: the new one asks only for the rest.
$amount = ($o->payment_amount ?? $o->total) - ($o->payment_status === 'EXPIRED' ? $o->paid_amount : 0);
$o->forceFill(['payment_attempt' => $attempt, 'payment_id' => null, 'payment_status' => null, 'paid_amount' => 0,
'payment_amount' => $amount, 'payment_external_id' => "order-{$o->id}".($attempt > 1 ? "-{$attempt}" : '')])->save();
}
return [$o->payment_external_id, round((float) ($o->payment_amount ?? $o->total), 2)]; // never from the request
});
try {
$payment = $hub->createPayment(['external_id' => $externalId, 'amount' => $amount, 'motive' => "Pedido #{$order->id}"]);
} catch (TaloHubException $e) {
report($e);
return back()->withErrors(['payment' => 'No pudimos generar los datos de pago. Probá de nuevo en un momento.']);
}
DB::transaction(fn () => PaymentSync::apply($payment)); // CVU, alias, expiry, status; a retry may return it paid
return redirect()->route('orders.payment', $order);
}
public function status(Order $order, TaloHub $hub) // for the buyer's screen only: never fulfils
{
if ($order->payment_id !== null && in_array($order->payment_status, ['PENDING', 'UNDERPAID'], true)) {
rescue(fn () => $hub->getPayment($order->payment_id), report: false); // brings the hub's events, so our webhook, up to date
}
// The STORED status: the page renders it, so answering the hub's would reload it in a loop until the webhook lands.
return response()->json(['status' => $order->fresh()->payment_status]);
}
}
```
Cast `total`/`payment_amount`/`paid_amount` to `decimal:2` and `payment_expires_at`/`fulfilled_at` to `datetime` in the Order model. `resources/views/orders/payment.blade.php` shows CVU, alias, exact amount and expiry, asks for the rest when UNDERPAID, and polls only to refresh the screen (3 s backing off to 10 s, paused while the tab is hidden, stops on the final page):
```blade
@php($ars = fn ($n) => '$ '.number_format((float) $n, 2, ',', '.'))
<h1>Pedido #{{ $order->id }}</h1>
@if (in_array($order->payment_status, ['SUCCESS', 'OVERPAID'], true))
<p>¡Pago recibido! Estamos preparando tu pedido.</p>
@elseif (in_array($order->payment_status, [null, 'EXPIRED'], true))
<p>@error('payment') {{ $message }} @else Los datos de pago vencieron o no se generaron. @enderror</p>
<form method="POST" action="{{ route('orders.pay', $order) }}">@csrf <button>Generar datos de pago</button></form>
@else
<p>Transferí exactamente <strong>{{ $ars($order->payment_amount) }}</strong> al CVU <code>{{ $order->payment_cvu }}</code> (alias
<code>{{ $order->payment_alias }}</code>) antes del {{ $order->payment_expires_at->timezone('America/Argentina/Buenos_Aires')->format('d/m/Y H:i') }}.</p>
@if ($order->payment_status === 'UNDERPAID')
<p>Recibimos {{ $ars($order->paid_amount) }}: transferí los {{ $ars($order->payment_amount - $order->paid_amount) }} que faltan.</p>
@endif
<script>
(() => {
const url = @json(route('orders.payment.status', $order)), shown = @json($order->payment_status);
let delay = 3000; // 3 s, backing off to 10 s; the server renders each new state
const poll = async () => {
try {
const res = document.hidden ? null : await fetch(url, { headers: { Accept: 'application/json' } });
if (res?.ok && (await res.json()).status !== shown) return location.reload();
} catch {}
setTimeout(poll, (delay = Math.min(delay * 1.5, 10000)));
};
setTimeout(poll, delay);
})();
</script>
@endif
```
## Webhook endpoint
`Route::post('/webhooks/talo', TaloWebhookController::class);` in `routes/api.php` → `POST /api/webhooks/talo`, in the stateless `api` group (no session, cookies or CSRF). Register it per environment: `npx talo-hub webhooks add https://<host>/api/webhooks/talo` (checks the URL, prints the `whsec_…` once; `--dotenv <file>` writes it as `TALO_HUB_WEBHOOK_SECRET`). What breaks it in Laravel:
- Verifying `json_encode($request->all())` or `$request->json()`: re-encoding changes the bytes (`\/`, unicode escapes, `15000.0`). Verify `$request->getContent()` (TrimStrings/ConvertEmptyStringsToNull only touch input).
- A route in `routes/web.php` gets CSRF (419) and a session. If it must live there, exclude it in `bootstrap/app.php` `withMiddleware()`: Laravel 13 `$middleware->preventRequestForgery(except: ['webhooks/talo']);`, 11/12 `$middleware->validateCsrfTokens(except: ['webhooks/talo']);` (a deprecated alias in 13). Tested: 200 excluded, 419 not. The URL check of `webhooks add` refuses a 419 (verdict `csrf`), and `webhooks test` shows it too.
- `auth`, `auth:sanctum`, `verified`, `signed`, `throttle` (`$middleware->throttleApi()`) or any redirect (login, http→https, www) on this path. Apache's default `public/.htaccess` 301-redirects a trailing slash: register the URL exactly as routed.
`app/Talo/WebhookSignature.php` (constant time, hex check, 300 s window):
```php
namespace App\Talo;
// talo-hub-signature: t=<unix seconds>,v1=<64 lowercase hex>; v1 = HMAC-SHA256(secret, "<t>.<raw body>")
final class WebhookSignature
{
public static function verify(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', (string) $header) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, null);
if ($value === null || isset($parts[$key])) {
return false; // malformed, or t / v1 repeated
}
$parts[$key] = $value;
}
[$t, $v1] = [$parts['t'] ?? '', $parts['v1'] ?? ''];
if (! ctype_digit($t) || abs(($now ?? time()) - (int) $t) > 300 || preg_match('/^[0-9a-f]{64}\z/', $v1) !== 1) {
return false;
}
return hash_equals(hash_hmac('sha256', "{$t}.{$rawBody}", $secret), $v1); // constant time
}
}
```
`app/Http/Controllers/TaloWebhookController.php`: verify → parse → 2xx for non-payment events and known event ids → GET the payment (503 if that fails, so the hub retries) → one transaction: insert the event id + apply → 200. The worst case is one 8 s hub read, inside the 10 s budget.
```php
namespace App\Http\Controllers;
use App\Talo\PaymentSync;
use App\Talo\TaloHub;
use App\Talo\TaloHubException;
use App\Talo\WebhookSignature;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TaloWebhookController
{
public function __invoke(Request $request, TaloHub $hub)
{
$raw = $request->getContent(); // the exact signed bytes; never re-encode $request->all()
// `talo-hub events listen --forward-to` relays events unsigned, with talo-hub-forwarded: 1.
$forwardedInDev = config('services.talo.accept_forwarded') === true && app()->isLocal()
&& $request->header('talo-hub-forwarded') === '1';
if (! $forwardedInDev) {
$secret = (string) config('services.talo.webhook_secret');
throw_if($secret === '', new \RuntimeException('TALO_HUB_WEBHOOK_SECRET is not set')); // 500, logged, retried
if (! WebhookSignature::verify($raw, $request->header('talo-hub-signature'), $secret)) {
return response()->json(['error' => 'invalid signature'], 400);
}
}
$event = json_decode($raw, true);
if (! is_array($event) || ! is_int($event['id'] ?? null) || ! is_string($event['type'] ?? null)) {
return response()->json(['error' => 'invalid event'], 400);
}
$paymentId = $event['data']['payment']['id'] ?? null;
if (! str_starts_with($event['type'], 'payment.') || ! is_string($paymentId)) {
return response()->json(['received' => true]); // webhook.test, account.updated
}
if (DB::table('talo_webhook_events')->where('id', $event['id'])->exists()) {
return response()->json(['received' => true, 'duplicate' => true]);
}
try {
$payment = $hub->getPayment($paymentId); // act on the current status, not the snapshot
} catch (TaloHubException $e) {
report($e);
return response()->json(['error' => 'could not read the payment'], 503); // the hub retries
}
DB::transaction(function () use ($event, $payment) {
$new = DB::table('talo_webhook_events')->insertOrIgnore(['id' => $event['id'],
'type' => $event['type'], 'payment_id' => $payment['id'], 'created_at' => now()]);
if ($new === 1) { // 0: a parallel delivery of this event got here first
PaymentSync::apply($payment);
}
});
return response()->json(['received' => true]);
}
}
```
`app/Talo/PaymentSync.php`, shared with checkout and reconciliation: status only moves forward; the conditional update fulfils once. It also stores CVU, alias and expiry, so an order whose create answer was lost still gets them from the webhook.
```php
namespace App\Talo;
use App\Jobs\FulfilOrder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
final class PaymentSync
{
// Statuses only move forward: an older read never overwrites a newer status.
private const RANK = ['PENDING' => 0, 'UNDERPAID' => 1, 'SUCCESS' => 2, 'EXPIRED' => 2, 'OVERPAID' => 3];
/** Applies a payment READ FROM THE HUB to its order. Call it inside DB::transaction(). */
public static function apply(array $payment): void
{
$rank = self::RANK[$payment['status']] ?? null;
if ($rank === null) {
return;
}
DB::table('orders')
->where('payment_external_id', $payment['external_id'])
->where(fn ($q) => $q->whereNull('payment_status')
->orWhereIn('payment_status', array_keys(array_filter(self::RANK, fn ($r) => $r <= $rank))))
->update(['payment_id' => $payment['id'], 'payment_status' => $payment['status'], 'payment_amount' => $payment['amount'],
'paid_amount' => $payment['paid'] ?? 0, 'payment_cvu' => $payment['cvu'], 'payment_alias' => $payment['alias'],
// in the app's timezone, which is how a timestamp column is read back
'payment_expires_at' => Carbon::parse($payment['expires_at'])->setTimezone(config('app.timezone')), 'updated_at' => now()]);
if (! in_array($payment['status'], ['SUCCESS', 'OVERPAID'], true)) {
return; // UNDERPAID is NOT paid: never fulfil it
}
// Fulfil once: only the update that flips fulfilled_at from NULL affects a row.
$won = DB::table('orders')->where('payment_id', $payment['id'])->whereNull('fulfilled_at')
->update(['fulfilled_at' => now()]);
if ($won === 1) {
// database queue: the job row commits with fulfilled_at. Redis/SQS: see below.
FulfilOrder::dispatch(DB::table('orders')->where('payment_id', $payment['id'])->value('id'));
}
}
}
```
`app/Jobs/FulfilOrder.php`: `php artisan make:job FulfilOrder` (`ShouldQueue`, constructor `public int $orderId`); emails, invoices and stock go in `handle()`, never in the request. With `QUEUE_CONNECTION=database` (the Laravel 11+ default) the job row commits atomically with `fulfilled_at` (tested: a rollback leaves neither); run `php artisan queue:work`. On Redis/SQS the push is outside the transaction: `->afterCommit()` avoids a job for a rolled-back fulfilment, but a crash right after the commit loses it, so keep this job on the database queue (`->onConnection('database')` plus a `queue:work database` worker). `dispatch(...)->afterResponse()` needs no worker but dies with the PHP process: only for work you can lose.
## Durable storage
`php artisan make:migration add_talo_payments`, `up()` (your `orders` table already holds the total as `decimal(12, 2)`, never float; `down()` drops the indexes, columns and table):
```php
Schema::create('talo_webhook_events', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary(); // the hub's event id: the unique key that makes it once
$table->string('type', 40);
$table->string('payment_id', 150)->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::table('orders', function (Blueprint $table) {
$table->unsignedSmallInteger('payment_attempt')->default(0);
$table->string('payment_external_id', 100)->nullable()->unique(); // order-1001, order-1001-2…
$table->string('payment_id', 150)->nullable()->unique(); // PAR-talo-hub-…
$table->string('payment_status', 12)->nullable()->index();
$table->decimal('payment_amount', 12, 2)->nullable(); // what this payment asks for
$table->decimal('paid_amount', 12, 2)->default(0);
$table->string('payment_cvu', 22)->nullable();
$table->string('payment_alias', 64)->nullable();
$table->timestamp('payment_expires_at')->nullable();
$table->timestamp('fulfilled_at')->nullable();
});
```
Not in memory, the session, `static` properties or the `array`/`file` cache: PHP-FPM workers, servers and deploys don't share them, so a duplicate would fulfil twice. The primary key on `talo_webhook_events.id` and `UPDATE … WHERE fulfilled_at IS NULL` hold under parallel deliveries (tested on PostgreSQL: `payment.updated`, `payment.paid`, a duplicate of it and a reconcile run at once gave one fulfilment and one job; the duplicate's `insertOrIgnore` waits for the first commit and returns 0). `insertOrIgnore` is `ON CONFLICT DO NOTHING` (PostgreSQL), `INSERT OR IGNORE` (SQLite), `INSERT IGNORE` (MySQL/MariaDB, where it also hides other errors).
## Reconciliation
`routes/console.php` (imports `PaymentSync`, `TaloHub`, `TaloHubException`, `DB`, `Schedule`): every 5 minutes, orders still PENDING or UNDERPAID whose payment moved in the last 3 days (`updated_at`, so a new payment on an old order counts) are re-read and applied.
```php
Artisan::command('talo:reconcile {--days=3}', function (TaloHub $hub) {
$orders = DB::table('orders')->select('id', 'payment_id')->whereNotNull('payment_id')
->whereIn('payment_status', ['PENDING', 'UNDERPAID'])
->where('updated_at', '>=', now()->subDays((int) $this->option('days')));
foreach ($orders->lazyById(100) as $order) { // by id: safe while updated rows leave the filter
try {
$payment = $hub->getPayment($order->payment_id);
} catch (TaloHubException $e) {
report($e);
continue; // the next run tries again
}
DB::transaction(fn () => PaymentSync::apply($payment));
}
})->purpose('Re-read Talo payments still PENDING or UNDERPAID, in case a webhook never arrived');
Schedule::command('talo:reconcile')->everyFiveMinutes()->withoutOverlapping(10)->onOneServer();
```
Check it with `php artisan schedule:list`. The host runs `* * * * * cd /path-to-app && php artisan schedule:run >> /dev/null 2>&1` (or `php artisan schedule:work` as a process). `withoutOverlapping(10)`: a run killed mid-way (deploy, OOM) frees its lock after 10 minutes, not the default 24 hours. It and `onOneServer()` need a shared lock store (`CACHE_STORE=database`, the default, or redis).
## Local development
Run `php artisan serve` (http://localhost:8000), `php artisan queue:work` (runs `FulfilOrder`) and `npx talo-hub events listen --forward-to http://localhost:8000/api/webhooks/talo`. Forwarded events are unsigned (`talo-hub-forwarded: 1`): accepted only with `APP_ENV=local` and `TALO_HUB_DEV_FORWARD=true`, both in `.env` (`php artisan serve` doesn't pass most shell variables to the app). Sail: `http://localhost/api/webhooks/talo`; Herd/Valet: `http://<app>.test/api/webhooks/talo` (if that redirects to https, forward to `php artisan serve` instead: a 301/302 turns the POST into a GET). Signed deliveries need a public https URL (a deployment or a tunnel).
## Deploy
- URL `https://<host>/api/webhooks/talo`, final (no redirect, no trailing slash), with no basic auth, login, Cloudflare challenge or WAF rule in front.
- Dev/staging: a `tph_test_` key and their own endpoint + secret. Production only: the `tph_live_` key (`npx talo-hub login --production`, `keys create … --production`) and its own endpoint (`webhooks add <url> --production`, then `webhooks test <id> --production`) + secret; `APP_ENV=production`, `APP_DEBUG=false`, no `TALO_HUB_DEV_FORWARD`. Deploys run `php artisan migrate --force` and `php artisan config:cache`.
- Forge: variables in the site's environment; toggle **Laravel Scheduler** (adds the every-minute `schedule:run`); a queue worker for `php artisan queue:work`. A plain VPS: the cron line above plus Supervisor or systemd for `queue:work`.
- Laravel Cloud: variables per environment; **Scheduler** toggle on the App compute cluster; a managed queue or worker cluster; with replicas, `onOneServer()` keeps one reconcile run; no hibernation in production (it pauses the scheduler).
- Railway: variables per environment; `php artisan schedule:work` and `php artisan queue:work` as two more long-running services of the same repo (Railway cron services can skip runs).
## Test it
`tests/Unit/WebhookSignatureTest.php` (PHPUnit, `php artisan test`):
```php
namespace Tests\Unit;
use App\Talo\WebhookSignature;
use PHPUnit\Framework\TestCase;
class WebhookSignatureTest extends TestCase // vector: https://talopartnerhub.vercel.app/docs/webhooks.md
{
private const BODY = '{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}';
private const V1 = 'ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e';
public function test_vector_tampering_time_window_and_malformed_headers(): void
{
$ok = fn (?string $header, string $body = self::BODY, int $now = 1793000000) // $now = t: no 5-minute check
=> WebhookSignature::verify($body, $header, 'whsec_test_secret', $now);
$sig = 't=1793000000,v1='.self::V1;
$this->assertTrue($ok($sig));
$this->assertFalse($ok($sig, str_replace('sandbox', 'production', self::BODY))); // tampered body
$this->assertTrue($ok($sig, now: 1793000300));
$this->assertFalse($ok($sig, now: 1793000301)); // stale t
$this->assertFalse($ok($sig, now: 1792999699)); // t in the future
foreach ([null, '', 'v1='.self::V1, 't=1793000000', substr($sig, 0, -1), substr($sig, 0, -1).'z',
't=1793000000,v1='.strtoupper(self::V1), $sig.',v1='.self::V1] as $header) {
$this->assertFalse($ok($header), (string) $header); // missing, short, non-hex, uppercase, repeated
}
}
}
```
Feature tests (`RefreshDatabase`, `Http::fake()` for the hub, `Queue::fake()`) sign `$raw` with `time()` and post it byte for byte with `$this->call('POST', '/api/webhooks/talo', [], [], [], $this->transformHeadersToServerVars($headers), $raw)`. Assert: hub says SUCCESS → 200, `Queue::assertPushed(FulfilOrder::class, 1)`; same event id again → 200, still one; zeroed `v1` or no header → 400, nothing written; hub says UNDERPAID → not fulfilled; hub 502 → 503 and no event row, then the retry fulfils; the status endpoint answers the stored status even when the hub already says SUCCESS; after an UNDERPAID payment EXPIRED, the next one asks only for the rest.
Sandbox (deployed, or forwarding locally):
1. Check out an order, `npx talo-hub sandbox pay <payment_id>` → SUCCESS in ~10–25 s, fulfilled once.
2. New order, `--amount` below the price → UNDERPAID, the page asks for the rest, not fulfilled; pay the rest → fulfilled. Another with `--amount` above → OVERPAID → fulfilled.
3. The same event twice: `events listen --forward-to …` in two terminals delivers every event twice → two 200s, one fulfilment (`npx talo-hub events list --payment <id>`).
4. `npx talo-hub webhooks test <id>` → delivered; `npx talo-hub doctor` → ready.
## Checklist
- [ ] `TALO_HUB_*` only in `.env`/host variables, read via `config('services.talo.*')`
- [ ] Amount from the order row (minus what an expired payment received); `external_id` `order-<id>`, a new suffix only after EXPIRED
- [ ] Webhook in `routes/api.php` (or CSRF-excluded), no auth or redirect, URL registered exactly
- [ ] Verifier on `$request->getContent()`, passes the test vector, 400 on failure
- [ ] Unsigned forwarded events only with `APP_ENV=local` + `TALO_HUB_DEV_FORWARD=true`
- [ ] Payment re-read before acting (503 if that fails); event id + status + fulfil-once update in one `DB::transaction`
- [ ] UNDERPAID never fulfilled; SUCCESS/OVERPAID once; slow work in `FulfilOrder` with a worker
- [ ] Status endpoint answers the stored status (no reload loop) and never fulfils
- [ ] `talo:reconcile` scheduled and the scheduler running on the host
- [ ] Sandbox plan passed, `webhooks test` delivered, `doctor` ready; `tph_live_` only in production
Edge (Cloudflare Workers, Supabase Edge Functions, Deno)
Firma verificada con Web Crypto, secretos del runtime, idempotencia en D1 o Postgres, y Cron Triggers o pg_cron para conciliar.
Ver talo-stack-edge/SKILL.md
---
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.