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, Arbitrum, Solana, 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.

Solana works slightly differently — its key format can't derive addresses from a public key, so the deposit addresses are generated in your browser up front instead. See Chains & tokens for what that changes.

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, Arbitrum, Solana, 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.
  • 5 networks. Ethereum, BSC, Arbitrum, Solana, 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, Arbitrum, Solana, 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 enabled the 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 2FA the 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

1% is charged on every received payment, including partial (underpaid) payments and overpayments — each deposit accrues 1% of its own USD-equivalent amount as 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_collect in 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 collection address (the invoice is marked redirected: true in its API response). When the customer pays, the funds land on a Cassia address 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.
  • Arbitrum and Solana invoices are never redirected. Auto-collect doesn't support those two chains yet — regardless of your balance zone, Arbitrum and Solana payments always land in your wallet. Top up on Ethereum, BSC, or Tron if your balance needs clearing.
  • Underpaid redirected invoices. If a customer pays only part of a redirected invoice, the partial amount stays on a Cassia address 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, 50 on Arbitrum, 19 on Tron; on Solana, every contributing signature reaching the finalized commitment level). 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 and Arbitrum, ~2 min on BSC, ~3 min on Ethereum.
  • Solana can be slower to notify. Solana deposits are detected two ways: a live notification from our chain provider, and a sweep that runs about once a minute. The sweep is the guarantee — budget for it, and treat the live notification as an optimisation that may or may not arrive first. So invoice.paid can trail the transaction by a minute or two. invoice.confirmed is checked by the same ~1-minute sweep for the first hour after payment, so it normally trails invoice.paid by about a minute; past that hour a slower 10-minute sweep takes over and it can trail by up to ~10 minutes. Both events still arrive, and the order and payload contract are identical to every other chain.
  • 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)
Arbitrumeip155:42161*~13s (50 blk × 0.25s)
Solanasolana:mainnetfinalized commitment
Trontron:mainnet~57s (19 blk × 3s)
Sepolia (testnet)eip155:11155111~36s (3 blk × 12s)
Tron Nile (testnet)tron:testnet~57s (19 blk × 3s)

* Arbitrum's on-chain USDT contract reads “USD₮0”, not “USDT” — Tether's canonical successor token on Arbitrum since a Feb-2025 migration, same issuer and 1:1 peg as classic USDT. Cassia's token: "USDT" parameter is unaffected; this only matters if you're inspecting the contract directly on Arbiscan.

Solana is mainnet-only (there is no Solana testnet in Cassia) and generate-only — you cannot connect it by pasting a key. Both USDT (Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB) and USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) are the standard 6-decimal SPL Token mints. Finality is a commitment level, not a block count, so the table shows finalized rather than a depth — see Invoice lifecycle for how quickly the webhook actually reaches you.

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

Solana wallets: generate-only, and you own the recovery

Every other chain Cassia supports lets you hand over one public key and derive an unlimited number of deposit addresses from it. Solana's key format (ed25519) has no such derivation — there is no “Solana xpub” to paste, and no way for Cassia to produce a new address from a key you already hold. So Solana wallets are created one way only: the in-browser generator, which derives a fixed pool of 200 addresses on your device and uploads only the public addresses.

  • Derivation path: m/44'/501'/{index}'/0', indices 0199, all segments hardened. This is the path Phantom and Solflare enumerate natively when you import a seed phrase. It is shown to you on the seed-backup screen at generation time — the same path is repeated on the wallet card afterwards, under Solana recovery.
  • The pool is finite. 200 addresses, and Cassia cannot mint more for that seed. A single_use Solana wallet that runs out of never-used addresses falls back to recycling addresses that have cooled down, exactly like reuse — so invoices keep working, with the weaker cross-talk profile that recycling implies. If every address in the pool is simultaneously in use or still inside its cooldown, invoice creation fails with solana_pool_exhausted until one frees up. On a single_use wallet the dashboard warns you as the fresh count runs down — on a reuse wallet it doesn't, because a fresh count of zero is that strategy's normal steady state rather than a fault.
  • A second Solana wallet replaces the first. Generating one does give you a fresh pool, but Cassia never fails over between wallets on a chain: new invoices draw only from your most recently added Solana wallet, so whatever is still unused in the older pool stops being handed out, and it is one more seed phrase to keep safe. Running low on never-used addresses forces nothing — a single_use wallet keeps working by recycling, losing only the guarantee that no address is handed out twice. Do it only when the single_use guarantee is specifically required.
  • No testnet. Dry-run your integration on Sepolia or Tron Nile instead.
