Integration reference & audit

Gateway API

A USD stable currency payment gateway with card-PSP fallback: HD-wallet deposits priced in USD, a unified card charge that routes across connected providers, an embeddable hosted checkout, and signed outbound webhooks. Every endpoint, payload and status code below was executed against the codebase — the sample responses are real output, not illustrations.

Overview

The backend is Django. It exposes three separate planes, all mounted under /api/ by PaymentProvider/urls.py:

PlanePrefixAuthWho uses it
Merchant API /api/v1/… X-API-Key Your server. This document.
Public checkout /api/psp/checkout-sessions/… None (session id is the capability) The embedded iframe, in the payer's browser
Dashboard /api/app/… JWT bearer The Next.js dashboard only — not a public integration surface

Inbound PSP webhooks land on /api/webhooks/<psp_name>, and the Django admin is at /admin/.

Base URL

https://api.xlopay.me in production, http://127.0.0.1:8000 in development.

Content type

JSON in, JSON out. Response keys are camelCase; money is a decimal string, timestamps are ISO-8601.

Errors

Non-2xx responses are always {"error": "…"}. Status codes carry the meaning.

Authentication

Signing up in the dashboard mints two secrets with different powers. Keeping them apart is the whole security model — and it is why there is no way to mint them over the API:

KeyGrantsWhere it goes
ak_live_… Identifies you and authorises reads plus non-custodial writes. X-API-Key header on every request.
sk_live_… Unlocks the wallet — derives addresses, signs, moves funds. In the JSON body as skKey, only on endpoints that touch the seed.

Both header forms work:

X-API-Key: ak_live_xxxxxxxxxxxxxxxxxxxxx
# — or —
Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxx

A missing, unknown or non-ACTIVE key returns 401 {"error": "invalid or missing API key"}.

⚠ The API key is also a vault key

The raw API key derives the key that decrypts your stored PSP credentials for the duration of a request. It is not a bearer token you can treat casually — leaking it exposes more than read access. Rotate through the dashboard if it is ever exposed.

Saved keys

If saveKeyEnabled is on for your account, endpoints that need sk_live will fall back to the stored copy when you omit skKey. One exception is deliberate: a withdrawal to a custom destination always demands the explicit key, so a leaked API key alone cannot drain the account to an arbitrary address.

Two-factor authentication

Turn on TOTP — Google Authenticator, Authy, 1Password, any of them — from Settings → Two-factor authentication in the dashboard. Enabling it needs a code from the app before it takes effect, so an unfinished setup can never lock you out, and it issues ten single-use backup codes shown exactly once. They are stored hashed: nobody, including us, can read them back to you later.

It does not touch your API key

2FA gates the dashboard login and nothing else. The X-API-Key plane this document describes is unchanged and always will be — there is no human at the other end of your server's cron job to read a code off a phone, and an integration that stopped working the day someone enabled 2FA on their account would be a worse outcome than the one 2FA prevents.

So: protect the browser session that can move funds and rotate keys, and leave machine-to-machine authentication to the key it was designed around.

If you drive the dashboard API directly, note that POST /api/auth/login answers a 2FA account with {"mfaRequired": true, "mfaToken": "…"} and no token pair. Post that token with a code to POST /api/auth/mfa/verify to get the session. The challenge token authorises nothing on its own, expires in five minutes, and stops working if the account password changes underneath it.

The Python client

Every example below uses sdk/xlopay.py from this repository. It needs only requests. One named method per endpoint — there is no raw get/post escape hatch, because a caller that hand-builds paths gets to hand-build the auth too, and that is how an skKey ends up on a request that had no business carrying one.

import os
from xlopay import XloPay, GatewayError

gw = XloPay(
    api_key=os.environ["XLOPAY_API_KEY"],
    sk_key=os.environ.get("XLOPAY_SK_KEY", ""),   # wallet key, optional
    base_url="https://api.xlopay.me",
)

try:
    currencies = gw.list_currencies()
except GatewayError as e:
    # e.status is None when the gateway was never reached (DNS, TLS, timeout).
    # That distinction matters: "refused" is final, "not reached" is unknown.
    print(e.status, e.body)
Every call is POST + JSON + X-API-Key

Reads included. One shape for the whole plane means auth, arguments and errors are always in the same place, and no identifier — an email, an external id, a payment hex id — ever lands in a URL where proxies, browser history and access logs would retain it. Paths are named actions (/api/v1/users/list, /api/v1/users/create) rather than REST resources, which is what lets read and write share a prefix without fighting over a verb.

Two credentials, two jobs. The API key travels in the X-API-Key header on every request and authorises it. The sk_live key unlocks the wallet, is sent in the body, and only by the methods that actually derive or sign — marked needs sk_live below. Everything else never sees it.

The client does not retry

Every endpoint is a POST and several of them move money. A transport error means the outcome is unknown, and retrying an unknown outcome is how a payment gets made twice. Retry from your own code, keyed on your own invoiceNumber / uid, so a repeat is recognisable as one.

Getting your keys

Accounts are created in the dashboard, not over the API

There is no public endpoint on this plane. Sign up in the dashboard; it shows you the two keys once, at the only moment a human is present to read them.

An unauthenticated endpoint that mints credentials is the one thing here an attacker can reach while holding nothing at all, so it does not exist — which takes signup floods, throwaway accounts and key harvesting off the API entirely. POST /api/v1/merchants is gone and answers 404.

You get two keys, and they are not interchangeable:

KeyWhere it goesWhat it does
ak_live_… X-API-Key header, on every request Identifies your account and authorises the call.
sk_live_… skKey in the body, only where marked Unlocks the wallet: decrypts your seed to derive addresses and sign payouts. Never leaves your backend.

If saveKeyEnabled is on for your account, the endpoints that need sk_live fall back to the stored copy when you omit it — with one deliberate exception, see withdrawals.

Verifying a key

POST/api/v1/me X-API-Key
me = gw.get_account()
print(me["name"], me["apiKeyPrefix"], me["saveKeyEnabled"])

Currencies & wallets

POST/api/v1/currencies/list X-API-Key

The authoritative set of chain / quoteCurrency pairs you may pass to GeneratePayment. Only tokens enabled for your account appear; a native coin maps to an empty address.

currencies = gw.list_currencies()

for chain, tokens in currencies.items():
    for symbol, contract in tokens.items():
        print(f"{chain:5} {symbol:6} {contract or '(native)'}")

Response · 200

{
  "ETH": {"USDC": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
          "USDT": "0xdac17f958d2ee523a2206206994597c13d831ec7"},
  "BSC": {"USDC": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d",
          "USDT": "0x55d398326f99059ff775485246999027b3197955"},
  "SOL": {"USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
          "USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"},
  "BTC": {"BTC": ""}
}
POST/api/v1/wallets/list X-API-Key
POST/api/v1/wallets/one-time X-API-Key + sk_live
wallets = gw.list_wallets()

# A standalone hot wallet, outside the payment flow.
hot = gw.create_one_time_wallet(chain="BSC", label="invoice-8841")   # needs sk_live
print(hot["address"], hot["hexId"])

Generate a payment

POST/api/v1/payments/create X-API-Key + sk_live

Quotes a USD stable currency amount for a USD value, mints a dedicated one-time wallet, and returns the address the payer must fund. The quote is the requested amount plus your platform fee, converted at the current price and rounded up to the token's precision so it is never short.

FieldTypeNotes
amountnumberRequired. Value in priceCurrency.
quoteCurrencystringRequired. USD stable currency token to be paid, e.g. USDT.
chainstringRequired. BSC, ETH, SOL or BTC.
uidstringYour order reference. Echoed back and included in webhooks.
priceCurrencystringOptional, default USD.
skKeystringRequired unless a saved key is enabled — the wallet is derived from the seed.
payment = gw.create_payment(          # needs sk_live
    amount=49.00,
    quote_currency="USDT",
    chain="BSC",
    uid="order-1001",
)

