AAVEX Pay
API reference v1

Accept crypto in about twenty lines

Two ways in. Open a checkout and send the customer a link — we handle the currency choice, the address, the QR and the waiting. Or open an invoice yourself if you already know which coin they are paying in and want to render the payment page inside your own app.

0.5% of what you process, and nothing else. No monthly fee, no minimum, no setup cost, and no charge for a month you took no payments in. The commission comes out of the payment on chain as it is swept to you — 0.45% above $50,000 a month, 0.4% above $250,000 — so there is never an invoice from us to pay, and nothing about your account with us can stop your checkout working. You choose whether you absorb it or your customer does; see who pays the commission.

Everything below is the same API in test and live mode. Start with a test key, drive your own webhook handler end to end with simulate-payment, then swap the key. Nothing else changes.

🔑 Authentication

A bearer token on every request. Two kinds exist and they are not interchangeable: an API key for your server, and a session for a human in the dashboard.

# Your server. The key carries its own mode and its own permissions.
curl https://api.avexpay.net/v1/organizations/ORG_ID/invoices \
  -H "Authorization: Bearer ak_live_…"
GET/v1/organizations

Who this credential belongs to. For an API key, exactly one organisation — this is how a server finds the orgId that every other path needs, so nothing has to be copied out of the dashboard by hand (though the API keys tab shows it too).

# 200
{ "data": [{ "id": "7b2c1e40-…", "name": "Your Store", "slug": "your-store" }] }

Two projects are two organisations, not two keys. A key belongs to one organisation and so does everything that organisation holds: its currencies, its wallets, its webhooks, its invoices and its commission balance. Two keys made in one organisation are two credentials over one set of books — the invoices mix, the reports add up together, and both shops are paid into the same wallets. If the two are meant to be separate, open a second organisation from Team → Organisations in the dashboard and make that project's key there; the picker in the sidebar moves between them. If they are not meant to be separate, one key is enough, and metadata on each invoice is how you tell the two apart in one set of reports.

Keys are shown once, at creation, and stored only as a hash — we cannot recover one for you. Each key carries a list of permissions, chosen when it is created, and a call it lacks the permission for fails with 403 scope_missing naming the one it needed. A shop's key needs invoice:create and invoice:read and nothing else.

invoice:createOpen checkouts and invoices.
invoice:readRead them back, list them, fetch receipts.
invoice:refundMark an over- or underpayment for return.
webhook:read / webhook:writeList, add, disable and replay webhook endpoints and deliveries.
asset:readWhich currencies and networks are enabled.
org:read, member:read, apikey:read, payout_address:readRead-only views of the rest.

A key can never be given apikey:write, payout_address:write or any permission that changes who is in the organisation: those are for a person with an authenticator, in the dashboard, never for a credential on a server.

Never put a key in a browser, a mobile app or anything a customer can read. Nothing in this API needs one client-side — the payer endpoints below take no credentials at all, which is exactly why.

🧪 Test and live

A key's prefix is its mode: ak_test_… or ak_live_…. Every invoice and checkout carries the mode it was created in, and echoes it back so your logs can never be ambiguous about which one you are looking at.

ak_test_…Only ever creates test objects. Asking for mode: "live" in the body changes nothing.
ak_live_…Only ever creates live objects, for the same reason in reverse.
sessionMay pass mode explicitly. Defaults to live.

That rule is one-directional on purpose. A key you paste into staging, CI or a third-party integration must not be able to take real money even if something asks it to — and a live key must not be able to quietly stop charging your customers.

A test invoice gets a deposit address that is not valid on any chain, so nothing can be sent to it by accident. Pay it with simulate-payment instead. Test volume never appears in your reports and never counts towards your bill.

💱 Amounts, and why they are strings

Every amount in this API is an integer in a string. Fiat is micro-dollars — "20000000" is $20.00. Crypto is the asset's smallest unit — "20100502512562814071" is 20.1005… USDT at eighteen decimals.

Not a style choice. Eighteen decimals does not fit in a double, and a gateway that rounded a balance would misreport what you are owed. Parse these with a big-integer type, never with parseFloat.

Amounts owed always round up to the asset's precision, so you are never left a fraction short of the figure you asked for. Valuations round down.

🧮 Who pays the commission

