DocsStart free
English
API docsCheckout buttons

API docsPayments

Checkout buttons

Take payments with no backend code. Configure a button once in the dashboard (Checkout Buttons) and paste one link on any page. Your page holds no key.

How it works#

  1. You create a button — it stores the amount, network and coin.
  2. You get a public button id (btn_…) and a ready-made link.
  3. A customer clicks it: Unheld creates the invoice and shows them a hosted payment page.
  4. You receive the usual invoice.* webhooks.

Copy it from the button in the dashboard (the API returns it as payUrl). It is a plain link, so it works in HTML, WordPress, Webflow or an email:

HTML
<a href="https://pay.unheld.io/pay/btn_abc123"
   style="display:inline-block;padding:12px 20px;border-radius:8px;
          background:#3b82f6;color:#fff;font-weight:600;text-decoration:none">
  Pay with crypto
</a>

Knowing who paid#

Declare the fields you want on the button (for example client_reference_id or order_id), then add them to the link:

https://pay.unheld.io/pay/btn_abc123?client_reference_id=user_123&order_id=42

They come back on the webhook as checkoutFields:

JSON
{
  "event": "invoice.paid",
  "invoiceId": "…",
  "checkoutFields": { "client_reference_id": "user_123", "order_id": "42" },
  "checkoutFieldsVerified": false
}

Only declared fields are accepted; anything else in the link is ignored.

A plain link can be edited by whoever holds it. Treat its fields as references, not proof of who paid. When they matter, sign the link.

Sign the link on your server with the button's signing secret — shown once when you create the button, and again only when you rotate it.

  1. Canonical string — the button's public id, then the expiry (Unix seconds), then every parameter you send sorted by name as name=value, one per line (\n). Leave out sig and exp.
  2. Signature — hex( HMAC-SHA256( secret, canonical ) ).
  3. Link — your parameters plus exp=<unix seconds> and sig=<hex>.
JavaScript
import { createHmac } from 'node:crypto';

function signedPayLink(payUrl, secret, params, ttlSeconds = 900) {
  const publicId = new URL(payUrl).pathname.split('/').pop();
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const canonical = [publicId, String(exp), ...Object.keys(params).sort().map((k) => `${k}=${params[k]}`)].join('\n');
  const sig = createHmac('sha256', secret).update(canonical).digest('hex');
  const url = new URL(payUrl);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  url.searchParams.set('exp', String(exp));
  url.searchParams.set('sig', sig);
  return url.toString();
}
// signedPayLink('https://pay.unheld.io/pay/btn_abc123', secret, { order_id: '42' })

A signed link gives you:

  • Verified fields — the webhook says checkoutFieldsVerified: true only when the signature held.
  • One invoice per order — the order is identified by the declared fields you sign, so put your order id in a declared field. Two presses carrying the same declared values, from any device, land on the same invoice — even after it is paid. On a button with no declared fields, presses share the invoice while it is open; once it is paid, the next press creates a new one.
  • No per-connection limit — signed presses come from your server, so the per-IP limit on new invoices does not apply; the button's cap and address reserve still do.
  • Your own expiry — a link past its exp is refused, and so is an exp more than 24 hours ahead.

A refused link shows the customer "This payment link is not valid" and never why. Tick Require signed links to refuse plain links altogether. Rotating the secret invalidates every link signed with the old one at once — update your server first.

Allowed websites#

A button can carry up to 20 hostnames: example.com allows that site and every subdomain of it; *.example.com allows the subdomains only. A press whose browser names another site in Origin or Referer is refused, and the customer sees "This payment link cannot be used from this website." A link opened from an email, a chat or a QR code names no site and is always allowed, and a script can send any header — the allowlist stops other pages hotlinking your button; the signature is what proves a link is yours.

Subscription buttons#

Set the button's type to subscription and pick a plan. Subscription links must be signed and carry client_reference_id — or the field you nominated as subscriberRefField, which must be one of the button's declared, required fields — inside the signed parameters, so nobody can reach another customer's subscription by guessing a reference:

https://pay.unheld.io/pay/btn_sub123?client_reference_id=user_123&exp=…&sig=…

The customer lands on a hosted subscription page; the same subscriber clicking again returns to their existing subscription.

Donation pages#

Set the button's type to donation and the link opens a hosted page where the donor chooses the amount — a tip jar for a stream, a creator page, a fundraiser. The page is https://app.unheld.io/donate/btn_…; the button's link (/pay/btn_…) redirects to it, so a Twitch panel, a !donate chat command or a bio link all work with the same URL.

What you configure on the button:

Setting Meaning
Amount presets Up to six amounts the donor can press, in dollars (amountMode: "usd") or in the coin (amountMode: "asset") — your choice per button
Custom amount Whether the donor may type their own amount
Minimum The smallest custom amount, in the same unit as the presets. Below it, the platform floor ($0.50) applies regardless
Donor name, message Two optional boxes; switch either off and it is not asked for
Title, description The page headline and a short text under it
Goal A target in US cents with an optional deadline — the page shows a progress bar of what has been raised
Recent donations Whether the page lists the latest paid donations (name if given, amount)
Default coin and network What the page opens on; the donor may switch to any coin you can be paid in

How a donation becomes money:

  1. The donor picks an amount and a coin. In dollar mode the amount is converted at the live price (the same feed and freshness the currency switch uses), rounded up to the coin's last base unit; a stablecoin is 1:1.
  2. An ordinary invoice is created for that amount — your address, your webhooks, the usual invoice.* events. Every anti-abuse rule a checkout button has applies: the per-connection limit, the open-payment cap, the line, the address reserve.
  3. The invoice carries the donation record: donorName, message, usdCents (the value at the press), amountMode, presetUsed and hiddenAt — on the invoice itself as donation, and on every webhook body for it.
JSON
{
  "event": "invoice.paid",
  "invoiceId": "…",
  "donation": { "donorName": "Ana", "message": "gg", "usdCents": 1000, "amountMode": "usd", "presetUsed": true, "hiddenAt": null }
}

Donor names and messages are typed by the donor and unverified; treat them as text to display, never as identity. The goal's "raised" figure sums usdCents over paid donations; a donation whose coin could not be priced at the press counts for nothing there, and the page says how many such donations there were. Signed links and the allowed-websites list bound where the link may be pressed from; the donation page itself always may.

What the page's own calls answer — GET /api/v1/public/donations/btn_… returns the page's data (404 for an unknown or non-donation button); POST /pay/btn_…/donate with { "amount", "preset", "chainId", "assetKey", "donorName", "message" } answers 200 { "kind": "invoice", "invoiceUrl" } or 200 { "kind": "waiting", "position", "waiting", "pollSeconds" } · 400 the amount is not a preset, is under the minimum or the floor, or the body is malformed · 422 that coin is not available on the page · 429 with Retry-After · 503 the coin cannot be priced right now, or checkout is paused for this account. These are the page's calls; you do not need to make them.

Stream overlays (OBS)#

A donation page can show donations on a live stream. On the button's card press Create overlay links: you get three secret URLs to add in OBS (or any streaming software) as Browser Sources — Alerts (plays a card when a donation is credited: name or "Anonymous", the dollar value and the coin amount, the message), Goal (the progress bar) and Recent (the latest donations). An alert plays only once the donation is credited, never when it is merely detected, and stays on screen 8 seconds; add ?seconds=15 to the alerts URL to change that. Send test alert plays a fixed test donation on your alerts overlay so you can check it before going live. The URLs are secret — whoever holds them can watch your alerts — and Rotate overlay links replaces them at once.

The overlays are pages, not an API you need: they read GET /api/v1/public/overlays/:token (the donation page's own view), GET /api/v1/public/overlays/:token/feed?since= (credited donations and test alerts after a timestamp) and watch GET /api/v1/public/overlays/:token/stream. If you build your own overlay, those three calls are what it needs.

Repeat clicks, busy buttons and cancelling#

  • Clicking twice. The same browser pressing the same plain link with the same fields gets the same invoice back while it is open, its expiry extended (up to one hour after it was first created). Each new invoice from an unsigned link counts against a per-connection limit (3 per button every 10 minutes), so refreshes and bots cannot drain your addresses.
  • The line. A button holds at most Max open payments at once unpaid invoices (you set it). When that many are open, the next customer sees a waiting page with their place in line, which opens their payment page as soon as a place frees up. A customer who closes the page drops out after about thirty seconds. The page offers Leave the line, which goes to your Cancel URL when the button has one.
  • Cancelling. A customer can cancel an unpaid invoice from the payment page. It ends the invoice (its address rests in the usual cooldown before reuse), sends you invoice.expired, and takes them to your Cancel URL. An invoice that has seen a payment cannot be cancelled.
  • Coming back. Set a Success URL (absolute http(s)) and the payment page sends the customer there once the payment is confirmed — on time or late.

What GET /pay/:publicId answers: 303 to the payment page · 200 the waiting page when the button is full (?leave=1 leaves the line) · 400 a required field is missing or longer than 512 characters, or the button's network is switched off · 403 the signature does not hold, or the press came from a site outside the allowlist · 404 unknown or deactivated button · 429 too many presses or new payments from one connection, or the button is full and no line can be kept (with Retry-After) · 503 checkout is unavailable for this account right now.

Buttons can also be managed over the API with a dashboard session — see the appendix.