API Reference

Cassia Docs

Non-custodial crypto payment processor. Create invoices, manage wallets, receive webhooks. Merchants keep custody; Cassia never holds or moves funds.

Overview

Cassia lets you accept USDT and USDC payments on Ethereum, BSC, or Tron straight to your own wallet — no custody, no KYC, one HTTP API. You hand over an xpub (a public key); Cassia derives a fresh deposit address per invoice, watches the chain, and POSTs a signed webhook when the payment lands.

How it works in 5 steps

  1. Generate an API key (Settings → API).
  2. Connect a wallet — paste an xpub from your existing wallet, or generate one in the browser via the built-in BIP39 tool.
  3. POST /api/invoices to mint a payment request.
  4. Receive a webhook event when paid.
  5. Grant your customer access — typically on invoice.confirmed.
bash
curl -X POST https://cassiapay.com/api/invoices \
  -H "Authorization: Bearer $CASSIA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chain": "eip155:56",
    "token": "USDT",
    "amount": 10.00,
    "metadata": { "order_id": "demo-1" }
  }'
An xpub is a public key. It does not give us access to your funds — only the ability to derive deposit addresses for you.
💡
Skip the boilerplate. Before you start, make sure you have three things ready in the dashboard:
  • API key — Settings → API → Create API key
  • Wallets connected — at least one wallet per chain you want to accept (Ethereum, BSC, Tron) — Wallets → Connect wallet
  • Webhook endpoint — your server URL + signing secret — Webhooks → Add endpoint
Then paste this page's URL into Claude Code, Cursor, or your favourite coding assistant along with your API key and webhook secret — it can wire up the full integration (invoice creation for all chains and tokens, webhook handler, signature verification, granting access) in a single prompt.

The pitch in one screen

  • 1% commission, post-paid. Start earning before you ever top up — no prepayment, no monthly fees.
  • No KYC, no custody. Funds land on your own wallet, instantly.
  • 3 networks. Ethereum, BSC, Tron — pick what your customers can pay on.

How Cassia compares

CassiaStripeTelegram Wallet Pay
CustodyNon-custodial — your walletCustodial — Stripe holds fundsCustodial — Telegram holds funds
KYCNoneRequired (business + ID)Required for payouts
Settlement assetUSDT / USDC, nativeFiat (USD/EUR/etc.)USDT (TON), TON, BTC
Refund mechanismYou send from your walletOne-click via dashboard / APIIn-app, Wallet-mediated
Fees1%, post-paid~2.9% + $0.30 per charge~0.4–3%, varies
NetworksEthereum, BSC, TronCard rails (Visa/MC/etc.)TON, BTC
Where money landsYour on-chain wallet, instantlyStripe balance → bank, T+2Wallet account → manual payout

Start with Non-custodial model (the one page you absolutely read first), then Credit line, Invoice lifecycle, and jump to Quickstart when you're ready to integrate.

Non-custodial model

Never send your seed phrase or private keys. We only need the xpub — a public key from which we derive deposit addresses. Anyone with your xpub can see your transactions but not move your funds.

Cassia does not custody customer payments. When a customer pays, the on-chain transaction lands directly on your own wallet. Cassia watches the chain and notifies you over a webhook, but at no point does the money pass through a Cassia-controlled wallet.

This is the most important thing to understand before integrating.

Frequently misunderstood

Can Cassia freeze my funds?
No. Funds never enter Cassia infrastructure. They're in your wallet from the moment the transaction confirms on-chain.
Can Cassia reverse a payment?
No. On-chain transactions are final. Cassia cannot pull funds out of your wallet.
Who handles refunds?
You do, from your own wallet. Cassia surfaces the customer's funding address and tx hash on the invoice object — you send the refund to that same address using your usual wallet tooling.
Why do I need to hand over an xpub?
Because Cassia doesn't custody. An xpub (BIP32 extended public key) lets Cassia derive a fresh deposit address for each invoice without ever seeing your seed phrase or private keys. The derived addresses are part of your wallet's key tree — only your seed can spend from them.
What if someone steals my API key?

Funds already in your wallet are safe: Cassia never holds them, and an API key alone can't sign on-chain transactions.

The real risk is future invoices. With 2FA enabledthe API key only allows creating invoices and reading history — adding or replacing a wallet requires a fresh TOTP code from your authenticator app, so an attacker can't redirect future deposits.

