Test mode · Whitechain testnet · no real money moves
WhiteFlowDocsDashboard
Developers / Docs

Accept USDT in
three steps.

Your customers pay in USDT on a hosted checkout and pay no network fee. Each payment settles straight to your wallet. Everything here runs on Whitechain testnet with test USDT.

USDT per payment
1–1,000
price currencies, locked 10 min
7
network fee for customers at checkout
0
SHA-256 signed webhooks
HMAC
01 / How it works

How a payment works.

The customer needs no wallet app and no gas, and the money never sits with us: it reaches you in the payment's own transaction.

  1. 01 / Customer

    Approves once

    On our hosted checkout they approve the exact amount, once. They pay no network fee and need no coin for gas.

  2. 02 / Gateway

    Sends it on Whitechain

    Our relayer sends the payment to the chain and pays the network fee itself.

  3. 03 / Your wallet

    USDT lands in it

    In the same transaction, the price less a 0.10 USDT relay fee goes to your wallet. No payout to wait for.

  4. 04 / Your server

    Hears it's paid

    We POST a signed payment.succeeded to each of your webhook endpoints. Fulfil the order on it.

  • 1.00 to 1,000.00 USDT per payment, priced in USDT or one of 6 other currencies.
  • Test only: Whitechain testnet and test USDT, so no real money moves.
  • You refund from your own wallet on the Payments screen; once the refund is synced, payment.refunded is sent.
02 / Quickstart

Three steps, then check it's paid.

A server call, a button, a webhook. Use your test keys, and nothing you do here moves real money.

Before you start

  • Your secret keyAn sk_test_ key from the Developers screen, kept on your server.
  • A public https URLFor webhooks. We only call public hosts, so while you build on your laptop, put a tunnel that gives you an https URL in front of it.
  • Test USDT for payersPaying needs test USDT on Whitechain testnet. The team funds the demo's own wallets and there's no public USDT faucet, so ask the team for test USDT during the hackathon.
  1. 01On your server

    Create a checkout

    With your secret key. Save the checkout's id and amount with your order.

    Server SDK
    const { id, url, amount } =
      await gateway.sessions.create({
        amount: "25.00",
        orderId: order.id,
      });
    
    Or with curl
    # Create a checkout
    curl "$GATEWAY_URL/api/v1/sessions" \
      -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-42" \
      -d '{
        "amount": "20.00",
        "currency": "EUR",
        "reference": "order-42",
        "returnUrl": "https://shop.example/"
      }'
    
    # Read it back with the id it returned:
    # paid when "status" is "paid"
    curl "$GATEWAY_URL/api/v1/sessions/$CHECKOUT_ID" \
      -H "Authorization: Bearer $GATEWAY_SECRET_KEY"
    
  2. 02In the browser

    Send the customer to pay

    Redirect to the checkout's url, or drop in the pay button. They pay in USDT and pay no network fee.

    Pay button
    <script src="…/v1/button.js"
      data-session="SESSION_ID"></script>
    
  3. 03On your server

    Mark the order paid

    We POST payment.succeeded, signed with MC-Signature. Verify it, check the amount, then fulfil.

    Webhooks
    const event = constructEvent(
      rawBody, signature, secret);
    
  4. 04In the browser

    Check it's paid

    Pay your checkout with test USDT, then look for it on the Payments screen, and for its payment.succeeded delivery, answered 2xx, under your endpoint on the Developers screen.

    Test mode
    1. aSimulate a payment: a test checkout for the demo shop, to see one first
    2. bPayments: the payment, its amount and its receipt
    3. cDevelopers: the payment.succeeded delivery and its status
03 / Pay button

One script tag, one button.

The button opens the hosted checkout. The amount comes from the checkout your server made, never from the page, and the button needs no key.

your-page.html
<!-- SESSION_ID: from your server -->
<script src="GATEWAY_URL/v1/button.js"
  data-session="SESSION_ID"
  data-label="Pay 25.00 USDT"></script>
Live preview · this page, strict CSP

The real button appears here.

This makes a real test checkout for the demo shop (25.00 USDT, test money), the way your server would, then loads /v1/button.js with its id.

