Quickstart
XPay has no separate merchant onboarding. Any XCall account can create a payment link, and any XCall account can pay one. Three calls get you from nothing to a payment you can watch settle.
Get a token
Sign in with your XCall email or username. The token goes in the
Authorizationheader of everything after this.curl -X POST https://api.xcall.cc/sandbox/v1/auth/login \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'platform: web' \ -H 'imei: 8f14e45fceea167a5a36dedd4bea2543' \ -d '{ "username": "[email protected]", "password": "sandbox-Pa55word" }'Create a link
A title and a currency are the only required fields. Leave
amountout for an open-amount link.curl -X POST https://api.xcall.cc/sandbox/v1/xpay/create \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_TOKEN' \ -d '{ "title": "Invoice #1048", "currency": "USDT", "amount": "250" }'Send the customer to checkout
The UUID from step 2 is the link's public id. Put it in the checkout URL, or encode that URL as a QR code.
https://app.xcall.cc/payment?link=<uuid>
API conventions
Four things surprise people coming from a REST gateway. Read these before the reference.
Everything is POST
The XCall API is RPC-over-POST. There are no GET, PUT or DELETE verbs and no path parameters — the action is the path and every input goes in the JSON body. `POST /v1/xpay/show` reads, `POST /v1/xpay/create` writes.
One response envelope
Every response is `{ "status": boolean, "data": ... }`, with `meta` added for paginated lists and `message` + `errors` on failure. Check `status` before reading `data`; do not rely on the HTTP code alone.
Amounts are decimal strings
Money is stored as DECIMAL(36,18) and serialised as a string, never a JSON number. Parse it with a decimal library — never with a float — or you will lose sub-cent precision on large invoices.
Links are addressed by UUID
A link's `uuid` is its public identifier and the only id you should put in a URL or QR code. The numeric `id` is internal and may differ between environments.
Request headers
Acceptapplication/jsonAlways send this, or errors come back as HTML.
Content-Typeapplication/jsonRequest bodies are JSON.
AuthorizationBearer <token>Required on every endpoint except `xpay/show`. The token comes from `auth/login`.
platformapp | web | adminClient kind. Defaults to `web`. Affects new-device checks and CAPTCHA enforcement.
imeistring (≤64)A stable random id your client mints once and reuses. Used for new-device detection — send the same value every time, or each login will trigger an email verification.
Authentication
Every XPay endpoint except xpay/show needs a bearer token. Tokens come from POST /auth/login and are sent as Authorization: Bearer <token>.
{
"status": true,
"data": {
"token": "84|kQx2Vd…",
"api_key": "…",
"token_type": "Bearer",
"user": { "id": 2087, "username": "sandbox_payer", "email": "[email protected]" }
}
}The new-device gate
Signing in from an imei the account has not used before does not return a token. It emails a code and returns a challenge instead:
{
"status": true,
"data": {
"needs_device_verification": true,
"identifier": "[email protected]",
"message": "A verification code has been sent to your email."
}
} Confirm it with POST /auth/verify-device ({ username, code }), which returns the same envelope as a successful login and trusts that device from then on. To avoid hitting this on every call, generate one random 32-character imei per installation, store it, and reuse it.
CAPTCHA
Sign-in, sign-up and xpay/pay are CAPTCHA-gated. Call POST /captcha/generate, show data.image (a data URI) to the user, and send back captcha_id plus captcha_answer. Each challenge is single-use — fetch a fresh one after any failure.
xpay/pay is the checkout call made by a human in a browser, so it always requires a CAPTCHA. Creating, updating and reporting on links are not gated, so a backend can manage links without solving one. Sandbox & test account
The sandbox is exposed through the isolated /sandbox/v1 path on the main API host. A guard limits it to two published test accounts and their payment links; production account tokens are rejected. The identifiers below never change.
- [email protected]
- Username
- @sandbox_merchant
- Password
- sandbox-Pa55word
- [email protected]
- Username
- @sandbox_payer
- Password
- sandbox-Pa55word
- Balance
- 10,000 USDT
Ready-made payment links
5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e3525 USDTSandbox — fixed amount — the amount is fixed and the payer cannot change it.
7c2e6f04-1a8b-4d92-b3f5-6e0a9c8d7b21payer choosesSandbox — open amount — amount_editable is true, so you must send an amount.
Try one call right now
This needs no token and no account — it is the same call the checkout page makes:
curl -X POST https://api.xcall.cc/sandbox/v1/xpay/show \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"uuid":"5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35"}'Pay a link from this page
https://api.xcall.cc/sandbox/v1This runs the real calls against the sandbox, signed in as the seeded test payer. No live account and no real money is involved.
Running the sandbox yourself
The accounts above are created by an idempotent seeder in the XCall backend. Run:
php artisan db:seed --class="Database\Seeders\XPaySandboxSeeder" It is idempotent — re-running restores the payer's balance and leaves the link UUIDs alone. It refuses to run when APP_ENV=production unless you set XPAY_SANDBOX_SEED=true, because it creates accounts with published passwords.
Endpoint reference
Eight endpoints, all POST, all under https://api.xcall.cc/v1 in production. Paths below are shown against the sandbox.
/xpay/showNo authLook up a payment link
The only unauthenticated XPay endpoint. Powers the checkout page: given a link UUID it returns what to display, without leaking anything about the merchant beyond their username.
https://api.xcall.cc/sandbox/v1/xpay/show
Body
uuidstringrequired- The link UUID.
{
"uuid": "5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35"
}{
"status": true,
"data": {
"uuid": "5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35",
"title": "Sandbox — fixed amount",
"description": "A 25 USDT invoice. The payer cannot change the amount.",
"amount": "25.000000",
"amount_editable": false,
"currency": "USDT",
"payee": "sandbox_merchant",
"status": "active"
}
}- `amount_editable` is `true` exactly when the link was created without an amount — that is your signal to render an amount input.
- `status` here is derived, not the raw column: it is `active`, `disabled`, or `expired` once `expires_at` has passed.
- A missing UUID returns 422 with `Payment link not found.` — not a 404.
/xpay/createBearer tokenCreate a payment link
Creates a link owned by the authenticated user. Returns 201 with the full record, including the generated UUID.
https://api.xcall.cc/sandbox/v1/xpay/create
Body
titlestring (≤150)required- Shown to the payer at checkout.
currencystring (≤12)required- Must be an active currency — `USDT` or `TRX`. Rejected otherwise.
descriptionstring (≤500)- Second line at checkout.
amountnumeric ≥ 0- Omit or send null for an open-amount link the payer fills in.
expires_atdate- After this instant the link reports `expired` and cannot be paid.
redirect_urlurl- Stored on the link. See the note below before relying on it.
callback_urlurl- Stored on the link. See the note below before relying on it.
{
"title": "Design retainer — October",
"description": "Invoice #1048",
"amount": "250",
"currency": "USDT"
}{
"status": true,
"data": {
"id": 31,
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
"user_id": 1042,
"title": "Design retainer — October",
"description": "Invoice #1048",
"amount": "250.000000000000000000",
"currency_code": "USDT",
"status": "active",
"expires_at": null,
"redirect_url": null,
"callback_url": null,
"created_at": "2026-09-26T14:02:11.000000Z",
"updated_at": "2026-09-26T14:02:11.000000Z"
}
}- Links are reusable by default: one link can be paid any number of times by different payers. For a single-use invoice, disable it after the first payment.
- `redirect_url` and `callback_url` are validated and persisted, but the current release does not redirect the payer or send a server-to-server callback. Reconcile by polling `xpay/report` until they are dispatched.
/xpayBearer tokenList your links
Paginated list of the links you own, newest first, each with a count of successful payments.
https://api.xcall.cc/sandbox/v1/xpay
Body
pageinteger- Defaults to 1.
per_pageinteger- Defaults to 20, capped at 100.
{
"page": 1,
"per_page": 20
}{
"status": true,
"data": [
{
"id": 31,
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
"title": "Design retainer — October",
"amount": "250.000000000000000000",
"currency_code": "USDT",
"status": "active",
"paid_count": 2
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
}/xpay/updateBearer tokenUpdate a link
Partial update. Only the fields you send are changed; everything else is untouched.
https://api.xcall.cc/sandbox/v1/xpay/update
Body
uuidstringrequired- The link to update. Must be one you own.
titlestring (≤150)descriptionstring (≤500) | null- Send null to clear.
amountnumeric ≥ 0 | null- Send null to convert a fixed link into an open-amount one.
expires_atdate | nullredirect_urlurl | nullcallback_urlurl | nullstatusactive | disabled- Anything else is ignored.
{
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
"amount": "275",
"status": "active"
}{
"status": true,
"data": {
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
"amount": "275.000000000000000000",
"status": "active"
}
}- `currency` cannot be changed after creation — create a new link instead.
/xpay/disableBearer tokenDisable a link
Stops the link accepting new payments. Existing payments are untouched and the link can be re-enabled with `xpay/update`.
https://api.xcall.cc/sandbox/v1/xpay/disable
Body
uuidstringrequired- The link to disable. Must be one you own.
{
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44"
}{
"status": true,
"data": {
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
"status": "disabled"
}
}/xpay/reportBearer tokenPer-link report
Every successful payment against one link, with gross received and total fees. Until callbacks are dispatched, this is the endpoint to poll for reconciliation.
https://api.xcall.cc/sandbox/v1/xpay/report
Body
uuidstringrequired- The link to report on. Must be one you own.
{
"uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44"
}{
"status": true,
"data": {
"link": { "uuid": "b7f2c1a9-…", "title": "Design retainer — October" },
"count": 2,
"total_received": "499.900000",
"total_fees": "0.100000",
"payments": [
{
"id": 88,
"payer_user_id": 2087,
"payer_email": "[email protected]",
"amount": "250.000000000000000000",
"fee": "0.050000000000000000",
"net_amount": "249.950000000000000000",
"currency_code": "USDT",
"status": "paid",
"paid_at": "2026-09-26T14:20:02.000000Z"
}
]
}
}- `total_received` sums `net_amount` — what actually reached your wallet — not the gross the payers sent.
/xpay/payBearer + CAPTCHAPay a link
Settles a payment from the authenticated payer's internal balance. This is what the checkout page calls. Returns 201 with the payment record.
https://api.xcall.cc/sandbox/v1/xpay/pay
Body
uuidstringrequired- The link being paid.
amountnumeric ≥ 0- Required for open-amount links, ignored for fixed ones.
captcha_idstringrequired- From `captcha/generate`.
captcha_answerstringrequired- What the user read off the CAPTCHA image.
{
"uuid": "5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35",
"captcha_id": "8f14e45f-ea9c-4d1b-9b8a-3c2d1e0f7a6b",
"captcha_answer": "7K2M9"
}{
"status": true,
"data": {
"id": 88,
"link_id": 31,
"payer_user_id": 2087,
"amount": "25.000000000000000000",
"fee": "0.005000000000000000",
"net_amount": "24.995000000000000000",
"currency_code": "USDT",
"status": "paid",
"paid_at": "2026-09-26T14:20:02.000000Z"
}
}- The whole settlement runs in one database transaction: payer debited, merchant credited the net, platform credited the fee, two ledger-backed transactions written. It either all happens or none of it does.
- You cannot pay your own link — the API rejects it.
- The payer needs enough available balance; there is no credit and no partial payment.
/xpay/adminBearer + xpay.manageAll payments (admin)
Platform-wide list of XPay payments. Requires the `xpay.manage` permission and is used by the admin panel, not by merchants.
https://api.xcall.cc/sandbox/v1/xpay/admin
Body
statuspending | paid | failed- Optional filter.
pageintegerper_pageinteger
{
"status": "paid",
"page": 1
}{
"status": true,
"data": [
{
"id": 88,
"amount": "25.000000000000000000",
"status": "paid",
"link": { "uuid": "5b1d4e2a-…", "title": "Sandbox — fixed amount" },
"payer": { "id": 2087, "username": "sandbox_payer" }
}
],
"meta": { "current_page": 1, "per_page": 20, "total": 1, "last_page": 1 }
}Payment lifecycle
What actually happens between a customer tapping Pay and the money being in your wallet.
- 1
Link created
activeThe merchant calls `xpay/create`. Nothing is reserved and nothing is charged.
- 2
Payer opens the checkout
activeThe page calls the public `xpay/show` to render the title, amount and merchant. No authentication yet.
- 3
Payer confirms
pending → paidA payment row is written as `pending`, then the ledger moves inside one transaction and the row flips to `paid` with `paid_at` set. A failure anywhere rolls the whole thing back.
- 4
Balances move
paidThe payer is debited the gross. The merchant is credited gross minus fee. The platform wallet is credited the fee. Two transactions are recorded — `xpay_out` for the payer, `xpay_in` for the merchant.
- 5
Both sides notified
paidThe merchant gets an in-app notification and an email; the payer gets an in-app notification. The link stays active for the next customer.
redirect_url and callback_url are accepted and stored on a link, but this release does not redirect the payer or POST to your server when a payment completes. Until it does, reconcile by polling xpay/report for the link, or read the merchant's xpay_in transactions. Fees & amounts
XPay charges 0.02% of each payment, taken from the merchant's side. The payer is debited the gross; the merchant is credited gross minus fee; the platform wallet receives the fee. Nothing is added on top of what the customer sees.
The rate is configurable server-side, so read it rather than hardcoding it. POST /fee is public and returns the whole schedule:
{
"status": true,
"data": {
"transfer_rate": "0.0001",
"xpay_rate": "0.0002",
"withdraw_network_multiplier": "3",
"withdraw_fee": { "USDT": "5", "TRX": "5" }
}
}Precision
Amounts are DECIMAL(36,18) in the database and always serialised as strings. A JSON number cannot hold them without loss — parse with a decimal library, compare as decimals, and never round before sending.
Errors
Failures come back with status: false and a human-readable message. Validation failures add an errors map keyed by field name.
{
"status": false,
"message": "This payment link is no longer active."
}
// Validation failures add a field map:
{
"status": false,
"message": "The title field is required.",
"errors": { "title": ["The title field is required."] }
} Note that most business-rule failures are 422, not 404 or 409 — including an unknown link UUID. Branch on message, or on your own pre-checks, rather than on the status code alone.
401Unauthenticated.Missing, malformed or expired bearer token.
403This action is unauthorized.Valid token, but the account lacks the permission the route requires (`xpay/admin`).
404Resource not found.The route itself does not exist — check the method is POST and the path is prefixed with `/v1`.
422Payment link not found.No link with that UUID, or it belongs to another merchant on an owner-only endpoint.
422This payment link is no longer active.The link is disabled, or `expires_at` is in the past.
422You cannot pay your own link.The authenticated payer owns the link.
422An amount is required for this link.An open-amount link was paid without an `amount`.
422Amount must be greater than zero.The resolved amount was zero or negative.
422Insufficient USDT balance.The payer's available balance does not cover the gross amount. The currency code in the message varies.
422(field validation)Input failed validation. The `errors` object maps each field to its messages.
Go-live checklist
- Swap the base URL from the sandbox to
https://api.xcall.cc/v1. - Sign in as your real XCall account and store that token — sandbox tokens are not valid in production.
- Generate and persist one
imeiper installation so you are not re-verifying the device on every login. - Read the fee rate from
POST /feeinstead of hardcoding 0.02%. - Parse every amount as a decimal string.
- Handle the
needs_device_verificationbranch of the login response. - Fetch a fresh CAPTCHA after every failed
xpay/pay. - Poll
xpay/reportto reconcile, since callbacks are not dispatched yet. - Disable a link after payment if it represents a single invoice — links are reusable by default.