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.1% protocol fee taken atomically on-chain.

Overview

The Paydex API is organized around payments. You authenticate (an API token for integrations, your wallet for the dashboard), 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.9% to your wallet and 0.1% 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. The machine-readable OpenAPI spec is served at https://api.paydex.io/openapi.json if you would rather generate a client or diff your integration against it.

Authentication

There are two credentials, both sent the same way: Authorization: Bearer <credential>. An integration (a shop plugin, a backend, anything that runs without a person present) authenticates with a server API token. The dashboard and wallet sessions sign in with the wallet itself (Sign-In with Ethereum). If you are wiring a store or a server to Paydex, the API token is the credential you want: start there.

Server API tokens

Create one in the dashboard under Settings, Integrations: you name the token and your merchant wallet signs a statement naming it (Paydex: create API token "My store"). A standing credential is held to the same bar as redirecting payouts, the wallet key and not just a stealable session token, so a hijacked dashboard session cannot mint one. The same flow exists over the API:

Create a token: challenge, sign, mint
POST /v1/merchant/token-nonce
{ "name": "My store" }
→ { "nonce": "…", "message": "…Paydex: create API token \"My store\"…", "expiresAt": "…" }

# sign the message with the merchant wallet, then
POST /v1/merchant/tokens
{ "message": "<the message>", "signature": "0x8d3c…1b" }
→ 201 { "token": "pdx_live_4f9a…", "id": "ptk_7Hq2Xw", "name": "My store", "prefix": "pdx_live_4f9" }

The secret appears exactly once, in that 201 response. Only its sha256 hash and a short display prefix are stored, so it can never be re-shown; if you lose it, revoke and create a new one. Send it as Authorization: Bearer pdx_live_… on every call. An account holds at most 5 tokens: name each for the store it serves, and the dashboard shows when each was last used so you can tell a live token from a dead one before revoking.

