Developers
API documentation.
Accept crypto non-custodially across EVM chains, Solana, and Tron. Each payment gets a unique on-chain address; funds settle directly to you with a transparent 0.4% protocol fee taken atomically on-chain.
Overview
The Paydex API is organized around payments. You authenticate with your wallet (Sign-In with Ethereum), create a payment, and show your customer the returned address. A relayer watches the address and sweeps it once the payment lands, usually within minutes, splitting 99.6% to your wallet and 0.4% to Paydex. You never give up custody.
We can't promise an exact time: detection depends on the network's block time, and settlement on how quickly the sweep transaction is included. Drive your own flow from the payment.confirmed and payment.settled webhooks rather than a timer.
Base URL: https://api.paydex.io · All requests and responses are JSON. Amounts are decimal strings in display units (e.g. "49.99"), never floats.
Authentication
Auth is wallet-based (EIP-4361). Request a nonce, sign the returned message with the merchant wallet, then exchange the signature for a bearer token used on all subsequent requests.
POST /v1/auth/nonce
{ "address": "0x1A2b…9A0b", "chainId": 56 }
→ { "nonce": "f3Ah9Qk2Tz", "message": "api.paydex.io wants you to sign in…", "expiresAt": "…" }POST /v1/auth/verify
{ "message": "<the message>", "signature": "0x8d3c…1b" }
→ { "token": "eyJhbGci…", "expiresAt": "…", "merchant": { "id": "mer_72Ka9Lp", … } }Send the token as Authorization: Bearer <token> on every authenticated request. Your wallet is your account: no passwords, no API keys to leak.
Read-only delegates. You can give a bookkeeper or teammate view access without sharing your wallet. Request a challenge from POST /v1/merchant/delegate-nonce with { "action": "add", "address": "0x…" }naming the delegate's own wallet, sign the returned message with your sign-in wallet, and submit the signature to POST /v1/merchant/delegates. The mechanics mirror payout changes: the statement names the exact wallet, and challenges are single-use and expire in 10 minutes. The delegate then signs in through the flow above with their own wallet and receives a token scoped to read. They can see payments and settings, and every change they attempt (payments, settling, payout addresses, webhooks) is rejected with 403 read_only_delegate. Revoking works the same way with { "action": "remove", … }and takes effect on the delegate's next request. Your delegates are listed on GET /v1/merchant, and each account holds at most 10.
Create a payment
By default you only set the amount: your customer chooses the network and currency (USDT/USDC on BNB Chain, Ethereum, Tron, Solana, …) on the hosted checkout, and the deposit address is derived the moment they pick. Every supported asset is a USD stablecoin, so the amount carries across networks 1:1. Posting the same reference again is idempotent (it upserts).
curl https://api.paydex.io/v1/payments \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "amount": "49.99", "reference": "order_10432" }'{
"id": "pay_3xKq8Z1mP",
"reference": "order_10432",
"status": "pending",
"chainSelection": "customer",
"address": null,
"amount": "49.99",
"asset": null,
"chain": null,
"feeBps": 40,
"expiresAt": "2026-06-08T10:30:00Z"
}Send the customer to the hosted checkout at /pay/<id>: it lists the available networks (options on GET /v1/checkout/<id>, already filtered to what you can accept and what the amount permits) and calls POST /v1/checkout/<id>/select when they pick. The payer can switch networks while the payment is still pending; once funds are seen the rail is locked. Building your own checkout? Those two endpoints are public: the unguessable payment id is the capability.
To force a specific rail instead (e.g. an invoice that must be USDT on Tron), pin it by passing chain and asset together:
curl https://api.paydex.io/v1/payments \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "amount": "49.99", "asset": "USDT", "chain": "bsc", "reference": "order_10432" }'Pinned payments return the deposit address immediately. It is derived deterministically (CREATE2), no contract deployed yet, cryptographically bound to your payout and the protocol fee. Where the customer can pay from depends on depositMode on the payment: null (EVM/Tron) and keypair (Solana exchange-inbound) accept transfers from any wallet or an exchange withdrawal; pda (Solana wallet-only) must be paid from a wallet, because exchanges reject its off-curve address. For keypair deposits an exchange withdrawal fee that shaves the amount is tolerated up to underpayToleranceBps.
Chains with material settlement fees enforce a minimum payment: Ethereum 100, BNB Smart Chain 10, Tron 200 (stablecoin units, roughly USD). Base and Solana have no minimum: fees there are a fraction of a cent. Read the live values from minPayment on GET /v1/chains. A pinned payment below the minimum is rejected with a 400; on a customer-choice payment the networks that cannot take the amount are simply not offered, and GET /v1/checkout/<id> returns them under unavailable with a reason (below_minimum, not_enabled) so you can show the customer why.
Tron and Solana payouts: your EVM sign-in wallet receives payouts on EVM chains, but Tron and Solana use base58 addresses. Register them once (below, or in the dashboard's Settings page). Until you do, those networks are simply not offered to the customer at checkout, and pinning chain: "tron" / chain: "solana" is rejected:
Setting a payout address takes two steps and a wallet signature. Your bearer token alone cannot move where money is paid out: it lives in browser storage and is the realistic thing for an attacker to steal, whereas your wallet key is not. Request a challenge naming the exact address, sign it with your merchant wallet, then submit the signature.
curl https://api.paydex.io/v1/merchant/payout-nonce \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "tronAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE" }'
→ { "nonce": "f3Ah9Qk2Tz", "message": "…Paydex: set Tron payout address to TQn9…", "expiresAt": "…" }curl -X PATCH https://api.paydex.io/v1/merchant \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "message": "<the message>", "signature": "0x8d3c…1b" }'Note the PATCH carries no address: it comes from the challenge bound to that nonce, so a captured signature can never be replayed to point payouts somewhere else. Challenges are single-use and expire in 10 minutes. Always check the message names the address you intended before signing. Every change emits a merchant.payout_changed webhook: if one arrives that you did not authorize, your payouts are being redirected.
The Payment object & statuses
A payment moves through these statuses over its lifecycle:
Expired does not mean the money is gone. An expired payment that already holds funds is watched indefinitely; an unfunded one is watched for a further 48 hours in case the customer pays late. A late transfer that brings the balance up to the accepted amount revives the payment: it goes back to detected/confirmed and settles normally. A late transfer that is still short is recorded in amountReceived and keeps the address under watch, but deliberately leaves the status at expired.
On settlement, feeAmount, merchantAmount, and settlementTxHash are populated. The split runs on what actually arrived, so merchantAmount = amountReceived − feeAmount − networkFee (amountReceived equals amount for an exact payment; for a tolerance-confirmed exchange payment it may be slightly less). Fetch a payment any time with GET /v1/payments/{id} or list with GET /v1/payments.
networkFee is the sweep gas we fronted, billed back to you as a separate line. It is charged only where a chain’s settlement cost is material (Ethereum, BNB Smart Chain, Tron) and only when gas billing is switched on for that chain on this instance: it is off unless configured. It is absorbed on Base and Solana, where it is a fraction of a cent. The voucher is signed, time-limited, and hard-capped by the contract at 5% of the payment: anything larger is rejected on-chain. The value recorded is read back from the settlement transaction’s Settled event, so you can check it against the chain yourself. Where nothing is billed the field is 0 or null.
Settlement
A plain token transfer can't trigger code on any chain, so settlement is a second transaction: a relayer deploys the per-order forwarder and sweeps the funds, splitting them atomically on-chain. The fee is trustless contract math (basis points); an optional network fee is a signed, capped voucher.
Settlement is normally automatic. You can force it with POST /v1/payments/{id}/settle once a payment is confirmed. Because the on-chain release is permissionless, a merchant can always self-settle and is never dependent on Paydex to be paid.
Webhooks
Subscribe an endpoint to receive events: payment.detected, payment.confirmed, payment.settled, payment.expired, and merchant.payout_changed. Pass the list you want, or an empty array to receive all of them. Event names are validated: anything not in that catalog is rejected with 400 invalid_request, so a typo fails at creation instead of silently never delivering. Each delivery is signed.
merchant.payout_changed is delivered to every active endpoint whatever its list, and cannot be switched off: it is how you find out your payout destination moved. Note its data carries merchant, not payment.
curl https://api.paydex.io/v1/webhooks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.yourstore.com/paydex/webhook", "events": ["payment.confirmed", "payment.settled"] }'{
"id": "whk_5TgQ2x",
"url": "https://api.yourstore.com/paydex/webhook",
"events": ["payment.confirmed", "payment.settled"],
"secret": "whsec_a1b2c3d4…",
"active": true,
"createdAt": "2026-06-08T10:30:00Z"
}The signing secret appears exactly once, in this 201 response. Store it immediately: every later read, including GET /v1/webhooks, returns "secret": null, and there is no way to re-show or rotate it. If you lose it, delete the endpoint and create a new one.
Manage endpoints with GET /v1/webhooks (list, secrets null), PATCH /v1/webhooks/{id} (update), and DELETE /v1/webhooks/{id} (204; deliveries stop and the secret is gone for good). A PATCH may change url, events, or both; omitted fields keep their value, the same event validation applies, and the stored secret never changes, so deliveries keep verifying against the secret you already hold.
curl -X PATCH https://api.paydex.io/v1/webhooks/whk_5TgQ2x \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "events": ["payment.settled"] }'
→ 200 { "id": "whk_5TgQ2x", "events": ["payment.settled"], "secret": null, … }GET /v1/webhooks/{id}/deliveriesreturns the endpoint's 20 most recent deliveries, newest first, so you can see what was sent and how it went without digging through your own logs. delivered flips true on the first 2xx from your server; until then attempts counts tries so far and lastError holds the most recent failure.
[
{ "id": "evt_9QzR4m", "type": "payment.settled", "createdAt": "2026-06-08T10:31:00Z",
"delivered": true, "attempts": 1, "lastError": null },
{ "id": "evt_2KfW8p", "type": "payment.confirmed", "createdAt": "2026-06-08T10:29:12Z",
"delivered": false, "attempts": 3, "lastError": "HTTP 500" }
]POST /v1/webhooks/{id}/test responds 202 { "queued": true } and pushes a synthetic payment.settled event through the real pipeline: same signature, same retry schedule, and it shows up in the deliveries list. The payload is unmistakably fake: the payment id starts with pay_test_, the amount is "1.00", and the event body carries a top-level "livemode": false. Real events never carry a livemode field. No payment record is created, so a test can never pollute your payment list or your reconciliation.
Paydex-Signature: t=1717840800,v1=<hex hmac-sha256>Verify by computing HMAC-SHA256(secret, "{t}.{rawBody}") over the raw request body, comparing in constant time, and rejecting deliveries whose t is more than 5 minutes old. Use the bytes exactly as received: parsing and re-serialising the JSON changes them and the signature will no longer match. Each endpoint has its own secret.
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.PAYDEX_WEBHOOK_SECRET; // whsec_… for THIS endpoint
const seen = new Set(); // replace with a persistent store in production
// express.raw keeps the exact bytes; a JSON body parser would re-serialise them.
app.post("/paydex/webhook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const header = req.get("Paydex-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
// Reject stale timestamps (older than 5 minutes) to block replays.
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end();
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
const sig = Buffer.from(parts.v1 || "", "hex");
const good = Buffer.from(expected, "hex");
if (sig.length !== good.length || !crypto.timingSafeEqual(sig, good)) {
return res.status(400).end();
}
const event = JSON.parse(raw);
// Retries resend the same event id: dedupe so nothing is processed twice.
if (seen.has(event.id)) return res.status(200).end();
seen.add(event.id);
if (event.livemode === false) {
// A dashboard test event: signature and shape are real, the payment is not.
}
switch (event.type) {
case "payment.settled":
// fulfil the order using event.data.payment
break;
}
res.status(200).end();
});Return any 2xx to acknowledge. Anything else, including a connection failure, is retried with exponential backoff starting at 2 seconds (2s, 4s, 8s, 16s, …), up to 8 delivery attempts in total (the first try plus seven retries, roughly 4 minutes from first to last). Your handler must therefore be idempotent: dedupe on the event id.
{
"id": "evt_9QzR4m",
"type": "payment.settled",
"createdAt": "2026-06-08T10:31:00Z",
"data": { "payment": { "id": "pay_3xKq8Z1mP", "status": "settled", … } }
}Chains & assets
List what's supported at runtime with GET /v1/chains and GET /v1/assets?chain=bsc. Decimals vary per asset and chain: USDT is 6 decimals on Ethereum but 18 on BSC; USDC is 6 on Ethereum/Base. Always read decimals from the assets endpoint rather than assuming.
Errors
Errors return a consistent shape with an HTTP status. Branch on code, never on the message text: messages are written for people and get reworded; codes are stable. Every error carries a requestId. Quote it when you contact us and we can find the exact request in our logs.
{ "code": "invalid_request", "message": "amount must be a positive decimal string", "requestId": "req_8f2c4d" }General codes
Creating a payment: POST /v1/payments, all 400
These five fire only when you pin a rail. A customer-choice payment (no chain/asset) instead filters out networks that cannot take the payment and lists them under unavailable on the checkout; it is rejected with invalid_request only when no network at all could accept it.
Choosing a network: POST /v1/checkout/{id}/select