Developers

XPay documentation

Everything needed to take a payment with XPay: the request conventions, how to get a token, every endpoint with real request and response bodies, the exact error strings, and a sandbox with a funded test account you can pay from this page.

Livehttps://api.xcall.cc/v1
Sandboxhttps://api.xcall.cc/sandbox/v1

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.

  1. Get a token

    Sign in with your XCall email or username. The token goes in the Authorization header of everything after this.

    1 — sign in
    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"
      }'
  2. Create a link

    A title and a currency are the only required fields. Leave amount out for an open-amount link.

    2 — create
    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"
      }'
  3. 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.

    3 — checkout URL
    https://app.xcall.cc/payment?link=<uuid>
Prefer to click rather than curl? Create the link from the XCall app under XPay, then jump straight to the sandbox to watch a payment settle.

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/json

Always send this, or errors come back as HTML.

Content-Typeapplication/json

Request bodies are JSON.

AuthorizationBearer <token>

Required on every endpoint except `xpay/show`. The token comes from `auth/login`.

platformapp | web | admin

Client 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>.

Successful login
{
  "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:

Device challenge
{
  "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.

Server-to-server integrations.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.

Merchant — owns the links
Username
@sandbox_merchant
Password
sandbox-Pa55word
Payer — has a balance to spend
Username
@sandbox_payer
Password
sandbox-Pa55word
Balance
10,000 USDT

Ready-made payment links

5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e3525 USDT

Sandbox — fixed amount — the amount is fixed and the payer cannot change it.

7c2e6f04-1a8b-4d92-b3f5-6e0a9c8d7b21payer chooses

Sandbox — 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:

Public lookup
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

Live sandbox console https://api.xcall.cc/sandbox/v1

This 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:

Seed the sandbox
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.

Treat sandbox credentials as public. They are printed on this page. Never reuse that password on a live account. The path guard prevents production users and payment links from being used here.

Endpoint reference

Eight endpoints, all POST, all under https://api.xcall.cc/v1 in production. Paths below are shown against the sandbox.

POST/xpay/showNo auth

Look 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.
Request
{
  "uuid": "5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35"
}
Response 200
{
  "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.
POST/xpay/createBearer token

Create 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.
Request
{
  "title": "Design retainer — October",
  "description": "Invoice #1048",
  "amount": "250",
  "currency": "USDT"
}
Response 200
{
  "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.
POST/xpayBearer token

List 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.
Request
{
  "page": 1,
  "per_page": 20
}
Response 200
{
  "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
  }
}
POST/xpay/updateBearer token

Update 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 | null
redirect_urlurl | null
callback_urlurl | null
statusactive | disabled
Anything else is ignored.
Request
{
  "uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
  "amount": "275",
  "status": "active"
}
Response 200
{
  "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.
POST/xpay/disableBearer token

Disable 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.
Request
{
  "uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44"
}
Response 200
{
  "status": true,
  "data": {
    "uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44",
    "status": "disabled"
  }
}
POST/xpay/reportBearer token

Per-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.
Request
{
  "uuid": "b7f2c1a9-4d3e-4f10-9a22-7c5e8d1b0f44"
}
Response 200
{
  "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.
POST/xpay/payBearer + CAPTCHA

Pay 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.
Request
{
  "uuid": "5b1d4e2a-9c3f-4a17-8e60-2f7c1b9d4e35",
  "captcha_id": "8f14e45f-ea9c-4d1b-9b8a-3c2d1e0f7a6b",
  "captcha_answer": "7K2M9"
}
Response 200
{
  "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.
POST/xpay/adminBearer + xpay.manage

All 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.
pageinteger
per_pageinteger
Request
{
  "status": "paid",
  "page": 1
}
Response 200
{
  "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. 1

    Link created active

    The merchant calls `xpay/create`. Nothing is reserved and nothing is charged.

  2. 2

    Payer opens the checkout active

    The page calls the public `xpay/show` to render the title, amount and merchant. No authentication yet.

  3. 3

    Payer confirms pending → paid

    A 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. 4

    Balances move paid

    The 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. 5

    Both sides notified paid

    The merchant gets an in-app notification and an email; the payer gets an in-app notification. The link stays active for the next customer.

There are no callbacks yet.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.

Payer is debited250.000000
Fee at 0.02%-0.050000
Merchant is credited249.950000

The rate is configurable server-side, so read it rather than hardcoding it. POST /fee is public and returns the whole schedule:

POST /fee
{
  "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.

Error shapes
{
  "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 imei per installation so you are not re-verifying the device on every login.
  • Read the fee rate from POST /fee instead of hardcoding 0.02%.
  • Parse every amount as a decimal string.
  • Handle the needs_device_verification branch of the login response.
  • Fetch a fresh CAPTCHA after every failed xpay/pay.
  • Poll xpay/report to reconcile, since callbacks are not dispatched yet.
  • Disable a link after payment if it represents a single invoice — links are reusable by default.
Something missing or wrong here? Open a ticket from support in the XCall app.