Our cut is taken on chain, by the contract that sweeps the payment to you: it sends us the commission and you the rest, in one transaction. There is no second transfer available, so "the customer pays the fee" cannot mean one. It means the invoice asks for more, so that what is left after the split is the price you quoted.

Set a default with POST /v1/organizations/:orgId/commission/fee-payer, and override it for one order by sending feePayer when you create an invoice or a checkout. A checkout that sends nothing follows your default at the moment the customer picks a currency, not the moment you created the link — a link can sit unopened for an hour, and a change you made in between was meant to apply.

merchantdefaultThe customer sends your price. You receive it less the commission.
payeropt inThe customer sends your price plus the commission. You receive your price.
# $20.00 of USDT, at 0.5%, with the customer paying the fee
{
  "assetId": "…",
  "amountFiatMicros": "20000000",
  "feePayer": "payer"
}

# 201 Created — amountDue is grossed up, amountNet is what reaches you
{
  "amountDue": "20106564793405046747",
  "amountNet": "20000000000000000000",
  "feeBps": 53,
  "networkFeeBps": 3,
  "feePayer": "payer"
}

The transfer costs something, and that part is always the customer's. feeBps above is 53 rather than your 50, and the three is networkFeeBps: what the chain charged to move this particular payment, measured when the invoice was created and expressed as a share of it. A $20 order on BNB Chain at a quiet moment is issued for about $20.01; the same order on TRON is issued for $20.00, because there the payment lands in your own wallet and we send nothing.

It is not subject to feePayer, and that is deliberate rather than an oversight. The commission is a price you may absorb as a courtesy to your customer; absorbing the cost of the transfer would mean paying to be paid. It is not our revenue either — it reimburses the wallet that pays for your settlements.

It is a rate rather than a fixed few cents because it has to be. The deposit address is a hash over the contract's own arguments and the rate is one of them, so what is charged is fixed at the moment the address exists and can never be revisited. A fixed fee would need a second transfer, and there is nobody to send it.

The customer is told. On the hosted checkout the commission appears as its own line, because a customer charged more than your price and not told why has been overcharged as far as they can tell. If your own payment page renders the invoice yourself, show it the same way — amountDue and amountNet give you the figures.

Two things this does not change. Our cut is the same either way, so this is your pricing decision and not ours. And it is snapshotted on the invoice: changing your default never moves the amount on an invoice already issued, because a payer may already be looking at it.

On an asset we take no commission on — Telegram Stars — feePayer: "payer" changes nothing at all. There is no fee to pass on, and we will not invent one for you to keep.

On TRON it works, but not on chain. There the payment goes straight into your own wallet and no contract of ours is in the path, so there is nothing to split. The commission is charged to your balance instead, and feePayer: "payer" still grosses the invoice up — you keep your price and the commission you owe cancels out.

Which also makes TRON the one chain with no networkFeeBps at all. Nothing of ours moves the money, so there is no transfer for anyone to pay for.

The minimum, and why it moves

Each chain has a smallest order it can carry, derived from live gas rather than set in a table: roughly two hundred and fifty times what one settlement costs there. At a quiet tenth of a gwei that is about $1.43 on BNB Chain and $11 to $17 on Ethereum, where one settlement costs four or five cents at today's gas; below it, POST /v1/invoices answers 422 amount_below_minimum and the hosted checkout shows that network as unavailable rather than letting a customer pick it.

The reason is not the gas itself — your customer covered that. It is that the charge is fixed when the invoice is created and the gas is paid when it settles, so a chain that got dearer in between leaves a gap. On a large order the commission absorbs it; on a small one it cannot, and we would rather refuse the order than take it and discover that later.

TRON, Solana and TON have no such floor, down to fifty cents. The payer pays a wallet of yours directly, so there is no settlement of ours to pay for and nothing about the order size can go wrong — which is what those networks are for, and where small orders belong.

👛 Your own wallets, on any network

A payment in a network's own coin — BNB, ETH, POL, TRX, SOL, TON — needs one of these. Those transfers emit no event, so they are found by watching the balance of wallets we know about, which works for yours and not for the per-invoice addresses we derive. Without a wallet on the network the coin is offered as unavailable and an invoice in it is refused; tokens are unaffected.