What a token may call is a fixed allowlist, enforced centrally before any route handler runs. Exactly this, and nothing else:

  • GET /v1/merchant: read the account (the response carries no delegate or token lists, so a leaked token cannot even enumerate the account's other credentials).
  • POST /v1/payments and GET /v1/payments: create and list payments.
  • GET /v1/payments/{id}: read one payment.
  • POST /v1/payments/{id}/settle: force settlement of a confirmed payment.
  • The full webhook surface: GET/POST/PATCH/DELETE on /v1/webhooks and /v1/webhooks/{id}, including /deliveries and /test, so an integration can point deliveries at its own shop.

Everything else, payout addresses, delegates, notifications, payment links, and creating or revoking tokens, is refused with 403 integration_token_scope: a leaked token can never redirect funds or mint further credentials. (GET /v1/chains, GET /v1/assets, and the checkout endpoints are public and need no credential at all.)

Revoke with DELETE /v1/merchant/tokens/{id}or one click in the dashboard. Revocation is deliberately not signature-gated: it is the emergency brake for a leaked token, and removing a credential only ever reduces access. It takes effect on the token's very next request, which then fails with 401 invalid_token.

Sign-In with Ethereum

The dashboard and wallet sessions authenticate with the wallet itself (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, and nothing money-directing ever moves without its signature. POST /v1/auth/logout ends the session: the token stops working at once, and later calls with it answer 401 session_ended.

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. The reference is the idempotency primitive: there is deliberately no Idempotency-Key header. Retrying POST /v1/payments with the same reference and matching details returns the same payment; a repeat with different details is refused with 409 reference_conflict instead of creating a second charge.

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",
  "payerNetworkFee": null,
  "amountDue": "49.99",
  "asset": null,
  "chain": null,
  "feeBps": 10,
  "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 (amountDue 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: the 0.1% fee and any networkFee off, the rest to you.
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 amountDue 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.

Your customer pays the network fee for the settlement transaction. When they pick a network, the checkout quotes it from that network's current prices with a small margin and freezes it on the payment as payerNetworkFee; they then send amountDue, which is amount + payerNetworkFee, and paid, underpaid and overpaid are all measured against it. On Tron the fee is the market price of the energy the transaction uses, even when Paydex leases that energy for less. A fee is added only where a chain's settlement cost is material (Ethereum, BNB Smart Chain, Tron) and gas billing is switched on for that chain on this instance. On Base and Solana nothing is added and Paydex absorbs the fraction of a cent.

networkFee is what settlement actually billed for that transaction, normally exactly payerNetworkFee, so you receive the amount less the commission. 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.underpaid, 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",
  "verifiedAt": null,
  "disabledReason": null,
  "disabledAt": null
}

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.

The URL must be an https:// address on the public internet. Plain HTTP, a user name or password in the URL, localhost, .internal and dotless names, and hosts that resolve to private, loopback or other reserved addresses are refused with 400 webhook_url_invalid (webhook_url_unresolvable when DNS has no such host). Paydex checks again on every delivery, against the address it actually connects to, and does not follow redirects, so register the final URL. An account can have up to 10 endpoints.

About 30 seconds after you create an endpoint, Paydex sends it a test event (described below) to verify it: the first 2xx sets verifiedAt. It retries like any other event, so a server that stores the secret only after this call returns still verifies once it has.

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, and active; 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. A new URL is checked like at creation and restarts verification. "active": false pauses the endpoint, and "active": true turns it back on once its URL passes the check again.

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. status is pending, retrying (with nextAttemptAt), delivered, or failed (retries used up, or the endpoint was deleted or turned off). attempts counts tries so far, lastError holds the most recent failure, and recentAttemptskeeps each send's HTTP status, error, and the start of your server's reply, so an error stays visible even after a later retry succeeds. test marks test events, including the verification.

GET /v1/webhooks/{id}/deliveries
[
  { "id": "evt_9QzR4m", "type": "payment.settled", "createdAt": "2026-06-08T10:31:00Z", "test": false,
    "delivered": true, "attempts": 1, "lastError": null, "status": "delivered",
    "nextAttemptAt": null, "deliveredAt": "2026-06-08T10:31:01Z",
    "recentAttempts": [
      { "at": "2026-06-08T10:31:01Z", "statusCode": 200, "error": null,
        "durationMs": 212, "responseExcerpt": null, "manual": false }
    ] },
  { "id": "evt_2KfW8p", "type": "payment.confirmed", "createdAt": "2026-06-08T10:29:12Z", "test": false,
    "delivered": false, "attempts": 3, "lastError": "HTTP 500", "status": "retrying",
    "nextAttemptAt": "2026-06-08T10:34:55Z", "deliveredAt": null,
    "recentAttempts": [
      { "at": "2026-06-08T10:29:55Z", "statusCode": 500, "error": "HTTP 500",
        "durationMs": 87, "responseExcerpt": "invalid signature", "manual": false },
      …
    ] }
]

POST /v1/webhooks/{id}/deliveries/{eventId}/resend sends one recorded event again, right away, whatever its status: after you fix your endpoint, or to replay something your server lost. It carries a fresh signature and the same event id, shows up in recentAttempts marked manual, and leaves the remaining automatic retries on their schedule. Each endpoint in GET /v1/webhooks also carries a health summary (ok, failing with the error and since when, or unknown before the first send). If an endpoint keeps failing for a few minutes, you are also told on your notification channels (push or Telegram), once per failing run.

Paydex turns an endpoint off (active: false with a disabledReason) when every delivery fails for 3 days (failing), when it accepts no delivery in the day after it is saved (unverified), or when its address is refused at delivery time (destination_refused), and tells you on the same channels. Blocking an account also turns its endpoints off (blocked); they stay off after the block is lifted. Events that happen while it is off are not sent to it, and resend and test calls answer 409 webhook_endpoint_inactive. PATCH with "active": true turns it back on.

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. This is also the event Paydex sends on its own to verify a newly saved endpoint, so answer it with a 2xx.

The scheme, spelled out: every delivery carries the header Paydex-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 computed with this endpoint's whsec_ secret over the exact string "{t}.{rawBody}" (the timestamp, a literal dot, then the raw request body); verify it, reject deliveries whose t is more than 5 minutes old, and dedupe on the event id, because retries resend the same event.

Header
Paydex-Signature: t=1717840800,v1=<hex hmac-sha256>

Compare in constant time, and use the body 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);

  // A dashboard test event: signature and shape are real, the payment is not. Acknowledge
  // it so retries stop, but RETURN before any fulfilment: never ship goods on a test event.
  if (event.livemode === false) return res.status(200).end();

  switch (event.type) {
    case "payment.settled":
      // fulfil the order using event.data.payment
      break;
  }
  res.status(200).end();
});

Return any 2xxwithin 15 seconds to acknowledge. Anything else, including a connection failure or a slower answer, is retried for about 3 days: 14 delivery attempts in total, with the wait growing from 10 seconds to a day (10s, 30s, 2m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 24h, 24h). Retries go to the endpoint's current URL, so correcting a wrong URL lets pending events through, and deleting or turning off the endpoint stops them. A redirect (3xx) counts as a failure: Paydex does not follow it. Requests carry User-Agent: Paydex-Webhooks/1.0. Your handler must 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", … } }
}

Testing your integration

There is no sandbox or test mode yet. The cheapest full happy-path test is a real payment on Base or Solana: neither has a minimum, and Paydex absorbs the network fee on both, so a 0.50 USDC order costs a few cents in total and exercises everything a real sale does: creation, detected, confirmed, settled, and every webhook, end to end, on the same code paths your customers will hit.

Testnet deployments exist and are used for development, but the hosted API at api.paydex.io serves mainnet networks only, so small-on-mainnet is the recommended route. To exercise only your webhook handler, POST /v1/webhooks/{id}/test (above) pushes a synthetic signed event through the real pipeline with no payment involved.