print("Send exactly", payment["payAmount"], payment["quoteCurrency"])
print("To address  ", payment["address"])
print("Expires     ", payment["dateExpired"])

# Persist these two against your order — they are how you reconcile later.
order.payment_hex_id = payment["hexId"]
order.wallet_hex_id  = payment["walletHexId"]

Response · 201

{
  "hexId": "b86a10a2dc",
  "walletHexId": "ff599af979",
  "address": "0x6CF6716F27057F87d91Ee20fF0c5f39C220Dc271",
  "uid": "order-1001",
  "chain": "BSC",
  "priceCurrency": "USD",
  "quoteCurrency": "USDT",
  "amountRequested": "49.00",
  "payAmount": "49.000000000000000000",
  "payAmountUsd": "49.00",
  "totalReceived": "0E-18",
  "status": "pending",
  "dateCreated": "2026-07-25T22:51:47.938179+00:00",
  "dateExpired": "2026-07-26T02:51:47.938179+00:00",
  "datePaid": null
}
Decimal strings, not floats

payAmount is a string at full token precision ("0E-18" is a zero Decimal). Parse with decimal.Decimal, never float, or you will quote the wrong amount.

The one-time wallet expires after ONE_TIME_WALLET_TTL_HOURS (default 4 hours). Late funds are still credited, but the quote may have moved by then.

Payment status

POST/api/v1/payments/get X-API-Key
POST/api/v1/payments/status X-API-Key

GET by payment id returns the same shape as GeneratePayment. POST /status takes a walletHexId and adds the individual on-chain deposits, with live confirmation counts.

from decimal import Decimal

status = gw.get_payment_status(order.wallet_hex_id)

received = Decimal(status["totalReceived"])
required = Decimal(status["payAmount"])

print(f"{status['status']}: {received} / {required} {status['quoteCurrency']}")

for tx in status["txs"]:
    print(f"  {tx['txHash'][:16]}… {tx['amount']} "
          f"({tx['confirmations']}/{tx['requiredConfirmations']} confs)")

Response · 200

{
  "walletHexId": "ff599af979",
  "paymentId": "b86a10a2dc",
  "status": "pending",
  "quoteCurrency": "USDT",
  "payAmount": "49.000000000000000000",
  "totalReceived": "0E-18",
  "txs": []
}

Payment statuses

StatusMeaning
pendingNothing received yet.
underpaidSome funds arrived, still short of payAmount. Fires payment.underpaid; topping up flips it to paid.
paidReceived total reached the quote. Fires the payment.paid webhook exactly once.
expiredPassed dateExpired without completing.
cancelledCancelled out of band.
⚠ Prefer webhooks over polling

Deposits are detected by background chain monitors, so paid arrives on its own schedule. Poll only as a safety net — and back off. Treat the payment.paid webhook as the primary signal.

Polling as a fallback

import time
from decimal import Decimal

def await_payment(gw, wallet_hex_id, *, timeout_s=3600, interval_s=15):
    """Block until a payment settles. Returns the final status payload."""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        s = gw.get_payment_status(wallet_hex_id)
        if s["status"] in ("paid", "expired", "cancelled"):
            return s
        time.sleep(interval_s)
        interval_s = min(interval_s * 1.5, 120)   # back off
    raise TimeoutError(f"payment {wallet_hex_id} did not settle in {timeout_s}s")

Withdrawals

POST/api/v1/withdrawals/list X-API-Key
POST/api/v1/withdrawals/create X-API-Key + sk_live

Accepted work returns 202 — the transaction is signed and broadcast by a background worker, so poll GET /api/v1/withdrawals or wait for the webhook for the final on-chain result.

# Sweep the full balance to your configured withdrawable address.
# Omitting `destination` is the safe path — it needs no explicit sk_live.
job = gw.create_withdrawal(            # needs sk_live
    chain="BSC",
    token="USDT",
    # amount_usd=250.00,      # omit to withdraw everything
)
print(job)   # 202 Accepted — a summary of the queued payout
Custom destinations require the raw sk_live

Passing destination without skKey returns 403, even when saved keys are enabled. A leaked API key alone must never be able to send funds to an attacker-chosen address.

Payment users

A payment user is one of your customers, held on our side. It is the identity both halves of the gateway hang off: it owns a derived USD stable currency deposit address on every chain, and it owns vaulted cards. That is what makes "this customer's saved payment methods" a real thing rather than a merchant-wide pool.

Four identifiers address the same user. Pass whichever you have as identifier in the request body — never in the URL.

IdentifierFieldMatching
OurshexIdAlways present, system-unique, opaque. The one to store.
Your own user idexternalIdExact. Unique per merchant when set.
EmailemailCase-insensitive.
UsernameusernameCase-insensitive. Unique per merchant.
Users are also created by card payments

A card charge always resolves to a user, and creates one when what you sent matches nobody — even when you send nothing identifying at all. Those users have a hexId and a username derived from it, but no deposit addresses: minting those needs sk_live, which a card charge does not carry. Everything else about them is identical, and addresses can be derived onto the same user later.

Resolution order is fixed

Lookups try externalId → email → username and stop at the first hit. The order is deliberate and stable, so an email that happens to equal another user's username can never resolve ambiguously. Prefer hexId when you have it: it is ours, opaque, and cannot collide with anything.

Create a user

POST/api/v1/users/create X-API-Key + sk_live

CreateUser(user_id) in its smallest form. Supply any one of userId, email or username — a missing username is derived from the id you gave. Deposit addresses on every chain are minted in the same call, which is why sk_live is required (they are derived from your seed).

FieldNotes
userIdYour id for this person. Stored as externalId. Also accepted as externalId.
email · usernameThe other two identifiers.
firstName · lastName · phone · countryProfile. country is ISO-3166 alpha-2, upper-cased for you.
statusactive (default) or blocked. A blocked user cannot be issued a checkout session or charged — the charge is refused with 422 before the card is touched. Deposits still credit. A payment can never write this field.
metadataAny JSON object. Never interpreted by the gateway.
skKeyRequired unless a saved key is enabled.
# The minimal form — just your own user id.
user = gw.create_user(external_id="cust-9001")   # needs sk_live

# …or with a full profile.
user = gw.create_user(
    external_id="cust-9001",
    email="ada@acme.test",
    first_name="Ada", last_name="Lovelace",
    country="gb",
    metadata={"tier": "gold", "signupSource": "ios"},
)

for w in user["wallets"]:
    print(w["chain"], w["address"])

Response · 201

{
  "id": "1",
  "hexId": "a3f19c2b04",          // ours, always present
  "merchantId": "1",
  "username": "cust-9001",          // derived from userId
  "externalId": "cust-9001",
  "email": "ada@acme.test",
  "phone": "",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "country": "GB",
  "status": "active",
  "metadata": {"tier": "gold"},
  "userIndex": 2,
  "balanceUsd": 0.0,
  "createdAt": "2026-07-25T23:30:46.566542Z",
  "updatedAt": "2026-07-25T23:30:46.575188Z",
  "lastSeenAt": null,
  "wallets": [
    {"hexId": "496f29b4a2", "chain": "BSC",
     "address": "0x5218d7a0ab99A646c29cdBE40b0363699Ca26Be4",
     "derivationPath": "m/44'/60'/0'/0/2", "endUserId": "1"}
    /* … one per chain … */
  ]
}

A duplicate userId is rejected with 400. Users created without one do not collide — the uniqueness constraint applies only when the field is set.

Look a user up

POST/api/v1/users/get X-API-Key
POST/api/v1/users/update X-API-Key
POST/api/v1/users/list X-API-Key
# All three of these return the same user.
gw.get_user("cust-9001")        # by your id
gw.get_user("ada@acme.test")    # by email
gw.get_user("sevenup")          # by username

