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.

Base URLhttps://spotex.trade/api/merchant/v1

No code at all?

You don't need the API to take payments: make a payment link or a "Pay with SpotEx" button in the Merchant Center and paste it into your site. The API is for shops that want the checkout inside their own pages, and for payouts.

Quickstart

Three steps from nothing to a paid test order.

  1. 01

    Get keys

    Apply at Merchant, then make a test key (sk_test_) under Developers.

  2. 02

    Create a checkout

    One call with the price and your order number. Send the buyer to its url.

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

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.

RequestKey needsDoes
POST/checkout-sessionsany keyOne order paid on SpotEx's own page: send the buyer to its url; they choose the coin and how to pay
GET/checkout-sessions/:idany keyA session: open, paid (with its checkout id) or expired
GET/coinsany keyThe coins you accept, their USD rate, and whether each can be sent from any wallet
POST/checkoutsany keyAn on-chain checkout with its own address and exact amount, to show in your design
GET/checkouts/:idany keyOne checkout: status, what arrived, what is still confirming, what is left
GET/checkoutsany keyYour checkouts, newest first. ?status=paid|pending, ?limit (max 100), ?before=<id>
POST/checkouts/:id/simulatetest keyTest mode only: { "outcome": "paid" | "partial" | "expired" }
GET/balancelive keyWhat is in your merchant wallet, which payouts are paid from
POST/payouts/transferstransferPay a SpotEx user from the merchant wallet, by username or email. Instant
GET/payouts/transfers/:idlive keyOne transfer you sent
POST/payouts/withdrawalswithdrawSend from the merchant wallet to an address on-chain
GET/payouts/withdrawals/:idlive keyOne 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 carries session_id and your order_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 address and amount (a QR code of the address helps), or send the buyer to hosted_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: true in GET /coins.
  • Paid in parts? SpotEx holds what arrived until the rest does (remaining says 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: fee and net say 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.

FieldTypeMeaning
idstringThe checkout's id.
object"checkout"
livemodebooleanFalse for test mode. Never fulfil an order on a false one.
session_idstring | nullThe checkout session (or payment link) it was paid through.
statusstringpending, partially_paid, paid or expired.
methodstringonchain (sent from a wallet) or balance (paid from a SpotEx balance).
item, order_idstringWhat you named it, and your own order number.
amount_usddecimalThe price in dollars.
coin, amount, rate_usdstring, decimalThe coin, the exact amount owed in it, and the rate it was quoted at.
addressstring | nullWhere to send it: this checkout's own address. In test mode a test_ stand-in.
received, confirming, remainingdecimalWhat has arrived, what is seen but still confirming, and what is left to send.
fee, netdecimal | nullOnce 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_latebooleanCompleted by money first seen after the quote ran out: check its value.
duplicatebooleanA second payment for a checkout session already paid.
accepted_shortbooleanLess than the amount was accepted as paid: by you, or within your shortfall setting.
expires_at, paid_at, created_atISO 8601
hosted_urlstringSpotEx'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.

EventWhen
checkout.paidPaid 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.partialMoney 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.extraMore arrived after it was paid; it is in your wallet less the fee.
checkout.refundedYou 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.
testFrom 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-sessions and their reads work exactly as live, and every object carries "livemode": false.
  • A test checkout's address starts with test_: it is not an address, never send coins to it. Pay it with POST /checkouts/:id/simulate, or open its hosted_url (or a test session's url) and press a button.
  • Your webhook gets checkout.paid and checkout.partial as 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 withdrawal security_review. The amount is reserved in your wallet meanwhile.
  • network is needed only for a coin sent on more than one chain. A withdrawal's network fee comes out of your wallet on top of amount.
  • 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." } }
StatusCodeMeans
400invalid_requestA field is missing or wrong: the message says which.
400insufficient_fundsNot enough in the merchant wallet for the payout and its network fee.
401unauthorizedMissing, wrong or revoked key.
403permission_deniedThe key lacks the transfer or withdraw permission.
403ip_not_allowedThe request came from outside the key's IP allowlist.
403not_whitelistedA whitelist-only key sending to an address not on your whitelist.
403merchant_unavailableYour merchant account is not taking payments.
403test_modeA live-only call (balance, payouts) made with a test key.
404not_foundNo such object, or not yours.
409conflictThe request clashes with the object's state.
429rate_limitedOver 300 requests a minute for this key: wait Retry-After seconds.
503paused, unavailablePayments 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.