Without 2FAthe API key is sufficient to add or replace a wallet, which would route the next invoice's deposits to the attacker's xpub. Enable 2FA in Settings — it's the difference between a spoiled-records incident and a stolen-payments one. Either way, rotate the key the moment you suspect it's leaked.

Credit line & top-up

Cassia is post-paid. You can start earning before you ever top up — there's no prepayment, no minimum balance, no monthly fee.

How billing works

Every invoice that reaches paid accrues 1% of its USD-equivalent amountas commission. Because Cassia is non-custodial and never touches your funds, it can't skim that fee on the way past — instead it tracks the accrued commission as a small running balance on your account.

Your job: keep the balance ≥ $0

The dashboard surfaces your balance prominently and warns ahead of zero. When you see negative, top up.Don't treat the negative side as runway — it's the cue that the fee meter has been ticking and it's time to clear it.

Topping up

Dashboard → Credits → Top up. Pick chain + token, send the exact amount to the one-time deposit address. Balance updates within a few minutes of confirmation.

What if I don't top up?

New invoice creation pauses if your balance dips too far negative. Pending invoices still accept payments — existing customers aren't stranded. Top up to resume creating new invoices.

Low-balance protection (opt-in)

Enable auto_collectin Settings → Credits to keep Cassia running automatically when your balance dips deep. The feature is off by default; it's a trade-off between uninterrupted service and full self-custody of every customer payment.

When it activates

The decision depends on how negative your balance currently is and how big the incoming invoice is. Three behaviours:

  • Balance positive or zero— never redirect. Every customer payment lands in your wallet, exactly as if the toggle were off. Auto-collect doesn't touch healthy operation.
  • Balance mildly negative— small invoices redirect, big ones still go to your wallet. Why the asymmetry: a customer's big sale shouldn't be silently routed away from your wallet just because you accrued a tiny amount of commission. Small invoices, on the other hand, are useful for chipping away at the debt without surprising anyone.
  • Balance deeply negative— every invoice redirects, regardless of size. You're close to the limit and need any incoming payment to clear the debt; continuing to send payments to your wallet would only accelerate the slide.

The exact thresholds (when “mildly negative” tips into “deeply negative”, and what counts as a “small” invoice) are calibrated against your account's individual credit allowance and aren't user-configurable. The dashboard's balance widget is the practical signal: when it goes red, expect auto-collect to be active.

What it does

While active, the next invoice you create gets its deposit address swapped to a Cassia treasury address (the invoice is marked redirected: truein its API response). When the customer pays, the funds land on Cassia's treasury instead of your wallet, but your account balance is credited with the full received amount minus the standard 1% commission. Net effect: the customer's payment clears your accrued debt automatically — same end-state as if you had topped up by the same amount, just without the manual step.

When it deactivates

As soon as redirected payments climb your balance back into the healthy or mildly-negative zone, auto-collect either goes idle or scales back to small-only redirects. If usage later pushes the balance deep again, it activates again. Self-tuning, no manual reset needed.

Things to know

  • Big sales redirect only when needed. A large invoice (e.g. a $500 subscription) is redirected only if your balance is already deeply negative — not because of a small amount of accrued commission. In the mildly-negative zone, big sales still land in your wallet.
  • Testnet invoices are never redirected. Sepolia and Tron Nile activity is excluded from billing entirely; auto-collect only applies on mainnet.
  • Underpaid redirected invoices. If a customer pays only part of a redirected invoice, the partial amount stays on Cassia treasury and your balance is not credited until the invoice eventually flips to paid. Practically rare.
  • The redirected flag is visible. GET /api/invoices/{id}returns it. You can use this signal in your back-office to display a “billed-via-Cassia” badge if it's useful.

Invoice lifecycle

Every invoice moves through a small state machine. Knowing which state triggers which webhook is the difference between a clean integration and a flaky one.

  • pending — created, address assigned, waiting for payment. TTL: 60 minutes.
  • paid — matching transaction detected on-chain, not yet block-finalized. Webhook: invoice.paid.
  • confirmed — payment reached finality (12 blocks on Ethereum, 15 on BSC, 19 on Tron). Webhook: invoice.confirmed.
  • underpaid — transaction landed, amount received less than expected. Webhook: invoice.underpaid. Check received_amount and decide.
  • expired — TTL elapsed without matching transaction. Webhook: invoice.expired.
  • cancelled — you called cancel before payment. Terminal; no webhook.

Webhook timing

  • invoice.paid fires seconds after the customer broadcasts.
  • invoice.confirmed fires after finality — seconds on Tron, ~2 min on BSC, ~3 min on Ethereum.
  • The same invoice usually generates both. See When to grant access for which one to key your logic off.