Register one to ten of your own addresses on a network and we hand them out. The payer's transfer goes straight into your wallet: no contract of ours in the path, nothing to sweep, no network fee for us to pass on, and no minimum order size. Each invoice asks for a very slightly different amount — 20.05, 20.03, then 20.011 — and that is how two payments to one address are told apart. It works on every network, including the ones where we also deploy per-invoice contracts; a wallet of yours on a network is used in preference to those.

The rule for a payment that does not match exactly: if one invoice is open on the wallet, it is credited to that invoice whatever the amount — an exchange rounded the withdrawal, or the payer typed the round number. If two or more are open, nothing on the chain says which it was for, so nothing guesses; it goes to our operators, where the payer's support ticket meets it.

GET/v1/organizations/:orgId/deposit-wallets
walletsYour pool. A retired wallet stays listed — invoices already open on it are still paid.
pendingAlways empty now that additions are immediate; kept so older clients keep parsing.
POST/v1/organizations/:orgId/deposit-wallets
chainrequired — any supported network. Up to a hundred wallets per network.
addressrequired — your own address. Checked, so a mistyped one is refused rather than accepted.
labelYour name for it, so a support conversation can refer to one.
DELETE/v1/organizations/:orgId/deposit-wallets/:walletId
—Retires it. Immediate, and invoices already open on it are still credited.
DELETE/v1/organizations/:orgId/deposit-wallets/pending/:changeId
—Cancels a scheduled addition. Any member may do this.

Every wallet is live the moment it is added. These are addresses your customers' money lands in, so adding one is owner-only, needs your second factor proven again, and emails everyone in your organization at once. A member who does not recognise an address retires it, which is immediate, and changes their password.

Retiring is immediate. Deliberately not symmetrical: adding an address can redirect money and waits; taking one away can only stop money arriving somewhere, and if you have spotted a wrong address you should not have to wait a day to stop using it.

Our commission on these payments is a balance. We are not in the path, so there is nothing for us to take a cut of as it passes — it is charged to your account instead and cleared by your next invoice on another network.

📉 Your balance

On most chains the commission never touches your books: the contract that sweeps a payment to you takes our cut in the same transaction, and there is nothing to settle afterwards. TRON is different, and the difference is in your favour — the payer sends to your own wallet, so there is no contract of ours in the path, no sweep, and no network fee for us to pass on.

The commission on those payments has to be collected some other way, so it is charged to a balance. Take $100 on TRON at 0.5% and your balance reads -500000 — fifty cents, in micro-dollars, owed to us.

GET/v1/organizations/:orgId/balance
balanceUsdMicrosSigned. Negative means you owe us.
creditLimitUsdMicrosHow far the balance may go before invoicing on accruing chains stops.
canInvoiceOnAccruingChainsfalse once the limit is passed.
entriesThe most recent 50, newest first — every movement, with the invoice it came from.
# GET /v1/organizations/:orgId/balance
{
  "balanceUsdMicros": "-500000",
  "creditLimitUsdMicros": "500000000",
  "canInvoiceOnAccruingChains": true,
  "entries": [
    { "kind": "accrual",  "amountUsdMicros": "-500000", "invoiceId": "…" }
  ]
}

How it is settled. Your next invoice on a chain where the cut can be taken on chain carries a slightly higher fee, and the extra clears the balance. It is capped at 2% on top of your own rate, so an invoice never has a surprising amount taken out of it — a large balance is cleared over several invoices rather than out of one. The raise applies to you and never to your customer: it is your balance, and grossing it onto a payer would charge them for it.

accrual−Commission on a payment we could not take a cut of.
recovery+Cleared by the raised fee on a later invoice.
settlement+You paid us directly.
adjustment±A correction, with a reason recorded.

A reversed payment reverses its commission. If a reorg takes a payment away, the accrual it created is undone. You are never left owing us a cut of a sale that did not happen.

There is a limit. Past $500 owed, new invoices on chains that accrue are refused with 402 balance_owed until the balance is settled. Chains where the commission is taken on chain keep working — those invoices are what clears it.

🧾 Checkouts — the shortest path

You know the amount; only your customer knows which coin they hold. A checkout holds the amount and lets them choose, which is why you do not have to pick an asset up front.

