Capay Global

Crypto payments

Accept USDT, USDC and other coins with one call to POST /v1/collections. Crypto is live on every approved business: there is nothing to switch on. This page is the whole integration, including the request shape the API insists on, what to show the payer, the amount rule and refunds.

The five rules that make it work

  • currency is always "USD". Prices are in US dollars; the payer sends the same amount in USDT or USDC (other coins are priced at a locked rate).
  • country is omitted, or "ZZ". Crypto is international: a local country code such as "CM" is refused.
  • method is "crypto", and crypto is an object { "asset", "network" }, for example { "asset": "USDT", "network": "TRC20" }.
  • No msisdn, no bank fields. The response gives you a deposit address instead.
  • Show the payer crypto_deposit (address, exact amount, network) or send them to crypto_deposit.payment_page_url, then wait for the webhook. Never mark an order paid from your own page.

Create the payment

Use your live secret key (sk_live_) for real payments and an Idempotency-Key per attempt, exactly as for other collections. The response is 201 with status "pending", auth_model "NONE" and the crypto_deposit the payer must fund.

request
curl -X POST https://api.capayglobal.com/v1/collections \
  -H "Authorization: Bearer sk_live_XXXXXXXXXXXX" \
  -H "Idempotency-Key: 7f3e0c1a-2b4d-4e6f-8a9b-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "15.00",
    "currency": "USD",
    "method": "crypto",
    "crypto": { "asset": "USDT", "network": "TRC20" },
    "merchant_reference": "order-1042",
    "customer": { "name": "Ada Obi", "email": "[email protected]" }
  }'
201 response
{
  "status": "success",
  "data": {
    "id": "col_9xK2mQ7LpZ4a",
    "object": "collection",
    "status": "pending",
    "amount": "15.00",
    "currency": "USD",
    "country": "ZZ",
    "method": "crypto",
    "auth_model": "NONE",
    "crypto_deposit": {
      "asset": "USDT",
      "asset_kind": "stable",
      "network": "TRC20",
      "network_name": "Tron (TRC20)",
      "address": "TKxVpxiVrT3i7KUvKxbnVbNass78GyUfbz",
      "memo": "",
      "amount": "15.00",
      "usd_amount": "15.00",
      "currency": "USD",
      "rate": null,
      "rate_expires_at": null,
      "expires_at": "2026-10-27T10:00:00.000Z",
      "sandbox": false,
      "payment_page_url": "https://pay.capayglobal.com/c/col_9xK2mQ7LpZ4a"
    },
    "crypto_amounts": {
      "requested": "15.00",
      "received": null,
      "difference": null,
      "outcome": "awaiting_payment",
      "refund": null
    },
    "customer_instruction": { "code": "send_crypto", "action_required": true, "...": "..." }
  },
  "request_id": "b6c1..."
}

Requests that are refused

Almost every failed crypto integration sends the same body as for mobile money. The API answers 422 validation_error and names the field. If you see method_not_available, check that method is exactly "crypto", currency is "USD" and country is omitted or "ZZ"; if it persists, send us the request_id.

422 validation_error
// Refused with 422 validation_error, and the field that is wrong:
{ "currency": "XAF", ... }              // crypto collections are priced in USD
{ "country": "CM", ... }               // crypto collections are international: omit country or send ZZ
{ "method": "crypto" }                 // crypto: pick the coin and network to pay with
{ "msisdn": "+2376..." }               // not used for crypto, leave it out

Coins and networks

Stablecoins are 1 to 1 with the USD price. Other coins are quoted at a price locked for 30 minutes, with a small buffer. GET /v1/crypto/networks returns the live list with each network's minimum and block explorer; read it rather than hard-coding.

  • USDT: TRC20, BEP20, ERC20, SOL, POLYGON, ARBITRUM, AVAXC.
  • USDC: BEP20, ERC20, SOL, POLYGON, ARBITRUM, AVAXC, BASE.
  • Other coins (price locked 30 minutes): BTC, ETH, BNB, SOL, TRX, LTC, BCH, DOGE, ADA, POL, XAUT, SHIB, PEPE, EIGEN, on the networks the list shows.
GET /v1/crypto/networks
curl https://api.capayglobal.com/v1/crypto/networks \
  -H "Authorization: Bearer sk_live_XXXXXXXXXXXX"

What to show the payer

The simplest path is to redirect to crypto_deposit.payment_page_url: our page shows the QR code, the exact amount, the network warning in the payer's language and confirms on its own. If you build your own page, show these fields and nothing less. A stablecoin address is offered for one hour; a non-stablecoin price is locked for 30 minutes.

FieldWhat it means
addressWhere to send. One address per payment; never reuse it for another order.
amountThe exact amount to send, in the coin (asset). Tell the payer to send exactly this.
network / network_nameThe only network that works. The same coin on another network cannot be received.
memoRequired when not empty (some networks need a tag).
expires_atUntil when the address is offered. A late payment is held for review, not lost.
rate / rate_expires_atNon-stablecoins: the locked USD price and when it ends. Null for USDT and USDC.
payment_page_urlOur hosted page for this payment.

Exact amount: the rule

Always tell the payer to send exactly the amount shown. Exchanges (Binance and others) take their withdrawal fee out of the amount sent, so the payer must add that fee on top, or less arrives.

More than asked completes the payment for the amount that arrived (its amount then becomes the received amount). Less than asked is never completed: the payment is held (status in_reconciliation, reason_code amount_mismatch) and the only way out is a refund of what arrived. Every crypto collection carries crypto_amounts so you can see asked against received.