# Editing a profile needs no sk_live — it derives nothing.
gw.update_user("cust-9001", first_name="Grace")

# Block a user: no new checkout sessions, no charges. Deposits still credit.
gw.update_user("cust-9001", status="blocked")

# Fuzzy search across id / email / username / name.
for u in gw.list_users(search="acme"):
    print(u["externalId"], u["email"], u["status"])
Identifiers go in the body, not the URL

Pass identifier in the JSON body. An email, or an id containing /, + or @, needs no encoding and cannot collide with a sibling action on the same prefix — and it stays out of proxy logs, browser history and referrer headers.

Their deposit addresses

POST/api/v1/users/wallets X-API-Key

The user plus every derived deposit address, both as a flat list and keyed by chain — which is what "where do I send USDT on BSC for this customer" actually needs.

w = gw.get_user_wallets("cust-9001")

print(w["user"]["firstName"])
print(w["byChain"]["BSC"])       # 0x5218d7a0ab99A646c29cdBE40b0363699Ca26Be4

for addr in w["addresses"]:
    print(addr["chain"], addr["address"], addr["hexId"])

Response · 200

{
  "user": { /* the user object, without the wallets array */ },
  "addresses": [ /* full wallet objects, one per chain */ ],
  "byChain": {
    "BSC": "0x5218d7a0ab99A646c29cdBE40b0363699Ca26Be4",
    "ETH": "0x5218d7a0ab99A646c29cdBE40b0363699Ca26Be4",
    "SOL": "6Xy…",
    "BTC": "bc1q6mkhklyl0cx0n3q60383znnp7uptj2ldy7m2fq"
  }
}

Their saved cards

POST/api/v1/psp/users/cards X-API-Key
wallet = gw.list_user_cards("cust-9001")

for c in wallet["cards"]:
    print(f"{c['brand']} ••••{c['last4']} exp {c['expMonth']}/{c['expYear']}")

To vault a card against a user directly, pass userId to POST /api/v1/psp/cards. Cards created without a userId stay merchant-level, exactly as before.

Card providers

POST/api/v1/psp/providers/list X-API-Key

The catalogue of connectable providers and the credential fields each one needs.

ProviderKeyRequired credentialsRefunds
StripestripesecretKey · opt webhookSecretYes
PayItpayitbearerToken, clientSlug · opt descriptor, webhookSecretYes
Forex PSPforexapiKey, chargeUrl · opt webhookSecretNo
Webhook PSPwebhookapiKey, chargeUrl · opt webhookSecretNo
for p in gw.list_psp_providers():
    if p["hidden"]:          # the generic "webhook" escape hatch
        continue
    required = [f["key"] for f in p["fields"] if f["required"]]
    print(f"{p['label']:12} needs {required}")

Connect a PSP

POST/api/v1/psp/configs/create X-API-Key
POST/api/v1/psp/configs/list X-API-Key
POST/api/v1/psp/configs/update X-API-Key

priority is the fallback order — lower runs first. Credentials are encrypted at rest and are write-only: reads report hasCredentials and never the values.

# Primary processor.
gw.connect_psp(
    provider="stripe", psp_name="Stripe",
    priority=1, active=True,
    credentials={
        "secretKey": os.environ["STRIPE_SECRET_KEY"],
        "webhookSecret": os.environ["STRIPE_WEBHOOK_SECRET"],
    },
)

# Fallback, tried only when the primary declines synchronously.
gw.connect_psp(
    provider="payit", psp_name="PayIt",
    priority=2, active=True,
    credentials={
        "bearerToken": os.environ["PAYIT_TOKEN"],
        "clientSlug": "acme",
        "descriptor": "ACME LTD",
    },
)

for cfg in gw.list_psp_configs():
    print(cfg["priority"], cfg["providerLabel"],
          "active" if cfg["active"] else "paused",
          "creds✓" if cfg["hasCredentials"] else "creds✗")

The unified charge

POST/api/v1/payments/charge X-API-Key

One card payload, routed through your connected PSPs in priority order. It falls through to the next provider only on a synchronous decline — and only if more than one PSP is connected. Every attempt is recorded on the charge.

HTTPMeaning
200succeeded, or pending with a 3-DS redirectUrl.
402Every provider declined. The attempts array explains each one.
422Could not even be attempted — no active PSPs, bad amount.
503saveCard requested but the card vault is not configured.
result = gw.charge_card(
    amount_usd=49.00,
    currency="USD",
    invoice_number="order-1001",

    # Card
    card_number="4111111111111111",
    exp_month="12",
    exp_year="2030",
    cvv="123",

    # Billing profile — several PSPs decline without it
    first_name="Ada",   last_name="Lovelace",
    email="ada@acme.test", phone="+15551234567",
    street1="1 Analytical Way", city="London",
    state="LDN", country="GB", postal_code="EC1A",

    # Who is paying — your own id, an email or a username. Optional:
    # an unknown one creates the user, and sending none at all still
    # resolves or creates one. See the callout below.
    user_id="cust-8814",

    return_url="https://acme.test/thanks",
)

if result["status"] == "succeeded":
    fulfil(result["invoiceNumber"], psp=result["pspName"])

elif result["status"] == "pending":
    # 3-DS: send the shopper to the bank, settle on the webhook.
    redirect_to(result["redirectUrl"])

else:
    for a in result["attempts"]:
        print(f"{a['pspName']}: {a['status']} {a['errorCode']} {a['errorMessage']}")

Error response · 422 (verified)

{"error": "no active PSPs connected — connect a provider first"}
Every charge comes back with a payer

Unlike a one-time settlement wallet — which is only an address — a card payment has a person behind it, so the response carries an endUser block:

{"status": "succeeded",
 "endUser": {"hexId": "a3f19c2b04", "externalId": "cust-8814",
              "email": "ada@acme.test", "username": "cust-8814", "id": "12"},
 …}

We match on userId first, then the billing email. An identifier we do not recognise creates the user rather than failing, and a request with nothing identifying at all still gets one — minted with a hexId and a username derived from it. That means a charge is never anonymous, and the hexId is the handle to store and send back on that customer's next payment. A user whose status is blocked is refused with 422 before the card is charged.

This endpoint takes raw PAN

Sending full card numbers through your own server puts it in PCI DSS SAQ-D scope. If you do not want that scope, use the hosted checkout iframe instead — the card never touches your infrastructure.

Card vault

POST/api/v1/psp/cards/list X-API-Key
POST/api/v1/psp/cards/create X-API-Key
POST/api/v1/psp/cards/delete X-API-Key

Store a card once, charge it later by token. Reads never return the PAN, the CVV or the sealed blob — only brand, last 4 and expiry.

vault = gw.list_cards()
if not vault["vaultConfigured"]:
    raise RuntimeError("card vault keys are not deployed")

# Save at charge time…
paid = gw.charge_card(
    amount_usd=49.00, card_number="4111111111111111",
    exp_month="12", exp_year="2030", cvv="123",
    first_name="Ada", last_name="Lovelace",
    email="ada@acme.test",
    save_card=True, card_label="Ada — personal Visa",
)
token = paid["savedCard"]["token"]

# …then charge on file later, with no card data in the request.
gw.charge_card(
    amount_usd=49.00,
    saved_card_token=token,
    invoice_number="order-1002",
)
Recurring billing is not on this plane

A full subscription engine exists (plans, intervals, retries, dunning) but it is only reachable from the dashboard via JWT at /api/app/psp/subscriptions. To bill on a schedule from your own server today, store the card token and drive the cadence yourself with the charge endpoint above. See Finding 5.

Hosted checkout (the iframe)

Implemented end to end — backend sessions, the public pay endpoints, the React checkout page and the dashboard snippet generator are all present and wired together, with the xlopay Node SDK as the supported way to embed it.