POST/v1/organizations/:orgId/checkouts
amountFiatMicrosrequired — micro-dollars. "25000000" is $25.00.
referenceYour order id. Also the idempotency key — see below.
descriptionShown to the customer above the amount.
successUrlWhere the payer goes once the payment is final: a “Return to your store” button the moment it confirms, and the page goes there by itself five seconds later. Without one the payer is told the payment is confirmed and stays on the receipt. Confirm the order from the webhook, never from this redirect — the URL carries nothing signed.
cancelUrlA “back to the store without paying” link, shown until a payment is seen.
ttlMsHow long they have to choose. One minute to one day; one hour by default.
modeSession credentials only. An API key's mode always wins.
curl -X POST https://api.avexpay.net/v1/organizations/ORG_ID/checkouts \
  -H "Authorization: Bearer ak_test_…" \
  -H "Content-Type: application/json" \
  -d '{"amountFiatMicros":"25000000","reference":"order-1042","description":"Order 1042"}'

# 201 Created
{
  "id": "9c1f…",
  "url": "https://avexpay.net/pay/9c1f…",
  "status": "open",
  "mode": "test",
  "amountFiatMicros": "25000000",
  "expiresAt": "2026-08-18T10:04:00.000Z"
}

Send the customer to url. Retrying with the same reference returns the same checkout and a 200 instead of a 201 — so a dropped response never becomes two payment links for one order, only one of which will ever be marked paid.

Once it is dead, the order is free again. A checkout that expired or was cancelled releases its reference, so asking for that order again opens a new checkout with a new url and a 201. That is what lets your "pay again" button work after a customer's window has closed. A checkout that has been paid never releases it: asking again returns the paid one, because a second link for an order already paid is how a customer pays twice.

So treat both codes as success and always use the url from the response you just got. Code that assumed the id could never change is the one thing here worth a second look.

GET/v1/organizations/:orgId/checkouts/:sessionId

The checkout as you see it, including invoiceId once the customer has chosen. Poll this, or take the webhook — the webhook is better.

POST/v1/organizations/:orgId/checkouts/:sessionId/cancel

Withdraw a checkout before it is paid. A paid checkout cannot be cancelled: the money has arrived, and pretending otherwise would leave your customer looking at a cancelled page having paid.

📥 Invoices — when you already know the asset

Use these if you are rendering the payment page yourself, or the customer has already chosen elsewhere. You need an assetId you have enabled and a payout address on its chain.

POST/v1/organizations/:orgId/invoices
assetIdrequired — from your asset list.
amountFiatMicrosFor fiat and fixed_rate pricing.
amountTokenFor token pricing. Exactly one of the two.
referenceYour order id, and the idempotency key — see the note below the response.
ttlMsHow long the payer has. Fifteen minutes by default.
modeSession credentials only.
curl -X POST https://api.avexpay.net/v1/organizations/ORG_ID/invoices \
  -H "Authorization: Bearer ak_test_…" \
  -H "Content-Type: application/json" \
  -d '{"assetId":"…","amountFiatMicros":"20000000","reference":"order-1042"}'

# 201 Created
{
  "id": "4d2a…",
  "mode": "test",
  "chain": "bsc",
  "status": "pending",
  "amountDue": "20100502512562814071",
  "amountPaid": "0",
  "depositAddress": "AVEXTEST-BSC-…",
  "memo": null,
  "feeBps": 53,
  "networkFeeBps": 3,
  "toleranceBps": 50,
  "expiresAt": "2026-08-18T09:19:00.000Z"
}

Send only that asset, on that chain, to that address. The address belongs to this invoice alone and can only ever pay your own wallet — but a different token, or the right token on the wrong chain, is not recoverable by us or by anyone.

The same reference twice returns the first invoice and a 200, so a retry after a dropped response never becomes two deposit addresses for one order. Once that invoice has expired the order id is released and the next request opens a new invoice with a new address and a 201 — otherwise an order whose payment window closed could never be paid at all. A paid invoice keeps its reference for good.

memo is non-null on TON, where a transfer carries a text comment and that comment is what names the invoice. Show it to your customer as prominently as the amount: the hosted page does, and a payer who omits it is matched by the amount rules instead, which is a fallback rather than the mechanism.

GET/v1/organizations/:orgId/invoices
statuspending, confirming, paid, underpaid, overpaid, expired.
chainOne chain.
referenceExact match on your own order id.
from, toISO timestamps.
limit1–200. Fifty by default.
cursorFrom nextCursor. Keyspace paging, not offset — this list grows while you read it, and an offset would silently skip rows.
GET/v1/organizations/:orgId/invoices/:invoiceId