WooCommerce

The official plugin adds a Crypto (USDT / USDC) method to your store's checkout (classic and block-based, HPOS compatible). Your customer is redirected to the hosted Paydex checkout, picks the network there, pays from any wallet or exchange, and is returned to your order-received page. Signed webhooks then move the order on their own: notes on detection, completion on settlement, on-hold on a partial payment, and your choice of Failed, Cancelled or note-only when the window expires.

Download the plugin (v1.0.0, .zip)

Setup takes five minutes and no code:

  1. Install and activate the plugin in WordPress (Plugins, Add New, Upload Plugin, choose the zip above).
  2. In the Paydex dashboard, Settings, Integrations: create an API token named after the store and copy it (shown once).
  3. In WooCommerce, Settings, Payments, Paydex: paste the token and save.
  4. Press Register webhook automatically: the plugin creates the endpoint at Paydex and stores the signing secret itself.
  5. Enable the gateway and place a small test order.

The token can only create, read, and settle payments and manage its webhook endpoint (the full allowlist is under API tokens above); payout addresses always require your wallet's signature, so neither a leaked token nor a compromised WordPress admin can redirect funds. Orders and Paydex payments are tied by an idempotent per-store reference, so a double-clicked Pay button can never create two charges. Store currency must be USD, since stablecoin amounts map 1:1 without exchange-rate risk.

Chains & assets

List what's supported at runtime with GET /v1/chains (name, type, native currency, and per-chain minPayment) and GET /v1/assets?chain=bsc (contract address and decimals per asset). Both are public: no credential needed.

Decimals vary per chain

The same symbol has different decimals on different chains: USDT is 18 decimals on BNB Smart Chain but 6 on Ethereum and Tron; USDC is 6 on Ethereum, Base, and Solana. If your integration converts amounts to base units, always read decimals from GET /v1/assets at runtime, never hardcode them: a hardcoded 6 on BSC is off by a factor of a trillion, and a wrong conversion is a mis-send, not a rounding error.

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.
403 account_blockedPaydex blocked this account, so none of its credentials work. A blocked wallet's sign-in answers 403 wallet_blocked, and a request from a blocked network 403 ip_blocked.Contact Paydex support if you believe it is a mistake. Retrying will not help.
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.
429 rate_limitedToo many requests in a short time; see Rate limits below. The Retry-After header says how many seconds to wait.Wait that long, then send the same request again.
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.
wallet_check_failed403. This is the account's first payment or payment link, and its sign-in wallet holds less than the minimum (see First payment below). The message says what was found.Add funds to the sign-in wallet, then send the same request again. It is checked once.
wallet_check_unavailable503. The first-payment wallet check could not read the wallet's balance just now.Retry in a minute.
account_not_trusted403. Paydex reviewed the account and stopped it from creating payments and payment links. Existing payments carry on.Contact Paydex support.

Most of these fire only when you pin a rail (amount_below_minimum can also come from the payer picking a network at checkout). 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
400 amount_below_minimumThe chosen network has a minimum this payment is under (for example Tron starts at 200 USDT).Pick a network the amount can use; the message names the minimum and the rails that fit.
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.

Rate limits

A request over a limit is answered with 429 rate_limited and a Retry-After header in seconds. Nothing about the request itself is wrong: wait that long and send it again. Limits count per IP address (an IPv6 address by its /64 network); requests made with an API token count against the token instead.

  • Every request: 600 per minute
  • Hosted checkout and payment-link endpoints: 120 per minute
  • Sign-in (nonce and verify together): 60 per hour
  • Failed sign-ins: 10 per 15 minutes, then sign-in pauses for 15 minutes
  • New accounts: 3 per day
  • POST /v1/payments, per account: 120 per minute, or 20 during the account's first day
  • POST /v1/webhooks/{id}/test, per endpoint: 1 per 10 seconds

The public payment-link start endpoint also caps each link at 10 new payments per minute and answers 429 link_rate_limited.

First payment

Before an account's first payment or payment link, Paydex checks once that its sign-in wallet holds at least $5 on the supported networks. It only reads the balance and never moves or holds it. With less, creating the payment answers 403 wallet_check_failed; add funds and send it again. GET /v1/merchant carries firstPaymentCheck, with the coins and networks that count, while the check is still ahead. After that, payments are created as usual while Paydex reviews new accounts; an account it marks not trusted answers 403 account_not_trusted when creating payments.

Blocked access

Paydex can block a network or an account that abuses the service. Requests from a blocked network answer 403 ip_blocked; a blocked account's tokens answer 403 account_blocked, and its wallet's sign-in 403 wallet_blocked. A blocked account's webhook endpoints are turned off and its unpaid payments expire; payments that already hold money still settle to the merchant. If you believe a block is a mistake, contact support.