After expired:the address is still part of your wallet's key tree. If the customer sends a late payment, funds land on your wallet — but Cassia won't fire a webhook (the invoice record is closed). Show expiry clearly in your UI.

Chains & tokens

Supported matrix

ChainCAIP-2 idUSDTUSDCFinality
Ethereumeip155:1~3 min (12 blk × 12s)
BSCeip155:56~45s (15 blk × 3s)
Trontron:mainnet~57s (19 blk × 3s)
Sepolia (testnet)eip155:11155111~36s (3 blk × 12s)
Tron Nile (testnet)tron:testnet~57s (19 blk × 3s)

Testnet rails (Sepolia, Tron Nile) are excluded from revenue, billing, and credit consumption — see Testing your integration.

Developer details

  • Ethereum and BSC share m/44'/60'/0' (BIP44 coin type 60). One xpub works for ETH mainnet and every EVM chain Cassia supports.
  • Tron uses m/44'/195'/0' (coin type 195). Different address derivation algorithm — an Ethereum-derived xpub will not produce valid Tron addresses.
  • Cassia accepts standard xpub version bytes (0488b21e). ypub/zpub (SegWit Bitcoin), tpub (Bitcoin testnet format), and xprv (private keys) are rejected. ETH/Tron testnets use the same xpub format as mainnet — see Testing your integration.

See Wallet setup guide for step-by-step extraction from Ledger, Trezor, Trust Wallet, TronLink, MetaMask, and offline BIP39 tooling.

Edge cases

The four situations every integration trips over. Bake these checks into your handler before going live.

SituationStatus fieldWhat you do
Underpaidstatus: 'underpaid'Check received_amount vs expected_amount. Don't grant access until you decide policy: prorated access, or wait for top-up.
Expiredstatus: 'expired'Create a new invoice. The old one is terminal.
Duplicate webhookSame event + invoice_id arrives twiceIdempotent handler: dedupe by (event, invoice_id) and ignore the second.
Late paymentstatus: 'paid' (or confirmed) on a previously expired invoiceCassia surfaces it as a late_payment event in your invoice timeline. Refund or accept per your policy.

Note: late_payment is recorded on the invoice timeline (visible in the dashboard event log and GET /api/invoices/:id) but does not fire a webhook — the invoice record is already terminal.

Quickstart

60 seconds, assuming you've read the three core concepts above.

1. Create an API key

Go to Settings APICreate API key. The key is shown once— save it immediately. Cassia stores only a hash; we can't recover lost keys.

2. Connect wallets — one per chain

You need one wallet registered per chain you want to accept payments on. Dashboard → Wallets → Connect wallet.

Fastest path (recommended): choose Generate — Cassia generates a BIP39 seed phrase in your browser and registers wallets for all three mainnet chains (Ethereum, BSC, Tron) plus both testnets in one step. Nothing leaves your browser during generation.

Have your own xpub? Choose Paste xpuband register each chain separately. You need the BIP44 account-level xpub (depth 3) — the key labelled “Account Extended Public Key” in tools like iancoleman.io/bip39.

Pick an address strategy when connecting:

  • reuse (recommended) — pool of addresses, Cassia recycles free ones. Cheaper to sweep.
  • single_use — fresh address per invoice. Higher address-count, simpler audit.

3. Create your first invoice

bash
curl -X POST https://cassiapay.com/api/invoices \
  -H "Authorization: Bearer $CASSIA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chain": "eip155:1",
    "token": "USDT",
    "amount": 10.00,
    "metadata": { "order_id": "demo-1" }
  }'
# chain: "eip155:1" (Ethereum) | "eip155:56" (BSC) | "tron:mainnet" (Tron)
# token: "USDT" | "USDC"