Cassia does not sweep or consolidate your Solana funds, and never asks for a seed phrase back. Payments land on the 200 generated addresses and stay there until you move them. There is no built-in sweep tool in v1, and no screen anywhere in Cassia accepts an existing seed phrase — if something asks you to type one in, it is not us.
To move the money, import your 12 words into Phantom or Solflare and send from the accounts holding a balance. Most accounts appear on their own; wallets stop scanning after a run of unused addresses, so if one is missing, add accounts manually up to its index. Write the seed phrase down when Cassia shows it — it is the only copy, and it is the only way back to these funds.

Developer details

  • Ethereum, BSC, and Arbitrum 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.
  • Solana uses SLIP-10 ed25519 at m/44'/501'/{index}'/0' (coin type 501), indices 0–199. There is no xpub: ed25519 has no public-key-only child derivation, so the pool is derived in your browser and uploaded as 200 finished addresses. See Solana wallets above.
  • 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 xpub extraction from Ledger, Trezor, Trust Wallet, TronLink, MetaMask, and offline BIP39 tooling — EVM and Tron only, since Solana has no xpub to extract.

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 five mainnet chains (Ethereum, BSC, Arbitrum, Solana, Tron) plus both testnets in one step. Nothing leaves your browser during generation.

Solana can only be connected this way. It has no xpub to paste and no manual-tooling route — the generator is the only path. It derives 200 Solana addresses on your device at m/44'/501'/{index}'/0', and Cassia has no sweep tool for them: recovery means importing your seed into Phantom or Solflare yourself. Read Solana wallets before you generate.
Already connected wallets before Arbitrum was added? No need to regenerate anything — go to Wallets → Connect wallet → Paste xpub, select Arbitrum, and paste the same xpub you used for Ethereum or BSC (they share the same derivation path). It registers as a standalone wallet rather than joining your original generated group, which is cosmetic only.

Have your own xpub? Choose Paste xpub and 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. This path covers Ethereum, BSC, Arbitrum and Tron; Solana is not available here.

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.
  • On Solana both strategies draw from the same fixed 200-address pool — it never grows. single_use prefers a never-used address and falls back to recycling once none is left; reuse prefers a cooled-down address and takes a never-used one only when nothing has cooled down yet.

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) | "eip155:42161" (Arbitrum)
#      | "solana:mainnet" (Solana) | "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. id is 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" | "eip155:42161" | "solana:mainnet" | "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 429 as "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 records it on our side; safe to retry idempotent requests after a delay.
solana_network_unavailable
solana_monitoring_unavailable
503Solana only. A chain provider Cassia depends on is failing, so no deposit address could be prepared (first code) or registered for monitoring (second). Transient — retry. The invoice was not created and nothing was reserved, so a retry is safe and is not a duplicate. Not a problem with your request.

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. Solana is the one exception: it has no xpub, so a Solana wallet is a pool of 200 addresses your browser derived — see the Solana pairing shape below.

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, each chain at most once). 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 7 chain wallets from one seed in one click.
  • GET /api/public-keys — list with usage stats: addresses_count, in_use_count, fresh_count, received_lifetime, last_activity_at, is_active.
  • PATCH /api/public-keys/:id — switch strategy between single_use and reuse.
  • DELETE /api/public-keys/:id — soft-delete (revoke). Historical derivations remain.

The Solana pairing shape

POST /api/public-keys/batch accepts two kinds of pairing, and Solana's is structurally different: there is no xpub, so it carries a root public key plus the pre-derived pool. A Solana pairing must use the Solana shape — { chain: "solana:mainnet", xpub } is rejected.