Good to know
  • Ids are 1 to 64 letters, digits, - and _. Anything else renders nothing and logs a console error.
  • Put the tag in the body, where the button should appear. Use one gateway per page: the first copy of the script handles every button.
  • The button styles itself inline, so a strict style-src CSP keeps it. To change its look, use your own element instead.
  • A click handler of yours that calls event.preventDefault() stops the redirect.
  • Strip data-mc-* attributes from anything your users can post, or someone could add a button for their own checkout.
Pay button attributes
AttributeWhat it does
data-sessionA checkout id from your server. A press opens /checkout/{id} on the gateway that served the script.
data-linkA payment link's slug instead. A press opens /l/{slug}.
data-labelThe button's text. Default Pay with USDT.
data-mc-session, data-mc-linkThe same, on an element of your own. Load the script once without data-session; one click listener handles every marked element, even ones rendered later.
your own element
<!-- once, without data-session -->
<script src="GATEWAY_URL/v1/button.js">
</script>

<!-- then mark any element, even later -->
<button data-mc-session="SESSION_ID">
  Pay now
</button>
<button data-mc-link="LINK_SLUG">
  Pay by link
</button>
04 / Server SDK

Create, read and list, from your server.

@merchant-capital/sdk is TypeScript with no dependencies. It holds your secret key, so it refuses to run in a browser, refuses a pk_ key and talks only to an https gateway.

Your server's environment

Your test gateway URL is under Test mode below. Make a secret key on the Developers screen, and copy the webhook secret when you add your endpoint there. Both are shown once.

.env (server only)
GATEWAY_URL=https://your-gateway.example
GATEWAY_SECRET_KEY=sk_test_…
GATEWAY_WEBHOOK_SECRET=whsec_…

Create a checkout

sessions.create
import { Gateway } from "@merchant-capital/sdk";

const gateway = new Gateway(
  process.env.GATEWAY_SECRET_KEY!,
  { baseUrl: process.env.GATEWAY_URL! },
);

// Price in USDT (the default), USD, EUR,
// GBP, INR, AED or SGD. The customer pays
// USDT at a rate locked for the checkout.
const session = await gateway.sessions.create(
  {
    amount: "20.00",
    currency: "EUR",
    orderId: "order-42", // → reference
    returnUrl: "https://shop.example/",
  },
  // a retry gets the same checkout back
  { idempotencyKey: "order-42" },
);

// Save session.id and session.amount (the
// USDT due) with your order; webhooks
// name the checkout by its id. Then send
// the customer to session.url, or give
// session.id to the pay button.

Read it back

Polling works too: an order is paid when the status is paid and the amount is yours.

sessions.retrieve
import { sameAmount } from "@merchant-capital/sdk";

const checkout = await gateway.sessions
  .retrieve(order.sessionId);
// status: "open", "pending" (confirming),
// "paid" or "expired"
const paid = checkout.amount;
if (
  checkout.status === "paid" &&
  sameAmount(paid, order.amountUsdt)
) {
  await markPaid(order.id);
}

List payments

Every payment to your wallet, newest first, with how much of it was refunded.

payments.list
const page = await gateway.payments.list({
  limit: 20,
});
for (const payment of page.data) {
  // paymentId, sessionId, amount,
  // refunded, txHash, paidAt, reference
  await reconcile(payment);
}
// next page: list({ cursor: page.next })

Or with curl

The same create and read with no SDK. Send reference where the SDK says orderId, and keep the Idempotency-Key when you retry.

curl
# Create a checkout
curl "$GATEWAY_URL/api/v1/sessions" \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42" \
  -d '{
    "amount": "20.00",
    "currency": "EUR",
    "reference": "order-42",
    "returnUrl": "https://shop.example/"
  }'

# Read it back with the id it returned:
# paid when "status" is "paid"
curl "$GATEWAY_URL/api/v1/sessions/$CHECKOUT_ID" \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY"

Options and errors

Gateway options
OptionDefaultWhat it does
baseUrlRequiredThe gateway's https origin (http only for localhost). No user:pass, ?query or #hash.
timeoutMs10000Per request.
maxRetries2Network errors and 5xx answers are retried, with the same idempotencyKey. A 4xx never is.
fetchglobal fetchYour own fetch, for tests or a proxy.