Response (fields you'll use):

json
{
  "id": "5b3e…",
  "chain": "eip155:56",
  "token": "USDT",
  "amount": "10.00",
  "deposit_address": "0xC0FFEE…",
  "expires_at": "2026-05-04T18:30:00Z",
  "status": "pending",
  "redirected": false,
  "metadata": { "order_id": "demo-1" }
}

deposit_address is where your customer pays. idis what you'll match webhook events against. No follow-up GET needed — the address is in the POST response.

4. Show the address to your customer

Display deposit_address as a QR and copyable string. Warn: same chain, same token — paying USDT-ERC20 to a USDT-BEP20 address is a lost payment.

5. Wait for the webhook

Register an endpoint under Webhooks. When the customer pays, Cassia POSTs the invoice to your URL. See Receiving webhooks for payload, signature, and which event to key your business logic off.

Full minimal example (Node)

End-to-end flow: create an invoice on demand, return the address to your client, verify the signed webhook, grant access on finality.

js
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const CASSIA_KEY = process.env.CASSIA_KEY;
const WEBHOOK_SECRET = process.env.CASSIA_WEBHOOK_SECRET;
const seen = new Set(); // swap for Redis/DB in production

// 1) Mint an invoice when a customer clicks "Pay".
// chain: "eip155:1" | "eip155:56" | "tron:mainnet"
// token: "USDT" | "USDC"
app.post('/checkout', express.json(), async (req, res) => {
  const r = await fetch('https://cassiapay.com/api/invoices', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${CASSIA_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      chain: req.body.chain,
      token: req.body.token,
      amount: req.body.amount,
      metadata: { user_id: req.body.user_id },
    }),
  });
  const invoice = await r.json();
  res.json({
    address: invoice.deposit_address,
    amount: invoice.amount,
    chain: invoice.chain,
    token: invoice.token,
    expires_at: invoice.expires_at,
  });
});

// 2) Receive and verify the webhook. Use raw body for HMAC.
app.post('/webhooks/cassia',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ts = req.header('X-Cassia-Timestamp');
    const sig = (req.header('X-Cassia-Signature') || '').replace(/^sha256=/, '');
    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)
      .digest('hex');
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
      return res.status(401).end();
    }
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      return res.status(401).end(); // replay protection
    }

    const evt = JSON.parse(req.body.toString('utf8'));
    const dedupeKey = `${evt.event}:${evt.invoice_id}`;
    if (seen.has(dedupeKey)) return res.status(200).end();
    seen.add(dedupeKey);

    // 3) Grant on confirmed (final). Skip "paid" for high-value flows.
    if (evt.event === 'invoice.confirmed') {
      grantAccess(evt.invoice_id);
    }
    res.status(200).end();
  },
);

app.listen(8080);

Authentication

Cassia accepts two credential types, each for a different context.

Bearer API keys (csk_live_*)

What merchants integrate with.

  • Format: csk_live_ + 32 hex chars.
  • Passed as Authorization: Bearer csk_live_....
  • Scope: one key per merchant account, full API access.
  • Storage: only a SHA-256 hash is persisted. Plaintext is shown once on creation and never again.
  • Rotation: POST /auth/api-key/rotate issues a new key; old key stays valid for 24 hours. Use the window to redeploy, then the old key expires automatically.

Session cookies

Used by the Cassia dashboard itself. Never hit the API with a cookie from your code — it's bound to the merchant's browser and rejected cross-origin. Dashboard mutations require TOTP (2FA) regardless of the cookie. API-key requests don't require TOTP — the key is the factor.

Endpoints

  • POST /api/auth/logout — authenticated. Clears the session cookie server-side. Returns { ok: true }. Allowed even when an account is in soft-suspended (read-only) mode so a suspended merchant can sign out cleanly.
  • POST /api/auth/api-key/rotate — issue a fresh csk_live_*. Old key keeps working for 24h.

API behavior

Idempotency

POST /api/invoices is not idempotent — each call creates a new invoice. Cassia does not honor an Idempotency-Key header; deduplicate on your side using your own request key before calling. Webhook receivers should be idempotent — see Edge cases.

Rate limits

Two limits are currently enforced — be defensive with 429:

  • 1000 req/min per IP across all endpoints.
  • 10 invoice creates/sec per merchant on POST /api/invoices.

These ceilings are well above normal usage and exist to prevent runaway clients. We may add tighter per-key application limits later; treat 429as "back off and retry with jitter".

Pagination

List endpoints accept limit (default 50, hard cap varies per endpoint) and offset. GET /api/billing/transactions caps at 200. CSV exports paginate server-side and stream the full filtered result.

Error envelope

Every error response uses the same JSON shape:

json
{
  "error": "CONFLICT",
  "message": "Wallet for this chain is already registered."
}

Error code catalog

