# Checkout: payment links and the transfer widget

> Talo's payment page, reusable payment links (/l/<id>, with a QR) and the <talo-checkout> widget or shadcn component, with the read-only checkout_token behind it.

Three ways to show the buyer where to transfer, from least to most code. In all of them the order is fulfilled on the signed webhook (`payment.paid`, then re-read the payment; see [webhooks](https://talopartnerhub.vercel.app/docs/webhooks.md)): link pages and the widget are only UI, and anyone can fire a browser event.

| You want | Use | Your code |
|---|---|---|
| To send the buyer somewhere to pay one order | the payment's `payment_url`: Talo's page with the amount, alias and CVU (copy buttons), a countdown, the account holder and CUIT | create the payment on the server, redirect to `payment_url` |
| To sell without a checkout: a chat, a bio, a QR at the counter | a payment link, `/l/<id>` | none: create the link once |
| To keep the buyer on your own page | the `<talo-checkout>` widget, or its React twin from the shadcn registry | create the payment on the server, render the widget with its `id` and `checkout_token` |

## Payment links

Create one (scope `charge`):
- API: `POST /api/v1/payment-links { "title": "Clase de yoga", "amount": 15000 }`, or `"min_amount": 1000, "max_amount": 50000` instead of `amount` for an amount the buyer types.
- CLI: `npx talo-hub links create --title "Clase de yoga" --amount 15000` (or `--min 1000 --max 50000`; also `--description`, `--redirect-url`). Then `links list`, `links get <id>`, `links disable <id>`.
- MCP: create_payment_link, list_payment_links, get_payment_link, disable_payment_link.
- Dashboard: Links de pago.

Rules: `title` 1 to 80 characters after trimming (the buyer sees it; it is every payment's `motive`); `description` up to 280; either `amount` alone, or `min_amount` and `max_amount` with min ≤ max, all positive with up to 2 decimals and at most 100,000,000 ARS; `redirect_url` an absolute https URL (http only for localhost), passed to Talo as each payment's `redirect_url`. Errors: 422 `invalid_title`, `invalid_description`, `invalid_amount`, `invalid_redirect_url`; an unknown id or another account's link, 404 `link_not_found`.

```json
{ "id": "Ab3dE6gH9jKq", "url": "https://talopartnerhub.vercel.app/l/Ab3dE6gH9jKq", "qr_url": "https://talopartnerhub.vercel.app/l/Ab3dE6gH9jKq/qr.svg",
  "environment": "sandbox", "title": "Clase de yoga", "description": null, "amount": 15000, "min_amount": null, "max_amount": null,
  "redirect_url": null, "active": true, "created_at": "2026-10-29T15:00:00.000Z" }
```

- **The buyer** opens `url`: the title, the description, the amount (or a field for it, with the range), a notice on sandbox links that no real money moves, and a "Pagar por transferencia" button. Pressing it creates a payment (`external_id` `link_<link id>_<random>`, `motive` = title) and sends the buyer to its `payment_url` on Talo. Pressing it again from the same browser within a day, while that payment is still PENDING (or UNDERPAID, after a partial transfer) for the same amount, gives back the same payment instead of a new CVU.
- **The QR**: `qr_url` is an SVG of `url` (cached for a day), ready to print.
- **Limits**: 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. Past a limit the page asks the buyer to try again later. The network is told apart by a keyed hash of the link and the IP, kept for a day only for these limits; the IP is never stored.
- **Fulfilment**: link payments emit the same events as any payment. On `payment.paid`, `data.payment.external_id` is `link_<link id>_<random>` and `motive` is the title: deliver what the link sells. Link ids are 12 letters and digits (`[0-9A-Za-z]`), so `external_id.slice(5, 17)` is always the link id, whatever the random part holds. `GET /api/v1/payment-links/{id}` lists the payments buyers started (id, status, amount, external_id, created_at), newest first.
- **Disabling** (`DELETE /api/v1/payment-links/{id}`, scope `charge`) is for good: create a new link to sell again. Payments already started can still be paid. A link already disabled answers 410 `link_disabled`. Disconnecting the account disables all its links. A disabled or unavailable link shows the buyer "Este link de pago no está disponible", with no details.

## The transfer widget

The server creates the payment (`POST /api/v1/payments` with an amount the server decides, never one from the browser) and hands the page only `id` and `checkout_token`:

```html
<script src="https://talopartnerhub.vercel.app/widget/v1/talo-checkout.js" defer></script>
<talo-checkout payment-id="PAR-…" token="ct_…"></talo-checkout>
```

- Attributes: `payment-id` and `token` (required); `hub` (optional: the hub's base URL; by default the origin the script was loaded from). Changing one restarts it.
- It shows the amount, alias and CVU with copy buttons and the expiry, and follows the status: PENDING (waiting for the transfer), UNDERPAID (what is still missing, in the amount field), SUCCESS or OVERPAID (paid), EXPIRED (don't transfer to these details), plus "not found" for a 404. Sandbox payments get a badge that no real money moves, and there is a link to `payment_url`. The texts are in Spanish (es-AR).
- It reads the public view every 4 s, backs off up to 30 s on errors, pauses while the tab is hidden, and stops at SUCCESS, OVERPAID or EXPIRED, on a 404, or 15 minutes after `expires_at`.
- Events, bubbling and composed: `talo-status` (detail: the public view, whenever status or paid change), `talo-paid`, `talo-expired`, `talo-error` (detail: `{ code, status, retrying }`). The element's `payment` property is the last view. Use them to update the page, never to fulfil.
- Theme: CSS custom properties on the element or an ancestor: `--talo-accent`, `--talo-text`, `--talo-muted`, `--talo-bg`, `--talo-border`, `--talo-radius`, `--talo-font`.
- Content-Security-Policy: add `https://talopartnerhub.vercel.app` (or the `hub` you set) to `script-src` (the script) and `connect-src` (its reads). `style-src` needs no change: the widget styles itself with a constructed stylesheet, not inline styles. Browsers without constructed stylesheets (Safari before 16.4) get a `<style>` instead, which a strict `style-src` blocks: the widget still works there, unstyled.

### React and Next.js (shadcn registry)

- Component: `npx shadcn@latest add https://talopartnerhub.vercel.app/r/talo-payment-status.json` → `<TaloPaymentStatus paymentId={payment.id} token={payment.checkout_token} />` (optional `hubUrl`, `onPaid`, `onExpired`, `className`), the widget's behavior in React + Tailwind.
- Next.js App Router block: `npx shadcn@latest add https://talopartnerhub.vercel.app/r/talo-checkout-nextjs.json` adds that component plus `lib/talo-hub.ts` (server-only: `createTaloPayment({ amount, externalId, motive? })`, `getTaloPayment(id)`, `verifyTaloWebhook(rawBody, signatureHeader)`), `app/api/webhooks/talo/route.ts` (verifies the signature, then calls `handleTaloEvent`) and `lib/talo-events.ts`, where you write `markOrderPaid` (it throws until you do, so the hub keeps retrying). Env: `TALO_HUB_API_KEY`, `TALO_HUB_WEBHOOK_SECRET`. Create the payment in a Server Action and pass `id` and `checkout_token` to the order page.

## The checkout token

- `checkout_token` comes in the answers to `POST /api/v1/payments` and `GET /api/v1/payments/{id}` (and the MCP tools create_payment and get_payment), not in lists or webhook events.
- It reads one payment's public view and nothing else: no other payment, no writes, no account data. It is valid 15 days from when it was issued (each read of the payment returns a fresh one; Talo payments last at most 14 days). It may go to the browser, a page's HTML or a URL; the API key never may.
- It can't be revoked one by one: disconnecting the account stops them all.

`GET /api/public/payments/{id}?token=ct_…`, with no key, from any origin (CORS `*`):

```json
{ "id": "PAR-…", "status": "PENDING", "amount": 15000, "currency": "ARS", "paid": 0, "cvu": "0000…", "alias": "…",
  "expires_at": "2026-10-30T15:00:00.000Z", "motive": "Pedido #1001", "payment_url": "https://…", "environment": "sandbox" }
```

Every field is always there (null when Talo has no value). A bad, expired or another payment's token, a disconnected account and a missing payment all answer the same 404 `not_found`. A 200 is cached by the CDN for 3 seconds (`cache-control: public, s-maxage=3, stale-while-revalidate=10`), so many open tabs cost one read; errors are `no-store`. Reading it also brings the payment's events up to date.

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