Quickstart
API key → invoice → hosted checkout → webhook, against the merchant API.
Last updated Oct 27, 2026
Everything below runs against a local stack (see Local development); swap the host for your deployment. API=http://localhost:3005.
1. An API key
The merchant API accepts either the dashboard's cookie session (plus x-organization-id) or an organization API key (Authorization: Bearer fk_live_…). API-key traffic is recorded in the request log (GET /api/v1/crypto/requests, dashboard → Developers → Logs).
Create one in the dashboard (Developers → API keys; the secret is shown once) or through the API with your session:
curl -X POST "$API/api/v1/organizations/$ORG_ID/api-keys" \
-H 'content-type: application/json' -H "x-organization-id: $ORG_ID" --cookie "$SESSION" \
-d '{"name":"backend","permissions":["crypto-invoices:create","crypto-invoices:read","webhooks:manage"]}'Operators can also mint one from the server: bun run --cwd apps/server mint:api-key --organization-id=<id> --email=<owner> --name=backend (default permissions *:*).
[!WARNING] A key that can pay out (
crypto-payouts:create,crypto-payouts:manageor*:*) needs a TOTP step-up when it is minted (totp_code, 2FA required). See Payouts.
2. An invoice
curl -X POST "$API/api/v1/crypto/invoices" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' \
-H 'idempotency-key: order-77310' \
-d '{
"amount": "100.00", "currency": "USD",
"network": "POLYGON_AMOY", "token": "USDT",
"fee_payer": "CUSTOMER", "markup_percent": 2,
"merchant": "halden_type", "splits": [{ "account": "kerning_weekly", "percent": 10 }],
"title": "Halden Grotesk — Desktop license", "merchant_order_id": "order-77310",
"buyer_email": "[email protected]", "redirect_url": "https://shop.example.com/thanks"
}'Idempotency-Keyis required. Repeating the request with the same key and body returns the same invoice (200, headerIdempotent-Replay: true); the same key with another body is409 IDEMPOTENCY_KEY_REUSED.merchantandsplitsreference accounts created withPOST /api/v1/crypto/accounts(kindMERCHANTorPARTNER). Withoutmerchant, the organization's own PLATFORM account is the seller.- Omit
networkandtoken(and send"mode": "test"or"live") to let the buyer choose on the checkout. POST /api/v1/crypto/quotereturns the same breakdown without creating anything.
The response carries id (inv_…), status (PENDING), total ("102.50": 100 + 0.50 % + 2 %), deposit_address, payment_uri (EIP-681), required_confirmations, expires_at and pay_url.
3. The buyer pays
Send the buyer to pay_url (<CRYPTO_CHECKOUT_BASE_URL>/pay/<id>, default APP_URL). The hosted checkout shows the exact amount, the QR code and a Pay with wallet button (EIP-1193: switch or add the chain, check balances, send the ERC-20 transfer). It moves on its own: PENDING → DETECTED (with a live confirmation count) → PAID, then the receipt with the split bar, a printable invoice and the way back to redirect_url.
To embed instead of redirecting, the client ships <CryptoCheckout /> (apps/client/components/aliquot/embed/, demo at /embed); inside Telegram, /pay/:id?tg=1 runs as a Mini-App.
4. The webhook
curl -X POST "$API/api/v1/organizations/$ORG_ID/webhooks/endpoints" \
-H 'content-type: application/json' -H "authorization: Bearer $KEY" \
-d '{"url":"https://shop.example.com/hooks/aliquot","events":["invoice.paid","invoice.overpaid","refund.claimable"]}'Registering an endpoint needs webhooks:manage. The response includes the endpoint secret (shown once). Each delivery is a POST with the envelope { id, type, occurredAt, data } and the headers X-Webhook-Event, X-Webhook-Timestamp and X-Webhook-Signature: t=<unix>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>"):
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody: string, header: string, secret: string): boolean {
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/) ?? [];
if (!t || !v1) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Crypto events: invoice.created, invoice.detected, invoice.paid, invoice.overpaid (sent together with invoice.paid), invoice.underpaid, invoice.expired, payout.completed, payout.failed, refund.claimable (carries claim_url and buyer_email), refund.completed. Payloads use snake_case and decimal strings (data.invoice_id, …). Fulfil the order on invoice.paid.