One invoice with its payments and its settlement. Reversed payments are included and marked, rather than hidden — a payment you saw yesterday vanishing today would look like we lost it.

POST /v1/organizations/:orgId/invoices/:invoiceId/simulate-payment test mode only
amountSmallest units. Defaults to the full amount; pass less to produce an underpayment.

Credits a test invoice as though a payment arrived, firing the same webhooks the real path fires. This is how you test your handler without a faucet.

Refused with 409 not_test_mode on a live invoice, always. A live invoice is paid on chain or not at all.

⭐ Telegram Stars

If you sell inside Telegram, Stars can sit alongside your crypto orders — one invoice model, one dashboard, one webhook stream. What we do for Stars is narrower than for crypto, and the difference is structural rather than something we will fill in later, so it is worth reading before you build.

Your bot takes the Stars, not us. Stars paid to a bot land in that bot's own Telegram balance. There is no address, no chain and nothing for a third party to intercept — so AVEX is the record, not the custodian. We cannot hold them, settle them, or take a percentage of them, and we do not pretend to: a Stars invoice always carries feeBps: 0.

Setting up

Enable the XTR currency and set your own rate for it. Nothing prices Stars — Telegram sells them in bundles at varying rates — so the rate is yours, the same way it is for a token you issued. Stars are whole units, so an invoice too small to express in them is refused rather than rounded up.

Two ways to take them

They differ in who talks to Telegram, and nothing else. Same invoice, same dashboard, same webhook — and the same rule that the Stars land in your bot's balance either way.

Your bot does it. You call createInvoiceLink yourself and report the charge back to us with the route below. Nothing of yours is stored here, and your bot keeps its own webhook. This is the rest of this section, and the right choice if you already have a bot your customers talk to.

We do it. Connect your bot in the dashboard, under Currencies, and Stars appear on your hosted payment page beside your other currencies — the payer taps a button that opens Telegram, and we answer the payment. Nothing below is needed: no createInvoiceLink, no reporting call.

Connecting a bot takes over its webhook. Telegram allows one per bot, so every update that bot receives comes to us. We answer the two payment updates and forward the rest to a URL you give — set one if that bot does anything else, or use a bot that does not. Your token is stored encrypted and no route ever returns it.

Taking a payment with your own bot

POST/v1/organizations/:orgId/invoices

The same route as any invoice, with the XTR asset. You get back a depositAddress of the form telegram:<uuid> — not an address, a payload. Put it in invoice_payload when your bot calls Telegram's createInvoiceLink with currency: "XTR".

POST /v1/organizations/:orgId/invoices/:invoiceId/telegram-payment
chargeIdrequired — telegram_payment_charge_id from successful_payment. Also the idempotency key.
amountStarsrequired — total_amount from the same object.
payloadinvoice_payload. Checked against the invoice when sent, which catches a payment reported against the wrong order.
# In your bot's successful_payment handler
curl -X POST https://api.avexpay.net/v1/organizations/ORG_ID/invoices/INVOICE_ID/telegram-payment \
  -H "Authorization: Bearer ak_live_…" \
  -H "Content-Type: application/json" \
  -d '{"chargeId":"...","amountStars":"200","payload":"telegram:4d2a..."}'

Safe to call twice. Telegram does not promise an update arrives exactly once, so the charge id is the idempotency key — a repeat answers alreadyRecorded: true and credits nothing further. Reporting the same charge against a different invoice is refused with 422 charge_reused: that is an integration bug, not a retry, and treating it as one would mark a second order paid against the first one's money.

What this means for your figures

The evidence for a Stars payment is your API key and nothing else — there is no chain for us to read. So Stars are recorded as self-reported and contribute no priced volume to your reports. They are visible as what they are rather than blended into figures we can stand behind.

🌐 Payer endpoints no credentials

The hosted checkout page uses these, and you can too if you are building your own. They take no authentication — the session id in the URL is the whole authorisation, so treat that link as a secret and send it only to the customer it belongs to.

Nothing here reveals your payout address, your commission or your other orders. Browser access is allowed only from origins you register with us.

GET/pay/:sessionId/statepublic

The amount, your business name, the status, and — once a currency is chosen — the address to pay. Poll this to follow the payment.

GET/pay/:sessionId/optionspublic

Every currency and chain you can be paid in, with the exact amount for each at a rate we will honour. An asset we cannot price right now comes back available: false with a reason rather than disappearing.

