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:
| Plane | Prefix | Auth | Who 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:
| Key | Grants | Where 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 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.
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.
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)
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.
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
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:
| Key | Where it goes | What 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
me = gw.get_account()
print(me["name"], me["apiKeyPrefix"], me["saveKeyEnabled"])
Currencies & wallets
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": ""}
}
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
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.
| Field | Type | Notes |
|---|---|---|
amount | number | Required. Value in priceCurrency. |
quoteCurrency | string | Required. USD stable currency token to be paid, e.g. USDT. |
chain | string | Required. BSC, ETH, SOL or BTC. |
uid | string | Your order reference. Echoed back and included in webhooks. |
priceCurrency | string | Optional, default USD. |
skKey | string | Required 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
}
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
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
| Status | Meaning |
|---|---|
pending | Nothing received yet. |
underpaid | Some funds arrived, still short of payAmount. Fires payment.underpaid; topping up flips it to paid. |
paid | Received total reached the quote. Fires the payment.paid webhook exactly once. |
expired | Passed dateExpired without completing. |
cancelled | Cancelled out of band. |
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
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
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.
| Identifier | Field | Matching |
|---|---|---|
| Ours | hexId | Always present, system-unique, opaque. The one to store. |
| Your own user id | externalId | Exact. Unique per merchant when set. |
email | Case-insensitive. | |
| Username | username | Case-insensitive. Unique per merchant. |
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.
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
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).
| Field | Notes |
|---|---|
userId | Your id for this person. Stored as externalId. Also accepted as externalId. |
email · username | The other two identifiers. |
firstName · lastName · phone · country | Profile. country is ISO-3166 alpha-2, upper-cased for you. |
status | active (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. |
metadata | Any JSON object. Never interpreted by the gateway. |
skKey | Required 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
# 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"])
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
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
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
The catalogue of connectable providers and the credential fields each one needs.
| Provider | Key | Required credentials | Refunds |
|---|---|---|---|
| Stripe | stripe | secretKey · opt webhookSecret | Yes |
| PayIt | payit | bearerToken, clientSlug · opt descriptor, webhookSecret | Yes |
| Forex PSP | forex | apiKey, chargeUrl · opt webhookSecret | No |
| Webhook PSP | webhook | apiKey, chargeUrl · opt webhookSecret | No |
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
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
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.
| HTTP | Meaning |
|---|---|
200 | succeeded, or pending with a 3-DS redirectUrl. |
402 | Every provider declined. The attempts array explains each one. |
422 | Could not even be attempted — no active PSPs, bad amount. |
503 | saveCard 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"}
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.
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
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",
)
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.
-
Your server creates a session
POST /api/v1/psp/checkout-sessionswith your API key. The amount is fixed here and cannot be altered from the browser. -
Your page embeds the iframe
with
mountCheckout, or by hand from the returnedembedPath. -
The iframe loads the session
GET /api/psp/checkout-sessions/<id>— a display-only view: amount, currency, reference, merchant name. No secrets. -
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. - It routes through your PSP chain exactly like the unified charge — same fallback, same attempt trail.
-
Terminal state
Success marks the session
paidand shows a return link. A decline leaves it open so the payer can try another card. 3-DS moves it topending_authwith a verification link.
GeneratePaymentLink
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.
| Field | Notes |
|---|---|
amountUsd | Required. Fixed server-side; the browser cannot alter it. |
userId | Any of the three identifiers. Unknown ⇒ 404. |
uid · trackingId · reference | Your 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. |
expiresInMinutes | 1–10080. Default 60. Out-of-range values clamp rather than error. |
theme | Presentation object — see below. |
allowSaveCard | Default true. Offer "save this card". |
allowCardManagement | Default true. Allow removal from inside the frame. |
metadata · returnUrl · currency | As 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
# 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
display | What it is |
|---|---|
inline | Flows in your layout. Height tracks the content. The default. |
modal | Centred overlay with a scrim. Opens on open(); closes on Escape, scrim click or the close button. Locks page scroll while shown. |
drawer | Slides in from right / left / bottom, full height. |
headless | No 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.
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 sale3-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.
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.
| Key | Accepts | Effect |
|---|---|---|
primary | #rgb … #rrggbbaa | Brand colour: header gradient, focus rings, selected card. |
accent | hex colour | Second gradient stop. |
gradient | linear-/radial-gradient(…) | Overrides the generated gradient outright. |
radius | digits, e.g. 16 or 16px | Card corner radius. |
mode | light · dark | Colour scheme hint. |
logoUrl | https URL | Replaces the padlock in the header. |
buttonLabel | text, ≤60 chars | "Pay" → "Top up", "Donate", … |
merchantLabel | text, ≤60 chars | Display name, if different from your account name. |
locale | BCP-47 tag | Currency 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",
},
)
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.
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})
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
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.
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
| Status | Meaning |
|---|---|
pending | Not yet paid, still open. A decline returns here so another card can be tried. |
pending_auth | 3-DS in flight; settles on the inbound PSP webhook. |
paid | Settled. Terminal — a second payment attempt is rejected. |
failed | Terminal failure. |
expired | Past expiresAt without completing. |
What the iframe does and does not do
| Behaviour | Status | Detail |
|---|---|---|
| Amount fixed server-side | Yes | Set at session creation; the browser cannot change it. |
| Single-use | Yes | Paying a paid session raises 409. |
| No API key in the page | Yes | The session id is the only capability. |
| Retry after a decline | Yes | Session stays open; inline "try another card" error. |
| 3-DS redirect | Yes | Rendered as a target="_top" link so it breaks out of the frame. |
| PSP fallback inside checkout | Yes | Same routing chain as the unified charge. |
| Saved-card picker | Yes | A user-scoped session pools that user's cards; most recent usable one preselected, expired ones disabled. |
| Add / remove cards in-frame | Yes | Add vaults against the user; remove is a soft deactivate behind a confirm step. |
| Configurable expiry | Yes | expiresInMinutes, 1–10080, default 60. Out-of-range clamps. |
| Theming | Yes | Server-side theme on the session, injected as CSS variables. Allowlisted on write. |
| Cross-user card isolation | Yes | Every card operation filtered by (merchant, end_user); foreign token ⇒ 404. |
| Card-testing rate limit | Per-IP only | 20 attempts / 10 min, keyed on IP alone. Not per session or per card. |
| Framing allowlist | No | Any origin may embed it. Finding 3 |
postMessage to parent | No | Parent page cannot observe completion. Finding 4 |
| Apple Pay / Google Pay | No | Card 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.
| Family | Endpoint field | Carries | Speaks |
|---|---|---|---|
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 |
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
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.
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
# 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
}
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".
type | Fires when | Act 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. |
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.
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)
}
}
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/listcarries the net — what reached you after collection costs. BothamountUsdand the tokenamountare 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"
}
}
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.
status | Means | Act 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
}
}
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.
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.
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
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 path | Schedule | Visible? |
|---|---|---|
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. |
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.
# 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"])
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.
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.
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.
| Response | Means | Provider should |
|---|---|---|
200 | Settled and forwarded. | Stop. |
409 | Not yet confirmable by the provider. | Redeliver. run_psp_reconcile is the backstop if it never does. |
400 | Could not be attributed to a merchant. | Check the callback URL carries the merchant id. |
404 | No active config for that provider name. | Connect the PSP first. |
429 | Too 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.
| Path | Auth | SDK method | Purpose |
|---|---|---|---|
| Account | |||
/api/v1/me | key | gw.get_account() | Account profile |
| Wallets | |||
/api/v1/wallets/list | key | gw.list_wallets() | Every deposit address |
/api/v1/wallets/one-time | key + sk | gw.create_one_time_wallet() | Mint a hot wallet |
| Payment users | |||
/api/v1/users/create | key + sk | gw.create_user() | CreateUser — by externalId / email / username |
/api/v1/users/list | key | gw.list_users() | List / search users |
/api/v1/users/get | key | gw.get_user() | Fetch one user |
/api/v1/users/update | key | gw.update_user() | Edit a profile |
/api/v1/users/wallets | key | gw.get_user_wallets() | User + deposit addresses |
| Ledger | |||
/api/v1/transactions/list | key | gw.list_transactions() | All detected deposits, net |
/api/v1/currencies/list | key | gw.list_currencies() | Enabled chain/token pairs |
| USD stable currency payments | |||
/api/v1/payments/create | key + sk | gw.create_payment() | GeneratePayment |
/api/v1/payments/get | key | gw.get_payment() | Payment detail |
/api/v1/payments/status | key | gw.get_payment_status() | getTransactionStatus |
| Withdrawals | |||
/api/v1/withdrawals/create | key + sk | gw.create_withdrawal() | Request a payout |
/api/v1/withdrawals/list | key | gw.list_withdrawals() | Payout history |
| Outbound webhooks | |||
/api/v1/webhook/get | key | gw.get_webhook() | Current webhook config |
/api/v1/webhook/set | key | gw.set_webhook() | Set / clear the webhook URL |
/api/v1/webhooks/deliveries/list | key | gw.list_webhook_deliveries() | Last 100 deliveries |
/api/v1/webhooks/deliveries/resend | key | gw.resend_webhook_delivery() | Re-queue any delivery, same eventId |
| Card PSPs | |||
/api/v1/psp/providers/list | key | gw.list_psp_providers() | Provider catalogue |
/api/v1/psp/configs/create | key | gw.connect_psp() | Connect a PSP |
/api/v1/psp/configs/list | key | gw.list_psp_configs() | Connected PSPs, in priority order |
/api/v1/psp/configs/update | key | gw.update_psp() | Change priority / credentials |
/api/v1/psp/configs/delete | key | gw.disconnect_psp() | Disconnect |
/api/v1/payments/charge | key | gw.charge_card() | The unified charge |
/api/v1/payments/charges/list | key | gw.list_charges() | Charges, newest first |
/api/v1/payments/charges/get | key | gw.get_charge() | One charge, by id or your reference |
| Card vault | |||
/api/v1/psp/cards/create | key | gw.save_card() | Vault a card |
/api/v1/psp/cards/list | key | gw.list_cards() | Cards on file |
/api/v1/psp/cards/delete | key | gw.delete_card() | Remove a card |
/api/v1/psp/users/cards | key | gw.list_user_cards() | One user's cards |
| Recurring billing | |||
/api/v1/psp/subscriptions/create | key | gw.create_subscription() | Bill a vaulted card on a schedule |
/api/v1/psp/subscriptions/list | key | gw.list_subscriptions() | Every subscription |
/api/v1/psp/subscriptions/update | key | gw.update_subscription() | Pause / resume / cancel |
/api/v1/psp/subscriptions/cancel | key | gw.cancel_subscription() | Cancel outright |
| Facilitator billing | |||
/api/v1/psp/invoices/list | key | gw.list_invoices() | Invoices + this month’s running total |
| Hosted checkout | |||
/api/v1/psp/payment-links | key | gw.create_payment_link() | GeneratePaymentLink |
/api/v1/psp/checkout-sessions | key | gw.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
| Code | Meaning |
|---|---|
400 | Malformed or missing fields; unsafe webhook URL; failed PSP signature. |
401 | Bad API key, or a bad skKey on an endpoint that needs one. |
402 | Card declined by every provider. Body carries the attempt trail — not an error in the client sense. |
403 | Custom withdrawal destination without an explicit sk_live. |
404 | Unknown payment, delivery, card, config or session. |
405 | Wrong HTTP method for the route. |
409 | State conflict — resending a non-FAILED delivery, paying a settled session. |
422 | Well-formed but unprocessable — unpriceable token, no active PSPs, insufficient balance. |
429 | Rate limited. |
503 | Card vault not configured. |
Rate limits
| Endpoint | Limit | Key |
|---|---|---|
POST /api/psp/checkout-sessions/<id>/pay | 20 / 10 min | client IP |
POST /api/webhooks/<pspName> | 120 / min | client IP |
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.