PayX API & webhook docs

PayX is a non-custodial payment tracker. The server never holds private keys — it only watches the chain on your behalf, matches inbound transfers to invoices you create, and notifies your handler via signed webhooks. In managed mode funds land at addresses derived from your xpub; in monitored mode they land at the single address you provided. Either way PayX never holds the keys.

This page covers what's already shipped: the concepts, the API surface, and the webhook protocol. If you're integrating for the first time, read Quick start and the Concepts section before the API reference — the API field names assume the vocabulary from there.

Quick start

  1. Sign up at payx.lol/register and confirm the email code.
  2. Open Methods and add a payment method (xpub for managed mode, single address for monitored).
  3. Generate an API key under Settings → API keys. The full key is shown once; store it.
  4. Configure your webhook URL on Settings → Webhooks. The secret is auto-generated — reveal and copy it into your handler (re-revealable any time via the eye icon). Click Send test to verify your handler signature parsing.
  5. Create your first invoice:
    curl -X POST https://payx.lol/api/invoices \
      -H "X-API-Key: <your 64-char hex key>" \
      -H "Content-Type: application/json" \
      -d '{
        "chainId": "tron_mainnet",
        "currency": "USDT",
        "amount": "10.50",
        "redirectUrl": "https://your.shop/order/123/done"
      }'
  6. Redirect the buyer to the returned payUrl. Your handler receives a paid event when the chain confirms.

Managed vs monitored mode

Every accepted method is one of two modes. The choice determines what fields you supply at creation time and what shows up on each invoice.

ManagedMonitored
SourceYour xpub. Server derives a fresh address per invoice.One address you provide.
Memo neededNo — each invoice has a unique address.Yes — the buyer must include a memo. Only available on chains with a payer-controllable memo field (Solana, native TRX).
PrivacyEach buyer sees a different address.All payments share one public address.
SetupPaste your xpub once; verify the index-0 address matches your wallet.Paste a single address.

The server only ever sees the public xpub — it can derive but not sign. Most ERC20 / TRC20 token methods are managed-only because the transfer call data has no payer-controllable memo field.

Invoice status lifecycle

new ──> pending ──> partial ──> paid (terminal)
                            ╲
                             ─> expired (terminal, after late window)

new / pending / partial ──> canceled (terminal, merchant-initiated)

Late-payment window

After expiresAt, an invoice that hasn't reached paid enters a grace period (lateWindowMinutes, configurable per account on Settings). Inbound transfers during this window still credit the invoice and flag it is_late: true in the payload. Once the window closes the invoice flips to expired terminal state.

Any inbound transfer that lands on a PayX-tracked address after the invoice is no longer claimable (expired / canceled / gone) surfaces as a separate unmatched event — payment-level, no invoice attached, so your handler can decide whether to refund, credit, or alert.

Cancelling an invoice immediately clips its late-window to now, so a cancelled invoice can never silently re-credit on a delayed payment.

Underpayment tolerance

Crypto rounding, fee rebates and exchange-rate slippage at the buyer's wallet sometimes produce a payment that's a few cents short of the invoiced amount — technically underpaid, practically the order should clear. The underpaymentThresholdRelative setting is the percentage of the invoiced amount the merchant is willing to accept as missing without flipping the invoice to partial.

Default is unset → 0% (strict equality). The merchant must explicitly set a non-zero value on Settings to enable tolerance. Value is a percentage: 0.5 means 0.5%, 1 means 1%.

Concretely, with tolerance set to 0.5: an invoice for 10.00 USDT becomes paid as soon as confirmed transfers sum to ≥ 9.95 USDT. At the default (unset), anything below 10.00 stays partial and the invoice waits through the late window for a top-up.

Address pool & reuse

Managed-mode addresses are reused across invoices when safe. Two reasons PayX doesn't burn a fresh derivation index per invoice:

The pool is scoped to your (chain, xpub) — methods that share an xpub share the pool. Each invoice's targetAddress is still dedicated for the invoice's lifetime; what's recycled is the slot after the invoice terminates.

Address states

