Aliquot
Payments

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:manage or *:*) 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-Key is required. Repeating the request with the same key and body returns the same invoice (200, header Idempotent-Replay: true); the same key with another body is 409 IDEMPOTENCY_KEY_REUSED.
  • merchant and splits reference accounts created with POST /api/v1/crypto/accounts (kind MERCHANT or PARTNER). Without merchant, the organization's own PLATFORM account is the seller.
  • Omit network and token (and send "mode": "test" or "live") to let the buyer choose on the checkout.
  • POST /api/v1/crypto/quote returns 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.

On this page