POST/pay/:sessionId/selectpublic
assetIdrequired — from /options.

Creates the invoice and returns the address. Picking the same asset twice is a no-op. Switching before anything is sent gives a new address; switching after money is on its way is refused with 409 locked.

🧾 Receipts no credentials

Every checkout has a receipt at {url}/receipt. Creating a checkout returns it as receiptUrl, so you can file it with the order straight away — it answers 409 not_paid until the payment lands. Once it does, the payer reaches it from the payment page themselves, with no sign-in.

GET/pay/:sessionId/receiptpublic

What was bought, from whom, for how much, in which asset on which chain, and the transaction hashes. The hashes are the point: everything else on a receipt is something we assert, and those are something the payer can check against a public chain without trusting either of us.

An overpayment gets a receipt, and it says so. The money arrived, so the payer is entitled to the record — but it names the excess and says you owe them the difference, rather than printing as a clean settlement. An underpayment gets no receipt at all: the bill is not settled, and a document that looked like one would be worse than none.

A test receipt says it is a test, above everything else on the page. Nothing was sent — a test invoice's address is valid on no chain — and a document headed "Paid in full" is exactly the thing somebody files and later produces as proof of a payment that never happened.

🔔 Webhooks

Take the webhook rather than polling. Every delivery is signed; the secret is shown once when you create the endpoint.

POST/v1/organizations/:orgId/webhook-endpoints
urlrequired — HTTPS.
eventsrequired — at least one of *, invoice.confirming, invoice.paid, invoice.underpaid, invoice.overpaid, invoice.expired, payment.reversed. Anything else is a 400 naming the list. * is all of them, present and future. An endpoint subscribed to nothing receives nothing, which looks exactly like a broken integration.
# 201 Created — store the secret now, it is shown once.
{ "id": "…", "secret": "whsec_…" }
GET/v1/organizations/:orgId/webhook-endpoints

Your endpoints with pending and failed delivery counts. Never the secret.

POST/v1/organizations/:orgId/webhook-endpoints/:endpointId/disable

Stop delivering, without deleting the history.

POST/v1/organizations/:orgId/webhook-endpoints/:endpointId/enable

Resume.

GET/v1/organizations/:orgId/webhook-deliveries

Recent attempts with the last error, so you can see why your endpoint is not receiving instead of guessing.

POST/v1/organizations/:orgId/webhook-deliveries/:deliveryId/replay

Queue one delivery again after you have fixed your end.

What arrives

One POST per event, JSON, with these headers. There is no other scheme and no other header names — not Svix's, not Stripe's.

avex-signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 of `${t}.${rawBody}` with your whsec_… secret. The only header you must check.
avex-eventThe event name, same as type in the body.
avex-idempotency-keyStable across every retry of one event. Dedupe on this, or on id in the body — they identify the same thing.
avex-attempt1 on the first try, counting up.
# invoice.paid
{
  "id": "6f1c…",                 // this event's id — one per event, never the invoice's
  "type": "invoice.paid",
  "event": "invoice.paid",          // the same, older name
  "invoiceId": "36f0…",
  "reference": "order-1042",     // what you passed at creation
  "mode": "live",                // refuse a test event against a live order
  "chain": "bsc",
  "status": "paid",
  "amountDue": "1012000000000000000",
  "amountPaid": "1012000000000000000",
  "txHash": "0x…"
}

One invoice produces several events — confirming when a transfer is seen, then paid (or underpaid, overpaid) when it is final — and they share invoiceId. A handler that dedupes on invoiceId keeps the first and drops the one that mattered. Dedupe on id; act on status.

Verifying a delivery

Compare in constant time, and check the timestamp. A signature check that leaks timing, or that accepts an hour-old payload, is not a signature check.

# Node — parse the header, then check
const header = req.headers['avex-signature'];            // "t=1725800000,v1=ab12…"
const timestamp = Number(header.match(/t=(\d+)/)[1]);
const signature = header.match(/v1=([0-9a-f]+)/)[1];
const expected = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) reject();
if (Math.abs(Date.now() / 1000 - timestamp) > 300) reject();

Deliveries retry, so your handler must be idempotent — key on the event id, not on having received the request. Answer 2xx quickly and do the work after; anything else is retried with backoff. And read the amount, not just the status: an underpaid invoice has real money against it.