errorHTTPMeaning
UNAUTHORIZED401Missing/invalid API key or session cookie.
FORBIDDEN403Account suspended (read-only mode), or TOTP missing on a 2FA-gated route.
NOT_FOUND404Resource not visible to your merchant_id.
CONFLICT409E.g. duplicate xpub, max active API keys reached.
RATE_LIMITED429Per-IP or per-merchant ceiling hit. Back off + retry.
VALIDATION_ERROR400Request body failed schema validation. The message field names the offending property.
ERROR400Generic 400 — usually a wallet or invoice domain rule (wrong xpub depth, wrong chain family, uncancellable status). Read message for the human-readable reason.
INTERNAL_ERROR500Unhandled server error. Cassia logs to Sentry; safe to retry idempotent requests after a delay.

Soft-suspend (read-only mode)

If your account is suspended (after an emergency-lock or admin action), all writes return 403 Forbidden with message Account suspended — read-only mode. Contact support. GET requests and POST /api/auth/logout still succeed so you can finish ongoing reconciliation and sign out cleanly.

Invoices

Checkout flow (recommended)

  1. Customer clicks "Pay" in your UI.
  2. Server: POST /api/invoices with { chain, token, amount, metadata }.
  3. Show the returned address to the customer (QR + copyable).
  4. Customer sends payment from their wallet.
  5. Cassia observes the tx and calls your webhook with invoice.paid.
  6. Grant access (digital goods) or start fulfillment (physical goods). For high-value orders, wait for invoice.confirmed — see When to grant access.

Polling fallback

If you can't expose a public webhook yet, poll GET /api/invoices/:id every 5–10 seconds while the invoice is pending. Move to webhooks before shipping anything real — polling loses state on server restart and costs more at scale.

Handling underpaid