A failed call throws GatewayError with the gateway's status and message (see Errors). Redirects are refused, so use the gateway's final URL as baseUrl. If you price in a currency and the checkout comes back without a quote in it, sessions.create throws rather than charge 20 EUR as 20 USDT.

05 / Webhooks

Know it's paid, signed.

When a checkout is paid, from your server's checkout, a payment link or a page, we POST payment.succeeded to each of your endpoints.

Add an endpoint

On the Developers screen, or POST /api/v1/webhooks with your secret key. An https URL on a public host, up to 5 per merchant. The whsec_ secret is shown once.

Answer fast

Any 2xx within 5 seconds counts as delivered. We don't follow redirects, and we never read your answer, only its status.

Expect repeats

Retries and Resend send the same body with the same event id. Skip an id you have already handled.

Events

Webhook events
EventWhenExtra data fields
payment.succeededA checkout is paid: one your server made, or one a payer opened from a payment link or page. Once per checkout.None beyond the fields below
payment.refundedA refund is synced: after you refund on chain, `POST /api/v1/payments/{id}/refunds/sync` names the transaction and we check MerchantPay's own Refunded log. Once per refunded total.refunded (the total so far) and refund_amount (this refund)

The request

headers
POST /your/webhook/path
Content-Type: application/json
MC-Signature: t=1793923200,v1=<64 hex>
payment.succeeded
{
  "id": "evt_Qm3xT8kLp2RvA9sZ",
  "type": "payment.succeeded",
  "created": 1793923200,
  "data": {
    "id": "9b2f6c1e-4d7a-4f3b-8e21-5a0c…",
    "status": "paid",
    "amount": "22.51",
    "currency": "USDT",
    "reference": "order-42",
    "paymentId": "42",
    "txHash": "0x7d1e…c03a",
    "link_id": null,
    "page_id": null
  }
}

What's in data

payment.succeeded data fields
FieldMeaning
idThe checkout's id: the one you saved when you created it.
status"paid".
amountThe USDT the customer paid, after any voucher.
currency"USDT".
referenceYour orderId, or the link's reference_id or slug, or the page's slug.
paymentId, txHashThe payment on chain.
link_id, page_idSet when the checkout came from a payment link or page; null otherwise.

created is unix seconds. Amounts may come formatted differently from what you stored, so compare them with sameAmount, not ===.

Verify, then fulfil

The MC-Signature header is t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 of <t>.<raw body> with your endpoint's secret. constructEvent compares it in constant time and refuses an event more than 5 minutes old or ahead (toleranceSec, default 300).

app/api/webhooks/route.ts
import {
  constructEvent,
  sameAmount,
  SIGNATURE_HEADER,
  WebhookSignatureError,
} from "@merchant-capital/sdk";

const ok = () => new Response("ok");

// A Next.js route handler. Pass the raw
// body: re-serialized JSON won't verify.
export async function POST(req: Request) {
  let event;
  try {
    event = constructEvent(
      await req.text(),
      req.headers.get(SIGNATURE_HEADER),
      process.env.GATEWAY_WEBHOOK_SECRET!,
    );
  } catch (err) {
    if (err instanceof WebhookSignatureError) {
      return new Response("bad signature", {
        status: 400,
      });
    }
    throw err; // no secret set: a 500
  }
  // a test event, or one you already
  // handled: do nothing
  if ("test" in event.data) return ok();
  if (await seen(event.id)) return ok();

  if (event.type === "payment.succeeded") {
    // find the order by the checkout id
    // you saved, never by reference
    // alone, and check it was paid in full
    const order = await orderByCheckout(
      event.data.id,
    );
    const paid = event.data.amount;
    if (
      order &&
      sameAmount(paid, order.amountUsdt)
    ) {
      // one transaction: mark it paid and
      // remember event.id
      await markPaid(order.id, event.id);
    }
  }
  return ok();
}
Retries
  1. At onceAttempt 1
  2. 1 min laterAttempt 2
  3. 5 min laterAttempt 3
  4. 30 min laterAttempt 4, the last

