SpotEx Pay for developers
Merchant API
Take crypto payments on your own site, in your own design, and pay out from your merchant wallet. JSON over HTTPS, with SDKs for Node.js, Python and PHP.
https://spotex.trade/api/merchant/v1No code at all?
Quickstart
Three steps from nothing to a paid test order.
- 01
Get keys
Apply at Merchant, then make a test key (
sk_test_) under Developers. - 02
Create a checkout
One call with the price and your order number. Send the buyer to its
url. - 03
Hear it was paid
Set a webhook address. Check its signature, then fulfil the order on
checkout.paid.
curl -X POST https://spotex.trade/api/merchant/v1/checkout-sessions \
-H "Authorization: Bearer $SPOTEX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043" \
-d '{ "amount_usd": "25.00", "item": "Gift box",
"order_id": "1043",
"return_url": "https://yourshop.com/thanks" }'With a test key that is all real except the money: open the session's url, press Pay in full, and your webhook gets checkout.paid with "livemode": false.
SDKs
Official, open source and dependency-free. Each verifies webhooks and handles idempotency keys for you. Or call the API directly: it is plain JSON over HTTPS.
Node.js
TypeScript types, no dependencies
npm install github:SpotEx-trade/spotex-pay-nodeREADME and examples on GitHubPython
Standard library only, 3.8+
pip install git+https://github.com/SpotEx-trade/spotex-pay-pythonREADME and examples on GitHubPHP
PHP 7.4+, curl
composer config repositories.spotex vcs https://github.com/SpotEx-trade/spotex-pay-php && composer require spotex/payREADME and examples on GitHub
Keys and permissions
Every request carries your secret key as a bearer token: Authorization: Bearer sk_live_….
- Every key can create and read checkouts and read your balance. Nothing more unless you choose it.
- Transfer and withdraw are separate permissions, off unless you tick them. A key with either must have an IP allowlist: requests from any other address are refused.
- A withdraw key can be whitelist-only: it then sends only to addresses on your withdrawal whitelist, which you add in the Merchant Center with the identity check.
- A test key (
sk_test_…) works in test mode. It can never pay out. - Revoke a key and it stops working at once. Keys see only your own merchant account. Keep them on your server, never in a web page or an app.
- 300 requests a minute per key; past that, 429 with a Retry-After header.
Endpoints
All under the base URL. Amounts are decimal strings, never numbers, so nothing is lost to floating point.
| Request | Key needs | Does |
|---|---|---|
| POST/checkout-sessions | any key | One order paid on SpotEx's own page: send the buyer to its url; they choose the coin and how to pay |
| GET/checkout-sessions/:id | any key | A session: open, paid (with its checkout id) or expired |
| GET/coins | any key | The coins you accept, their USD rate, and whether each can be sent from any wallet |
| POST/checkouts | any key | An on-chain checkout with its own address and exact amount, to show in your design |
| GET/checkouts/:id | any key | One checkout: status, what arrived, what is still confirming, what is left |
| GET/checkouts | any key | Your checkouts, newest first. ?status=paid|pending, ?limit (max 100), ?before=<id> |
| POST/checkouts/:id/simulate | test key | Test mode only: { "outcome": "paid" | "partial" | "expired" } |
| GET/balance | live key | What is in your merchant wallet, which payouts are paid from |
| POST/payouts/transfers | transfer | Pay a SpotEx user from the merchant wallet, by username or email. Instant |
| GET/payouts/transfers/:id | live key | One transfer you sent |
| POST/payouts/withdrawals | withdraw | Send from the merchant wallet to an address on-chain |
| GET/payouts/withdrawals/:id | live key | One withdrawal: status and txid once broadcast |
Checkout sessions
The quickest integration: no checkout screen of your own. One call, then send the buyer to its url.
curl -X POST https://spotex.trade/api/merchant/v1/checkout-sessions \
-H "Authorization: Bearer $SPOTEX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043" \
-d '{ "amount_usd": "25.00", "item": "Gift box",
"order_id": "1043",
"return_url": "https://yourshop.com/thanks" }'- The buyer chooses a coin and pays from their SpotEx balance or any wallet, then gets a button back to
return_url, which must be on your website. - A session takes one payment and closes once paid, or after
expires_in_minutes(default 60, up to 1440). The webhook's checkout carriessession_idand yourorder_id. - Payments start at $1 from a SpotEx balance and $5 from a wallet, up to $5,000.
Checkouts in your own design
Create a checkout, show its address and amount in your page, and wait for the webhook.
curl -X POST https://spotex.trade/api/merchant/v1/checkouts \
-H "Authorization: Bearer $SPOTEX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043" \
-d '{ "amount_usd": "25.00", "coin": "DOGE",
"item": "Gift box", "order_id": "1043" }'{
"id": "cm...",
"object": "checkout",
"livemode": true,
"status": "pending",
"method": "onchain",
"item": "Gift box",
"order_id": "1043",
"amount_usd": "25.00",
"coin": "DOGE",
"amount": "138.88888889",
"rate_usd": "0.18",
"address": "D8x...",
"received": "0",
"confirming": "0",
"remaining": "138.88888889",
"expires_at": "2026-10-27T10:30:00.000Z",
"hosted_url": "https://spotex.trade/checkout/cm...",
...
}- Show
addressandamount(a QR code of the address helps), or send the buyer tohosted_url, SpotEx's own page for it. - The price is held for 30 minutes. Coins that can be sent from any wallet are marked
onchain: trueinGET /coins. - Paid in parts? SpotEx holds what arrived until the rest does (
remainingsays how much), or until you accept it as paid in the Merchant Center. Set a shortfall to accept (Settings, up to 5%) and a payment that short or less is paid on arrival. - When paid, the amount less the 0.5% fee is in your merchant wallet:
feeandnetsay what each was. - Chose to receive payments as one coin (Settings)? Each payment in another coin is sold on the spot market as it lands, within 2% of the market price and with no trading fee, for 1% in all on what converts.
The checkout object
What every checkout endpoint returns, and what webhooks carry in data.checkout.
| Field | Type | Meaning |
|---|---|---|
id | string | The checkout's id. |
object | "checkout" | |
livemode | boolean | False for test mode. Never fulfil an order on a false one. |
session_id | string | null | The checkout session (or payment link) it was paid through. |
status | string | pending, partially_paid, paid or expired. |
method | string | onchain (sent from a wallet) or balance (paid from a SpotEx balance). |
item, order_id | string | What you named it, and your own order number. |
amount_usd | decimal | The price in dollars. |
coin, amount, rate_usd | string, decimal | The coin, the exact amount owed in it, and the rate it was quoted at. |
address | string | null | Where to send it: this checkout's own address. In test mode a test_ stand-in. |
received, confirming, remaining | decimal | What has arrived, what is seen but still confirming, and what is left to send. |
fee, net | decimal | null | Once paid: SpotEx's fee, and what reached your merchant wallet. |
convert_* | convert_to, convert_status (pending, sent, done or failed), convert_fee and convert_note: set when you receive payments as one coin. | |
paid_late | boolean | Completed by money first seen after the quote ran out: check its value. |
duplicate | boolean | A second payment for a checkout session already paid. |
accepted_short | boolean | Less than the amount was accepted as paid: by you, or within your shortfall setting. |
expires_at, paid_at, created_at | ISO 8601 | |
hosted_url | string | SpotEx's own checkout page for it. |
Webhooks
We POST every event to your webhook address, signed, and retry with backoff for about a day until your server answers 2xx.
| Event | When |
|---|---|
checkout.paid | Paid in full, or accepted by you. Fulfil the order on this one, unless duplicate is true: then it is a second payment for an order already paid, which you accepted, and you may want to refund it. |
checkout.partial | Money arrived and is held for you: part of the amount (remaining says what is left), all of it but late and worth less now (paid_late), or a second payment on a checkout session already paid (duplicate). |
checkout.extra | More arrived after it was paid; it is in your wallet less the fee. |
checkout.refunded | You refunded some or all of it from the Merchant Center. |
payout.withdrawal.* | broadcast (with txid), completed, failed, rejected, cancelled: a withdrawal you sent moving on. data.withdrawal is it as GET /payouts/withdrawals/:id shows it. |
payout.transfer.* | completed, rejected: a transfer you sent, including one held for approval. data.transfer. |
test | From the "Send a test event" button in the Merchant Center. |
POST /your-webhook
SpotEx-Event: checkout.paid
SpotEx-Delivery: evt_8f1c...
SpotEx-Signature: t=1793071234,v1=5b2c...
{ "id": "evt_8f1c...", "type": "checkout.paid", "created": 1793071234,
"livemode": true, "data": { "checkout": { ... } } }Check the signature before trusting anything in the body: v1 is an HMAC-SHA256 of <t>.<raw body> with your webhook signing secret. Refuse events whose t is more than five minutes old, and treat the same id twice as one event.
import { constructEvent } from "spotex-pay";
// rawBody: the exact bytes received, before any JSON parsing
const event = constructEvent(rawBody, req.headers["spotex-signature"], secret);
if (event.type === "checkout.paid" && event.livemode) {
fulfil(event.data.checkout.order_id); // once per event.id
}The webhook address must be HTTPS on a public domain name, on port 443 or 8443. Redirects are not followed.
Test mode
Build and test your integration without moving any money.
- Make a test key in Merchant Center → Developers. With it,
POST /checkouts,POST /checkout-sessionsand their reads work exactly as live, and every object carries"livemode": false. - A test checkout's
addressstarts withtest_: it is not an address, never send coins to it. Pay it withPOST /checkouts/:id/simulate, or open itshosted_url(or a test session'surl) and press a button. - Your webhook gets
checkout.paidandcheckout.partialas it would live, signed the same way, with"livemode": false: check it before fulfilling an order. - Test and live never mix: a test key can't read live checkouts, nor a live key test ones. Payouts and the balance need a live key. Test objects are deleted after 30 days.
Payouts
Pay out from your merchant wallet, never from your Spot balance, with a key that has the permission.
# Pay a SpotEx user (instant)
curl -X POST https://spotex.trade/api/merchant/v1/payouts/transfers \
-H "Authorization: Bearer $SPOTEX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-1043" \
-d '{ "to": "alice", "coin": "USDT", "amount": "25",
"note": "Refund for order 1043" }'
# Withdraw on-chain
curl -X POST https://spotex.trade/api/merchant/v1/payouts/withdrawals \
-H "Authorization: Bearer $SPOTEX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: supplier-2026-10-27" \
-d '{ "coin": "USDT", "network": "BSC",
"address": "0x...", "amount": "250" }'- Confirmed by your key alone: no email and no authenticator code.
- Sent automatically up to $1,000 a payout and $5,000 in 24 hours. Larger ones are accepted and wait for SpotEx to approve: a transfer is then
pending_review, a withdrawalsecurity_review. The amount is reserved in your wallet meanwhile. networkis needed only for a coin sent on more than one chain. A withdrawal's network fee comes out of your wallet on top ofamount.- Transfers follow the rules for any transfer between accounts (the minimum amount, and transfers being switched on) and go to the same account up to 20 times a day.
- Payouts stop while SpotEx has withdrawals paused, for a frozen account, and for a while after a security change on your account.
Idempotency
Retrying is always safe if you send the same key.
Send an Idempotency-Key header (8 to 100 letters, digits, - or _): the same request with the same key returns the same checkout or payout, however often it is sent. It is required for payouts. After a timeout, retry with the same key, never a new one, or you may pay twice. The SDKs make one for you when you leave it out; for payouts, always pass your own.
Errors
A non-2xx status, with a code to branch on and a message to log.
{ "error": { "code": "insufficient_funds", "message": "Not enough USDT in the merchant wallet." } }| Status | Code | Means |
|---|---|---|
| 400 | invalid_request | A field is missing or wrong: the message says which. |
| 400 | insufficient_funds | Not enough in the merchant wallet for the payout and its network fee. |
| 401 | unauthorized | Missing, wrong or revoked key. |
| 403 | permission_denied | The key lacks the transfer or withdraw permission. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | not_whitelisted | A whitelist-only key sending to an address not on your whitelist. |
| 403 | merchant_unavailable | Your merchant account is not taking payments. |
| 403 | test_mode | A live-only call (balance, payouts) made with a test key. |
| 404 | not_found | No such object, or not yours. |
| 409 | conflict | The request clashes with the object's state. |
| 429 | rate_limited | Over 300 requests a minute for this key: wait Retry-After seconds. |
| 503 | paused, unavailable | Payments or payouts are paused for now: retry later with the same Idempotency-Key. |
The SDKs raise these as an error with status, code and message, and a network failure or timeout as network_error.