API documentation / Payment gateway
Payment gateway API
Take crypto on your website. You ask for a payment, send your customer to our checkout, and get a signed webhook when it is paid. Confirmed payments credit your Plaxswap merchant balance. Withdraw to your wallet after reviewing the network fee.
Overview
JSON over HTTPS at https://plaxswap.com. Coin amounts are decimal strings in coin units ("99.5"), never floats. Times are ISO 8601 in UTC. A coin on one network has an id such as usdt-tron, usdc-base or btc.
How one payment works:
- Your server creates a payment with the price and your order number. You get a
checkoutUrl. - You send your customer to it. They pick a coin, see exactly how much to send, and send it.
- The coin you receive is passed on as it is. Another coin is converted by Uniswap (same network) or SwapKit (between networks). It is added to your Plaxswap merchant balance.
- We POST a signed
payment.paidwebhook to your server, and the customer returns to yourreturnUrl.
Confirmed payments are added to your Plaxswap merchant balance, in the coin and network you receive. Plaxswap holds these coins until you withdraw them to your own wallet; you pay the network fee of each withdrawal. (Older direct-wallet payments still go straight to the address set when they were created.) The platform fee is 1.5% of a payment. Network fees and Uniswap or SwapKit costs are paid by your customer, inside the amount they send.
Quickstart
1. Get your keys
Sign up on /merchants. You get a live key (pxs_live_...), a test key (pxs_test_...) and a webhook secret (whsec_...), shown once. Keep them on your server.
2. Create a payment
curl -s -X POST https://plaxswap.com/api/v1/payments \
-H "Authorization: Bearer pxs_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": "100",
"currency": "USD",
"receive": "usdt-tron",
"orderId": "1042",
"description": "2 x T-shirt",
"returnUrl": "https://shop.example/orders/1042/thanks",
"cancelUrl": "https://shop.example/cart"
}'The answer is the payment. Send your customer to its checkoutUrl:
{
"id": "pay_7m2k9q4x8c1v5b3n",
"object": "payment",
"livemode": false,
"status": "pending",
"amount": "100",
"currency": "USD",
"receive": { "id": "usdt-tron", "assetId": "USDT", "networkId": "tron", "label": "USDT · Tron (TRC-20)", "decimals": 6, "memoRequired": false },
"receiveAddress": null,
"receiveAmount": "100",
"expectedAmount": "100",
"receivedAmount": "0",
"fee": { "bps": 150, "paidBy": "customer", "amount": "1.5" },
"orderId": "1042",
"checkoutUrl": "https://plaxswap.com/checkout/pay_7m2k9q4x8c1v5b3n",
"expiresAt": "2026-10-20T11:00:00.000Z",
"transactions": [],
...
}3. Handle the webhook
When the payment changes we POST it to your webhook address. Check the signature, then act on data.status. Ship when it is paid.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PLAXSWAP_WEBHOOK_SECRET; // whsec_...
// The raw body is needed for the signature check.
app.post("/plaxswap-webhook", express.raw({ type: "application/json" }), async (req, res) => {
const header = req.get("x-px-signature") || "";
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header) || [];
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!v1 || !fresh || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(400);
const event = JSON.parse(req.body);
const payment = event.data;
// Test events must never fulfil real orders. Bind to the stored gateway payment ID, not a customer-provided order number.
if (!event.livemode || !payment.livemode) return res.sendStatus(200);
if (event.event === "payment.paid") {
const expected = await findShopOrderByPaymentId(payment.id);
if (!expected || expected.gatewayPaymentId !== payment.id || expected.orderId !== payment.orderId) return res.sendStatus(400);
// This function must deduplicate event.id and atomically fulfil only once; compare the expected coin and amount too.
await fulfilOrderOnce(expected, event.id, payment);
}
res.sendStatus(200); // any 2xx within 8 seconds
});4. When the customer comes back
The return page should not trust the browser. Read the payment from your server with GET /api/v1/payments/:id (or wait for the webhook) before you show "paid".
Test mode
Payments created with a test key never move real coins. The checkout shows a test banner, a pretend address, and buttons that act out each outcome. You can do the same from your server:
curl -s -X POST https://plaxswap.com/api/v1/payments/pay_7m2k9q4x8c1v5b3n/simulate \
-H "Authorization: Bearer pxs_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "outcome": "paid" }'Outcomes: paid, partially_paid, overpaid, late, expired, failed. Each sends the same webhook a live payment would, with "livemode": false and the header x-px-livemode: false. A test payment takes one outcome; make another to try the next. Test keys see only test payments, live keys only live ones.
Keys and requests
Send your secret key as Authorization: Bearer pxs_live_... (or in x-api-key). Call the API from your server only: never put a secret key in a web page or an app. Every answer carries an x-request-id; quote it when you write to support.
Each key can make 300 requests a minute. Make more keys, or switch one off if it leaked, on your merchant page or with POST /api/v1/merchant/keys and PATCH /api/v1/merchant/keys/:id.
Make retries safe with an Idempotency-Key header on POST /api/v1/payments: the same key with the same body returns the same payment (200 instead of 201); with a different body it is refused (409).
Creating a payment
| Field | Required | What it is |
|---|---|---|
amount | yes | The price, as text: "100", "25.50". |
currency | yes | A fiat code (USD, EUR, INR...) converted when the payment is made, or a coin (USDT, BTC...). USD prices count one to one for USDT, USDC and DAI. |
receive | no | The coin you receive, such as usdt-tron. Default: the one saved on your account. |
receiveAddress | no | Older direct-wallet accounts only: where this payment goes. Default: your saved address (needed when you name another coin). Accounts with a merchant balance cannot set it: payments go to the balance. |
receiveMemo | no | Memo or tag of that address, on networks that use one. |
orderId | no | Your order number, shown to the payer and searchable (?orderId=). |
description | no | Shown to the payer. |
returnUrl | no | https address the payer goes to after paying. |
cancelUrl | no | https address of the checkout's back link. |
expiresInMinutes | no | 15 to 1440. Default 60. |
customerPaysFee | no | true: the payer pays the platform fee on top. Default: your account's choice. Accounts with a merchant balance: always true. |
metadata | no | Up to 20 keys of your own, text values. Sent back in every webhook. |
Amounts and fees
| Field | Meaning |
|---|---|
receiveAmount | The price in your coin, fixed when the payment is made. |
expectedAmount | What you should receive: receiveAmount, less the 1.5% platform fee when you pay it (fee.paidBy merchant). |
receivedAmount | What reached you (your balance, or your address on older direct-wallet payments) so far. |
fee | The platform fee: bps (100 = 1%), who pays it, and its amount in your coin. |
The payer is asked for a little more than the price when their coin is converted (a price cushion of 0.5%), so a small price move while their coins confirm still covers you. Whatever is left over reaches you. What arrives is passed on as it is: a short payment is passed on and marked partly paid, an extra one is passed on with overpaid: true, and coins that arrive after the payment expired (up to 24 hours) are passed on with late: true. Refunds of payments that reached you are yours to make.
Statuses
| Status | Meaning |
|---|---|
pending | Waiting for the payer. They may have picked a coin and be about to send. |
confirming | Coins arrived and are being confirmed, checked or converted. Do not ship yet. |
paid | The payer sent the amount asked and the payment settled. Verify the payment reference, amount and mode before fulfilling the order. |
partially_paid | Less than asked settled. While the payment is open the payer can send the rest; after that it stays partly paid. |
expired | Nothing arrived in time. Coins can still arrive up to 24 hours later: then a payment.paid or payment.partially_paid with late true follows. |
cancelled | You cancelled it before anything arrived. |
failed | The payer's coins were sent back to them (the conversion failed or they could not be accepted). |
Flags: late (coins arrived after it expired), overpaid (clearly more than asked), underReview (a transfer is held for a compliance check; it continues, or goes back to the payer). Each transfer is listed in transactions with its transaction ids and what it gave you.
Coins
GET /api/v1/gateway/coins lists the coins you can receive and what payers can pay each one with. The payer can always pay with your own coin. Other coins work where Uniswap swaps them on the same network, or where SwapKit carries them between networks (mainly BTC, ETH, and USDT or USDC on Ethereum and Arbitrum, into Tron USDT as well). The checkout shows only what can pay that payment at that moment. Payers pass our checkout checks: country rules, an identity check above our limits, and sanctions and wallet screening.
Webhooks
Copy your shop’s callback URL into Merchant → Webhooks, or configure it with PATCH /api/v1/merchant. Every event is a POST with the whole payment in data:
| Event | When |
|---|---|
payment.confirming | Coins for the payment arrived and are being confirmed, checked or converted. |
payment.paid | The payer sent the amount asked and it reached your address. |
payment.partially_paid | Less than asked reached your address. The payer can still send the rest while the payment is open. |
payment.expired | Nothing arrived in time. Coins can still arrive late (up to 24 hours), then a payment.paid or payment.partially_paid with late true follows. |
payment.cancelled | You cancelled the payment. |
payment.failed | The payer's coins were sent back (the conversion failed or they could not be accepted). |
payment.updated | A flag or the amount changed without a status change (overpaid, a review, more coins arrived). |
ping | A test webhook from POST /api/v1/merchant/webhooks/test. |
{
"id": "evt_3k9q2m7x8c1v5b4n",
"event": "payment.paid",
"livemode": true,
"createdAt": "2026-10-20T10:22:05.000Z",
"data": { "id": "pay_7m2k9q4x8c1v5b3n", "object": "payment", "status": "paid", "orderId": "1042", "receivedAmount": "99.84", ... }
}The x-px-signature header is t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your webhook secret>. Check it against the raw body, refuse old timestamps (older than 5 minutes), and answer any 2xx within 8 seconds. Failed deliveries are retried with growing gaps, 10 times over about a day, so the same event can arrive twice: use id to ignore repeats. See past deliveries and send one again on your merchant page. A test ping: POST /api/v1/merchant/webhooks/test.
Code samples
PHP
<?php
$key = getenv('PLAXSWAP_KEY'); // pxs_live_... or pxs_test_...
$ch = curl_init('https://plaxswap.com/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Content-Type: application/json', 'Idempotency-Key: order-' . $orderId],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '100', 'currency' => 'USD', 'receive' => 'usdt-tron',
'orderId' => (string) $orderId, 'returnUrl' => 'https://shop.example/thanks',
]),
]);
$payment = json_decode(curl_exec($ch), true);
header('Location: ' . $payment['checkoutUrl']);
// The webhook endpoint:
$raw = file_get_contents('php://input');
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $_SERVER['HTTP_X_PX_SIGNATURE'] ?? '', $m)) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, getenv('PLAXSWAP_WEBHOOK_SECRET'));
if (!hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) { http_response_code(400); exit; }
$event = json_decode($raw, true);
if ($event['event'] === 'payment.paid') { mark_order_paid($event['data']['orderId']); }
http_response_code(200);Python
import hashlib, hmac, os, time, requests
from flask import Flask, abort, request
API = "https://plaxswap.com/api/v1"
KEY = os.environ["PLAXSWAP_KEY"]
def create_payment(order_id, usd):
r = requests.post(f"{API}/payments", timeout=15,
headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": f"order-{order_id}"},
json={"amount": str(usd), "currency": "USD", "receive": "usdt-tron", "orderId": str(order_id)})
r.raise_for_status()
return r.json()["checkoutUrl"]
app = Flask(__name__)
@app.post("/plaxswap-webhook")
def webhook():
raw = request.get_data()
parts = dict(p.split("=", 1) for p in request.headers.get("x-px-signature", "").split(",") if "=" in p)
expected = hmac.new(os.environ["PLAXSWAP_WEBHOOK_SECRET"].encode(), parts.get("t", "").encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
abort(400)
event = request.get_json()
if event["event"] == "payment.paid":
mark_order_paid(event["data"]["orderId"])
return "", 200Node.js
const res = await fetch("https://plaxswap.com/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PLAXSWAP_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${order.id}`,
},
body: JSON.stringify({ amount: order.total, currency: "USD", receive: "usdc-base", orderId: order.id, returnUrl: `https://shop.example/orders/${order.id}` }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const payment = await res.json();
redirect(payment.checkoutUrl);Errors
Every error is { "error": { "code", "message", "details"? } } with a matching HTTP status. The message is plain English. details.field names the field when there is one.
| Code | HTTP | Meaning |
|---|---|---|
invalid_api_key | 401 | The key is missing, mistyped, or not a merchant key. |
key_disabled | 401 | The key was switched off. Use another key or switch it on from your merchant page. |
merchant_disabled | 403 | Your account is switched off. Write to support. |
invalid_amount | 400 | amount is not a plain positive number. |
invalid_currency | 400 | currency is not a fiat code we convert, nor a coin we know. |
invalid_receive_coin | 400 | receive is not a coin the gateway pays out in. details.allowed lists them. |
invalid_address | 400 | The receiving address failed its format or checksum check, or is missing for that coin. |
address_not_allowed | 403 | The receiving address failed the sanctions or risk screen. |
amount_out_of_range | 400 | The payment is below about 1 US dollar or above the coin's limit. |
invalid_url | 400 | returnUrl or cancelUrl is not an https address. |
invalid_expiry | 400 | expiresInMinutes is outside 15 to 1440. |
invalid_metadata | 400 | metadata has more than 20 keys, a bad key name or a value over 500 characters. |
idempotency_conflict | 409 | The Idempotency-Key was used before with a different body. |
not_found | 404 | No such payment for this key (test keys see only test payments). |
not_cancellable | 409 | Coins arrived, or the payment is closed. |
rate_limited | 429 | Too many requests with one key in a minute. |
service_paused | 503 | New payments are paused for a moment. Try again later. |
rate_unavailable | 503 | No current price to convert your currency. Try again in a minute. |
Endpoint reference
Payment gateway
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/merchant/swaps | Owner wallet conversion history and auto-conversion policy |
| POST | /api/v1/merchant/swaps | Confirm a quoted wallet conversion batch |
| POST | /api/v1/merchant/swaps/quote | Preview a merchant balance swap or consolidation |
| POST | /api/v1/merchant/swaps/cancel | Cancel an unstarted wallet conversion |
| PATCH | /api/v1/merchant/swaps/preferences | Set or revoke auto-conversion consent |
| GET | /api/v1/payments | List your payments |
| POST | /api/v1/payments | Create a payment |
| GET | /api/v1/payments/{id} | A payment as it stands now |
| POST | /api/v1/payments/{id}/cancel | Cancel a payment nothing was paid to |
| POST | /api/v1/payments/{id}/simulate | Test mode: act out a payment's outcome |
| GET | /api/v1/gateway/coins | Coins you can receive, and what payers can pay each with |
Merchant account
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/merchant/overview | Filtered settled income, coin prices and orders |
| GET | /api/v1/merchant/security | Authenticator status and recovery-code count |
| POST | /api/v1/merchant/security | Enroll or recover a mandatory merchant authenticator |
| POST | /api/v1/merchant/security/approve | Approve one exact account or withdrawal request with TOTP |
| GET | /api/v1/merchant/wallet | Balances, ledger activity and withdrawals |
| POST | /api/v1/merchant/onboarding | Complete business profile |
| POST | /api/v1/merchant/withdrawals/quote | Preview a withdrawal and network fee |
| POST | /api/v1/merchant/withdrawals | Confirm the exact withdrawal preview |
| GET | /api/v1/merchant/notifications | Notification inbox and channel preferences |
| PATCH | /api/v1/merchant/notifications | Mark inbox items read or change opted-in channels |
| GET | /api/v1/merchant | Your merchant account |
| PATCH | /api/v1/merchant | Change your account's settings |
| POST | /api/v1/merchant/keys | Create another key |
| PATCH | /api/v1/merchant/keys/{id} | Switch a key on or off |
| POST | /api/v1/merchant/webhook-secret | A new webhook signing secret |
| GET | /api/v1/merchant/webhooks | Recent webhook deliveries |
| POST | /api/v1/merchant/webhooks/test | Send a signed ping webhook |
| POST | /api/v1/merchant/webhooks/{id}/resend | Send a webhook again |
| POST | /api/v1/merchants/signup | Legacy signup endpoint (retired) |
Checkout
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/checkout/{id} | The hosted checkout's view of a payment |
| POST | /api/v1/checkout/{id}/quote | How much to send with one coin |
| POST | /api/v1/checkout/{id}/pay | Pay with a coin |
| POST | /api/v1/checkout/{id}/simulate | Test mode: the checkout's outcome buttons |
Every field of every request and answer is in the OpenAPI document.
Going live
- Check the coin you receive on your merchant page. Payments are added to your merchant balance; withdraw to your own wallet from there.
- Make a test payment, act out paid, partly paid and expired, and check that your webhook handler does the right thing.
- Check the signature on every webhook, and read the payment from the API before you ship.
- Swap the test key for the live key on your server.
- Read the merchant terms.