json
{
  "strategy": "single_use",
  "pairings": [
    { "chain": "eip155:1", "xpub": "xpub6C..." },
    {
      "chain": "solana:mainnet",
      "rootPublicKey": "<base58 ed25519 public key>",
      "addresses": ["<base58>", "…exactly 200 entries…"]
    }
  ]
}

addresses must contain exactly 200 base58 addresses, derived on the client at m/44'/501'/{index}'/0' for indices 0–199. Cassia derives and stores the USDT and USDC associated token accounts for every address; each invoice is watched on the associated token account for its own token. This endpoint is what the dashboard generator calls; there is no separate single-wallet Solana route, and POST /api/public-keys rejects solana:mainnet outright.

Reading the pool counters

GET /api/public-keys returns fresh_count alongside addresses_count and in_use_count. fresh_count is the number of addresses that have never been handed to an invoice — it is not addresses_count − in_use_count, which also counts already-used addresses waiting out their cooldown. On a fixed-size Solana pool that difference is the whole signal: a pool cycled once through reuse can read as “180 free” by subtraction while having zero never-used addresses left. If you build your own low-pool alerting, key it off fresh_count.

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/addresses or 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.

Arbitrum and Solana wallets return available: false. Live on-chain balance lookups aren't wired up for those two chains yet, so both endpoints answer “not known” rather than an indistinguishable $0.00 — the dashboard shows “Balance lookup not yet available for this chain” on those cards. Invoices, webhooks and billing are unaffected. For a Solana wallet you can read balances yourself against any Solana RPC using the pool addresses.
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. eip155:42161 (Arbitrum) and solana:mainnet are not accepted here yet and return a 400 — top up on Ethereum, BSC, or Tron. Accepting customer payments on those chains is unaffected.
  • 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

Confirmed deliberately 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.confirmed for 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 OK as 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.

Arbitrum and Solana have no testnet counterpart in Cassia. Both are mainnet-only; dry-run the shared parts of your integration on Sepolia (for the EVM chains) or Tron Nile.

Available testnets

  • Sepolia (eip155:11155111) — Ethereum testnet. Use it to validate any EVM integration (Ethereum, BSC, Arbitrum, and other EVM chains all share the same derivation and adapter contract). Stablecoin: USDC. Faucets: faucet.circle.com for USDC (contract 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238), sepoliafaucet.com for ETH gas.
  • Tron Nile (tron:testnet) — Tron testnet. Stablecoin: USDT. Faucet: nileex.io for TRX and USDT — the faucet dispenses USDT contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf, which is what Nile invoices use. Nile has more than one “USDT” deploy; pay with the faucet's contract — a deposit of any other “USDT” token will not be credited.

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 or Arbitrum Testnet? Sepolia and Nile cover both chain families Cassia supports (EVM and Tron). Adding a testnet for every additional EVM chain 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.

Referral program

Refer other merchants and earn a share of Cassia's 1% commission on everything they process — for as long as they stay. Your share is set by a tier (Bronze 20% / Silver 25% / Gold 30%) recalculated monthly from your referrals' previous-month volume; new referrers earn 30% for their first 30 days. Earnings accrue to a separate referral_balance you can transfer to your Credits balance, or — in partner mode — withdraw to a crypto wallet.

Share links

  • cassiapay.com/r/{code} — standard link.
  • cassiapay.com/r/{code}-b10 — bonus link: new merchants who sign up through it get $10 in commission Credits. You don't earn on the $10 itself, only on what they pay afterward.

Endpoints

  • GET /api/referrals — tier, current rate, promo state, this-month volume, balances, and your referees (emails masked).
  • GET /api/referrals/ledger — paginated balance ledger. Filters: ?balance_type=commission|referral, ?event_type=…, ?limit, ?offset.
  • GET /api/referrals/rate-history — your tier/rate changes over time (the most recent is current).
  • GET /api/referrals/payouts — your payout requests.
  • POST /api/referrals/transfer — TOTP. Body { amount }. Moves referral earnings into your Credits balance (rejected in partner mode).
  • POST /api/referrals/partner-mode — TOTP. Body { enabled }. Partner mode swaps Transfer for crypto payouts.
  • POST /api/referrals/payout — TOTP, partner mode only. Body { amount, asset, address, chain }. asset is USDT/USDC; chain is mainnet-only and the asset must be supported on it (USDT only on Tron). Minimum $50.