When the customer sends less than amount, the invoice enters underpaid and fires invoice.underpaid. Payload includes received_amount. Three options:

  • Partial fulfillment — grant proportional access.
  • Refund— send funds back from your wallet (Cassia can't do this for you — see non-custodial).
  • Ask for the remainder — out of band; create a new invoice for the difference.

Endpoints

  • POST /api/invoices — create.
  • GET /api/invoices — list, paginated, filterable by status, chain, token.
  • GET /api/invoices/:id — fetch one with transaction log and webhook delivery history.
  • POST /api/invoices/:id/cancel — cancel unpaid invoice. Idempotent on already-cancelled invoices. Throws 400 if the invoice is paid, confirmed, or expired. Only pending and underpaid are cancellable.
  • GET /api/invoices/export.csv — RFC 4180 CSV of all invoices matching filters. Query: status, chain, token, include_testnet=true. Streams the full filtered result; no pagination cursor needed at current volumes.
  • POST /api/invoices/acknowledge — dismiss underpaid alerts on the dashboard.

Wallets (public keys)

A "wallet" in Cassia is an xpub you've registered for a specific chain. Cassia derives deposit addresses from the xpub — we never see your seed, we never custody, we can't sign or move funds.

All mutations (POST, PATCH, DELETE) require TOTP (2FA). Set up 2FA in Settings before calling these.

Endpoints

  • POST /api/public-keys — register xpub. Body: { chain, xpub, strategy }.
  • POST /api/public-keys/batch — atomic multi-pairing register. Body: { strategy: 'reuse'|'single_use', pairings: [{chain, xpub}, ...] } (1–10 pairings). Returns { group_id, registered, skipped }. One TOTP code gates the whole operation; either every pairing commits with a shared wallet_group_id, or all are rolled back. Used by the in-browser BIP39 generator to register all 5 chain wallets from one seed in one click.
  • GET /api/public-keys — list with usage stats.
  • PATCH /api/public-keys/:id — switch strategy between single_use and reuse.
  • DELETE /api/public-keys/:id — soft-delete (revoke). Historical derivations remain.

Troubleshooting

"I only see a seed phrase, not an xpub."
Correct — never paste the seed into Cassia or any website. Export the xpub separately. See wallet setup guide for the offline BIP39 tool route.
"MetaMask / Trust Wallet don't show an xpub."
They hide it. Export the 12/24-word seed from the app, then derive the xpub offline via an air-gapped BIP39 tool. Pick ETH or TRX depending on your target chain.
"Ledger / Trezor?"
Use a companion CLI (Ledger Live CLI, Electrum) or open the device in MEW Wallet — surfaces the account-level xpub at m/44'/60'/0' for EVM.
"I pasted and got 'wrong prefix'."
You probably pasted ypub/zpub (Bitcoin SegWit). Cassia accepts only standard xpub. Regenerate in the right format.
"Can I use my exchange deposit address?"
No.Exchange deposit addresses are custodial (the exchange holds the key), rotate without notice, and can't be derived from. Use your own wallet — that's the point of Cassia.

Checking on-chain balances

Cassia surfaces stablecoin balances (USDT, USDC) for each connected wallet. Per-chain totals are available on /wallets and via GET /api/public-keys/:id/balance. To see which specific addresses hold funds today, use GET /api/public-keys/:id/addressesor the "View addresses" link on each wallet card.

Empty addresses (already swept to your treasury, or never used) are hidden — both in the API response and in the UI.

Each row carries a source field: invoice means the address was assigned to a Cassia invoice (with invoice_id set); direct means a deposit landed without going through an invoice — webhooks did not fire for those payments. The wallet card splits the same view into Through Cassia stats vs the On-chain live total.

Balances are cached server-side for 30 seconds and rate-limited at 60 requests per minute per merchant. For higher-frequency monitoring, run your own balance polling against the chain directly using the address list.

bash
curl -H "Authorization: Bearer csk_live_..." \
  https://cassiapay.com/api/public-keys/pk_abc123/balance
bash
curl -H "Authorization: Bearer csk_live_..." \
  https://cassiapay.com/api/public-keys/pk_abc123/addresses

Billing & account

Read your credit balance, top up, and pull commission-log transactions. See Credit line & top-up for the conceptual model.

Endpoints

  • GET /api/balance — returns { balance, credit_limit, auto_collect, status }. status is 'critical' when balance has reached credit_limit, otherwise 'ok'.
  • POST /api/topup — create a top-up request. Body: { chain, token, amount }. Returns the one-time deposit address and Cassia-side tracking id.
  • GET /api/topup/history — paginated list of past top-ups. Query: limit, offset.
  • GET /api/topup/:id — poll a single top-up by id (merchant-scoped).
  • GET /api/billing/transactions — recent commission_log rows joined to invoice for per-row context (chain, token, amount paid). Query: limit (≤ 200, default 100). Powers the Credits page transactions table.

Account closure

Closing an account is irreversible — connected wallets are soft-deleted, PII is anonymized, and the session cookie is cleared. Closure is gated on three preconditions that you can query first.

Endpoints

  • GET /api/account/close-requirements — returns { activeInvoices, creditsNonNegative, connectedWallets, canClose }. canClose requires activeInvoices === 0, creditsNonNegative (no debt), connectedWallets === 0.
  • POST /api/account/lock-request — sends a confirmation email with a one-hour magic link. Clicking the link suspends the account into read-only mode (see Soft-suspend).
  • POST /api/account/close — TOTP-gated, permanent. Body: { reason: string } (1–500 chars). Returns { status: 'closed' }.

Webhooks management

This section covers managing webhook endpoints. For payload format and signature, see Receiving webhooks.

💡
Before going live: send a test event with POST /api/webhooks/:id/test. Payload is a real webhook.ping, signed with your real secret, but marked synthetic and not persisted. One click catches ~90% of production integration bugs.

Endpoints

  • POST /api/webhooks — register. Returns signing_secret once. Save it.
  • GET /api/webhooks — list.
  • GET /api/webhooks/health — per-endpoint success-rate over 24h.
  • PATCH /api/webhooks/:id — update URL or toggle active.
  • DELETE /api/webhooks/:id — soft-delete.
  • POST /api/webhooks/:id/test — send synthetic webhook.ping. Returns status + latency.
  • POST /api/webhooks/:id/redeliver-failed — re-enqueue failed events for this endpoint.
  • GET /api/webhook-deliveries — recent deliveries. Filters: endpoint, status, period.
  • GET /api/webhook-deliveries/:id — delivery details + signed-request preview.

Receiving webhooks

Cassia POSTs to your registered endpoint whenever an invoice changes state.

Event types

  • invoice.paid — tx detected, not yet finalized.
  • invoice.confirmed — block-finalized. Money-good.
  • invoice.underpaid— tx detected, amount < expected.
  • invoice.expired — TTL elapsed without matching tx.
  • webhook.ping — synthetic, only from test endpoint.

Timing

invoice.paid typically arrives 1–10 seconds after the customer broadcasts. invoice.confirmed arrives 10–60 seconds later (depends on chain finality — see Chains & tokens for per-chain numbers). invoice.expired fires shortly after the 60-minute TTL elapses if no matching transaction was seen.

Payload

json
{
  "event": "invoice.paid",
  "invoice_id": "19dac332-1bd7-7c7d-bdce-fb0bebcfa772",
  "amount": "10.00",
  "received": "10.00",
  "chain": "eip155:56",
  "token": "USDT",
  "tx_hash": "0x1234..."
}

All numeric amounts are strings to avoid JS-float precision issues. The signing timestamp lives in the X-Cassia-Timestamp header, not in the body.

Eventamountreceivedtx_hash
invoice.paid
invoice.underpaid
invoice.confirmed
invoice.expired

Confirmeddeliberately doesn't re-send the transaction details — by the time it fires, you've already seen the same tx_hash on invoice.paid. If you need it later, fetch GET /api/invoices/{id}.

Test events (sent by POST /api/webhooks/:id/test) carry an extra timestamp and metadata: { test: true } in the body, and use a friendly chain: 'ethereum' string rather than the CAIP-2 gid (eip155:1). Don't strict-parse the chain field on test events.

Signature verification

Every request includes:

  • X-Cassia-Signature: sha256=<hex> — HMAC-SHA256 of {timestamp}.{raw_body} with your signing_secret.
  • X-Cassia-Timestamp: <unix_seconds> — when Cassia signed.

Verify before trusting the payload.Reject if verification fails OR timestamp is >5 minutes old (replay protection).

node.js
import crypto from 'node:crypto';

function verify(rawBody, timestamp, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const received = signatureHeader.replace(/^sha256=/, '');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(received),
  );
}
python
import hmac
import hashlib