StateWhenReusable?
ServingAn open invoice is using this address right now.No.
CooldownInvoice terminated recently; grace window before reuse. Different merchant-configured windows for paid vs. unpaid (see below).No — waits for cooldown to elapse.
HeldInvoice was partial (some money received, not enough), OR invoice was paid and the amount crossed your high-value threshold. Requires manual release.No — release via /wallets → Release.
AvailableNone of the above. Pool can pick it for the next invoice.Yes.

Which address gets picked for a new invoice

Allocator order: lowest derivation index among eligible addresses. Two eligibility regimes by invoice amount:

Pool empty → fall through to deriving a new index on your xpub.

After the invoice ends

Invoice outcomeAddress goes to
paid, low-valueCooldown for cooldownMinutesPaid (per-method) or the account-level late-window fallback. Back to pool after.
paid, high-valueHeld — you release it via /wallets when you're ready. Never auto-reused.
partialHeld. A top-up might still arrive on this address; the hold keeps the partial invoice addressable. Click Release to cancel the partial and free the address.
expired (no payments)Cooldown anchored to lateWindowUntil + cooldownMinutesUnpaid. Back to pool after.
canceledCooldown from now + cooldownMinutesUnpaid. (Cancel clips the late window to now, so there's nothing to anchor past.)

Per-method settings

Configurable on both the Add-method form and the inline editor on each managed method card at /methods. The two cooldown fields are pre-filled with your account-level lateWindowMinutes so the merchant always sees the effective value rather than a blank that could read as "hold forever"; change them freely or leave the default.

The API keeps null semantics on these cooldown fields (null → fall back to user.lateWindowMinutes) for legacy rows, but any save from the dashboard writes a concrete number.

Late-payment race (important)

If a tardy payer sends to an address after the cooldown has passed and the address has been reused by a new invoice, the payment credits the NEW invoice. PayX can't distinguish intent from the chain. Implications:

The Release button on /wallets

For any held address, /wallets shows a Release button. It clears the manual hold AND cancels any open invoice still bound to the address (clipping its late window to now so a tardy payer's transfer surfaces as unmatched rather than accidentally crediting the canceled invoice). Same behaviour as POST /api/wallets/addresses/:id/release.

Authentication

Every endpoint under /api accepts either of:

Use API keys for server-to-server. The dashboard handles Bearer refresh transparently for browser sessions.

Base URL: https://payx.lol/api. All examples below use absolute paths relative to this base.

Invoices

Create

POST/api/invoices

Choose the method (required, one of two ways)

FieldTypeNotes
methodIdstringThe id of one of your active methods (from GET /api/accepted-methods or the dashboard — 8 hex chars, displayed and copyable on every row of /methods). Recommended for SDKs — no chain-slug typos possible.
chainId + currencystringImplicit lookup of the active method on the pair. Convenient for quick curl examples. See supported pairs below.
Send one or the other. Sending both with values that resolve to the same method is allowed (idempotent). Sending both with conflicting values (e.g. methodId points at TRON USDT but chainId says eth_mainnet) returns 400. Sending neither also returns 400.

Other fields

FieldTypeNotes
amountstringDecimal string. Use a string, not a number — JSON numbers lose precision past 15 digits.
descriptionstring?Shown to the buyer on the pay page.
redirectUrlstring?Where to send the buyer after payment. http(s) only.
clientIdstring?Your own reference id. Echoed in webhooks.
metadataobject?Free-form JSON. Echoed in webhooks.

Optional header Idempotency-Key: <1-255 chars> — sending the same key twice returns the original invoice instead of creating a duplicate.

Supported chain & currency pairs

The full list of chainId values and the currencies enabled on each. Use the dashboard Methods page to actually configure one — listing here is for reference when you don't want to round-trip GET /accepted-methods.

chainIdNetworkCurrenciesDefault confs
tron_mainnetTRONUSDT19
tron_nileTRON Nile (testnet)USDT19
eth_mainnetEthereumETH, USDT, USDC, DAI12
eth_sepoliaEthereum Sepolia (testnet)ETH, USDC12
bsc_mainnetBNB ChainBNB, USDT, USDC, DAI15
bsc_testnetBNB Chain Chapel (testnet)BNB15
base_mainnetBaseETH, USDC5
base_sepoliaBase Sepolia (testnet)ETH, USDC5
arbitrum_mainnetArbitrumETH, USDT, USDC5
arbitrum_sepoliaArbitrum Sepolia (testnet)ETH, USDC5
sol_mainnetSolanaSOL, USDT, USDC32
sol_devnetSolana Devnet (testnet)SOL, USDC32

Custom ERC20 / TRC20 tokens beyond the curated list are supported via customContract on accepted-methods create — see Accepted methods.

Response fields

FieldNotes
idInvoice id; use this in subsequent API calls.
publicTokenBuyer-facing token.
payUrlFull URL of the buyer-facing pay page — https://payx.lol/pay/<publicToken>. Redirect the buyer here.
targetAddressThe address the buyer must send to. Unique per invoice in managed mode; the merchant's single address in monitored mode.
memo (monitored mode only)The memo string the buyer must include with their transfer. null for managed-mode invoices, since each address is already unique.
mode"managed" or "monitored", copied from the method.
chainId, currency, amountEchoed from the request. amount is a decimal string.
statusCurrent state — see lifecycle.
expiresAtWhen the invoice stops accepting on-time payments. After this it enters the late-payment window.
isLate, isOverpaidBoolean flags updated as payments come in. See the payload notes.
paidAt, canceledAt, expiredAtTerminal-state timestamps. Exactly one is non-null once the invoice is terminal; all null while open.
clientId, metadata, description, redirectUrlEchoed from the request.
createdAt, updatedAtISO timestamps.

List

GET/api/invoices

Query params: status, cursor, limit, sortBy (createdAt|amount|status|address), sortOrder (asc|desc), includeTestnets=true. Cursor-paginated.

Get one

GET/api/invoices/:id

List the invoice's payments

GET/api/invoices/:id/payments

Cancel

POST/api/invoices/:id/cancel

Only open invoices (new / pending / partial) can be cancelled. Cancelled invoices clip their late-payment window to now, so any further inbound transfer will surface as unmatched rather than crediting the cancelled invoice.

Accepted methods

A "method" is a (chain, currency, mode) triple you've configured. Most setup is done from the Methods dashboard page; the API mirrors what the UI does.

Lifecycle

StateMeaningTransitions
ActiveAccepting new invoices.→ Disabled (toggle); → Archived (PATCH :id/archive)
DisabledNo new invoices, in-flight ones keep being polled.→ Active (toggle); → Archived
ArchivedDone with this method; sits in a separate section at the bottom of /methods.→ Disabled (PATCH :id/restore); → gone (DELETE :id/force, gated)

Endpoints

GET/api/accepted-methods
POST/api/accepted-methods
PATCH/api/accepted-methods/:id
DELETE/api/accepted-methods/:id
PATCH/api/accepted-methods/:id/archive
PATCH/api/accepted-methods/:id/restore
DELETE/api/accepted-methods/:id/force

Create body — common fields

FieldNotes
chainId, currencySame vocabulary as invoices. Must be a supported pair on the chain.
mode"managed" or "monitored".
requiredConfirmationsOptional. Defaults to the chain's curated value. Mainnet floor: the chain default — lower values are refused with 400. Testnets are unconstrained.
highValueThresholdOptional. Invoices at or above this amount bypass the address pool — they always get a fresh address that stays held after the invoice terminates (no auto-reuse). Empty / null = feature off. See Address pool.
cooldownMinutesPaidOptional. How long a paid address stays out of the pool before reusable. Null = inherit user.lateWindowMinutes.
cooldownMinutesUnpaidOptional. Same for expired / canceled. Null = inherit.

Managed-mode-only fields

xpubRequired. Accepted depths: account (3, e.g. m/44'/195'/0') or chain (4, e.g. m/44'/195'/0'/0). Account-level is what TronLink / Ledger / MetaMask typically export. Master-level (depth 0) is technically parsed but derivation against it fails — a master xpub is public-only and can't do the hardened derivation steps the adapters need, so don't supply one. Encrypted at rest (AES-256-GCM), never displayed back in clear.

Monitored-mode-only fields

monitoredAddressRequired. The single address all invoices on this method credit to. Buyers attribute via the per-invoice memo.

Custom-token fields (managed mode, EVM chains in practice)

For tokens not in the curated supported pairs table. POST /api/accepted-methods/verify-token helps you populate these by reading symbol and decimals straight from the chain — it only supports EVM adapters, so custom tokens effectively live on EVM only.

customContractERC20 contract address.
customDecimalsRequired when customContract is set.

Permanent delete (:id/force) refuses if any invoice on the (chain, currency) pair still has its late-window open — so an in-flight payment can never lose the method row needed to credit it.

Wallets

GET/api/wallets

Returns { methods, orphanAddresses }. Each method bundles its derived addresses (managed) or its single address (monitored) with per-address incomingTotal, paymentCount, and the address-pool state flags (cooldownUntil, heldForInvoiceId, manualHold) so the dashboard can label each row. Orphans are addresses whose owning method was hard-deleted; they retain history but won't get new invoices.

Release address to pool

POST/api/wallets/addresses/:id/release

Clears manualHold + cooldownUntil + heldForInvoiceId, and cancels any open invoice still bound to the address in the same transaction. Use case: a partial invoice is sitting in your wallet UI and you've decided not to wait for the top-up, OR a paid high-value invoice was auto-held and you want to put the address back in rotation. Throttled to 60/hour. 404 for foreign addresses (no enumeration leak — same surface as missing).

Settings

GET/api/settings
PATCH/api/settings
FieldNotes
webhookUrl / webhookSecretMainnet pair. Secret is auto-generated at signup.
testnetWebhookUrl / testnetWebhookSecretTestnet pair. Used unless testnetUsesMainnetWebhook is on.
testnetUsesMainnetWebhookIf true, testnet events go to the mainnet pair too. Branch on the payload's is_testnet flag in your handler.
defaultExpirationMinutesHow long new invoices stay open before entering the late window.
lateWindowMinutesGrace period after expiresAt. See Late-payment window.
underpaymentThresholdRelativeSee Underpayment tolerance.

URL fields are validated on save: parseable, https-only in production, and any private/loopback hostname is refused.

Rotate the webhook secret

POST/api/settings/webhook-secret/regenerate/:env

:env is mainnet or testnet. Returns the new secret. Handlers using the old secret start failing signature checks immediately — rotate your handler config in lockstep.

Send a test event

POST/api/settings/webhook/test/:env

Fires a synthetic test event through the real delivery pipeline so you can verify your handler before any real invoice lands. Throttled to 60/hour. Test events drop on first failure (no retry envelope) — re-clicking is the recovery path.

Webhook configuration

Configure on /settings. Both secrets are auto-generated at registration; reveal them by clicking the eye icon, regenerate any time. Pick how testnet events are routed:

Event types

EventFires when
createdInvoice is created.
payment_detectedFirst inbound transfer matched. Invoice transitions new → pending.
partialConfirmed amount < required (after tolerance). Stays open until late-window expires.
paidConfirmed amount covers the target. Terminal.
expiredLate-window passed without coverage. Terminal.
canceledYou called cancel. Terminal.
unmatchedInbound transfer landed at a known address but no open invoice claims it. Payment-level event, no invoice attached.
testSynthetic event from the dashboard "Send test" button. Carries is_test: true.

Payload shape

{
  "event_id": "8f3c2c1e-…",
  "event_type": "paid",
  "timestamp": "2026-04-20T13:45:12.301Z",
  "is_testnet": false,
  "data": {
    "invoice": {
      "id": "01HXM3…",
      "public_token": "v1_…",
      "status": "paid",
      "amount": "10.50",
      "currency": "USDT",
      "chainId": "tron_mainnet",
      "target_address": "TGcW…",
      "memo": null,
      "is_late": false,
      "is_overpaid": false,
      "client_id": "order-123",
      "metadata": { "your": "stuff" },
      "expires_at": "2026-04-20T14:00:00Z",
      "created_at": "2026-04-20T13:30:00Z"
    },
    "payments": [
      {
        "id": "…",
        "tx_hash": "0xabc…",
        "amount": "10.50",
        "status": "confirmed",
        "from_address": "TPay…",
        "block_number": "67450123"
      }
    ]
  }
}

Notes on a few fields that aren't self-explanatory:

Signature verification

Every delivery includes an X-Signature header containing the HMAC-SHA256 of the raw request body, hex-encoded, signed with your webhook secret for the channel the event came from (mainnet or testnet — see routing).

on POST /your-webhook (request):
    raw_body   = request.body                   # bytes, BEFORE any JSON parse
    signature  = request.headers["X-Signature"] # hex string

    expected = hmac_sha256(secret, raw_body).hex()
    if not constant_time_equal(signature, expected):
        return 401

    payload = parse_json(raw_body)
    handle(payload)
    return 200

Use the raw bytes for HMAC — don't re-serialize the parsed JSON. Whitespace and key order would differ and the signature wouldn't match.

import hmac, hashlib, json, os
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["PAYX_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/payx/webhook")
def webhook():
    raw = request.get_data()  # raw bytes BEFORE Flask parses JSON
    sig = request.headers.get("X-Signature", "")
    if not verify(raw, sig):
        abort(401)

    payload = json.loads(raw)
    if payload.get("is_test"):
        return "", 200  # skip test events in production logic

    event_type = payload["event_type"]
    invoice = payload["data"]["invoice"]

    # Idempotency: dedupe on event_id.
    if event_type == "paid":
        mark_order_paid(client_id=invoice["client_id"], amount=invoice["amount"])
    elif event_type == "expired":
        release_held_inventory(invoice["client_id"])

    return "", 200
// Express + raw-body capture so HMAC sees the unmodified bytes.
import express from "express";
import crypto from "node:crypto";

const app = express();
const WEBHOOK_SECRET = process.env.PAYX_WEBHOOK_SECRET;

// Capture raw bytes BEFORE express.json() reformats the payload.
app.use(
  "/payx/webhook",
  express.raw({ type: "application/json", limit: "100kb" }),
);

app.post("/payx/webhook", (req, res) => {
  const signature = req.header("x-signature") ?? "";
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.body) // raw Buffer, not a parsed object
    .digest("hex");

  // constant-time compare — guards against timing attacks
  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const payload = JSON.parse(req.body.toString("utf8"));
  if (payload.is_test) return res.status(200).end();

  const { event_type, data } = payload;
  switch (event_type) {
    case "paid":
      markOrderPaid(data.invoice.client_id, data.invoice.amount);
      break;
    case "expired":
      releaseHeldInventory(data.invoice.client_id);
      break;
  }
  res.status(200).end();
});
Always return a 2xx fast. Acknowledge first, then do work asynchronously. PayX waits up to 10 s for a response and treats anything outside 200–299 (or a timeout) as a failure that triggers the retry envelope.

Mainnet vs testnet routing

The router picks one of three paths at enqueue time, based on the invoice's chain:

Branch on is_testnet in your handler before changing any production state. A sepolia payment must not settle a mainnet order.

Retry behaviour

Real events use a long exponential schedule: 1 m → 5 m → 15 m → 1 h × 24 → 6 h × 28 → failed, with ±10 % jitter at each step. Total envelope ≈ 8 days.

Test events are different. The first non-2xx response drops the delivery to failed immediately, no retry envelope. Test events are diagnostic; re-clicking "Send test" is the recovery path.

A manual retry from the dashboard or API never produces a duplicate delivery if an automatic retry is already queued — once the manual one succeeds, the queued attempt becomes a no-op.

Operational tools

/webhooks shows the full delivery log. Per-row actions:

On any invoice's detail page there's a compact "Webhook" card. If no delivery exists for the invoice yet (you configured webhooks late, or no reportable event has happened), the card offers a Send webhook now button. It maps the invoice's current status to the matching event type (paid → paid, expired → expired, etc.) and fires through the real-event pipeline. Useful when you forgot to set up webhooks before a customer paid.

Endpoints

GET/api/webhooks/deliveries?status=&invoiceId=&cursor=&limit=
POST/api/webhooks/deliveries/:id/retry
POST/api/webhooks/deliveries/:id/cancel
POST/api/webhooks/invoices/:invoiceId/send