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
- Generate an API key (Settings → API).
- Connect a wallet — paste an xpub from your existing wallet, or generate one in the browser via the built-in BIP39 tool.
- POST
/api/invoicesto mint a payment request. - Receive a webhook event when paid.
- Grant your customer access — typically on
invoice.confirmed.
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" }
}'- ✓ 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
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
| Cassia | Stripe | Telegram Wallet Pay | |
|---|---|---|---|
| Custody | Non-custodial — your wallet | Custodial — Stripe holds funds | Custodial — Telegram holds funds |
| KYC | None | Required (business + ID) | Required for payouts |
| Settlement asset | USDT / USDC, native | Fiat (USD/EUR/etc.) | USDT (TON), TON, BTC |
| Refund mechanism | You send from your wallet | One-click via dashboard / API | In-app, Wallet-mediated |
| Fees | 1%, post-paid | ~2.9% + $0.30 per charge | ~0.4–3%, varies |
| Networks | Ethereum, BSC, Tron | Card rails (Visa/MC/etc.) | TON, BTC |
| Where money lands | Your on-chain wallet, instantly | Stripe balance → bank, T+2 | Wallet 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
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
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
redirectedflag 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. Checkreceived_amountand decide.expired— TTL elapsed without matching transaction. Webhook:invoice.expired.cancelled— you called cancel before payment. Terminal; no webhook.
Webhook timing
invoice.paidfires seconds after the customer broadcasts.invoice.confirmedfires 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.
Chains & tokens
Supported matrix
| Chain | CAIP-2 id | USDT | USDC | Finality |
|---|---|---|---|---|
| Ethereum | eip155:1 | ✓ | ✓ | ~3 min (12 blk × 12s) |
| BSC | eip155:56 | ✓ | ✓ | ~45s (15 blk × 3s) |
| Tron | tron: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
xpubversion bytes (0488b21e).ypub/zpub(SegWit Bitcoin),tpub(Bitcoin testnet format), andxprv(private keys) are rejected. ETH/Tron testnets use the samexpubformat 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.
| Situation | Status field | What you do |
|---|---|---|
| Underpaid | status: 'underpaid' | Check received_amount vs expected_amount. Don't grant access until you decide policy: prorated access, or wait for top-up. |
| Expired | status: 'expired' | Create a new invoice. The old one is terminal. |
| Duplicate webhook | Same event + invoice_id arrives twice | Idempotent handler: dedupe by (event, invoice_id) and ignore the second. |
| Late payment | status: 'paid' (or confirmed) on a previously expired invoice | Cassia 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 → API → Create 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
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):
{
"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.
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/rotateissues 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 freshcsk_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:
{
"error": "CONFLICT",
"message": "Wallet for this chain is already registered."
}Error code catalog
error | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing/invalid API key or session cookie. |
FORBIDDEN | 403 | Account suspended (read-only mode), or TOTP missing on a 2FA-gated route. |
NOT_FOUND | 404 | Resource not visible to your merchant_id. |
CONFLICT | 409 | E.g. duplicate xpub, max active API keys reached. |
RATE_LIMITED | 429 | Per-IP or per-merchant ceiling hit. Back off + retry. |
VALIDATION_ERROR | 400 | Request body failed schema validation. The message field names the offending property. |
ERROR | 400 | Generic 400 — usually a wallet or invoice domain rule (wrong xpub depth, wrong chain family, uncancellable status). Read message for the human-readable reason. |
INTERNAL_ERROR | 500 | Unhandled 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)
- Customer clicks "Pay" in your UI.
- Server:
POST /api/invoiceswith{ chain, token, amount, metadata }. - Show the returned address to the customer (QR + copyable).
- Customer sends payment from their wallet.
- Cassia observes the tx and calls your webhook with
invoice.paid. - 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 bystatus,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. Throws400if the invoice ispaid,confirmed, orexpired. Onlypendingandunderpaidare 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.
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 sharedwallet_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 betweensingle_useandreuse.DELETE /api/public-keys/:id— soft-delete (revoke). Historical derivations remain.
Troubleshooting
m/44'/60'/0' for EVM.ypub/zpub (Bitcoin SegWit). Cassia accepts only standard xpub. Regenerate in the right format.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.
curl -H "Authorization: Bearer csk_live_..." \
https://cassiapay.com/api/public-keys/pk_abc123/balancecurl -H "Authorization: Bearer csk_live_..." \
https://cassiapay.com/api/public-keys/pk_abc123/addressesBilling & 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 }.statusis'critical'when balance has reachedcredit_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— recentcommission_logrows 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 }.canCloserequiresactiveInvoices === 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.
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. Returnssigning_secretonce. 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 syntheticwebhook.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
{
"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.
| Event | amount | received | tx_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}.
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 yoursigning_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).
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),
);
}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)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
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
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.paidfor 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.underpaidwithout first checkingreceived_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
- 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.
- Get faucet funds for the address Cassia derives.
- Create a test invoice via API or UI. Pick the testnet wallet you connected.
- Pay the invoice from MetaMask (with the Sepolia network selected) or TronLink (with Nile selected).
- Verify your webhook endpoint received the
invoice.paidcallback with a valid signature. - When ready, swap your environment's chain to
eip155:1(ortron:mainnet) and re-run — same code path.
Common mistakes
Things that derail integrations or pile up risk.
invoice.paid alone. Wait for invoice.confirmed, and for underpaid invoices verify received_amount first.Changelog
2026-05-18
- Operator dashboard. Internal
/panelkapage 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/balancereturns aggregated USDT/USDC totals per wallet; newGET /api/public-keys/:id/addresseslists every derived address that currently holds a stablecoin balance. Each row carries asourcefield —invoice(address was assigned to a Cassia invoice;invoice_idincluded) ordirect(deposit landed without an invoice — webhooks did not fire). 30s server-side cache, 60 req/min/merchant rate limit. Wallet cards on/walletsmirror the split as Through Cassia stats vs the On-chain live total. - POST /api/invoices includes deposit_address. Create-invoice response now exposes
deposit_addressdirectly — no follow-upGET /api/invoices/:idneeded 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=trueenforces single-active-endpoint per merchant.POST /webhooksandPATCH /webhooks/:id active=true409 with friendly copy when violated. Recently added invoice-rate-limit (10 req/sec/merchant) onPOST /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/batchregisters up to 10(chain, xpub)pairings atomically under a sharedwallet_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.csvreturns RFC 4180 output filterable by status / chain / token / testnet. - Logout endpoint.
POST /api/auth/logoutclears the session cookie server-side and remains callable in soft-suspended mode. - Soft-suspend mode. Suspended accounts return
403 ForbiddenwithAccount suspended — read-only mode. Contact support.on writes;GETand 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-deliveriesrows now carry per-call latency for endpoint health debugging. - Transactions endpoint.
GET /api/billing/transactionsjoinscommission_logto 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).