Your share is set by your tier — recalculated monthly; already-accrued earnings are never re-rated.

Projects

An account can contain multiple projects. Each project has its own wallets, API key, webhooks, and invoice history — useful for separating environments (staging vs production) or distinct products under one account.

Every existing account was automatically migrated: all existing wallets, API keys, webhooks, and invoices were moved into a single Default project. Your existing API key keeps working without any code change — it is now scoped to that Default project.

API-key callers: do not send an X-Cassia-Project header. Your key already determines the project; the header is for dashboard sessions only and is ignored (or unnecessary) for API-key requests.

Endpoints

  • GET /api/projects — list all projects on the account. Returns { projects: [{ id, name, created_at }, ...] }.
  • POST /api/projects — create a new project. Body: { name }. Returns the new project object.
  • PATCH /api/projects/:id — rename a project. Body: { name }. Returns the updated project object.

Changelog

2026-08-18

  • Fixed: GET /api/invoices/:id reported 0 for a confirmed transaction's amount. Each entry in transactions[] is assembled from two events — the detection, which carries the amount and the payer, and the confirmation, which carries neither. The confirmation was overwriting both, so once a payment finalised its amount read 0 and from_address could read null. Both values now survive confirmation. The invoice's own received total was never affected — if you reconcile on that field, nothing changes for you.
  • Top-ups now expire. A top-up deposit address is only monitored for 60 minutes, but unpaid top-ups stayed pending indefinitely and kept offering that address. They now move to expired once the window closes, so the dashboard stops pointing at an address that is no longer watched. A deposit that still arrives against an expired top-up is credited as before.

2026-08-14

  • Reliability: a succeeded write is no longer reported as 500. Cassia records an internal entry for every change you make. Until now, if writing that entry failed, the request returned 500 INTERNAL_ERROR even though the change itself had already been committed. Affected POST /api/invoices, POST /api/invoices/:id/cancel, POST /api/webhooks, PATCH /api/webhooks/:id, DELETE /api/webhooks/:id, POST /api/public-keys, POST /api/public-keys/batch, PATCH /api/public-keys/:id, DELETE /api/public-keys/:id and the API-key, top-up and account endpoints. These now return their normal success response. No request or response shape changes.
  • Why this matters for retries. On the affected endpoints a 500 could previously mean the write had succeeded, so a retry created a duplicate — a second invoice with a second deposit address, for example. That specific cause is gone. General advice is unchanged: treat 5xx as indeterminate and reconcile with GET /api/invoices before retrying a create.
  • Unchanged: payment detection, crediting, commission and webhook delivery. Nothing on the money path is affected, and no endpoint that previously returned 2xx now returns an error.

2026-08-13

  • Solana deposit detection is now event-driven too. A live notification from our chain provider can now trigger detection of a solana:mainnet deposit, instead of waiting for the next sweep. Nothing about the API changes — same events, same payloads, same ordering — and the ~1-minute sweep still runs and remains the guarantee, so keep budgeting for it rather than assuming a deposit is credited within seconds. Amounts and direction are still read from the chain itself, never from the notification.
  • GET /api/public-keys adds is_active. Boolean. true for the one wallet on that chain which POST /api/invoices will actually derive a deposit address from — the most recently added one. Cassia does not fail over between wallets on the same chain, so if you hold more than one, the others receive nothing. Merchants with a single wallet per chain see true everywhere. Additive field; no request changes.
  • POST /api/invoices can now return 503 on solana:mainnet. New codes solana_network_unavailable and solana_monitoring_unavailable. These replace an opaque 500 INTERNAL_ERROR that a provider outage used to produce: Cassia refuses to hand out a deposit address it could not prepare or could not register for monitoring, and now says so. Treat both as transient and retry — the invoice was not created and no address was reserved, so a retry is not a duplicate. If you switch on status codes, add 503 to your retry branch alongside 429. Other chains are unaffected.