The design goal is that the merchant page holds no secret and no amount. The session fixes the amount server-side and can be paid at most once, so the only thing in the browser is a session id.

  1. Your server creates a session POST /api/v1/psp/checkout-sessions with your API key. The amount is fixed here and cannot be altered from the browser.
  2. Your page embeds the iframe with mountCheckout, or by hand from the returned embedPath.
  3. The iframe loads the session GET /api/psp/checkout-sessions/<id> — a display-only view: amount, currency, reference, merchant name. No secrets.
  4. The payer submits their card POST /api/psp/checkout-sessions/<id>/pay, rate-limited to 20 attempts per 10 minutes per IP as a card-testing guard.
  5. It routes through your PSP chain exactly like the unified charge — same fallback, same attempt trail.
  6. Terminal state Success marks the session paid and shows a return link. A decline leaves it open so the payer can try another card. 3-DS moves it to pending_auth with a verification link.

GeneratePaymentLink

POST/api/v1/psp/payment-links X-API-Key

Pass a userId and the checkout becomes personal: that user's vaulted cards are pooled for selection, and they can add or remove cards in the frame. Omit it and you get a guest checkout — raw card entry, exactly as before.

FieldNotes
amountUsdRequired. Fixed server-side; the browser cannot alter it.
userIdAny of the three identifiers. Unknown ⇒ 404.
uid · trackingId · referenceYour references. All three ride through to the charge metadata. reference must be unique per session — it becomes the charge's invoiceNumber, which is the idempotency key, so a second session reusing one is refused rather than silently settling against the first payment. Omit it and the session id is used.
expiresInMinutes1–10080. Default 60. Out-of-range values clamp rather than error.
themePresentation object — see below.
allowSaveCardDefault true. Offer "save this card".
allowCardManagementDefault true. Allow removal from inside the frame.
metadata · returnUrl · currencyAs you'd expect.
link = gw.create_payment_link(
    user_id="cust-9001",
    amount_usd=49.00,
    currency="USD",
    uid="order-1001",
    tracking_id="trk-77",
    reference="inv-9",
    return_url="https://acme.test/thanks",
    expires_in_minutes=15,
    theme={"primary": "#10b981", "buttonLabel": "Top up"},
)

print(link["embedUrl"])     # ready to drop straight into an iframe
order.checkout_session_id = link["sessionId"]

Response · 201 (verified)

{
  "sessionId": "cs_db082a2ca5e4b6b0c751e8e1cabd8b7304d6",
  "merchantId": "1",
  "userId": "1",
  "externalUserId": "cust-9001",
  "amountUsd": 49.0,
  "currency": "USD",
  "reference": "inv-9",
  "uid": "order-1001",
  "trackingId": "trk-77",
  "metadata": {},
  "theme": {"primary": "#10b981", "buttonLabel": "Top up"},
  "allowSaveCard": true,
  "allowCardManagement": true,
  "returnUrl": "https://acme.test/thanks",
  "status": "pending",
  "embedPath": "/embed/checkout?session=cs_db082a2ca5e4b6b0c751e8e1cabd8b7304d6",
  "embedUrl": "https://xlopay.me/embed/checkout?session=cs_db082a2…",
  "expiresAt": "2026-07-25T23:45:59.736684Z",
  "createdAt": "2026-07-25T23:30:59.736684Z"
}
embedUrl vs embedPath

embedUrl is absolute, built from the PUBLIC_CHECKOUT_BASE_URL setting (which falls back to the first allowed CORS origin). Use it directly. embedPath stays relative for callers who host the checkout page themselves.

POST /api/v1/psp/checkout-sessions is the original name for this same endpoint and still behaves exactly as it always did — existing integrations need no change.

Embedding it

<iframe src="https://xlopay.me/embed/checkout?session=cs_31f7908ebd…"
        width="440" height="640" frameborder="0"
        style="border:0;max-width:100%"></iframe>

Generating that markup server-side:

from html import escape

def checkout_iframe(gw, *, amount, reference, return_url,
                    frontend="https://xlopay.me", width=440, height=640):
    """Create a session and return ready-to-render iframe markup."""
    s = gw.create_payment_link(
        amount_usd=str(amount),
        currency="USD",
        reference=reference,
        return_url=return_url,
    )
    url = escape(frontend + s["embedPath"], quote=True)
    return s["sessionId"], (
        f'<iframe src="{url}" width="{width}" height="{height}" '
        f'frameborder="0" style="border:0;max-width:100%"></iframe>'
    )


session_id, markup = checkout_iframe(
    gw, amount="49.00", reference="order-1001",
    return_url="https://acme.test/thanks")

# Store session_id against the order so the webhook can reconcile it.
order.checkout_session_id = session_id
The whole flow, end to end
# 1. Your server, with the Python SDK.  
link = gw.create_payment_link(
    amount_usd=49.00,
    user_id=user["hexId"],          # ours, opaque, always present
    allowed_origins=["https://acme.test"],
)

# 2. Your page, with the Node SDK.  
import { mountCheckout } from "xlopay"
mountCheckout({ container: "#checkout", sessionId: link.sessionId })

Passing a payment user’s hexId is what makes the checkout personal: it opens on that user’s saved cards with an Add a new card button, and only falls through to the card form when they have nothing saved. Guest checkouts — no userId — go straight to the form, since there would be nothing to show.

Sessions last 20 minutes by default (expiresInMinutes, 1–10080). Long enough to fill in a card and clear 3-D Secure; short enough that a link forwarded or left in a browser history is worthless by the time anyone tries it. Five card attempts per session, then it is spent.

The xlopay Node SDK

TypeScript, zero dependencies, three ways in and four presentations. It exists because a bare <iframe src> cannot size itself, cannot tell you the payer finished, and cannot be restyled without minting a new session.

npm install xlopay

1 · Imperative

import { mountCheckout } from 'xlopay'

const checkout = mountCheckout({
    container: '#checkout',
    sessionId: link.sessionId,
    baseUrl: 'https://pay.xlopay.me',
    display: 'inline',                       // or 'modal' | 'drawer' | 'headless'

    // The checkout itself.
    theme: { primary: '#10b981', accent: '#0ea5e9', radius: 16, mode: 'dark' },

    // The frame around it.
    appearance: { radius: 20, shadow: true, maxWidth: 460 },

    onSuccess: () => location.assign('/thanks'),
    onPending: ({ redirectUrl }) => console.log('3-DS issued', redirectUrl),
    onFailure: () => console.log('declined - the frame stays open for another card'),
})

checkout.open()    // modal / drawer
checkout.close()
checkout.destroy()

2 · Declarative — the embed tag

For templates that have nowhere to put JavaScript: a CMS block, a Rails view, a plain HTML page. Every option is an attribute, and they are live — change theme-primary and it restyles; add or remove open and a modal shows or hides.

<script type="module" src="/vendor/xlopay/index.js"></script>

<xlopay-checkout
    session-id="cs_db082a2c..."
    base-url="https://pay.xlopay.me"
    display="modal"
    theme-primary="#10b981"
    theme-mode="dark"
    max-width="460"
></xlopay-checkout>

Events bubble as CustomEvents — xlopay:success, xlopay:pending, xlopay:failure, xlopay:ready, xlopay:error, xlopay:close — so any framework binds them the way it binds anything else.

3 · Headless

mountCheckout({ sessionId, display: 'headless' })

No DOM at all. The plumbing without the widget, for a UI you are building yourself.

Presentations

displayWhat it is
inlineFlows in your layout. Height tracks the content. The default.
modalCentred overlay with a scrim. Opens on open(); closes on Escape, scrim click or the close button. Locks page scroll while shown.
drawerSlides in from right / left / bottom, full height.
headlessNo DOM.

theme vs appearance

