API Reference

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.

Base URLhttps://xazum.com/api/v1
AuthX-Api-Key
AssetUSDT · TRON (TRC-20)

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.

1

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.

2

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.

3

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.

4

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.

5

Verify the webhook and credit

Check the signature against the raw body, answer 2xx, and credit on deposit.screened. The full receiver is below.

6

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.

Your app
POST /invoices We take an address from the pool and return a payment_url.
Your app
Redirect the payer They land on our page. Set return_url and they come back to you afterwards.
Payer
Sends USDT From any wallet. We never see their keys and neither do you.
TRON
The block solidifies About a minute. We credit only from solidified blocks, so a reorg cannot un-credit a payment.
Xazum
Screening Sanctions and issuer blacklist. This gates the payout, never the receipt — an incoming transfer cannot be refused.
Your app
deposit.screened → credit the user The money is final on chain and clear to move.

Other

POST /api/v1/addresses Get a payer's permanent address

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.

Request body
FieldType
customer_ref string REQUIRED
Your identifier for this payer — a user id, not an email. The same value always returns the same address.
Responses
CodeReturns
200 OK
address · created_at · currency · customer_ref · network
201 Created
address · created_at · currency · customer_ref · network
POST /api/v1/invoices Create an invoice

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.

Request body
FieldType
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.
Responses
CodeReturns
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
GET /api/v1/invoices/{invoice_id} Read an invoice

Authoritative state. Poll this whenever a webhook has not arrived.

Parameters
NameInType
invoice_id path string REQUIRED
Responses
CodeReturns
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
GET /api/v1/invoices/by-order/{order_id} Read an invoice by your own id
Parameters
NameInType
order_id path string REQUIRED
Responses
CodeReturns
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
GET /api/v1/deposits List deposits

Every transfer we have seen for this merchant, newest first.

Parameters
NameInType
invoice_id query optional
status query optional
limit query integer optional
offset query integer optional
Responses
CodeReturns
200 OK
count · items
GET /api/v1/health Service health

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.

Responses
CodeReturns
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

EventFires whenWhat to do
deposit.confirmedTransfer seen on a solidified blockShow “processing”. Do not credit yet.
deposit.screenedScreening passedCredit the user. This is the one.
deposit.quarantinedScreening failedDo not credit. Escalate to a human.
invoice.paidInvoice fully coveredFulfil the order
invoice.underpaidLess arrived than askedCredit what arrived, or ask for the rest
invoice.overpaidMore arrived than askedCredit what arrived
invoice.expiredWindow closed unpaidClose the checkout
withdrawal.completedFunds sent to your payout addressReconcile

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

CodeMeaning
401Missing, unknown or revoked API key
403The project is paused or disabled
404No such invoice, or it belongs to another project
409Wrong endpoint for this project's address mode — the message says which to use
422Validation 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.

Xazum API v1.0.0 · generated from the running schema · OpenAPI JSON