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.

1) Request a nonce
POST /v1/auth/nonce
{ "address": "0x1A2b…9A0b", "chainId": 56 }

→ { "nonce": "f3Ah9Qk2Tz", "message": "api.paydex.io wants you to sign in…", "expiresAt": "…" }
2) Sign the message, then verify
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).

POST /v1/payments
curl https://api.paydex.io/v1/payments \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "amount": "49.99", "reference": "order_10432" }'
201 Created
{
  "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:

POST /v1/payments (pinned rail)
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.

1) Request the challenge
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": "…" }
2) Sign it, then submit the signature
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:

pendingCreated; waiting for the customer to send funds.
detectedA transfer to the payment address has been seen on-chain; the amount so far is in amountReceived.
confirmedThe full accepted amount (expected minus underpayToleranceBps) is sitting at the address; settlement is queued. Confirmation is by observed balance: there is no confirmation-count threshold.
settlingSettlement has started: the relayer is preparing and submitting the sweep. A payment can sit here briefly with no transaction on-chain yet, so don't treat it as proof of a broadcast.
settledSwept and split on-chain: 0.4% fee, the rest to you, minus networkFee where gas is billed.
underpaidSome funds arrived but less than the accepted amount; the address stays open for a top-up.
overpaidReserved, not currently emitted. An overpayment simply confirms and settles on the full amount received.
expiredThe window closed. This is a reporting state, not the end. See below.
failedReserved, not currently emitted. Settlement retries instead of giving up, so a payment is never abandoned with funds on it.

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.

POST /v1/webhooks
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"] }'
201 Created
{
  "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.

PATCH /v1/webhooks/{id}
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.

GET /v1/webhooks/{id}/deliveries
[
  { "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.

Header
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.

Verify (Node / Express)
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.

Event body
{
  "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.

4xx
{ "code": "invalid_request", "message": "amount must be a positive decimal string", "requestId": "req_8f2c4d" }

General codes

CodeWhen it firesWhat to do next
400 invalid_requestThe request is malformed or cannot be fulfilled as sent: a missing or bad field, or (on the public select endpoint) a network that is not on offer for this payment.Fix the request. On the checkout, reload and pick from the current list of networks.
401 unauthorizedMissing, expired, or wrong-wallet credentials.Sign in again for a fresh token, then retry.
401 challenge_staleThe payout-change challenge was already used, has expired, or does not match the change being submitted.Request a new challenge and sign that one.
404 not_foundNo payment or webhook endpoint with that id.Check the id. Retrying will not help.
409 conflictThe payment is in the wrong state for the call: forcing settlement before it is confirmed, accepting a shortfall on a payment that is not short, or a concurrent call got there first.Re-fetch the payment and act on its current status.
409 reference_conflictPOST /v1/payments reused a reference with a different network, currency, amount, or selection mode. A repeat with matching details returns the original payment unchanged; description, callbackUrl and metadata are ignored on a repeat and never updated.Reuse the existing payment, or use a new reference for a genuinely new order.
503 unavailableA currency's token contract is not configured on this instance.Not retryable. Use another network or currency, and tell us.

Creating a payment: POST /v1/payments, all 400

CodeWhen it firesWhat to do next
amount_below_minimumThe amount is below the pinned network's minimum payment (read minPayment on GET /v1/chains).Raise the amount, pin a cheaper network, or omit chain and asset so the customer picks from networks that take the amount.
chain_not_servedThe pinned chain is not served by this Paydex instance. The message lists what is.Pin a served chain, or omit chain and asset.
asset_not_supportedThe pinned asset does not exist on that chain (there is no USDC on Tron, no USDT on Base or Solana).Pick a supported pair from GET /v1/assets?chain=….
chain_unavailableThe chain is listed on Paydex but has no live settlement program yet, so no money can be taken on it.Use another network. Nothing on your account can change this.
payout_address_missingYou have no payout address on file for that network.Register one (POST /v1/merchant/payout-nonce, sign the challenge, PATCH /v1/merchant), then retry.

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

CodeWhen it firesWhat to do next
409 rail_pinnedThe merchant fixed the network for this payment; there is nothing to select.Pay on the rail shown on the page.
409 rail_lockedThe payment is past pending (funds seen, settled, or closed), so the network can no longer change.Reload the page and follow it: it shows what, if anything, is still owed.
409 payment_expiredThe payment window closed before a network was chosen.Ask the merchant for a new payment link.
409 rail_switch_limitThe network was already switched the maximum number of times.Ask the merchant for a new link. Earlier addresses stay watched, so funds already sent are not lost.
409 rail_unswitchableThe previously selected network cannot be switched away from on this instance.Pay to the address currently shown, or ask the merchant for a new link.
409 rail_fundedMoney already arrived on the previously selected network, so the payment stays there.Reload the page. If only part arrived, it shows the remainder to send. Do not pay the full amount again.
409 rail_racedTwo selections raced and this one lost.Reload and select again.
503 rail_check_failedThe previously shown address could not be balance-checked, so the switch was refused. That check is what keeps money already sent from being stranded.Retry in a minute, or pay the address currently shown; it is still the active one.