A delivery that never gets a 2xx is retried 3 times, then marked failed. Each attempt is signed again with a fresh t. Resend any delivery from the Developers screen.

Test events

“Send test event” on the Developers screen sends a signed payment.succeeded whose data is only {"test": true}. Your handler should answer 2xx and do nothing.

test event
{
  "id": "evt_…",
  "type": "payment.succeeded",
  "created": 1793923200,
  "data": { "test": true }
}
06 / API reference

API reference

Paths are on your gateway URL, and bodies are JSON. Your server needs the first group; the other two are here for completeness. Checkout, payment and webhook amounts come back with at least two decimals ("25.00"); the dashboard resources use snake_case and may drop trailing zeros ("25"). Times are ISO 8601.

  • Secret key

    Authorization: Bearer sk_test_…, from your server only. A publishable key (pk_) gets 403.

  • Dashboard sign-in

    The session cookie from signing in with your wallet at /merchant. The dashboard screens call these.

  • Sign-in or secret key

    Either one, so a setup script on your server can add its own endpoint.

  • Public

    No key. Your customers' browsers and our hosted pages call these.

Your server (secret key)

The calls a shop's server makes to take a payment. Start here.

Checkouts

Your server creates a checkout and sends the customer to its url. The SDK's sessions.create and sessions.retrieve.

POST
/api/v1/sessions
Secret key

Returns 201 {id, url, amount, currency, priced?, expiresAt}. amount is the USDT the customer pays; priced is the locked quote, there unless you price in USDT.

POST /api/v1/sessions parameters
NameTypeRequiredDescription
amountBody · string · RequiredstringRequiredYour price as a decimal string, like "25.00" (up to 6 decimals), in currency. 1.00 to 1,000.00 USDT once converted.
currencyBody · string · OptionalstringOptionalUSDT (the default), USD, EUR, GBP, INR, AED or SGD. Any but USDT is converted at a quote locked for the checkout.
referenceBody · string · OptionalstringOptionalYour order id, up to 40 characters, trimmed. It comes back on the checkout, the payment and the webhook. The SDK's orderId.
itemsBody · array · OptionalarrayOptionalUp to 20 {name, qty, price}: qty 1 to 999, price a string in currency. They must add up to amount.
returnUrlBody · string · OptionalstringOptionalWhere the checkout's back link goes: http or https, up to 200 characters. A hint for the browser, never proof of payment.
Idempotency-KeyHeader · string · OptionalstringOptional1 to 255 characters. The same key and body within 24 hours gets the first checkout back; another body gets 422.
Example request · curl
curl "$GATEWAY_URL/api/v1/sessions" \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42" \
  -d '{
    "amount": "20.00",
    "currency": "EUR",
    "reference": "order-42",
    "returnUrl": "https://shop.example/"
  }'
Example response · 201
{
  "id": "9b2f6c1e-4d7a-4f3b-8e21-5a0c3d9e7b14",
  "url": "https://your-gateway.example/checkout/9b2f6c1e-4d7a-4f3b-8e21-5a0c3d9e7b14",
  "amount": "22.51",
  "currency": "EUR",
  "priced": {
    "currency": "EUR",
    "amount": "20.00",
    "rate": "0.88875",
    "rateAt": "2026-11-06",
    "source": "Frankfurter (ECB and central-bank reference rates)"
  },
  "expiresAt": "2026-11-06T10:40:00.000Z"
}
GET
/api/v1/sessions/{id}
Secret key

Returns {id, status, amount, currency, priced?, reference, paymentId, txHash, refunded?}. status is open, pending, paid or expired; refunded is left out when we can't read it right now.

