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.

4 min readUpdated 3 August 2026api, rest, api-keys, checkout, refunds

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

The 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/validate

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

Confirms a key works and reports the merchant, mode and permissions it carries
{
  "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

Creates a payment session and returns the URL to redirect the buyer to

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=cancelled

The 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

Returns the current state of a payment session

: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

Cancels an unpaid session and fires payment.cancelled

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

Issues a full or partial refund to the buyer's original wallets
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"
  }'
  • paymentId is required and is the pi_… value.
  • amount is optional; omit it for a full refund. It cannot exceed the amount not already refunded.
  • reason is one of REQUESTED_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.

Reads a refund by its re_ identifier

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

  1. Validate the live key. GET /v1/validate should report mode: "LIVE" and status: "ACTIVE".

  2. Check the currency lists match. The currency you are about to charge in must be on both the merchant's allowedCurrencies and the platform's enabled wallet map.

  3. Confirm the checkout URL is public. Open the checkoutUrl from a live response in a private window. If it points at localhost, the operator has not set APP_PUBLIC_URL — see Install.

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