Accept USDT
without holding keys.
Create an invoice, send the payer to the hosted page, and act on the
signed webhook when the money is confirmed. Authenticate with
X-Api-Key. Every response is JSON, and amounts are decimal
strings — never floats.
Overview
Three rules carry most of the integration. The rest of this page is detail.
Send the payer to payment_url. Do not
render the address yourself. The hosted page locks the network, draws a QR
every wallet reads the same way, and shows the confirmation window — the
three things that stop a transfer nobody can recover.
Credit on the webhook, not on the API response. The response means an address was issued, nothing more.
Reconcile. Webhooks are a latency optimisation; the API is the truth.
Getting started
Six steps, in order. The last one is not optional — nothing before it proves the chain actually works end to end.
Issue an API key
Panel → Projects → your project → API keys → Issue. It is displayed once and stored as a hash; there is no way to read it back. Losing it costs nothing — issue another and revoke the old one.
Point us at your webhook
Same page, Webhook URL. Until it is set, notifications are
recorded as dropped — your project still works, but you
find out about payments by polling instead of in seconds.
The signing secret is shown once, when the project is created. Put it in your app's environment; you will need it in step 5.
Create an invoice
curl -X POST https://xazum.com/api/v1/invoices \ -H "X-Api-Key: $XAZUM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "topup-8842", "amount": "25.00", "return_url": "https://your-site.example/billing/done?o=topup-8842", "metadata": {"user_id": 8842} }'
order_id is idempotent: retrying returns the
original invoice with 200 instead of minting a second
address, which would mean one payment you could never reconcile.
Put your user id in metadata — it comes back on every
webhook and saves a lookup at the moment you most want the path to
be short.
Send the payer to payment_url
Redirect, or open it in a new tab. With return_url set,
a paid checkout brings them back to you by itself.
Do not render the address in your own UI. Every wallet disagrees about URI schemes, and a USDT transfer sent on the wrong network is gone — not recoverable by us, by you, or by their wallet.
Verify the webhook and credit
Check the signature against the raw body, answer
2xx, and credit on deposit.screened. The
full receiver is below.
Pay a real invoice, once
One or two USDT, from a real wallet, all the way through to your user seeing access. A staging test cannot tell you whether your middleware redirects our POST, whether your secret matches, or whether your ledger dedupes — and those are the three things that break.
Before you rely on it in production:
— a reconciliation job that polls
GET /invoices/by-order/{order_id} for anything your side
still thinks is unpaid;
— an alert on scanner_lag_blocks from
GET /health;
— 2xx for events you do not handle, so an
unknown event does not become a retry storm;
— dedupe on (txid, log_index), because
delivery is at-least-once by design;
— parse amounts as decimals. A float will be wrong eventually, and the error will be small enough that nobody notices for months.
Authentication
One header on every request, including /health. The key is
shown once when it is issued and stored as a hash — losing it costs
nothing, issue another and revoke the old one.
# every call looks like this curl https://xazum.com/api/v1/invoices \ -H "X-Api-Key: xz_live_…" \ -H "Content-Type: application/json" \ -d '{"order_id":"topup-8842","amount":"25.00"}'
A missing, unknown or revoked key all answer 401 with the
same body. That is deliberate: distinguishing them would tell an
attacker which keys once existed.
How a payment works
Worth reading once before the endpoints, because the shape explains why crediting happens where it does.
payment_url.return_url and they come back to you afterwards.Other
Return the deposit address for one of your users, creating it once.
This is the cheap path and the one to prefer for balance top-ups. The payer
keeps the same address forever, so their payments accumulate and one sweep
moves all of them — roughly a sixth of the on-chain cost of a fresh address
per payment. It also keeps every repeat payment on the cheaper transfer
(a transfer into a non-empty address costs half the energy), which is money
paid by your user, not by you.
Idempotent by construction: the same `customer_ref` always returns the same
address, so it is safe to call on every page load.
| Field | Type | |
|---|---|---|
| customer_ref | string | REQUIRED
Your identifier for this payer — a user id, not an email. The same value always returns the same address. |
| Code | Returns |
|---|---|
| 200 |
OK
address · created_at · currency · customer_ref · network
|
| 201 |
Created
address · created_at · currency · customer_ref · network
|
Issue a fresh deposit address for an amount.
Idempotent on `order_id`: retrying a create returns the original invoice
with 200 rather than minting a second address. That matters — a duplicate
address for one order means one payment the merchant may never reconcile.
| Field | Type | |
|---|---|---|
| amount | number | string | REQUIRED
Amount in USDT, e.g. "25.00". |
| order_id | string | REQUIRED
Your identifier for this payment. Reusing one returns the existing invoice instead of creating a second address. |
| currency | string | optional
Only USDT is supported today. |
| metadata | object | optional
Echoed back on every webhook. Max 4 KB. |
| return_url | string | optional
Where to send the payer when the checkout finishes. A button appears on the payment page, and a paid invoice redirects there automatically after a few seconds. http/https only. |
| ttl | integer | null | optional
Seconds before the address stops being advertised. Late payments are still credited. |
| Code | Returns |
|---|---|
| 200 |
OK
address · amount_received · amount_requested · created_at · currency · expires_at · id · metadata · network · order_id · outstanding · paid_at · payment_url · return_url · status
|
| 201 |
Created
address · amount_received · amount_requested · created_at · currency · expires_at · id · metadata · network · order_id · outstanding · paid_at · payment_url · return_url · status
|
Authoritative state. Poll this whenever a webhook has not arrived.
| Name | In | Type | |
|---|---|---|---|
| invoice_id | path | string | REQUIRED |
| Code | Returns |
|---|---|
| 200 |
OK
address · amount_received · amount_requested · created_at · currency · expires_at · id · metadata · network · order_id · outstanding · paid_at · payment_url · return_url · status
|
| Name | In | Type | |
|---|---|---|---|
| order_id | path | string | REQUIRED |
| Code | Returns |
|---|---|
| 200 |
OK
address · amount_received · amount_requested · created_at · currency · expires_at · id · metadata · network · order_id · outstanding · paid_at · payment_url · return_url · status
|
Every transfer we have seen for this merchant, newest first.
| Name | In | Type | |
|---|---|---|---|
| invoice_id | query | optional | |
| status | query | optional | |
| limit | query | integer | optional |
| offset | query | integer | optional |
| Code | Returns |
|---|---|
| 200 |
OK
count · items
|
Scanner lag is the number that matters.
A growing lag means deposits are arriving on chain and we are not seeing
them yet — payments look missing to your users while nothing is technically
"down". Alert on this, not on whether the API answers.
| Code | Returns |
|---|---|
| 200 |
OK
ok · scanner_lag_blocks
|
Webhooks
This is the half of the contract OpenAPI cannot describe, because it describes what we send you rather than what you call — and it is the half that decides whether a payment credits.
X-Xazum-Event: deposit.screened X-Xazum-Event-Id: 6bd0a2f4-… unique per delivery X-Xazum-Timestamp: 1786296893 unix seconds X-Xazum-Signature: v1=9f2c… HMAC-SHA256, hex
The signature is HMAC-SHA256(secret, "{timestamp}." + raw_body),
keyed with your project's webhook secret.
Verify against the raw body. Parsing and re-serialising JSON
changes whitespace and key order, and the signature then describes
something you no longer have. In Django that means
request.body — never request.POST or a DRF
request.data.
A 302 is not an acknowledgement. A signed POST does not survive a redirect, so a login-page redirect looks like success to your framework and like failure to us. Exempt the endpoint from auth middleware explicitly.
Return 2xx — including for events you have already processed.
Delivery is at-least-once: dedupe on X-Xazum-Event-Id and
answer 200 for repeats. Returning an error for a duplicate turns a
harmless retry into a retry storm. Retries are exponential, twelve
attempts, about a day in total — long enough to survive your deploy.
Events
| Event | Fires when | What to do |
|---|---|---|
| deposit.confirmed | Transfer seen on a solidified block | Show “processing”. Do not credit yet. |
| deposit.screened | Screening passed | Credit the user. This is the one. |
| deposit.quarantined | Screening failed | Do not credit. Escalate to a human. |
| invoice.paid | Invoice fully covered | Fulfil the order |
| invoice.underpaid | Less arrived than asked | Credit what arrived, or ask for the rest |
| invoice.overpaid | More arrived than asked | Credit what arrived |
| invoice.expired | Window closed unpaid | Close the checkout |
| withdrawal.completed | Funds sent to your payout address | Reconcile |
Why credit on screened and not on confirmed.
confirmed means the money is final on chain — the payer
really did pay. screened means it also cleared sanctions
and issuer-blacklist checks, so it can actually be moved to your
wallet.
Between those sits the case that matters: funds from a sanctioned or frozen source. They are real on chain and will never reach your payout address, because sweeping them would put the taint on the wallet holding everything else. Crediting a customer for money you can never withdraw is a loss you take twice.
invoice.* events fire only when the invoice actually
changes state. A second chunk landing on an already-partial
invoice does not re-fire invoice.underpaid — you hear about
that payment through deposit.confirmed.
A working receiver
Paste-ready for a Django consumer.
@csrf_exempt # signed, not cookie-authenticated @require_POST def xazum_webhook(request): if not _verify(request): return HttpResponseForbidden("bad signature") if request.headers["X-Xazum-Event"] != "deposit.screened": return HttpResponse("ignored") # 2xx, so it is not retried payload = json.loads(request.body) try: with transaction.atomic(): # UNIQUE(txid, log_index) is what makes this safe to receive # twice — and you will receive it twice, by design. Deposit.objects.create( txid=payload["txid"], log_index=payload["log_index"], user_id=payload["metadata"]["user_id"], amount=Decimal(payload["amount"]), ) credit_balance(payload["metadata"]["user_id"], Decimal(payload["amount"])) except IntegrityError: pass # already processed; normal return HttpResponse("ok") def _verify(request): signature = request.headers.get("X-Xazum-Signature", "") timestamp = request.headers.get("X-Xazum-Timestamp", "") if not signature.startswith("v1=") or not timestamp.isdigit(): return False if abs(time.time() - int(timestamp)) > 300: # replay window return False expected = hmac.new( settings.XAZUM_WEBHOOK_SECRET.encode(), f"{timestamp}.".encode() + request.body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature[3:])
(txid, log_index) identifies the transfer on chain and is
stable forever. It is the right idempotency key for your ledger too —
it is issued by consensus rather than by us, so it survives anything
either side does.
Errors
| Code | Meaning |
|---|---|
| 401 | Missing, unknown or revoked API key |
| 403 | The project is paused or disabled |
| 404 | No such invoice, or it belongs to another project |
| 409 | Wrong endpoint for this project's address mode — the message says which to use |
| 422 | Validation failed; the body names the field |
Errors return JSON with a detail field. Log it — the
messages are written to say what to do, not merely what went wrong.
Reconciliation
Run a job that finds invoices your side thinks are unpaid and asks
GET /invoices/by-order/{order_id}. Anything a webhook lost,
this finds. Without it, a delivery failure during a deploy becomes a
customer who paid and was never credited — and the only person who
notices is them.
GET /health reports scanner_lag_blocks. Alert
on it. A lag around 18–19 is normal: that is the distance to the
solidified head, which is where we read from. A growing lag
means payments are arriving on chain and not being seen, while nothing
is technically down.