GET /api/v1/sessions/{id} parameters
NameTypeRequiredDescription
idPath · string · RequiredstringRequiredThe checkout's id from create. A checkout another key made is a 404.
Example request · curl
curl "$GATEWAY_URL/api/v1/sessions/$CHECKOUT_ID" \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY"
Example response · 200
{
  "id": "9b2f6c1e-4d7a-4f3b-8e21-5a0c3d9e7b14",
  "status": "paid",
  "amount": "22.51",
  "currency": "EUR",
  "priced": {
    "currency": "EUR",
    "amount": "20.00",
    "rate": "0.88875",
    "rateAt": "2026-11-06",
    "source": "Frankfurter (ECB and central-bank reference rates)"
  },
  "reference": "order-42",
  "paymentId": "42",
  "txHash": "0x7d1e4b2a9c0f3e8d6b5a4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5dc03a",
  "refunded": "0.00"
}
How a checkout's status moves
  1. openWaiting for the customer to pay.
  2. pendingA payment is on its way. Don't make a new checkout: if it fails, this one is open again.
  3. paidThe payment is confirmed. Check the amount, then fulfil the order.
  4. expired (from open)Nothing was paid in time: 30 minutes, or 10 when priced in another currency. Make a new checkout.

open → pending → paid, or open → expired when nobody pays.

Payments

Every payment to your wallet, newest first, with what was refunded. The SDK's payments.list.

GET
/api/v1/payments
Secret key

Returns {data: [{paymentId, sessionId, invoiceId, amount, refunded, txHash, paidAt, reference}], next}, newest first. Pass next as cursor; it is null on the last page.

GET /api/v1/payments parameters
NameTypeRequiredDescription
limitQuery · integer · OptionalintegerOptionalHow many payments, 1 to 100. Default 20.
cursorQuery · string · OptionalstringOptionalThe next of the page before. Leave it out for the newest.
Example request · curl
curl "$GATEWAY_URL/api/v1/payments?limit=20" \
  -H "Authorization: Bearer $GATEWAY_SECRET_KEY"
Example response · 200
{
  "data": [
    {
      "paymentId": "42",
      "sessionId": "9b2f6c1e-4d7a-4f3b-8e21-5a0c3d9e7b14",
      "invoiceId": null,
      "amount": "22.51",
      "refunded": "0.00",
      "txHash": "0x7d1e4b2a9c0f3e8d6b5a4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5dc03a",
      "paidAt": "2026-11-06T10:32:05.000Z",
      "reference": "order-42"
    }
  ],
  "next": null
}

Dashboard

20 endpointsWhat the dashboard screens call with your sign-in. Webhook endpoints take your secret key too.

Webhooks

Where we send payment.succeeded. See Webhooks above for the event and its signature.

Webhooks endpoints
EndpointAuthBodyReply
POST/api/v1/webhooksSign-in or secret keyurl (https, a public host; up to 5 endpoints)201 {id, url, secret, created_at}. The secret is shown here only.
GET/api/v1/webhooksSign-in or secret keyNone{webhooks: [{id, url, created_at, deliveries}]}, each with its last 20 deliveries. Never the secret.
DELETE/api/v1/webhooks/{id}Dashboard sign-inNone{id, deleted: true}
POST/api/v1/webhooks/{id}/testDashboard sign-inNoneThe delivery {id, event, status, attempts, next_attempt_at, response_status, at}
POST/api/v1/webhooks/deliveries/{id}/replayDashboard sign-inNoneThe delivery, sent again now with the same event id

Payment pages

A page with your items, or an open amount. Prices always come from the stored page.