theme styles the checkout: primary, accent, gradient, radius, mode, locale. appearance styles the frame and its surroundings: radius, shadow, maxWidth, center, background, overlay, side, hideLoader, className. Two questions, two objects.

Identity stays server-side

logoUrl, merchantLabel and buttonLabel are not settable from the front end. They say who is being paid and what the button promises, so they are fixed when the session is created. A crafted checkout link can restyle the page; it can never re-brand it as a different business.

Anything else malformed is dropped with a console warning rather than in silence. The server drops bad values silently — correct for a payment page, unhelpful when you are branding one — so the SDK validates first and tells you which key and why.

onPending is not a sale

3-D Secure was issued. The payer leaves for their bank and the real outcome arrives on the session’s status URL, not in the browser. Treat it as in-flight and let your webhook confirm it; fulfilling here ships goods for payments that may never complete.

How the bridge is kept safe

The frame posts only a height, a status word and the session id you already had. Safety is enforced on receipt: a message is accepted only when the origin matches the checkout’s and event.source is that frame’s own window — the second check is what keeps two checkouts on one page from reading each other’s events.

Sandboxing is off by default and deliberately so: the frame is already cross-origin, and what a sandbox mainly adds here is blocking top-level navigation — which 3-D Secure needs. Pass sandbox: true for a value that keeps it working. To restrict who may frame the checkout, use allowedOrigins when you create the payment link.

Customising the checkout

The theme object is stored on the session, server-side — never read from the URL. That matters: a query-string theme is rewritable by anyone holding the link, so a phishing frame could restyle your checkout at will. Unknown keys and malformed values are dropped on write, so the blob can never carry markup.

KeyAcceptsEffect
primary#rgb#rrggbbaaBrand colour: header gradient, focus rings, selected card.
accenthex colourSecond gradient stop.
gradientlinear-/radial-gradient(…)Overrides the generated gradient outright.
radiusdigits, e.g. 16 or 16pxCard corner radius.
modelight · darkColour scheme hint.
logoUrlhttps URLReplaces the padlock in the header.
buttonLabeltext, ≤60 chars"Pay" → "Top up", "Donate", …
merchantLabeltext, ≤60 charsDisplay name, if different from your account name.
localeBCP-47 tagCurrency and number formatting.
gw.create_payment_link(
    user_id="cust-9001",
    amount_usd=49.00,
    theme={
        "primary": "#10b981",
        "accent": "#0ea5e9",
        "radius": "16px",
        "mode": "dark",
        "logoUrl": "https://acme.test/logo.svg",
        "buttonLabel": "Top up",
        "merchantLabel": "Acme Store",
        "locale": "en-GB",
    },
)
✓ What sanitising rejects

A non-hex colour, a non-https logo, a gradient containing url() or a stray ;, and every key not in the table above. Rejection is silent per-key — a bad colour falls back to the default rather than failing the whole session, because a styling typo should never stop a payment.

Saved cards inside the iframe

When the session names a user, the public session view carries their card list and the frame renders a picker. The payer can pay with a stored card, enter a new one, tick "save this card", or remove one.

POST/api/psp/checkout-sessions/<id>/cards public
DELETE/api/psp/checkout-sessions/<id>/cards/<token> public

These are driven by the frame itself; you rarely call them server-side. Paying uses the same /pay endpoint with a savedCardToken instead of card fields:

# What the iframe posts when the payer picks a stored card.
requests.post(f"{BASE}/api/psp/checkout-sessions/{session_id}/pay",
              json={"savedCardToken": "card_87be44144c689d4b…"})

# …and when they enter a new card and tick "save this card".
requests.post(f"{BASE}/api/psp/checkout-sessions/{session_id}/pay",
              json={"cardNumber": "4242…", "expMonth": "12",
                    "expYear": "2030", "cvv": "123",
                    "saveCard": True})
The isolation guarantee

A session id is a bearer capability, so every card operation it exposes is filtered by (merchant, end_user). A session for user A that presents user B's card token gets 404 — for paying and for removing. The same holds across merchants. This is covered by explicit tests.

Removal is soft: the card is deactivated, never deleted. It vanishes from the payer's list and can no longer be charged, but the record survives for chargeback and audit history and can be restored from the dashboard. A link can never destroy data.

Confirming the result server-side

⚠ Never trust the browser for fulfilment

The iframe emits no message to your page (see Finding 4), and returnUrl is just a link the payer may never click. Fulfil on the webhook, or by polling the session server-side.

GET/api/psp/checkout-sessions/<sessionId> public
import requests

def session_state(session_id, base="https://api.xlopay.me"):
    """Public display view — safe to poll, exposes no secrets."""
    r = requests.get(f"{base}/api/psp/checkout-sessions/{session_id}", timeout=15)
    r.raise_for_status()
    return r.json()

state = session_state(order.checkout_session_id)
if state["status"] == "paid":
    fulfil(order)

Response · 200

{
  "sessionId": "cs_31f7908ebd14b22bd9dee99de9602bb5f39c",
  "amountUsd": 49.0,
  "currency": "USD",
  "reference": "order-1001",
  "merchantName": "Acme",
  "status": "pending",
  "returnUrl": "https://acme.test/thanks",
  "open": true
}

Session statuses

StatusMeaning
pendingNot yet paid, still open. A decline returns here so another card can be tried.
pending_auth3-DS in flight; settles on the inbound PSP webhook.
paidSettled. Terminal — a second payment attempt is rejected.
failedTerminal failure.
expiredPast expiresAt without completing.

What the iframe does and does not do

BehaviourStatusDetail
Amount fixed server-sideYesSet at session creation; the browser cannot change it.
Single-useYesPaying a paid session raises 409.
No API key in the pageYesThe session id is the only capability.
Retry after a declineYesSession stays open; inline "try another card" error.
3-DS redirectYesRendered as a target="_top" link so it breaks out of the frame.
PSP fallback inside checkoutYesSame routing chain as the unified charge.
Saved-card pickerYesA user-scoped session pools that user's cards; most recent usable one preselected, expired ones disabled.
Add / remove cards in-frameYesAdd vaults against the user; remove is a soft deactivate behind a confirm step.
Configurable expiryYesexpiresInMinutes, 1–10080, default 60. Out-of-range clamps.
ThemingYesServer-side theme on the session, injected as CSS variables. Allowlisted on write.
Cross-user card isolationYesEvery card operation filtered by (merchant, end_user); foreign token ⇒ 404.
Card-testing rate limitPer-IP only20 attempts / 10 min, keyed on IP alone. Not per session or per card.
Framing allowlistNoAny origin may embed it. Finding 3
postMessage to parentNoParent page cannot observe completion. Finding 4
Apple Pay / Google PayNoCard fields only.

Two webhook families

This gateway sends you two kinds of event, and since 2026-07 they go to two separate endpoints. They are split because they are different data handled by different code on your side: a settlement deposit credits a wallet, a card outcome closes an invoice. One endpoint receiving both forced every integration to branch on the shape of the payload to find out which had arrived.

FamilyEndpoint fieldCarriesSpeaks
crypto webhookUrl On-chain money: deposits detected and confirmed, value leaving a wallet, payouts broadcast, and the payment.* lifecycle of a USD stable currency payment link. chain, txHash, token, amount
psp pspWebhookUrl Card outcomes normalised from a connected PSP — including the asynchronous result of a 3-DS charge. invoiceNumber, pspRef, status, currency
One endpoint is still supported

Leave pspWebhookUrl unset and card events are delivered to webhookUrl instead, so an integration written before the split keeps working with no change. Every delivery carries kind in the body and X-Gateway-Kind in the headers either way — so a single handler can route on one field rather than guessing from the payload's shape.

The fallback is one-directional. Setting only pspWebhookUrl does not send settlement events there: you did not ask for chain events on a handler written for invoices.

Deduplicate on eventId

Your handler will receive the same event more than once