def verify(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    received = signature_header.removeprefix("sha256=")
    return hmac.compare_digest(expected, received)
go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verify(rawBody []byte, timestamp, sigHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    received := strings.TrimPrefix(sigHeader, "sha256=")
    return hmac.Equal([]byte(expected), []byte(received))
}
php
<?php
function verify(string $rawBody, string $timestamp, string $sigHeader, string $secret): bool {
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    $received = preg_replace('/^sha256=/', '', $sigHeader);
    return hash_equals($expected, $received);
}

When to grant access

IMPORTANT: when to grant access. Webhooks are delivered at-least-once — your handler must be idempotent. Always grant on invoice.confirmed (final), not on invoice.paid (which can revert). Always check received_amount === expected_amount for underpaid events.

You usually see two events for the same payment: invoice.paid (tx detected) then invoice.confirmed (finalized).

  • Grant on invoice.paid for low-value, reversible-effort flows — TG bot subscriptions, digital goods under ~$50, SaaS trials. Faster UX; reorg risk is real but small and loss is capped.
  • Grant on invoice.confirmedfor high-value or irreversible fulfillment — physical goods, payments above ~$200, account top-ups unlocking large spend. Extra seconds/minutes are cheap insurance against a reorg that can't be undone.
  • Never grant on invoice.underpaid without first checking received_amount.

Retry policy

Failed deliveries (non-2xx or timeout >10s) are retried at [0, 30s, 2min, 10min, 1hr] — five attempts over ~75 min. After that, terminal; visible under GET /api/webhook-deliveries?status=failed. Replay with POST /api/webhooks/:id/redeliver-failed.

Idempotency

Cassia may call the same event more than once. Dedupe on (X-Cassia-Timestamp, invoice_id, event)at your side. Don't trust TCP success — trust your own idempotency key.

Respond fast, process later

Return 200 OKas soon as you've verified the signature and persisted the raw payload. Do slow work (DB writes, Telegram, business logic) asynchronously. A slow handler consumes your request budget and makes retries redundant for you.

Testing your integration

Cassia provides two testnets so you can dry-run an integration without spending real money. Testnet activity is excluded from your revenue dashboard, billing, and credit consumption — a TESTNET badge marks every wallet and invoice that uses a testnet chain.

Available testnets

  • Sepolia (eip155:11155111) — Ethereum testnet. Use it to validate any EVM integration (Ethereum, BSC, future EVM chains all share the same derivation and adapter contract). Stablecoin: USDC. Faucets: faucet.circle.com for USDC, sepoliafaucet.com for ETH gas.
  • Tron Nile (tron:testnet) — Tron testnet. Stablecoin: USDT. Faucet: nileex.io for TRX and USDT.

Step-by-step

  1. Connect a Sepolia or Tron Nile wallet via Wallets → Connect wallet — same xpub-paste flow as production. The chain dropdown groups testnets separately under their own section.
  2. Get faucet funds for the address Cassia derives.
  3. Create a test invoice via API or UI. Pick the testnet wallet you connected.
  4. Pay the invoice from MetaMask (with the Sepolia network selected) or TronLink (with Nile selected).
  5. Verify your webhook endpoint received the invoice.paid callback with a valid signature.
  6. When ready, swap your environment's chain to eip155:1 (or tron:mainnet) and re-run — same code path.
Why only Sepolia and Tron Nile, not BSC Testnet? Sepolia and Nile cover both chain families Cassia supports (EVM and Tron). Adding BSC Testnet would be redundant — same derivation, same adapter contract.

Common mistakes

Things that derail integrations or pile up risk.

Don't paste a deposit address from a centralized exchange(Binance, Coinbase, etc.). Cassia derives addresses from your wallet's xpub — exchange addresses can't be derived from. Use your own self-custody wallet.
Don't ever store your seed phrase on a server or paste it on a website.Cassia doesn't ask for it; nobody legitimate does. Only the public xpub leaves your machine.
Don't grant customer access on invoice.paid alone. Wait for invoice.confirmed, and for underpaid invoices verify received_amount first.
Don't treat a Cassia-connected wallet as long-term storage. Whether you imported an existing xpub or generated a fresh seed via the in-browser tool, treat the resulting wallet as a transit account — funds land there, you sweep them out. As soon as the balance accumulates to a meaningful amount, move it to cold storage. Track running balances on the dashboard Overview or the Wallets page.

Changelog

2026-05-18

  • Operator dashboard. Internal /panelka page added for platform metrics and merchant impersonation (view-only). No changes to the merchant-facing API surface.

2026-05-04

  • On-chain balance + derived-address listing. New GET /api/public-keys/:id/balance returns aggregated USDT/USDC totals per wallet; new GET /api/public-keys/:id/addresses lists every derived address that currently holds a stablecoin balance. Each row carries a source field — invoice (address was assigned to a Cassia invoice; invoice_id included) or direct (deposit landed without an invoice — webhooks did not fire). 30s server-side cache, 60 req/min/merchant rate limit. Wallet cards on /wallets mirror the split as Through Cassia stats vs the On-chain live total.
  • POST /api/invoices includes deposit_address. Create-invoice response now exposes deposit_address directly — no follow-up GET /api/invoices/:id needed to render the payment screen. Field name matches the detail endpoint.
  • 2FA gate on /wallets/new. Connecting a wallet now blocks at the entry point when 2FA is disabled, preventing the foot-gun where merchants would go through the in-browser seed-phrase ceremony only to hit a 401 at submit and lose the freshly generated mnemonic. Setup carries ?return=/wallets/newthrough all three steps; the final "Done" button changes to "Continue to wallet setup".
  • One active webhook endpoint cap. Partial unique index (merchant_id) WHERE active=true enforces single-active-endpoint per merchant. POST /webhooks and PATCH /webhooks/:id active=true 409 with friendly copy when violated. Recently added invoice-rate-limit (10 req/sec/merchant) on POST /api/invoices.

2026-04-30 — 2026-05-01

  • Testnet support. Sepolia (eip155:11155111) and Tron Nile (tron:testnet) added. Testnet activity is excluded from revenue, billing and credit consumption; analytics endpoints accept ?include_testnet=true.
  • Batch wallet generation. POST /api/public-keys/batch registers up to 10 (chain, xpub) pairings atomically under a shared wallet_group_id, gated by a single TOTP code. Supports the in-browser BIP39 generator that creates 5 chain wallets from one seed.
  • CSV invoice export. GET /api/invoices/export.csv returns RFC 4180 output filterable by status / chain / token / testnet.
  • Logout endpoint. POST /api/auth/logout clears the session cookie server-side and remains callable in soft-suspended mode.
  • Soft-suspend mode. Suspended accounts return 403 Forbidden with Account suspended — read-only mode. Contact support. on writes; GET and logout still succeed.
  • Invoice deposit / from address fields.Invoice objects now expose the derived deposit address and the customer's funding address (from the matched transaction) so you can issue refunds without leaving the dashboard data.
  • Webhook delivery latency_ms. GET /api/webhook-deliveries rows now carry per-call latency for endpoint health debugging.
  • Transactions endpoint. GET /api/billing/transactions joins commission_log to invoices for the Credits page table; up to 200 rows per call.
  • Recovery-codes flow change. TOTP setup now emits one-time recovery codes shown only on enrollment; regeneration requires re-entering an active TOTP.

2026-04-24 — Initial published API

First published merchant API surface. Covers authentication, invoices, wallets (public keys), webhooks management, webhook delivery contract, credits/top-up, and settings. No breaking changes (this is the initial contract).