Payment pages endpoints
EndpointAuthBodyReply
POST/api/v1/pagesDashboard sign-intitle, currency, items [{name, price}] (1 to 20) or open_amount {min?, max?}, description?, logo_url? (https), color? (#RRGGBB)201 {id, slug, url, title, description, logo_url, color, currency, items, open_amount, created_at, payments}
GET/api/v1/pagesDashboard sign-inNone{pages: [page]}, the newest 200

Vouchers

Discount codes for link and page checkouts.

Vouchers endpoints
EndpointAuthBodyReply
POST/api/v1/vouchersDashboard sign-incode (3 to 32 letters, digits, - or _), kind (percent or fixed), value ("10" or "5.00"), expires_at, max_redemptions, min_amount?201 {id, code, kind, value, expires_at, max_redemptions, times_redeemed, min_amount, active, arkiv_entity_key, created_at}
GET/api/v1/vouchersDashboard sign-inNone{vouchers: [voucher]}, newest first
POST/api/v1/vouchers/{id}/deactivateDashboard sign-inNone{id, active: false}. The code stops working at once.

API keys

Test keys only. We store a hash, so a key is shown once.

API keys endpoints
EndpointAuthBodyReply
POST/api/v1/keysDashboard sign-inlabel (up to 60 characters), kind ("sk", the default, or "pk")201 {id, label, kind, prefix, created_at, last_used_at, key}. The key is shown here only.
GET/api/v1/keysDashboard sign-inNone{keys: [{id, label, kind, prefix, created_at, last_used_at}]}, live keys only
DELETE/api/v1/keys/{id}Dashboard sign-inNone{id, revoked: true}. Calls with it stop working at once.

Reports and settlements

Read from your payments on chain. Dates are YYYY-MM-DD and inclusive.

Reports and settlements endpoints
EndpointAuthBodyReply
GET/api/v1/reportsDashboard sign-inQuery from, to (default the last 7 days, at most 92), tz? (default UTC){from, to, tz, days: [{date, gross, fees, net, refunds, count}], totals, kpis: {new_payers, returning_payers}}
GET/api/v1/reports.csvDashboard sign-inQuery as for reportsA CSV file: payment_id, created_utc, currency, gross, fee, to_investors, net, refunded, payer, tx_hash
GET/api/v1/settlementsDashboard sign-inQuery as for reports{balance, settlements: [{payment_id, gross, fee, to_investors, net, tx_hash, settled_at, explorer_url}]}, newest first
POST/api/v1/payments/{id}/refunds/syncDashboard sign-in{tx_hash} of your refund transaction; {id} is MerchantPay's payment number{payment_id, refunds: [{amount, total_refunded}], tx_hash}, and payment.refunded is sent once per refunded total. 400 if the transaction holds no refund of this payment, 502 if its receipt isn't readable yet (try again)

Hosted pages

9 endpointsPublic calls our checkout, link and page make for your payers. You don't need them to take a payment.

Link and page checkouts

Our hosted link and page call these when a payer opens them. The price comes from what you stored.

Link and page checkouts endpoints
EndpointAuthBodyReply
GET/api/v1/public/links/{slug}PublicNone{slug, merchant_name, amount, currency, description, status, paid_at}
POST/api/v1/public/links/{slug}/checkoutPublicamount, for a link without one. JSON or a form.303 to the checkout, or {checkout_url, session_id} with Accept: application/json. 410 once the link is paid, expired or cancelled.
GET/api/v1/public/pages/{slug}PublicNoneThe page's title, description, logo_url, color, currency, items, open_amount and merchant_name
POST/api/v1/public/pages/{slug}/checkoutPublicitems [{name, qty}] (qty 1 to 100), or amount for an open-amount page303 to the checkout, or {checkout_url, session_id}, as for links

Quotes and the hosted checkout

Our checkout page calls these. You don't need them to take a payment.

Quotes and the hosted checkout endpoints
EndpointAuthBodyReply
POST/api/v1/quotesPublicamount, currency201 {quote_id, currency, amount, usdt_amount, rate, rate_at, source, fetched_at, expires_at}, locked for 10 minutes
POST/api/v1/sessions/{id}/payPublicThe customer's signed approval {from, salt, signature, validBefore}, and amount, the USDT units it signed (optional; a string of digits, else 400){receiptId, txHash} once the chain shows the payment. On a payment link or page checkout, 409 {error, amountDue, discount, amountUnits} if amount isn't the total due now (a shop's own checkout ignores amount)
GET/api/v1/sessions/{id}/voucherPublicNone{amountDue, discount, amountUnits}
POST/api/v1/sessions/{id}/voucherPubliccode{amountDue, discount, amountUnits}. Link and page checkouts only.
DELETE/api/v1/sessions/{id}/voucherPublicNone{amountDue, discount, amountUnits} without the code
07 / Currencies and FX

Price in your currency, get USDT.

Set a price in any of these and the customer pays USDT at a rate locked for the checkout. Rates come from Frankfurter (ECB and central-bank reference rates).

Price currencies
CurrencyNamePricedRates we accept, per 1 USD
USDTTether USDCharged as isNone: USDT is what the customer pays
USDUS dollar1:1 with USDT1
EUREuroLive rate0.7555 to 1.0221
GBPPound sterlingLive rate0.6309 to 0.8536
INRIndian rupeeLive rate80.33 to 108.68
AEDUAE dirhamLive rate3.635775 to 3.709225
SGDSingapore dollarLive rate1.0858 to 1.469
  • 10 min

    A quote is locked for 10 minutes, and a checkout priced in EUR, GBP, INR, AED or SGD ends with it.

  • ±15%

    A rate outside its band (AED: its 3.6725 peg ±1%) or older than 7 days is refused: 503, nothing is created. Rates are cached for 15 minutes.

  • ↑ 0.01

    USDT is rounded up to the cent, so you never get less than your price. 20.00 EUR at 0.88875 per USD is 22.51 USDT.

  • 1 : 1

    USD is USDT at 1:1. Price in USD or USDT and the checkout keeps its 30 minutes, with no rate to fetch.

08 / Limits

Limits

Testnet limits, the same for every merchant.

Limits
WhatLimit
Per payment1.00 to 1,000.00 USDT, after conversion. Checkouts, links and pages alike.
AmountsDecimal strings with up to 6 decimals, like "25.00". Never floats.
Checkout lifetime30 minutes in USDT or USD. 10 minutes in any other currency: the checkout ends with its quote.
referenceUp to 40 characters, trimmed. The SDK's orderId: 1 to 40 characters, no spaces at either end.
returnUrlAn http or https URL of up to 200 characters, stored as the browser will read it.
itemsUp to 20 per checkout, qty 1 to 999, prices in the checkout's currency that add up to amount.
Idempotency-Key1 to 255 characters. The same key with the same body within 24 hours gets the first checkout back; another body gets 422. Only a created checkout is replayed, so a refused one frees the key.
payments.listlimit 1 to 100, default 20.
Keys and endpoints20 live API keys and 5 webhook endpoints per merchant.
Dashboard listsLinks and pages list the newest 200. A report covers up to 92 days.

Rate limits

Over a limit you get 429 with a plain message; wait a minute and try again. On this test server each limit is counted per server instance.

Rate limits
EndpointLimitCounted per
POST/api/v1/sessions60 a minutemerchant
POST/api/v1/quotes30 a minuteIP address
POST/api/v1/public/links/{slug}/checkoutPOST/api/v1/public/pages/{slug}/checkout20 a minuteIP address, links and pages together
POST/api/v1/sessions/{id}/voucherDELETE/api/v1/sessions/{id}/voucher10 a minuteIP address and checkout
POST/api/v1/vouchers30 an hourmerchant
POST/api/v1/webhooks/{id}/testPOST/api/v1/webhooks/deliveries/{id}/replay20 a minutemerchant, test and replay together
09 / Errors

Errors

Status codes you can branch on, and messages you can show.

Every error is JSON with one plain sentence in error, written so you can show it as is. The SDK throws a GatewayError with the same message and the HTTP status (0 when the gateway couldn't be reached), with your key replaced by [secret key].

400 · application/json
{ "error": "amount must look like 25.00 (up to 6 decimals)." }
Error status codes
StatusMeansWhat we sayWhat to do
400The request is missing something or a field is wrong.
  • “Send JSON like {"amount": "25.00"}.”
  • “amount must look like 25.00 (up to 6 decimals).”
Fix the field the message names, then send it again. The same request will fail the same way.
401No key, a wrong or revoked key, or not signed in.
  • “Missing or wrong API key.”
  • “Sign in to your merchant dashboard first.”
Check GATEWAY_SECRET_KEY on your server and that the key isn't revoked. On a dashboard call, sign in again.
403A publishable key on a secret-key route.
  • “Use a secret key (sk_…) on your server.”
Use an sk_test_ key from your server. pk_ keys are for the browser.
404Not found, or not yours: another merchant's id is a 404 too.
  • “No such checkout.”
  • “No such payment link.”
Check the id, and that the same merchant's key made it.
409It clashes with what is already there.
  • “You already have a link with that reference_id.”
  • “This checkout is already being paid.”
Don't create it again: read what's there, or pick another reference_id. A checkout being paid settles on its own.
410Gone: the checkout expired or is paid, or the link is paid, expired or cancelled.
  • “This checkout has expired. Ask the shop for a new one.”
Make a new checkout or link for the customer.
413The body is too large.
  • “That request is too large.”
Send less: fewer items or shorter text.
422An Idempotency-Key reused with a different body.
  • “This Idempotency-Key was already used for a different request.”
Use a new key for a new request. To retry, send the first body exactly as it was.
429Too many requests. Wait a minute.
  • “Too many checkouts at once. Wait a minute and try again.”
  • “Too many requests. Wait a minute and try again.”
Wait a minute, then retry. Slow down any loop that creates checkouts.
500Our mistake. The message never names internals.
  • “Something went wrong on our side. Try again.”
Retry with the same Idempotency-Key; the SDK does it for you.
502The chain or its index didn't answer us.
  • “The payment history isn't available right now. Try again in a minute.”
Try again in a minute. The SDK retries it twice first.
503Not available right now, or not set up on this server. Nothing was created.
  • “Exchange rates are unavailable right now. Price in USD or USDT, or try again in a minute.”
  • “We can't check API keys right now. Try again in a minute.”
Price in USD or USDT, or try again in a minute.
10 / Test mode

Test mode, all the way.

Everything runs on Whitechain testnet with test USDT. Keys are test keys (pk_test_, sk_test_), and no real money moves.

  • Chain

    Whitechain testnet (chain id 1874). Payments, refunds and reports are read from it.

  • Money

    Test USDT (“Tether USD”, 6 decimals) at 0xcedfB56113143d55b2358b8CFbB0dEB5Ad2f55a7.

  • Fees

    Customers pay no network fee: they sign once, and the gateway sends the payment on chain for them.

Test keys

Checking who is signed in…

Simulate a payment

Opens a test checkout for the demo shop (25.00 USDT, test money), the same way your server would.

Test scenarios

How to make each outcome happen before a real customer does, and what comes back. The codes and messages are the gateway's own.

Test scenarios
ScenarioHow to trigger itWhat you get
PaidPay a test checkout with test USDT, or press Simulate a payment.status reads paid, and each of your endpoints gets payment.succeeded.
ExpiredLeave a checkout unpaid for 30 minutes (10 when priced in EUR, GBP, INR, AED or SGD).410“This checkout has expired. Ask the shop for a new one.”status reads expired, and paying it is refused.
Paid twicePay the same checkout again from a second tab, during the first payment or after it.409“This checkout is already being paid.”“Already paid.”Refused, and the customer is charged once. After the first lands, the reply carries its receipt.
Link already paidOpen a single-use payment link again once it's paid.410“This payment link is paid.”Refused. Make the customer a new link.
Idempotency-Key reusedCreate a checkout, then send the same Idempotency-Key with a different body.422“This Idempotency-Key was already used for a different request.”Refused, and nothing is created.
Your endpoint is downPoint a webhook at a URL that answers 500, or doesn't answer, then pay.We try again after 1 minute, then 5, then 30 more, then mark it failed. Resend it from the Developers screen.
Bad signaturePut a wrong GATEWAY_WEBHOOK_SECRET on your server, then press Send test event.400constructEvent throws WebhookSignatureError and the webhook handler in Webhooks answers 400. We count it as failed and retry.
11 / Before you go live

Before you go live

Live mode isn't open yet, so everything here is test mode. Build for it now: each line keeps a shop from shipping an order it wasn't paid for.

  1. 01Mark an order paid only from payment.succeeded, or from sessions.retrieve saying paid. The browser coming back to your returnUrl is a hint, not proof.
  2. 02Find the order by the checkout id you saved, and check the amount with sameAmount. Never credit a session id the browser hands you.
  3. 03Verify the raw request body, before any JSON parsing.
  4. 04Skip an event id you have already handled, and record it in the same transaction that marks the order paid.
  5. 05Ignore test events: their data is {"test": true}.
  6. 06Keep sk_test_ keys on your server. Revoke a leaked key on the Developers screen; it stops working at once.
  7. 07Send amounts as strings like "25.00", never floats, and send an Idempotency-Key when you retry a create.
  8. 08Answer webhooks with a 2xx within 5 seconds, and do slow work after.
  9. 09Strip data-mc-* attributes from anything your users can post.