Every delivery carries an eventId in the body and in X-Gateway-Event-Id. It identifies the delivery, not the thing the delivery describes: it is constant across every automatic retry and every manual resend, and different for every genuinely new event.

Repeats are normal, not exceptional. They happen when:

  • your endpoint returns a non-2xx, or times out, and we retry — for up to 72 hours;
  • your endpoint succeeds but the response is lost in transit, so we retry something you have already processed;
  • a merchant presses Resend in the dashboard, which replays the delivery byte for byte with a valid signature.

A handler that credits an account, fulfils an order, or moves money must therefore record which ids it has processed and no-op on a repeat. Write the id in the same transaction as the effect. Crediting first and recording after leaves a window where a crash means the next delivery credits again — the exact failure the id exists to prevent.

# Django, and the shape ports to anything with transactions
event = Webhook(request.body, "crypto", verify_with=KEY,
                signature=request.headers.get("X-Gateway-Signature"))

with transaction.atomic():
    if Handled.objects.filter(event_id=event.event_id).exists():
        return HttpResponse(status=200)   # already done — ack, do nothing
    credit(event.amount_usd)
    Handled.objects.create(event_id=event.event_id)   # same commit
return HttpResponse(status=200)

Put a unique constraint on the column. It is what makes two deliveries arriving at the same instant safe: both check, both pass, and exactly one insert survives — the loser's transaction unwinds, credit included.

Cumulative amounts help too

Payment events report totalReceived and totalReceivedUsd as running totals for the payment, never as "what arrived just now". Credit the difference against what you have already credited and a repeated or out-of-order delivery resolves to zero on its own, independently of the event id.

Configuring both

POST/api/v1/webhook/set X-API-Key
# Set them independently. Omitting a field leaves it as it is, so setting
# one endpoint never silently wipes the other.
gw.set_webhook("https://acme.test/hooks/crypto")
gw.set_webhook(psp_url="https://acme.test/hooks/cards")

# Or both at once.
gw.set_webhook("https://acme.test/hooks/crypto",
               psp_url="https://acme.test/hooks/cards")

# An explicit empty string clears one.
gw.set_webhook(psp_url="")

Response · 200 — also what gw.get_webhook() returns

{
  "webhookUrl": "https://acme.test/hooks/crypto",
  "pspWebhookUrl": "https://acme.test/hooks/cards",
  "effective": {                       // where each family actually lands
    "crypto": "https://acme.test/hooks/crypto",
    "psp": "https://acme.test/hooks/cards"
  },
  "events": {
    "crypto": ["payment.confirmed", "payment.paid",
               "payment.underpaid", "withdrawal.sent"],
    "psp": ["payment.succeeded", "payment.failed", "payment.pending",
            "payment.updated", "refund.succeeded"]
  },
  "inbound": {                         // the OTHER direction — see below
    "pspCallbackUrl": "https://api.xlopay.me/api/webhooks/<pspName>/42",
    "direction": "your PSP → this gateway"
  },
  "signature": {
    "scheme": "HMAC_SHA256",
    "header": "X-Gateway-Signature",
    "message": "<raw request body>",
    "signingKey": "a3f1…"          // secret — store it like the API key
  },
  "toleranceSeconds": 300
}
Three URLs, two directions — do not mix them up

webhookUrl and pspWebhookUrl point at you: they are where this gateway delivers events. inbound.pspCallbackUrl points at us: it is the value to paste into your PSP's own dashboard so their callbacks reach the gateway. Charges created through this API already carry it as their statusUrl, so it only needs setting by hand for provider-initiated events — refunds, chargebacks, retries.

USD stable currency webhooks

Delivered to webhookUrl, with kind: "crypto".

typeFires whenAct on it by
payment.confirmed A deposit reached its confirmation threshold and was credited. This is the money-is-yours event. Crediting the payer. Idempotent on txHash.
withdrawal.sent A payout leg was broadcast to the network. Marking the payout in flight. It is broadcast, not yet confirmed.
payment.paid A USD stable currency payment link (GeneratePayment) received its full quoted amount. Fires exactly once. Fulfilling the order. See the payment-link section for the full field set.
payment.underpaid A payment link received some money but not the full quote. Fires on every part payment, so a payer settling in instalments produces one event each. Deciding what to do about a short payment — credit it, hold it, or ask for the rest. Read totalReceivedUsd (cumulative) and remaining.
What is not delivered, and why it will look like silence

That table is the whole list. Two filters sit in front of it, and an event either passes both or produces no delivery and no record at all — it will not appear in /api/v1/webhooks/deliveries/list either, so there is nothing to resend and nothing to find.

  • Anything not in the table. Sweeps, gas top-ups and fee transfers are the platform moving your money on your behalf; they mean nothing to an order system. wallet.outflow — value leaving a watched wallet by a route we did not initiate — is in this group and is no longer delivered. Earlier versions of this page described it as your stolen-key alarm. It is not: read outflows from /api/v1/transactions/list, and do not build an alert that waits for a push that will never arrive.
  • Anything at or below WEBHOOK_MIN_USD (default $2.00), on the settlement plane only. Dust deposits are overwhelmingly address-poisoning spam, and one webhook each hands the spammer a free way to hammer your endpoint. An event whose value cannot be priced is delivered rather than dropped — silence about a real payment is the worse failure.

Card events are not filtered: they go to a different URL under a different contract, and a checkout may depend on seeing every state change, failures included.

Reconcile on hexId, not txHash

Every settlement event carries three of our own ids. hexId is the transaction — issued when the row is created, so it exists and is quotable while the chain hash is still unconfirmed, and it never changes. walletHexId says which of your addresses was paid, and endUserHexId which of your customers it belongs to (null for a merchant-owned or one-time wallet).

txHash is still there — it is what a block explorer takes — but it is the chain's identifier, not ours: absent until broadcast, and on the Solana path it may be a synthetic placeholder when a real signature could not be resolved. Key your records on hexId.

Payload · payment.confirmed

POST https://acme.test/hooks/crypto
Content-Type: application/json
X-Gateway-Event: payment.confirmed
X-Gateway-Kind: crypto
X-Gateway-Event-Id: 2060ff5a6e
X-Gateway-Timestamp: 1785107508
X-Gateway-Signature: 4b81…

{
  "type": "payment.confirmed",
  "kind": "crypto",
  "eventId": "2060ff5a6e",      // THIS DELIVERY — deduplicate on it
  "sentAt": 1785107508,
  "data": {
    "hexId": "a41f9c02de",          // THE transaction — reconcile on this
    "walletHexId": "ff599af979",    // which address was paid
    "endUserHexId": "a318827e67",   // which customer — null if not a user wallet
    "chain": "SOL",                 // ETH | BSC | SOL | BTC
    "txHash": "5xY9…",             // the chain's id, for explorers
    "token": "USDC",
    "amount": 25.0,                // token units
    "amountUsd": 25.0,
    "source": "ws"                  // ws | reconcile (SOL only)
  }
}
This amountUsd is the gross. The one in transactions/list is not.

The same deposit reports two different numbers depending on where you read it, and this is the single easiest thing to get wrong:

  • Webhook payloads carry the gross — what the payer actually sent. That is what an invoice matches on, so netting it here would leave every order looking short.
  • /api/v1/transactions/list carries the net — what reached you after collection costs. Both amountUsd and the token amount are scaled, and the gross is not in that payload at all.

So a $25.00 deposit can webhook as 25.0 and list as 24.75, and neither is wrong. Pick one as your source of truth per ledger — do not reconcile one against the other and treat the difference as a missing payment. If a figure has to tie out against a block explorer, the webhook gross is the one that will.

Payload · withdrawal.sent