amount rule
asked 15.00, received 15.00  ->  completed               outcome "exact"
asked 15.00, received 16.00  ->  completed for 16.00     outcome "overpaid"  (amount becomes 16.00)
asked 15.00, received 14.50  ->  in_reconciliation       outcome "underpaid" (never completed; refund it)
nothing received in time     ->  expired
FieldWhat it means
crypto_amounts.requestedWhat the payer was asked to pay (USD).
crypto_amounts.receivedWhat actually arrived, from the network record. Null until a deposit arrives.
crypto_amounts.differencereceived minus requested, signed ("-0.50" when short).
crypto_amounts.outcomeawaiting_payment, exact, overpaid or underpaid.
crypto_amounts.refundThe refund once one is made: payout_id, amount, asset, network, address, created_at.

Statuses and webhooks

Fulfil the order on the collection.completed webhook (or a GET /v1/collections/{id} that says completed), never on the payer saying they paid. Network confirmations take from seconds to several minutes depending on the chain.

  • pending: address issued, nothing received yet.
  • processing: a deposit is seen on the network and is waiting for confirmations.
  • completed: confirmed, exact or more. Ship the order.
  • in_reconciliation (amount_mismatch): less than asked arrived, or the wrong coin or network. Not paid; refund the payer.
  • expired: nothing arrived in the window. A payment that arrives after it is held for review and can be refunded.
  • cancelled: a held short payment that has been refunded.

Refunds to the customer's wallet

POST /v1/refunds with the collection id and crypto { asset, network, address }: the customer's OWN receiving wallet, never the payment address they paid to (that is refused). Omit amount: a crypto refund returns the full amount paid. For a completed payment it comes out of your available balance; for a held short payment it is the amount that arrived, you pay nothing and the payment is cancelled.

The send goes through our crypto payout rail; the network fee comes out of the amount sent. Track it with payout_id and crypto_amounts.refund. Refunds above your daily automatic limit wait for one approval by Capay Global. You can also refund from the dashboard: open the payment and choose Refund to customer.

request
curl -X POST https://api.capayglobal.com/v1/refunds \
  -H "Authorization: Bearer sk_live_XXXXXXXXXXXX" \
  -H "Idempotency-Key: 0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a" \
  -H "Content-Type: application/json" \
  -d '{
    "collection_id": "col_9xK2mQ7LpZ4a",
    "reason": "customer asked",
    "crypto": { "asset": "USDT", "network": "BEP20", "address": "0x<the customer's own wallet>" }
  }'
201 response
{
  "status": "success",
  "data": {
    "object": "crypto_refund",
    "collection_id": "col_9xK2mQ7LpZ4a",
    "payout_id": "spo_7UU57WBuqNHb",
    "status": "sending",
    "amount": "15.00",
    "currency": "USD",
    "asset": "USDT",
    "network": "BEP20",
    "address": "0x...",
    "held_payment": false,
    "collection": { "...": "the collection, with crypto_amounts.refund filled in" }
  }
}

Withdrawals to your own wallet

Crypto payments credit your USD balance. You can send that balance out as USDT or USDC through the API, automatically, the same way payments come in.

First add your wallet once in the portal (Settings, Settlement accounts, a USDT or USDC wallet for the USD balance). Capay Global verifies it; wallets can never be added or changed through the API, so a leaked key cannot send money to a new address. GET /v1/crypto/wallets lists your wallets and their wallet_id.

Then call POST /v1/crypto/withdrawals with wallet_id, amount (USD, 1 USD = 1 USDT / USDC) and an Idempotency-Key. Your available USD balance is debited at once and the coins are sent within about a minute. The network fee is taken from the coin sent and quoted in fee.

Withdrawals to a verified wallet are sent automatically, whatever the wallet's age. Only an amount above the daily automatic limit (1,000,000 USD a day) waits for one Capay Global admin: the withdrawal then has status awaiting_approval, and a rejection returns the money to your balance.

Statuses: awaiting_approval, pending, processing (sent on-chain), completed (tx_hash filled) and failed (money back on your balance). Follow them with GET /v1/crypto/withdrawals/{id} or the crypto_withdrawal.completed and crypto_withdrawal.failed webhooks. Needs a key with payouts:write.

request
curl -X POST https://api.capayglobal.com/v1/crypto/withdrawals \
  -H "Authorization: Bearer sk_live_XXXXXXXXXXXX" \
  -H "Idempotency-Key: 2b1f6c1e-withdraw-0001" \
  -H "Content-Type: application/json" \
  -d '{ "wallet_id": "52cb91bc-ddcd-412f-b376-c2dc18042baa", "amount": "25.00" }'
201 response
{
  "status": "success",
  "data": {
    "id": "spo_8kQ2...",
    "object": "crypto_withdrawal",
    "status": "processing",
    "amount": "25.00",
    "currency": "USD",
    "asset": "USDT",
    "network": "TRC20",
    "address": "TXFL...",
    "fee": "1",
    "tx_hash": null,
    "approval_status": "auto",
    "livemode": true
  }
}

Testing in the sandbox

With an sk_test_ key the same request returns a placeholder address (SANDBOX-...) that no wallet accepts: never send real funds to it. The simulator completes the payment like any sandbox collection, and refunds work end to end, so you can build the whole flow before going live.

Still failing?

Send us the full error response, including request_id, and the request body you sent (without your key). Every request is logged, so we can trace it in minutes.

See also: Collections reference · Refunds reference · Webhooks · Sandbox