2026-08-12

  • Solana support. Accept USDT and USDC on solana:mainnet — both are the standard 6-decimal SPL Token mints. Mainnet only (no Solana testnet) and generate-only: Solana's key format (ed25519) has no public-key-only derivation, so there is no Solana xpub to paste. The in-browser generator derives a fixed pool of 200 addresses on your device at m/44'/501'/{index}'/0' (indices 0–199) and now produces seven chain pairings instead of six. POST /api/public-keys rejects solana:mainnet. Finality is the finalized commitment level rather than a block depth; Solana deposits are picked up by a ~1-minute sweep and confirmed by a 10-minute one, so its webhooks can lag the chain more than the other networks do. Full details in Solana wallets.
  • POST /api/public-keys/batch accepts a second pairing shape. Alongside { chain, xpub }, a pairing may now be { chain: "solana:mainnet", rootPublicKey, addresses } with exactly 200 base58 addresses. The two shapes are mutually exclusive per chain, and a batch may still contain each chain at most once. Existing xpub-only callers are unaffected — the change is purely additive.
  • GET /api/public-keys adds fresh_count. The number of that wallet's addresses which have never been handed to an invoice. Deliberately not addresses_count − in_use_count, which also counts already-used addresses waiting out their reuse cooldown — on a fixed-size Solana pool the two diverge badly. Additive field; no request changes.
  • Solana is excluded from top-ups, auto-collect and on-chain balance lookups. POST /api/topup returns 400 for solana:mainnet (as it already did for Arbitrum); low-balance redirects never trigger for Solana invoices, so those payments always land in your wallet; and GET /api/public-keys/:id/balance / GET /api/public-keys/:id/addresses answer available: false for Solana wallets rather than a misleading zero.
  • Recovering Solana funds is your job, not ours. Cassia ships no sweep or consolidation tool for Solana in v1 and never accepts a seed phrase back. The derivation path is published on the seed-backup screen and again on the wallet card; to move funds, import your seed into Phantom or Solflare and send from the accounts holding a balance.

2026-08-07

  • Arbitrum One support. Accept USDT and USDC on eip155:42161. Same EVM derivation as Ethereum/BSC — no new xpub needed if you've already connected an Ethereum or BSC wallet through the in-browser generator, which now produces six chain pairings instead of five. Finality: ~13s (50 blocks). Referral partner-mode payouts can also target Arbitrum now. Note: the on-chain USDT contract on Arbitrum reads “USD₮0” — Tether's canonical successor token since a Feb-2025 migration, same issuer and 1:1 peg as classic USDT.
  • Wallet balance responses distinguish “not yet available” from a genuine zero. GET /api/public-keys/:id/balance and GET /api/public-keys/:id/addresses now return an available boolean. Live on-chain balance lookups aren't wired up for Arbitrum wallets yet, so those responses come back with available: false instead of an indistinguishable $0.00 — the dashboard shows “Balance lookup not yet available for this chain” for those wallets. Invoices and webhooks are unaffected; this is limited to the on-chain balance/addresses widgets on the Wallets page.
  • Arbitrum invoices skip low-balance protection and account top-ups. Auto-collect redirects (see Low-balance protection) never trigger for chain: "eip155:42161" invoices, regardless of your balance zone — those payments always land in your wallet. POST /api/topup also returns a 400 for the same chain. Top up your balance on Ethereum, BSC, or Tron in the meantime; accepting customer payments on Arbitrum is unaffected either way.

2026-06-26

  • Commission is now charged on every received payment. Previously the 1% fee accrued only when an invoice reached paid. It now accrues on each deposit we receive on your behalf — including partial (underpaid) payments and overpayments — at 1% of that payment's amount. The Credits transactions table now shows one row per payment; GET /api/billing/transactions gains payment_amount and tx_hash fields (the Rate column is computed against payment_amount, so it stays ~1% for partials). Additive and forward-only — payments received before this change are unaffected.

