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:

ScopeAllows
invoices:writecreate and cancel invoices
invoices:readread invoices and their status
tokens:readlist payable currencies
wallets:writegenerate deposit wallet slots
wallets:readread 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

Set callbackUrl per invoice, or a default in Settings → Webhooks. Events:

EventMeaning
invoice.createdan invoice exists
invoice.method_selectedthe payer chose how to pay; the clock started
invoice.paidsettled. Ship the order
invoice.expiredthe payment window closed unpaid
invoice.canceledcancelled by you
payment.detectedfunds seen on chain, not yet confirmed
payment.confirmedenough confirmations
payment.underpaid / payment.overpaidoutside tolerance → needs your review
payment.late_receivedfunds 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 4xx is 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,
});
  1. Deploy the code above, with both variables set to the same current secret.
  2. Rotate in the dashboard. It shows the new secret once — there is no way to read one back.
  3. Put the new secret in PYLEX_WEBHOOK_SECRET, move the old one to PYLEX_WEBHOOK_SECRET_PREVIOUS, and deploy.
  4. 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:

CodeWhat it meansWhat to do
INVOICE_EXPIREDthe payment window closedcreate a new invoice
INVOICE_ALREADY_PAIDsettled alreadytreat as success
NO_WALLET_AVAILABLEthe merchant's deposit pool is emptyretry shortly; add slots
EXCHANGE_RATE_STALEno rate fresh enough to price fromretry shortly
ACCOUNT_SUSPENDEDbilling is unpaidsettle it; existing invoices still pay
RATE_LIMITEDtoo many requestshonour Retry-After
VALIDATION_FAILEDthe request is wrongfix 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; 429 carries Retry-After. The SDK honours it.
  • Uploads (transfer receipts) are size-capped and type-sniffed from their bytes — a .png that 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:

  1. A duplicate webhook. Deliver the same event twice and check you do not ship twice.
  2. 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)

MethodScopeNotes
createInvoice(input, opts?)invoices:writesends an idempotency key; returns the full invoice
getInvoice(id, opts?)invoices:read
getInvoiceStatus(id, opts?)invoices:readjust the status
cancelInvoice(id, opts?)invoices:writeonly before it is paid
getPayableTokens(opts?)tokens:readreturns token objects — use decimals to build an amount
generateWallets({ chainId, count? }, opts?)wallets:writeadds slots to your pool
generateWallet(chainId, opts?)wallets:writeone slot, for the common case
getPoolHealth(opts?)wallets:readwatch isLow before the pool empties
MethodNotes
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.

Ready to integrate?

Generate an API key in the dashboard and create your first invoice.

Open Dashboard