Integrating Pylex
Accept crypto, Telegram Stars and bank transfers. Pylex never holds your funds: crypto payments land at deposit addresses derived from your own factory contract and are swept to an address you control.
There are two ways in. Use the SDK unless you have a reason not to — it handles timeouts, retries, idempotency and webhook signatures, all of which are easy to get subtly wrong.
npm install pylex-gate
1. Get an API key
Dashboard → API keys → Create key. The key is shown once; only its hash is stored, so a lost key is replaced, never recovered.
Keys carry scopes. Grant the narrowest set that works:
| Scope | Allows |
|---|---|
invoices:write | create and cancel invoices |
invoices:read | read invoices and their status |
tokens:read | list payable currencies |
wallets:write | generate deposit wallet slots |
wallets:read | read your deposit pool |
A key with no scopes has full access — convenient for a first integration, and worth narrowing before you go live.
Send it as x-api-key on every authenticated request.
2. Create an invoice
import { PylexApi } from 'pylex-gate';
const pylex = new PylexApi({
baseUrl: 'https://api.your-pylex.example',
apiKey: process.env.PYLEX_API_KEY!,
});
const invoice = await pylex.createInvoice({
amount: 49.99,
fiatCurrency: 'USD',
externalId: 'order-4242', // your reference; appears on notifications
callbackUrl: 'https://shop.example/webhooks/pylex',
metadata: { customerId: 'cus_123' },
});
// Send the payer here.
redirect(invoice.paymentLink);
curl -X POST https://api.your-pylex.example/invoices \
-H "x-api-key: $PYLEX_API_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: order-4242" \
-d '{"amount":49.99,"fiatCurrency":"USD","externalId":"order-4242"}'
Idempotency
POST /invoices accepts an Idempotency-Key. Repeating a request with the same key
returns the original invoice rather than creating a second one — which is what makes
it safe to retry after a timeout.
The SDK generates a key automatically. Supply your own (your order id is a good choice) if you want retries across process restarts to collapse too.
Amounts
amount is a decimal in fiatCurrency. Everything the API returns afterwards — crypto
amounts, raw balances — is a string of integer base units, never a float. 0.1 + 0.2
is not 0.3, and an invoice is not a place to discover that.
3. Let the payer choose how to pay
The hosted page at invoice.paymentLink does this for you. Build your own only if you
need to; the flow is:
const info = await pylex.getInvoicePaymentInfo(invoice.id);
// info.availablePaymentMethods → ['CRYPTO', 'TELEGRAM_STARS', 'TRANSFER']
// info.chains → per-chain token options with the exact amount to send
Crypto. Selecting a token reserves a deposit address and locks the price:
const payment = await pylex.selectCryptoPayment(invoice.id, token.id);
// payment.depositAddress → where to send
// payment.cryptoAmount → exactly how much, in raw units
// payment.cryptoAmountDecimal → decimals, for rendering that amount
// payment.expiresAt → the clock starts here
token.id is the token-on-chain id from info.chains[].tokens[]. It already names
the chain, so there is no separate chain argument.
The quote shown before selection is indicative; the binding one is taken at selection, in
the same transaction that reserves the address. Show the payer cryptoAmountRaw to copy
into a wallet — the formatted amount is rounded for display and underpaying triggers
manual review.
Telegram Stars. Returns a deep link to the merchant's own bot:
const { starsAmount, starsDeepLink } = await pylex.selectStarsPayment(invoice.id);
Bank transfer. Returns the account details and the total including any markup:
const transfer = await pylex.selectTransferPayment(invoice.id, transferMethodId);
// transfer.requisites → what to show the payer
// transfer.amount → what they owe, including markup
The payer then uploads a receipt — one multipart/form-data request, which the SDK does
for you:
await pylex.submitTransferReceipt(invoice.id, file, {
filename: 'receipt.png',
payerNote: 'sent 14:02 from Acme Bank',
});
An operator then approves it in the dashboard. A receipt is a claim, not a payment:
the invoice only becomes PAID when a human confirms it, through the same code path a
confirmed on-chain payment uses.
4. Know when you have been paid
Webhooks (recommended)
Set callbackUrl per invoice, or a default in Settings → Webhooks. Events:
| Event | Meaning |
|---|---|
invoice.created | an invoice exists |
invoice.method_selected | the payer chose how to pay; the clock started |
invoice.paid | settled. Ship the order |
invoice.expired | the payment window closed unpaid |
invoice.canceled | cancelled by you |
payment.detected | funds seen on chain, not yet confirmed |
payment.confirmed | enough confirmations |
payment.underpaid / payment.overpaid | outside tolerance → needs your review |
payment.late_received | funds arrived after expiry → needs your review |
invoice.paid fires exactly once, for every payment method. That is the event to act on.
Verify every delivery. Signatures are v1=<hmac-sha256(secret, "timestamp.body")>,
hex-encoded, on X-Pylex-Signature, with the timestamp on X-Pylex-Timestamp:
import { verifyWebhook, parseWebhookPayload } from 'pylex-gate';
app.post('/webhooks/pylex', express.raw({ type: 'application/json' }), (req, res) => {
// The RAW bytes. Re-serialising a parsed body changes key order and breaks the check.
const result = verifyWebhook({
secret: process.env.PYLEX_WEBHOOK_SECRET!,
rawBody: req.body.toString('utf8'),
signature: req.header('x-pylex-signature')!,
timestamp: req.header('x-pylex-timestamp')!,
});
if (!result.ok) return res.status(401).json({ reason: result.reason });
const event = parseWebhookPayload(JSON.parse(req.body.toString('utf8')));
if (event.event_type === 'invoice.paid') {
// Idempotent, keyed on event.event_id: deliveries are AT-LEAST-once.
fulfilOrder(event.data.invoiceId as string, event.event_id);
}
// Answer 2xx quickly; do the work afterwards. A slow endpoint gets retried.
res.sendStatus(200);
});
Or skip the plumbing:
import { createExpressWebhookMiddleware } from 'pylex-gate';
app.post(
'/webhooks/pylex',
express.raw({ type: 'application/json' }),
createExpressWebhookMiddleware({
secret: process.env.PYLEX_WEBHOOK_SECRET!,
onPaid: async (event) => fulfilOrder(event.data.invoiceId as string, event.event_id),
// Under- and overpayments and late funds do NOT settle the invoice. A merchant who
// handles only `onPaid` never learns that money arrived and was not enough.
onNeedsReview: async (event) => flagForReview(event.data.invoiceId as string),
}),
);
Three properties are worth understanding:
- The timestamp is inside the signature, so a captured delivery cannot be replayed after the ±5 minute window — the timestamp cannot be edited without breaking the HMAC.
- Delivery is at-least-once. Make your handler idempotent on
event_id. - Retries follow a ladder — 10s, 1m, 5m, 30m, 2h, 6h — then stop. A
4xxis treated as a permanent rejection and is not retried.
Rotating the signing secret
Pylex signs with the new secret the instant you rotate, so an endpoint that knows only one secret rejects every delivery from that moment until it redeploys. Do it in this order and nothing is ever refused:
const result = verifyWebhook({
secret: process.env.PYLEX_WEBHOOK_SECRET!,
previousSecret: process.env.PYLEX_WEBHOOK_SECRET_PREVIOUS ?? null,
body: rawBody,
headers: req.headers,
});
- Deploy the code above, with both variables set to the same current secret.
- Rotate in the dashboard. It shows the new secret once — there is no way to read one back.
- Put the new secret in
PYLEX_WEBHOOK_SECRET, move the old one toPYLEX_WEBHOOK_SECRET_PREVIOUS, and deploy. - Retire the old secret in the dashboard, and drop the variable.
Both secrets are always checked, even when the first matches — returning early would let the response time reveal which one verified.
Polling (when you cannot receive webhooks)
import { PylexPollTimeoutError } from 'pylex-gate';
try {
const status = await pylex.pollInvoiceStatus(invoice.id, {
timeoutMs: 15 * 60_000,
signal: request.signal,
});
if (status === 'PAID') await fulfilOrder(invoice.id);
} catch (error) {
if (error instanceof PylexPollTimeoutError) {
// NOT the same as expired — we simply stopped waiting. The invoice may still be paid.
}
}
The interval backs off automatically. Do not poll in a tight loop: you will be rate
limited, and invoice.paid is a webhook away.
5. Errors
Every error, REST or SDK, has the same shape:
{ "code": "INVOICE_EXPIRED", "message": "this invoice has expired", "details": {} }
Branch on code, never on message — messages get reworded, codes do not.
import { isPylexError } from 'pylex-gate';
try {
await pylex.selectCryptoPayment(id, tokenOnChainId);
} catch (error) {
if (!isPylexError(error)) throw error;
switch (error.code) {
case 'INVOICE_EXPIRED': return showExpiredPage();
case 'NO_WALLET_AVAILABLE': return showTryAgainShortly();
case 'EXCHANGE_RATE_STALE': return showTryAgainShortly();
case 'RATE_LIMITED': return backOff(error);
default: throw error;
}
}
Codes worth handling explicitly:
| Code | What it means | What to do |
|---|---|---|
INVOICE_EXPIRED | the payment window closed | create a new invoice |
INVOICE_ALREADY_PAID | settled already | treat as success |
NO_WALLET_AVAILABLE | the merchant's deposit pool is empty | retry shortly; add slots |
EXCHANGE_RATE_STALE | no rate fresh enough to price from | retry shortly |
ACCOUNT_SUSPENDED | billing is unpaid | settle it; existing invoices still pay |
RATE_LIMITED | too many requests | honour Retry-After |
VALIDATION_FAILED | the request is wrong | fix it; retrying will not help |
The SDK also raises TIMEOUT, NETWORK and ABORTED for failures that never reached the
API. TIMEOUT and NETWORK are ambiguous — the request may still have been processed —
which is exactly why you should send an idempotency key.
error.retryable tells you whether a later attempt could plausibly succeed. The SDK has
already retried what it safely can.
6. Limits
- Rate limits are per plan;
429carriesRetry-After. The SDK honours it. - Uploads (transfer receipts) are size-capped and type-sniffed from their bytes — a
.pngthat is not a PNG is rejected regardless of what the filename says. - One receipt per invoice. Re-uploading is refused rather than leaving an operator with two images and no way to tell which one the payer means.
- Query depth on GraphQL is capped; list queries take a bounded
limit.
7. Testing
Point baseUrl at your testnet deployment and use a testnet chain (Sepolia, Amoy,
Shasta). Everything behaves identically; the coins are worthless.
Two things to exercise before going live, because both are easy to get wrong and neither shows up in the happy path:
- A duplicate webhook. Deliver the same event twice and check you do not ship twice.
- An underpayment. Send slightly less than the quoted amount. The invoice should
not become
PAID; it should appear in Needs review with the shortfall — and the funds are still swept, because where the money goes and whether you ship are separate questions.
8. The pylex-gate SDK
Everything above uses it. This is the full surface.
npm install pylex-gate # requires Node 18+
import { PylexApi } from 'pylex-gate';
const pylex = new PylexApi({
baseUrl: 'https://api.your-pylex.example',
apiKey: process.env.PYLEX_API_KEY!,
timeoutMs: 30_000, // per request
maxRetries: 3, // on 429, 5xx and network errors
fetch: undefined, // inject your own for a proxy, tracing or tests
});
Every method takes an optional final argument with { signal, timeoutMs }, so a request
handler can cancel outbound work when its client disconnects.
Merchant methods (need an API key)
| Method | Scope | Notes |
|---|---|---|
createInvoice(input, opts?) | invoices:write | sends an idempotency key; returns the full invoice |
getInvoice(id, opts?) | invoices:read | |
getInvoiceStatus(id, opts?) | invoices:read | just the status |
cancelInvoice(id, opts?) | invoices:write | only before it is paid |
getPayableTokens(opts?) | tokens:read | returns token objects — use decimals to build an amount |
generateWallets({ chainId, count? }, opts?) | wallets:write | adds slots to your pool |
generateWallet(chainId, opts?) | wallets:write | one slot, for the common case |
getPoolHealth(opts?) | wallets:read | watch isLow before the pool empties |
Payer methods (no key — reached with an invoice link)
| Method | Notes |
|---|---|
getInvoicePaymentInfo(id, opts?) | everything the payment page renders |
getPublicInvoiceStatus(id, opts?) | status alone |
selectCryptoPayment(id, tokenOnChainId, opts?) | reserves an address, locks the price, returns the amount |
selectStarsPayment(id, opts?) | returns starsAmount and starsDeepLink |
selectTransferPayment(id, transferMethodId, opts?) | returns bank details and the total |
submitTransferReceipt(id, file, opts?) | uploads the payer's proof in one multipart request |
pollInvoiceStatus(id, opts?) | waits for a terminal status |
healthCheck(opts?) | liveness — operational, and often not proxied publicly |
Types
The response types are generated from the API's own OpenAPI document, not written by hand:
pnpm --filter @pylex/public-api run openapi:emit # refresh openapi.json from the running API
npm run generate:types # regenerate src/generated/api.ts
They used to be hand-written and had drifted: getPayableTokens was typed string[]
while the endpoint returns objects, selectStarsPayment promised deepLink where the
API sends starsDeepLink, and the receipt upload called two routes that did not exist.
Every one of those type-checked cleanly and failed on the wire. Now a server-side change
becomes a compile error in the SDK the moment the document is regenerated.
paths, components and operations are exported too, if you want to type a proxy or a
mock server against the same contract.
Retries and idempotency
The SDK retries 429, 5xx and network failures with exponential backoff and jitter,
honouring Retry-After. It retries GET and DELETE freely; it retries a POST only
when the call carries an idempotency key, because otherwise a retry would create a second
invoice for one request.
createInvoice generates a key automatically. Supply your own if you want retries across
process restarts to collapse as well:
await pylex.createInvoice(input, { idempotencyKey: `order-${orderId}` });
Error handling
import { isPylexError, PYLEX_ERROR_CODES } from 'pylex-gate';
try {
await pylex.getInvoice(id);
} catch (error) {
if (!isPylexError(error)) throw error;
error.code; // 'INVOICE_EXPIRED' | 'TIMEOUT' | … typed, not free text
error.status; // HTTP status, or 0 if the request never got a response
error.attempts; // how many tries the SDK made
error.retryable; // whether a later attempt could plausibly succeed
}
PYLEX_ERROR_CODES is exported as a runtime array, so you can assert exhaustiveness in
your own tests.
TIMEOUT and NETWORK are the ambiguous ones: the request may or may not have reached
the API. That is precisely why a POST should carry an idempotency key.
A complete webhook endpoint
import express from 'express';
import { createExpressWebhookMiddleware } from 'pylex-gate';
const app = express();
app.post(
'/webhooks/pylex',
// RAW, before any JSON parser. The signature covers the exact bytes; re-serialising a
// parsed object reorders keys and every signature then fails.
express.raw({ type: 'application/json' }),
createExpressWebhookMiddleware({
secret: process.env.PYLEX_WEBHOOK_SECRET!,
onPaid: async (event) => {
// Deliveries are at-least-once. Deduplicate on event_id.
if (await alreadyHandled(event.event_id)) return;
await fulfilOrder(event.data.invoiceId as string);
await recordHandled(event.event_id);
},
onNeedsReview: async (event) => {
// Under/overpaid, or arrived after expiry. NOT settled — a person decides.
await flagForReview(event.data.invoiceId as string, event.event_type);
},
onExpired: async (event) => releaseStock(event.data.invoiceId as string),
onError: (error, event) => {
// Throwing here returns 500, and Pylex retries on the §6.12 ladder.
logger.error({ error, eventId: event?.event_id }, 'webhook handler failed');
},
}),
);
For Fastify or a Next.js route handler, use verifyWebhook and parseWebhookPayload
directly — the middleware is a convenience, not the only path.
9. Self-hosting the payment page
Deploy the payment-processor template, point a domain at it, and set
PYLEX_VERIFY_TOKEN from Settings → Domains. Pylex fetches
/.well-known/pylex-verify to confirm you control the domain, and re-checks periodically
— if it stops resolving, your payer page falls back to the platform domain and you are
told why.
Custom domains are an Enterprise feature, one origin per merchant.