{
  "type": "withdrawal.sent",
  "kind": "crypto",
  "sentAt": 1785107700,
  "data": {
    "batchId": "b-7f3c1a",        // THE handle here — a withdrawal is a batch
    "walletHexId": "ff599af979",    // the master wallet it left
    "chain": "ETH",                 // of legs, not one transaction row
    "token": "USDT",
    "txHash": "0x41d…",            // the payout leg
    "amountUsd": 480.25,           // NET — after both fees below
    "feeUsd": 4.80,                // platform fee
    "blockchainFeeUsd": 2.15,      // measured network cost
    "destination": "0xA1b2…"
  }
}

Payload · payment.paid / payment.underpaid

Both carry the same fields — the difference is only whether the quote has been met. Every amount is cumulative for the payment, not per transfer.

{
  "type": "payment.underpaid",
  "kind": "crypto",
  "eventId": "748d2812c6",
  "sentAt": 1785107900,
  "data": {
    "paymentId": "f8eb16ae3e",       // the payment link
    "uid": "order-4417",             // your own reference, echoed back
    "walletHexId": "2a9f907a1c",     // the deposit address we minted
    "status": "underpaid",
    "chain": "BSC",
    "quoteCurrency": "USDC",
    "amountRequested": "103.00",     // what you asked for, USD
    "payAmount": "103.0357534064",   // the quote, in the token
    "totalReceived": "3.0",          // arrived so far, IN THE TOKEN
    "totalReceivedUsd": "3.00",      // arrived so far, in USD ← credit on this
    "remaining": "100.0357534064",   // still outstanding, in the token
    "datePaid": null                 // set only once status is "paid"
  }
}
Credit totalReceivedUsd, not totalReceived

totalReceived is denominated in the quote token. Treating it as dollars happens to work for a stablecoin and is badly wrong for anything else — and a payment quoted in BTC would credit a customer three dollars for three bitcoin, or the reverse. totalReceivedUsd is each deposit valued at the moment it confirmed, so it is a real figure rather than a re-conversion at today's rate.

Because it is cumulative, the safe way to credit a part payment is totalReceivedUsd − (what you have already credited). That expression is naturally idempotent: a repeated or out-of-order delivery yields zero or a negative, and you do nothing.

PSP (card) webhooks

Delivered to pspWebhookUrl, with kind: "psp". These are normalised — the gateway has already verified the provider's signature, re-confirmed the outcome with the provider over TLS, and reconciled it against your charge. You are receiving the settled truth, not a raw provider body.

statusMeansAct on it by
succeeded Captured. The money is yours. Fulfilling. The only status safe to ship goods on.
failed Declined, or 3-DS abandoned. Releasing the order. Do not retry the same card automatically.
pending In flight — usually 3-DS with the payer at their bank. Waiting. A later delivery resolves it; the reconciler settles it if the provider goes quiet.

type carries the provider's own vocabulary for those states (payment.succeeded, payment.failed, payment.pending, payment.updated, refund.succeeded). Match on status when you only care about the outcome — it is normalised across every provider, type is not.

Payload

POST https://acme.test/hooks/cards
Content-Type: application/json
X-Gateway-Event: payment.succeeded
X-Gateway-Kind: psp
X-Gateway-Event-Id: e22cc3aa3a
X-Gateway-Timestamp: 1785107801
X-Gateway-Signature: 7c1e…

{
  "type": "payment.succeeded",
  "kind": "psp",
  "sentAt": 1785107801,
  "data": {
    "chargeHexId": "47974b1165",    // THE payment — ours, opaque, stable
    "endUser": {                     // WHO PAID. null on pre-attribution rows
      "hexId": "a318827e67",        // key your records on this
      "username": "ada",
      "externalId": "cust-1",       // your own id for them, if you sent one
      "email": "ada@acme.test"
    },
    "eventType": "payment.succeeded",
    "status": "succeeded",        // succeeded | failed | pending
    "amountUsd": 49.99,
    "currency": "USD",
    "pspName": "payit",             // which provider took it
    "pspRef": "ch_3PXy…",          // the provider's own id
    "invoiceNumber": "INV-1001",   // YOUR order reference
    "cardLast4": "4242",
    "cardBrand": "visa",
    "metadata": {}                  // whatever you attached to the charge
  }
}
This is how you tell which user paid

endUser.hexId is the answer to "who was this?". Every card payer has one — including a payer you told us nothing about, who still gets a hex id and a derived username at charge time — which is what makes it safe to key on where an email or an external id may be missing. Pass the same value as userId on a later charge to bill that person again.

chargeHexId identifies this payment attempt. invoiceNumber identifies your order and may repeat across attempts — a 3-DS retry reuses it. Reconcile the payment on chargeHexId; look the order up by invoiceNumber.

There is never a card number in here

Nothing in a PSP webhook carries a PAN, a CVV or an expiry. The card never reaches your webhook handler, which keeps that handler out of PCI scope.

You may not get an event for someone else's invoice

An inbound provider callback naming an invoice number that does not match one of your charges is recorded for audit but not forwarded — forwarding would put another merchant's amounts and references into your feed. If a payment seems to be missing, check that the invoiceNumber on the charge is the one the provider is quoting.

Verifying a delivery

Every delivery, both families, is signed with HMAC-SHA256 over the exact raw body, keyed on the signingKey from gw.get_webhook(). Fetch that key once and store it like the API key.

from xlopay import Webhook, WebhookError
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_KEY = os.environ["XLOPAY_WEBHOOK_KEY"]   # from gw.get_webhook()


@app.route("/hooks/cards", methods=["POST"])
def card_events():
    try:
        event = Webhook(
            request.data,                     # RAW bytes, not request.json
            "psp",
            verify_with=SIGNING_KEY,
            signature=request.headers.get("X-Gateway-Signature"),
        )
    except WebhookError:
        abort(400)                          # bad signature, stale, or wrong family

    if event.is_paid:
        fulfil(event.invoice_number)          # must be idempotent
    return "", 200
Verify the raw bytes, not a parsed dict

request.data, not request.json. Re-serialising a parsed body will not reproduce the bytes that were signed — key order and whitespace both change the digest. Passing a dict to Webhook() with verify_with raises rather than pretending to have checked.

The Webhook class

Webhook(payload, type) turns a raw delivery into a typed object. payload may be a dict, a JSON string or raw bytes. type is "crypto" or "psp", and is optional — every delivery carries kind, so omitting it reads that instead. Passing it makes the expectation explicit and raises on a mismatch, which is what you want on an endpoint that should only ever see one family.

from xlopay import Webhook

# Crypto — the three ids first; they are what you reconcile on
e = Webhook(body, "crypto")
e.hex_id, e.wallet_hex_id, e.end_user_hex_id, e.batch_id
e.chain, e.tx_hash, e.token, e.amount, e.amount_usd
e.from_address, e.destination
e.is_deposit, e.is_outflow, e.is_withdrawal

# PSP — the payment, then who made it
p = Webhook(body, "psp")
p.charge_hex_id, p.invoice_number
p.end_user_hex_id            # the payer's handle, or None
p.end_user                   # {hexId, username, externalId, email}
p.status, p.amount_usd, p.currency, p.psp_name, p.psp_ref
p.card_last4, p.card_brand, p.metadata
p.is_paid, p.is_failed, p.is_pending

# Both
e.type          # "payment.confirmed"
e.kind          # "crypto"
e.sent_at       # unix seconds
e.age_seconds()          # for your own replay window
e.sent_at_datetime      # aware UTC datetime
e.data                  # the raw data dict, nothing dropped
e["amountUsd"]          # wire names still work
e.get("somethingNew")    # a field newer than your SDK
e.to_dict()              # the original envelope, unmodified

Unrecognised fields are never dropped — a provider adding one should not require an SDK release, so anything unmapped stays reachable through .data, event["wireName"] and .get().

Retries and redelivery

Any 2xx is success. Anything else, or a timeout, is a failure and is retried.

