API integration
The merchant REST API — authenticating with an API key, creating a payment session, redirecting the buyer, reading status, cancelling and refunding, with the exact paths, fields and error shapes.
Six endpoints under /api/gateway/v1, all authenticated with an API key in a
header. The integration shape is the one every hosted checkout uses: your server
creates a session, you redirect the buyer to the URL you get back, and you learn
the outcome from a webhook or by polling.
The base URL is your platform's own origin.
https://exchange.example.com/api/gateway/v1The checkout page debits a wallet belonging to a signed-in user of the platform hosting the gateway. A visitor with no account cannot pay. Design the merchant's funnel around that or the integration will work perfectly and convert nobody.
Authentication
Send the key in X-API-Key. There is no bearer token, no signature on the
request, and no separate account identifier — the key identifies the merchant.
curl -H "X-API-Key: sk_live_..." https://exchange.example.com/api/gateway/v1/validateFour key prefixes exist, and the prefix alone decides both the mode and the privilege level:
| Prefix | Mode | Can create payments and refunds |
|---|---|---|
sk_live_ |
Live | Yes |
sk_test_ |
Test | Yes, against test payments only |
pk_live_ |
Live | No |
pk_test_ |
Test | No |
Authentication fails with 401 for a missing key, an unrecognised prefix, an
unknown key, a disabled key or an expired one. It fails with 403 when the
merchant is not ACTIVE, or when the key carries an IP allowlist and the caller
is not on it. Client IP is read from x-forwarded-for (first entry), then
x-real-ip, then cf-connecting-ip — if your proxy sets none of these, an
allowlist will reject every call.
Every successful authentication stamps lastUsedAt and lastUsedIp on the key,
which is how you tell a live key from a forgotten one.
{
"valid": true,
"merchant": {
"id": "…",
"name": "Acme Ltd",
"status": "ACTIVE",
"verificationStatus": "VERIFIED"
},
"mode": "LIVE",
"permissions": ["*"],
"keyType": "SECRET"
}Call this first when an integration misbehaves. It separates "the key is wrong" from "the request is wrong" in one round trip.
Create a payment session
It is POST /api/gateway/v1/payment/create, not POST /api/gateway/v1/payment.
The in-app API reference at /gateway/docs prints the shorter form on its
overview panel; the bundled WooCommerce plugin uses the correct one. A POST to
/api/gateway/v1/payment reaches no handler.
Required: amount, currency, returnUrl. Everything else is optional.
| Field | Type | Notes |
|---|---|---|
amount |
number | Minimum 0.01, and also checked against the platform min/max and the merchant's per-transaction limit |
currency |
string | Upper-cased before checking. Must be in both the merchant's list and the platform's wallet map |
walletType |
FIAT · SPOT · ECO |
Defaults to FIAT |
returnUrl |
URL | Where the buyer is sent after paying |
cancelUrl |
URL | Where the buyer is sent if they cancel; falls back to returnUrl |
webhookUrl |
URL | Per payment. There is no merchant-level webhook setting |
merchantOrderId |
string | Your own order reference, echoed on every event |
description |
string | Shown on the checkout page |
lineItems |
array | name, quantity, unitPrice required per item; description and imageUrl optional |
customerEmail · customerName |
string | Overwritten with the real buyer's details on completion |
metadata |
object | Free-form, returned with the payment and on webhooks |
expiresIn |
integer | Seconds, 300–86400. Defaults to gatewayPaymentExpirationMinutes × 60 |
curl -X POST https://exchange.example.com/api/gateway/v1/payment/create \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 49.99,
"currency": "USD",
"walletType": "FIAT",
"merchantOrderId": "ORDER-1042",
"description": "Annual plan",
"returnUrl": "https://shop.example.com/thanks",
"cancelUrl": "https://shop.example.com/cart",
"webhookUrl": "https://shop.example.com/hooks/bicrypto",
"customerEmail": "buyer@example.com"
}'const res = await fetch(
"https://exchange.example.com/api/gateway/v1/payment/create",
{
method: "POST",
headers: {
"X-API-Key": process.env.GATEWAY_SECRET_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 49.99,
currency: "USD",
merchantOrderId: "ORDER-1042",
returnUrl: "https://shop.example.com/thanks",
cancelUrl: "https://shop.example.com/cart",
webhookUrl: "https://shop.example.com/hooks/bicrypto",
}),
}
);
const payment = await res.json();
// store payment.id against your order, then:
redirect(payment.checkoutUrl);<?php
$ch = curl_init('https://exchange.example.com/api/gateway/v1/payment/create');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('GATEWAY_SECRET_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 49.99,
'currency' => 'USD',
'merchantOrderId' => 'ORDER-1042',
'returnUrl' => 'https://shop.example.com/thanks',
'cancelUrl' => 'https://shop.example.com/cart',
'webhookUrl' => 'https://shop.example.com/hooks/bicrypto',
]),
]);
$payment = json_decode(curl_exec($ch), true);
header('Location: ' . $payment['checkoutUrl']);{
"id": "pi_9fKq2mZx4Tn8bR6vLcYs1Dwe",
"status": "PENDING",
"amount": 49.99,
"currency": "USD",
"walletType": "FIAT",
"merchantOrderId": "ORDER-1042",
"description": "Annual plan",
"feeAmount": 1.75,
"netAmount": 48.24,
"checkoutUrl": "https://exchange.example.com/en/gateway/checkout/pi_9fKq…",
"expiresAt": "2026-08-03T14:30:00.000Z",
"createdAt": "2026-08-03T14:00:00.000Z"
}Store id against your order. It is the handle for every later call and it is
the value that arrives on every webhook.
feeAmount and netAmount are the platform's cut and the merchant's take,
calculated at creation from the merchant's own fee configuration. They are
informational at this point — the fee is not charged until the buyer actually
pays.
Redirect, and what comes back
Send the buyer to checkoutUrl. When they finish, the checkout appends two
query parameters to your URL:
https://shop.example.com/thanks?payment_id=pi_9fKq…&status=success
https://shop.example.com/cart?payment_id=pi_9fKq…&status=cancelledThe return URL is a browser navigation. It can be forged, replayed, or simply
never reached because the buyer closed the tab. Treat status=success as a
prompt to check, then confirm with a webhook or a status read before you ship
anything.
Read a payment
:id is the pi_… value. The response repeats everything from creation plus
customerEmail, customerName, metadata, completedAt and the live
status.
Statuses you will see:
| Status | Meaning |
|---|---|
PENDING |
Created, not yet paid. The normal starting state |
PROCESSING |
A confirmation is in flight inside a database transaction |
COMPLETED |
Paid. The merchant's pending balance has gone up |
CANCELLED |
Cancelled by the buyer on the checkout page, or by the merchant through the API |
EXPIRED |
Passed expiresAt without being paid. Set by the expiry job, up to five minutes late |
FAILED |
The confirmation hit a server fault |
REFUNDED · PARTIALLY_REFUNDED |
One or more refunds have been issued |
A payment created with sk_test_ is invisible to sk_live_ and vice versa —
the mismatch returns 404 Payment not found, not a 403. If a merchant swears
a payment exists and the API says it does not, check which key they are using
before you check anything else.
Cancel a payment
Only PENDING and PROCESSING sessions can be cancelled. Anything else is a
400 naming the current status. Cancelling a session the buyer has already
paid is not possible — use a refund.
Refund a payment
curl -X POST https://exchange.example.com/api/gateway/v1/refund \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"paymentId": "pi_9fKq2mZx4Tn8bR6vLcYs1Dwe",
"amount": 20.00,
"reason": "REQUESTED_BY_CUSTOMER",
"description": "Partial refund, one seat removed"
}'paymentIdis required and is thepi_…value.amountis optional; omit it for a full refund. It cannot exceed the amount not already refunded.reasonis one ofREQUESTED_BY_CUSTOMER,DUPLICATE,FRAUDULENT,OTHER. It defaults to the first.
Only COMPLETED and PARTIALLY_REFUNDED payments are refundable. The refund is
processed synchronously and comes back COMPLETED, or the whole thing rolls
back and you get an error — there is no pending state to poll.
What happens to the money: the merchant's gateway pending balance is debited,
the buyer's original wallets are credited in their original currencies in the
same proportions they paid, and the proportional share of your platform fee is
returned from the admin wallet to the buyer. A refund on a payment whose funds
have already been paid out will fail for insufficient balance — that is the
merchant's problem to top up, and yours to explain.
Errors
Every failure is a real HTTP status with a JSON body:
{
"message": "Currency GBP is not supported by this merchant",
"statusCode": 400
}Validation failures add a validationErrors array. Note that the OpenAPI schema
in the source describes a nested { "error": { "code", "message" } } envelope —
the runtime does not use it. Parse message and statusCode.
| Status | Typical cause |
|---|---|
| 400 | Missing field, bad URL, amount outside the platform or merchant limits, currency or wallet type not enabled, payment in a state that forbids the action |
| 401 | No key, malformed prefix, unknown, disabled or expired key |
| 402 | Buyer has insufficient funds — only ever seen on the checkout, never on these endpoints |
| 403 | Public key used where a secret key is required, missing key permission, merchant not ACTIVE, IP not allowlisted |
| 404 | Unknown payment or refund — including the test/live mismatch |
| 503 | The extension is not licensed or not installed on this server |
There is no dedicated rate limiter on the gateway v1 routes; they inherit the platform's global request limits. Do not build a polling loop tighter than a few seconds per payment — use webhooks.
Going live
-
Validate the live key.
GET /v1/validateshould reportmode: "LIVE"andstatus: "ACTIVE". -
Check the currency lists match. The currency you are about to charge in must be on both the merchant's
allowedCurrenciesand the platform's enabled wallet map. -
Confirm the checkout URL is public. Open the
checkoutUrlfrom a live response in a private window. If it points atlocalhost, the operator has not setAPP_PUBLIC_URL— see Install. -
Take one real payment for a small amount, then refund it. That exercises the fee, the merchant balance and the refund path in one go.
Next: Webhooks.