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
- Sign up at payx.lol/register and confirm the email code.
- Open Methods and add a payment method (xpub for managed mode, single address for monitored).
- Generate an API key under Settings → API keys. The full key is shown once; store it.
- 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.
- 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" }' - Redirect the buyer to the returned
payUrl. Your handler receives apaidevent 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.
| Managed | Monitored | |
|---|---|---|
| Source | Your xpub. Server derives a fresh address per invoice. | One address you provide. |
| Memo needed | No — 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). |
| Privacy | Each buyer sees a different address. | All payments share one public address. |
| Setup | Paste 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)
- new — created, no payments matched.
- pending — at least one payment detected (any status).
- partial — some confirmed but the total doesn't yet cover the target (after the underpayment tolerance — see below). Stays open through the late window past
expiresAt, in case the buyer tops up. - paid — confirmed amount covers the target.
- expired — late window passed without coverage.
- canceled — you called
POST /api/invoices/:id/cancel. - archived — legacy terminal state for very old rows. New invoices never land here, but the status filter on
GET /api/invoicesaccepts it for historical queries.
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.
Authentication
Every endpoint under /api accepts either of:
- Bearer token from the dashboard login flow (
Authorization: Bearer <jwt>) — short-lived, refreshed via/api/auth/refresh. - API key from Settings → API keys (
X-API-Key: <key>) — a 64-character hex string generated once per key (no prefix, no visible "live/test" tag — a key is valid for whatever account created it, across any chain). Long-lived, revocable per key, hashed at rest.
Use API keys for server-to-server. The dashboard handles Bearer refresh transparently for browser sessions.
https://payx.lol/api. All examples below use absolute paths relative to this base.
Invoices
Create
Choose the method (required, one of two ways)
| Field | Type | Notes |
|---|---|---|
methodId | string | The 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 + currency | string | Implicit lookup of the active method on the pair. Convenient for quick curl examples. See supported pairs below. |
methodId points at TRON USDT but chainId
says eth_mainnet) returns 400. Sending neither also
returns 400.
Other fields
| Field | Type | Notes |
|---|---|---|
amount | string | Decimal string. Use a string, not a number — JSON numbers lose precision past 15 digits. |
description | string? | Shown to the buyer on the pay page. |
redirectUrl | string? | Where to send the buyer after payment. http(s) only. |
clientId | string? | Your own reference id. Echoed in webhooks. |
metadata | object? | 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.
| chainId | Network | Currencies | Default confs |
|---|---|---|---|
tron_mainnet | TRON | USDT | 19 |
tron_nile | TRON Nile (testnet) | USDT | 19 |
eth_mainnet | Ethereum | ETH, USDT, USDC, DAI | 12 |
eth_sepolia | Ethereum Sepolia (testnet) | ETH, USDC | 12 |
bsc_mainnet | BNB Chain | BNB, USDT, USDC, DAI | 15 |
bsc_testnet | BNB Chain Chapel (testnet) | BNB | 15 |
base_mainnet | Base | ETH, USDC | 5 |
base_sepolia | Base Sepolia (testnet) | ETH, USDC | 5 |
arbitrum_mainnet | Arbitrum | ETH, USDT, USDC | 5 |
arbitrum_sepolia | Arbitrum Sepolia (testnet) | ETH, USDC | 5 |
sol_mainnet | Solana | SOL, USDT, USDC | 32 |
sol_devnet | Solana Devnet (testnet) | SOL, USDC | 32 |
Custom ERC20 / TRC20 tokens beyond the curated list are supported via customContract on accepted-methods create — see Accepted methods.
Response fields
| Field | Notes |
|---|---|
id | Invoice id; use this in subsequent API calls. |
publicToken | Buyer-facing token. |
payUrl | Full URL of the buyer-facing pay page — https://payx.lol/pay/<publicToken>. Redirect the buyer here. |
targetAddress | The 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, amount | Echoed from the request. amount is a decimal string. |
status | Current state — see lifecycle. |
expiresAt | When the invoice stops accepting on-time payments. After this it enters the late-payment window. |
isLate, isOverpaid | Boolean flags updated as payments come in. See the payload notes. |
paidAt, canceledAt, expiredAt | Terminal-state timestamps. Exactly one is non-null once the invoice is terminal; all null while open. |
clientId, metadata, description, redirectUrl | Echoed from the request. |
createdAt, updatedAt | ISO timestamps. |
List
Query params: status, cursor, limit, sortBy (createdAt|amount|status|address), sortOrder (asc|desc), includeTestnets=true. Cursor-paginated.
Get one
List the invoice's payments
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
| State | Meaning | Transitions |
|---|---|---|
| Active | Accepting new invoices. | → Disabled (toggle); → Archived (PATCH :id/archive) |
| Disabled | No new invoices, in-flight ones keep being polled. | → Active (toggle); → Archived |
| Archived | Done with this method; sits in a separate section at the bottom of /methods. | → Disabled (PATCH :id/restore); → gone (DELETE :id/force, gated) |
Endpoints
Create body — common fields
| Field | Notes |
|---|---|
chainId, currency | Same vocabulary as invoices. Must be a supported pair on the chain. |
mode | "managed" or "monitored". |
requiredConfirmations | Optional. Defaults to the chain's curated value. Mainnet floor: the chain default — lower values are refused with 400. Testnets are unconstrained. |
Managed-mode-only fields
xpub | Required. 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
monitoredAddress | Required. 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.
customContract | ERC20 contract address. |
customDecimals | Required 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
Returns { methods, orphanAddresses }. Each method bundles its
derived addresses (managed) or its single address (monitored) with per-address
incomingTotal and paymentCount. Orphans are
addresses whose owning method was hard-deleted; they retain history but
won't get new invoices.
Settings
| Field | Notes |
|---|---|
webhookUrl / webhookSecret | Mainnet pair. Secret is auto-generated at signup. |
testnetWebhookUrl / testnetWebhookSecret | Testnet pair. Used unless testnetUsesMainnetWebhook is on. |
testnetUsesMainnetWebhook | If true, testnet events go to the mainnet pair too. Branch on the payload's is_testnet flag in your handler. |
defaultExpirationMinutes | How long new invoices stay open before entering the late window. |
lateWindowMinutes | Grace period after expiresAt. See Late-payment window. |
underpaymentThresholdRelative | See Underpayment tolerance. |
URL fields are validated on save: parseable, https-only in production, and any private/loopback hostname is refused.
Rotate the webhook secret
: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
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:
- Dedicated testnet URL + secret — keeps test funds away from your production handler. The testnet secret is auto-generated at signup; you just fill in the URL.
- Same URL as mainnet (toggle
testnetUsesMainnetWebhook) — convenient if you run one handler that branches on the payload'sis_testnetflag. - Neither — testnet events are dropped server-side. This is the default state of a new account: a fresh merchant has no testnet URL and the toggle off, so testnet invoices won't emit webhooks until they opt in to one of the first two options. Safe default for production-only integrations.
Event types
| Event | Fires when |
|---|---|
created | Invoice is created. |
payment_detected | First inbound transfer matched. Invoice transitions new → pending. |
partial | Confirmed amount < required (after tolerance). Stays open until late-window expires. |
paid | Confirmed amount covers the target. Terminal. |
expired | Late-window passed without coverage. Terminal. |
canceled | You called cancel. Terminal. |
unmatched | Inbound transfer landed at a known address but no open invoice claims it. Payment-level event, no invoice attached. |
test | Synthetic 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:
event_id— unique per delivery. Use it as your idempotency key on the receiving side; a manual retry creates a newevent_idfor the same logical event.is_test— only present on test events.truemeans "synthetic, ignore in production logic".is_late— payment(s) for this invoice arrived during the late-payment window. Order is still valid; you may want to log it for fraud / SLA purposes.is_overpaid— confirmed amount exceeds the invoiced amount (buyer sent too much). Decide your refund policy out-of-band.memo— only set for monitored-mode invoices.nullfor managed-mode.amountandblock_number— strings, not numbers, to preserve precision.
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();
});
Mainnet vs testnet routing
The router picks one of three paths at enqueue time, based on the invoice's chain:
- If the chain is mainnet → mainnet URL + mainnet secret.
- If the chain is testnet AND
testnetUsesMainnetWebhook = true→ mainnet URL + mainnet secret. The payload'sis_testnetflag is your branch point. - If the chain is testnet AND a dedicated testnet URL is set → testnet URL + testnet secret.
- Otherwise (testnet, no fallback) → silently dropped server-side.
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:
- Retry — re-sends the event now.
- Cancel — visible on
pendingrows only. Use when you've handled the payment manually and don't need the webhook to fire.
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.