# Webhooks

> Events, the exact signature format, verification code, retries, ordering and a test vector.

Register: `POST /api/v1/webhook-endpoints { "url": "https://…" }` (scope full; the CLI's login key has it) → `{ id, url, secret }`, with `secret` (whsec_…) shown once. Ping it any time: `POST /api/v1/webhook-endpoints/{id}/test` (MCP test_webhook, CLI `webhooks test <id>`). To change the secret, register the URL again and remove the old endpoint once you deploy the new secret.

The signed path needs a public **https** URL (a deployment or a tunnel). `talo-hub events listen --forward-to` is for local development and does not sign.

Register the **final** URL: the hub doesn't follow redirects, so a trailing slash, an apex domain that redirects to www, or http all fail. `npx talo-hub webhooks add` first sends one unsigned POST and refuses a URL that redirects, is behind Vercel Deployment Protection, or accepts an unsigned event (a correct handler answers 400). A failed ping answers `{ delivered: false, status_code, error, reason }` with `reason` one of `redirect`, `vercel_protection`, `timeout`, `network`, `http_error`, and `error` saying what to fix. A `x-vercel-protection-bypass` value in the URL is never shown back.

## The request

`POST <your url>` with headers:
- `content-type: application/json`
- `talo-hub-event: <type>`
- `talo-hub-signature: t=<unix seconds>,v1=<64 lowercase hex chars>` (exactly one t and one v1, comma-separated, no spaces)

`v1 = hex(HMAC-SHA256(secret, "<t>.<raw body>"))`. Verify on the raw body bytes, and accept only when t is within 5 minutes of now (past or future).

Body: `{ "id": <integer>, "type", "created_at", "data" }`. For payment events `data` is `{ "payment": <payment object, see api>, "previous_status" }`; for `webhook.test` it is `{ "message", "environment" }`; for `account.updated` it is `{ "account_status" }`. Event ids are integers, increasing per hub.

- `payment.created`: Se creó un pago.
- `payment.paid`: El pago quedó SUCCESS u OVERPAID: entregá el pedido.
- `payment.updated`: Cambió el estado, el monto pagado o un reembolso.
- `payment.expired`: El pago venció sin pagarse.
- `account.updated`: Talo cambió el estado de la cuenta (por ejemplo, la activó).
- `webhook.test`: Evento de prueba enviado con /test: confirmalo con 2xx y no hagas nada más.

## Answer and retries

Answer 2xx within 10 seconds; anything else (or a timeout) is retried after 1 m, 5 m, 30 m, 2 h and 6 h, then given up. Answer 400 to a bad signature and 2xx to an event you already handled. Do slow work after answering.

## Ordering and repeats

Deliveries are retried independently, so events can arrive out of order or twice. Handle each event `id` once, and before acting re-read the payment (`GET /api/v1/payments/{id}`) instead of trusting the event snapshot. `payment.paid` is emitted when a payment first becomes SUCCESS or OVERPAID; a later transfer only emits `payment.updated`. Fulfil orders from the webhook (keeping a "fulfilled" flag per order), and use polling only to update what the buyer sees.

## Verification (Node)

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

To unit-test your verifier offline (with the 5-minute check disabled):
- secret: `whsec_test_secret`
- t: `1793000000`
- raw body: `{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}`
- expected v1: `ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e`

## Local development

`npx talo-hub events listen --forward-to http://localhost:3000/api/webhooks/talo` POSTs each **new** event with the same JSON body and headers `content-type`, `talo-hub-event` and `talo-hub-forwarded: 1`, and **no signature**. The few existing events it prints at start (with `"replayed": true` under `--json`) are not forwarded. Accept forwarded events only in local development: `NODE_ENV === "development"` (Next.js `next dev`), or an explicit `TALO_HUB_DEV_FORWARD=1` plus a `tph_test_` key in other stacks; never when deployed.

---
Human page: https://talopartnerhub.vercel.app/docs/webhooks · All pages: https://talopartnerhub.vercel.app/docs/index.md