Delivery pathScheduleVisible?
Payment-link lifecycle (payment.paid, payment.underpaid) immediate → +5 min → +1 hour → FAILED Yes — listable and re-sendable, below.
Event stream (payment.confirmed, withdrawal.sent, all PSP events) 1m → 5m → 15m → 1h → 3h → 12h, then every 12h up to WEBHOOK_MAX_RETRY_HOURS (default 72h) Yes — listable and re-sendable, same as the row above.
Respond fast, work later

The gateway waits only a few seconds for your response. Do the minimum — verify, enqueue, return 200 — and do the real work off the request. A handler that fulfils an order inline will eventually time out and be retried, and you will fulfil twice unless it is idempotent.

POST/api/v1/webhooks/deliveries/list X-API-Key
POST/api/v1/webhooks/deliveries/resend X-API-Key
# Re-drive everything that gave up (last 100 deliveries are returned).
for d in gw.list_webhook_deliveries():
    if d["status"] == "FAILED":
        gw.resend_webhook_delivery(d["hexId"])
Resend takes any delivery, not only failed ones

The loop above is the common case, not a restriction. The deliveries people actually need to replay are often the ones recorded as delivered: your endpoint returned 200 and then dropped the event — a deploy mid-request, a queue that lost the job, a handler that threw after acknowledging. Refusing those would leave no way to recover an event you can see was sent.

That is only safe because a resend carries the original eventId. A receiver that already acted on it must acknowledge and do nothing — see Deduplicate on eventId. If your handler ignores the id, this button credits a payment twice and nothing on our side can prevent it.

Inbound PSP callbacks

The opposite direction: where your PSP posts to this gateway. Not to be confused with pspWebhookUrl, which is where the gateway posts to you.

POST/api/webhooks/<pspName>/<merchantId> PSP signature
POST/api/webhooks/<pspName> PSP signature

gw.get_webhook()["inbound"]["pspCallbackUrl"] returns the exact value to paste into your provider's dashboard. Charges created through this API already carry it as their statusUrl, so it only needs setting by hand for provider-initiated events — refunds, chargebacks, retries.

Prefer the merchant-scoped form

The <merchantId> in the path is what attributes an unsigned delivery to one account. Without it the gateway falls back to matching on the invoice number, and refuses outright when several secret-less configs answer to the same provider name — because guessing would put one merchant's payment events into another's feed.

On arrival the gateway verifies the provider's signature, re-asks the provider over TLS whether the event really happened (a forged body cannot settle a charge — the amount and status acted on come back from the provider), reconciles any pending charge, and only then forwards the normalised event to your pspWebhookUrl.

ResponseMeansProvider should
200Settled and forwarded.Stop.
409Not yet confirmable by the provider.Redeliver. run_psp_reconcile is the backstop if it never does.
400Could not be attributed to a merchant.Check the callback URL carries the merchant id.
404No active config for that provider name.Connect the PSP first.
429Too many callbacks from one address.Back off.

Set webhookSecret on the PSP config wherever the provider supports it. Without one the delivery proves nothing about who sent it, and attribution falls back to the invoice number.

All endpoints

Every one is POST with a JSON body and an X-API-Key header. "key + sk" means the body must also carry skKey (or the account has a saved key on file). There is no GET on this plane and no public endpoint at all.

PathAuthSDK methodPurpose
Account
/api/v1/mekeygw.get_account()Account profile
Wallets
/api/v1/wallets/listkeygw.list_wallets()Every deposit address
/api/v1/wallets/one-timekey + skgw.create_one_time_wallet()Mint a hot wallet
Payment users
/api/v1/users/createkey + skgw.create_user()CreateUser — by externalId / email / username
/api/v1/users/listkeygw.list_users()List / search users
/api/v1/users/getkeygw.get_user()Fetch one user
/api/v1/users/updatekeygw.update_user()Edit a profile
/api/v1/users/walletskeygw.get_user_wallets()User + deposit addresses
Ledger
/api/v1/transactions/listkeygw.list_transactions()All detected deposits, net
/api/v1/currencies/listkeygw.list_currencies()Enabled chain/token pairs
USD stable currency payments
/api/v1/payments/createkey + skgw.create_payment()GeneratePayment
/api/v1/payments/getkeygw.get_payment()Payment detail
/api/v1/payments/statuskeygw.get_payment_status()getTransactionStatus
Withdrawals
/api/v1/withdrawals/createkey + skgw.create_withdrawal()Request a payout
/api/v1/withdrawals/listkeygw.list_withdrawals()Payout history
Outbound webhooks
/api/v1/webhook/getkeygw.get_webhook()Current webhook config
/api/v1/webhook/setkeygw.set_webhook()Set / clear the webhook URL
/api/v1/webhooks/deliveries/listkeygw.list_webhook_deliveries()Last 100 deliveries
/api/v1/webhooks/deliveries/resendkeygw.resend_webhook_delivery()Re-queue any delivery, same eventId
Card PSPs
/api/v1/psp/providers/listkeygw.list_psp_providers()Provider catalogue
/api/v1/psp/configs/createkeygw.connect_psp()Connect a PSP
/api/v1/psp/configs/listkeygw.list_psp_configs()Connected PSPs, in priority order
/api/v1/psp/configs/updatekeygw.update_psp()Change priority / credentials
/api/v1/psp/configs/deletekeygw.disconnect_psp()Disconnect
/api/v1/payments/chargekeygw.charge_card()The unified charge
/api/v1/payments/charges/listkeygw.list_charges()Charges, newest first
/api/v1/payments/charges/getkeygw.get_charge()One charge, by id or your reference
Card vault
/api/v1/psp/cards/createkeygw.save_card()Vault a card
/api/v1/psp/cards/listkeygw.list_cards()Cards on file
/api/v1/psp/cards/deletekeygw.delete_card()Remove a card
/api/v1/psp/users/cardskeygw.list_user_cards()One user's cards
Recurring billing
/api/v1/psp/subscriptions/createkeygw.create_subscription()Bill a vaulted card on a schedule
/api/v1/psp/subscriptions/listkeygw.list_subscriptions()Every subscription
/api/v1/psp/subscriptions/updatekeygw.update_subscription()Pause / resume / cancel
/api/v1/psp/subscriptions/cancelkeygw.cancel_subscription()Cancel outright
Facilitator billing
/api/v1/psp/invoices/listkeygw.list_invoices()Invoices + this month’s running total
Hosted checkout
/api/v1/psp/payment-linkskeygw.create_payment_link()GeneratePaymentLink
/api/v1/psp/checkout-sessionskeygw.create_payment_link()Same view, original name

The payer-facing plane is separate and deliberately unchanged: the hosted-checkout routes under /api/psp/checkout-sessions/<sessionId> and the inbound PSP callback /api/webhooks/<pspName>/<merchantId>. A browser and a PSP hold no API key — the session id and the confirm-with-the-PSP re-query are the controls there instead.

Errors & limits

CodeMeaning
400Malformed or missing fields; unsafe webhook URL; failed PSP signature.
401Bad API key, or a bad skKey on an endpoint that needs one.
402Card declined by every provider. Body carries the attempt trail — not an error in the client sense.
403Custom withdrawal destination without an explicit sk_live.
404Unknown payment, delivery, card, config or session.
405Wrong HTTP method for the route.
409State conflict — resending a non-FAILED delivery, paying a settled session.
422Well-formed but unprocessable — unpriceable token, no active PSPs, insufficient balance.
429Rate limited.
503Card vault not configured.

Rate limits

EndpointLimitKey
POST /api/psp/checkout-sessions/<id>/pay20 / 10 minclient IP
POST /api/webhooks/<pspName>120 / minclient IP
⚠ Limiters fail open

Both counters are backed by the Django cache. If no cache backend is reachable the check returns allow rather than blocking traffic — deliberate, but it means a Redis outage silently removes the card-testing guard on the public pay endpoint.