2026-06-23

  • GET /api/projects now includes per-project 30-day rollups. Each project object in the response now carries activity fields computed over a rolling 30-day window: revenue_30d (USD paid in the last 30 days), invoices_30d (invoices created in the last 30 days), underpaid (currently underpaid invoices), wallets (active wallets), webhooks_healthy (boolean — false when the active endpoint had a failed delivery in the last 24h), failing_webhooks (0 or 1), and last_payment_at (ISO-8601 timestamp of the most recent paid invoice, or null). No request changes needed; the new fields are additive.

2026-06-22

  • Projects: multiple isolated workspaces per account. An account can now contain multiple projects, each with its own wallets, API key, webhooks, and invoice history. New endpoints: GET /api/projects, POST /api/projects ({ name }), PATCH /api/projects/:id ({ name }).
  • API keys are now per-project. A csk_live_* key belongs to one project; requests authenticated with it automatically operate within that project. No action needed — every existing key was migrated to the account's initial Default project and continues to work without any code change.
  • Dashboard uses X-Cassia-Project header. Dashboard sessions send an X-Cassia-Project: <projectId> header to indicate the active project. API-key callers do not need this header — the key already determines the project automatically.

2026-06-16

  • Referral program: general availability. The referral program is now live for all merchants — the Referrals section is available in the dashboard and the /api/referrals endpoints are active. No code changes; existing API behaviour is unchanged.

2026-06-08

  • Referral earnings now vest on collected commission. A referrer's share is recorded when the 1% fee is charged but becomes withdrawable only once the referred merchant actually pays that fee (via top-up or auto-collect). The referral overview response adds pending_referral; each referee row in GET /api/referrals adds a pending field showing unvested earnings.

2026-06-06

  • Invoice/top-up amount validation no longer rejects clean sums. Amounts like 5.15, 13.73 or 0.1 were intermittently rejected with amount must be multiple of 0.000001 due to IEEE-754 float rounding in the validator. Any amount with up to 6 decimal places is now accepted; genuinely over-precise amounts (7+ decimals) are still rejected. No request changes needed — you can drop client-side workarounds that nudged the amount.
  • Tron Nile (tron:testnet) USDT detection. Nile invoices now use the USDT contract the official nileex.io faucet actually dispenses, TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf, so test deposits from the standard faucet are detected and credited (previously a different contract was configured and faucet deposits were silently skipped). Testnet only — mainnet behaviour is unchanged.
  • POST /api/referrals/activate — start referral promo window. Calling this endpoint starts the merchant's 30-day referral promo window. The call is idempotent: if the window was already started it simply returns without error. Response: { ok, just_activated }just_activated is true only on the first call. The dashboard masks the referral link until the merchant copies it; that copy action triggers this endpoint automatically.

2026-06-05 — dashboard

  • POST /api/auth/verify returns bonus_applied. When a brand-new merchant signs up through a +$10 bonus referral link, the verify response now includes bonus_applied: true so the dashboard can show a one-time welcome toast. Existing fields (ok, merchant_id) are unchanged.
  • GET /api/referrals adds promo_started_at. The overview response now exposes the promo start timestamp (nullable) so the dashboard's promo countdown shows exact elapsed/total progress.

2026-06-05

  • Referral program API. New merchant endpoints under /api/referrals: GET /api/referrals (tier, balance, referees), GET /api/referrals/ledger, GET /api/referrals/rate-history, GET /api/referrals/payouts, and TOTP-gated POST /api/referrals/transfer, POST /api/referrals/partner-mode, POST /api/referrals/payout ({ amount, asset, address, chain } — asset is USDT/USDC, chain is mainnet-only, and the asset must be supported on the chosen chain). Share links live at cassiapay.com/r/{code} (standard) and the -b10 variant (gives new merchants $10 in Credits). Rolled out gradually; see the 2026-06-16 entry for general availability.
  • Invoice received_usd column. Added to back referral volume accounting (1:1 with received for stablecoins). No change to existing response shapes.

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/new through 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).