⚙️ Before your first live payment

Three things, or invoices will be refused with a reason saying which.

GET/v1/organizations/:orgId/assets

Everything you could accept, with whether you have enabled it, its pricing mode and its review verdict. Only approved assets can be invoiced in.

GET/v1/organizations/:orgId/payout-addresses

Where your money goes, per chain. You need one on every chain you accept — funds can only ever move to an address you configured.

POST/v1/organizations/:orgId/payout-addresses

A change here is scheduled rather than immediate, and we email you. That delay is the difference between a stolen session costing you one order and costing you everything after it.

GET/v1/organizations/:orgId/commission

Your rate, the next rung on the ladder, and what you have processed this period. There is no monthly fee and no minimum: 0.5% of what you process, 0.45% above $50,000 a month, 0.4% above $250,000. The rate is reviewed when a month closes and moves in both directions.

POST/v1/organizations/:orgId/commission/fee-payer

{ "feePayer": "merchant" | "payer" }. Your default for new invoices; see who pays the commission. Invoices already issued keep the amounts they were created with.

GET/v1/organizations/:orgId/reports/volume

Live volume by chain and asset, counted from credited unreversed payments rather than from invoices marked paid. The two differ after a reorg, and this is the one you should reconcile against.

GET/v1/prices

The rates we are willing to quote, and which sources back them. A rate we cannot corroborate across sources is not returned at all.

GET/healthpublic

For your monitoring. No credentials.

🚦 Errors

Every failure is { "error": "code", "message": "…" }. Branch on error; show message to a human. The status tells you which of you has to act.

400invalid_requestThe body did not parse. fields says which.
401unauthenticatedNo credential, or not a valid one.
403scope_missingThe API key is real but was not given that permission. permission names it.
403permission_deniedA session whose role does not allow the action.
403elevation_requiredA dashboard action that needs a code from the authenticator within the last five minutes.
404not_foundNo such route, or no such object — also what you get for something belonging to another merchant, since confirming it exists would leak the id space. Every route is on this page; there is no /v1/health and no /v1/pay/….
409asset_disabledEnable it first.
409asset_unapprovedStill in review, or blocked.
409asset_unlistedWe have stopped accepting that currency — our side, not yours. Invoices already open still complete.
409no_payout_addressAdd one for that chain.
409fixed_rate_expiredYour own rate lapsed. Set a current one.
409not_test_modeThat only works on a test invoice.
409not_paidYou asked for a receipt before the payment landed. The link is real — come back once it has.
409not_starsThat invoice is not in Stars. A crypto invoice is paid on chain.
422charge_reusedThat Telegram charge is already recorded against another invoice.
422payload_mismatchThe Telegram payload belongs to a different invoice.
409lockedA payment is already on its way; the currency cannot change.
410expiredThe checkout link was real and is now gone. Issue a new one.
422amount_invalidZero, negative, both amounts at once, or an asset too coarse to express it.
422amount_below_minimumToo small for that chain right now. The message names the figure; a cheaper chain has no such floor.
429rate_limitedretry-after says how long.
503price_unavailableNo rate we trust right now. Retryable — retryAfterSeconds is a hint. We would rather refuse than misprice your invoice.

🔌 If you are on WooCommerce

There is a plugin, and it is the whole integration — you do not call this API at all. Install it, paste an organisation id, an API key and a webhook secret, and orders are paid in crypto. It is in this repository under integrations/woocommerce.

you set4 fieldsOrganisation id, API key, webhook secret, API base.
it sendsno customer dataThe order total, the order id, your store name, the return URLs. Not a name, an email or an address.
it completeson a webhookOnly a signed webhook marks an order paid. The page a customer returns to never changes an order.

The gateway hides itself until it is fully configured. A gateway missing its webhook secret would take a payment and never complete the order — the customer pays and waits while the shop sees nothing — so it does not appear at checkout at all until all four fields are filled in.

Underpaid, overpaid and late payments all put the order on hold rather than failing it. Real money arrived in every one of those cases, and an order marked failed with funds sitting against it is the version of this that loses somebody's payment.

Anything else

Two calls and one webhook. POST /checkouts to get a URL to send the customer to, then handle the webhook to know it was paid — every example on this page is a complete request. If your platform can do those two things, it can take crypto through AVEX, and the webhook section is the half worth reading twice.