How a payment works
The flow is the same in every mode. Only the destination of the money and the tooling around it differ.
checkout_url.provider — straight the method you chose.currency and sends USDC (Polygon) to a one-time address.paid. With an account you also get a webhook and a Telegram message.Base URL for every API call: https://api.utopay.app. Requests and responses are JSON (content-type: application/json). Amounts are numbers in the order's currency; times are Unix milliseconds.
Quick start
A real payment in one request — no sign-up. The buyer is sent straight to PayPal; if PayPal can't take this payment, our payment page opens with the other methods.
curl -X POST https://api.utopay.app/v1/pay \ -H "content-type: application/json" \ -d '{ "amount": 100, "currency": "USD", "payout_wallet": "0xYourPolygonWalletAddress000000000000000000", "provider": "paypal", "success_url": "https://yoursite.com/thanks" }' # → { "id": "ord_…", "checkout_url": "https://api.utopay.app/pay/ord_…/go?provider=paypal", … }
Redirect the buyer to checkout_url. About 95.5 USDC arrives in your wallet after the payment. With an account you would call POST /v1/payments with an API key instead — same fields, plus history, webhooks and the balance.
Compare the modes
| No account | Account · Direct | Account · Stored | |
|---|---|---|---|
| Endpoint | POST /v1/pay | POST /v1/payments | |
| Authentication | none | Bearer utopay_sk_… | |
| You receive | USDC (Polygon) in the wallet from the request | USDC (Polygon) in the project's wallet | Balance in the order's currency |
Send buyer straight to a method (provider) | ✓ | ✓ | ✓ |
| All currencies | ✓ | ✓ | ✓ |
| Dashboard & order history | — | ✓ | ✓ |
| Webhooks / Telegram alerts | — | ✓ | ✓ |
| Payment links from the dashboard | — | ✓ | ✓ |
| Test mode (sandbox) | — | ✓ | ✓ |
| Balance, exchange, swap, withdrawal | — | — | ✓ |
| Referral program | — | ✓ | ✓ |
| Fee | 4.5% | 4.5% | |
Without an account
POST /v1/pay
No Authorization header. The money always goes directly to payout_wallet — an EVM address that can receive USDC on Polygon. Same body as POST /v1/payments, plus:
| Field | Type | Required | Notes |
|---|---|---|---|
payout_wallet | string | ✓ | 0x + 40 hex characters. Where your share is sent. |
- Always
direct:"settlement": "custody"returns400— there is no balance to credit without an account. - Flat 4.5% fee, no referral program, no webhooks (
callback_urlis stored but not called), no test mode, standard checkout design. - Tracking: poll the public
GET /api/checkout/:id/status(no key), or watch your wallet.
Account & API keys
An account holds up to 20 projects (one per site). Each has its own keys, webhooks, wallet, branding, funds mode and stats; the balance is shared across the account.
Authorization: Bearer utopay_sk_live_xxxxxxxxxxxxxxxxxxxx
| Key | Format | Use |
|---|---|---|
| Live secret | utopay_sk_live_… | Server side only. Creates real payments. |
| Test secret | utopay_sk_test_… | Simulated payments, no money moves — Test mode. |
| Publishable | utopay_pk_… | Cannot create payments (403). Reserved for client-side use. |
A key is shown once; we store only its hash. Never put a secret key in browser or mobile code.
Create a payment
POST /v1/payments Bearer sk
| Field | Type | Req. | Notes |
|---|---|---|---|
amount | number | ✓ | > 0, in currency, rounded to 2 decimals. Max 1 000 000. |
currency | string | — | ISO code the buyer pays in, default USD. Case-insensitive. See Currencies. |
provider | string | — | Send the buyer straight to this payment method, skipping our page — e.g. paypal, revolut, stripe. Falls back to our page if unavailable. See Send buyer to a method. Alias: payment_option (wins if both are sent). |
settlement | string | — | "direct" or "custody". Default: the project's funds mode. |
payment_methods | string | — | What the buyer sees on the payment page: "both" (card & wallets + cryptocurrency tabs), "cards" or "crypto". Default: the project setting (Funds & payouts), otherwise "both". With crypto the buyer picks a coin and pays 1.5%; funds go straight to your crypto wallets and the order gets paid_with: "crypto". custody orders accept cards only. |
email | string | — | Buyer's email (validated), passed to the provider. |
description | string | — | Up to 500 chars. Shown to the buyer, returned in webhooks. |
success_url | string | — | Absolute http(s) URL: where the provider returns the buyer and the "Return to site" button after payment. |
cancel_url | string | — | Absolute http(s) URL, passed to the provider for a cancelled payment. |
callback_url | string | — | Absolute http(s) URL. Every event of this order (payment.succeeded, failed, underpaid, expired) is POSTed there in the webhook format, signed with x-utopay-signature whose HMAC key is sha256(secret key) (hex) of the key that created the order. Used by the CMS plugins. Payments with an API key only. |
curl -X POST https://api.utopay.app/v1/payments \ -H "Authorization: Bearer utopay_sk_live_xxxxxxxxxxxxxxxxxxxx" \ -H "content-type: application/json" \ -d '{ "amount": 250, "currency": "EUR", "email": "[email protected]", "description": "Invoice #118", "provider": "revolut", "success_url": "https://yoursite.com/thanks" }'
{
"id": "ord_Kd83mQp0aVxY",
"status": "pending",
"amount": 250, "currency": "EUR",
"checkout_url": "https://api.utopay.app/pay/ord_Kd83mQp0aVxY/go?provider=revolut",
"payment_page_url": "https://api.utopay.app/pay/ord_Kd83mQp0aVxY",
"provider": "revolut",
"routing": { "mode": "direct_to_provider", "requested_provider": "revolut", "reason": null },
"settlement": "direct",
"payout_coin": "USDC", "payout_network": "polygon",
… full list in Order object
}
Always redirect the buyer to checkout_url. Keep payment_page_url if you want a "choose another method" link — it always opens our page.
Send the buyer straight to a payment method
Works in every mode — with an API key, without an account, in dashboard payment links and in the no-code form on /payment-links. Set provider and the buyer goes directly to that provider (e.g. PayPal) without seeing our payment form.
400 with the list of valid ids. Known but unavailable now (offline, amount below its minimum, wrong currency) → the order is still created, checkout_url points to our page./pay/:id/go?provider=… checks again and redirects the buyer to the provider's own checkout.routing in the response tells you what happened: mode is direct_to_provider or payment_page; reason is null, provider_offline, provider_below_minimum, provider_currency, provider_disabled, or project_routing (picked from your project's preferred providers).
GET /v1/providers?amount=¤cy= public
Methods available right now for this amount and currency, with id, name, region, status, minimum_amount and minimum_currency. The list changes — check it rather than hard-coding.
provider | Method | Where it works | Minimum* |
|---|---|---|---|
stripe | Card, Apple Pay, Google Pay | US / EU | 2 USD |
paypal | PayPal | USA | 5 USD |
revolut | Revolut | EU / EEA | 6 EUR |
coinbase | Coinbase Pay | Global | 2 USD |
cashapp | Cash App | USA | 2 USD |
robinhood | Robinhood | USA | 5 USD |
klarna | Klarna (Pay Now) | Sweden | 125 SEK |
ideal | iDEAL | Netherlands | 46 EUR |
banxa | Card, bank transfer | Global | 11 USD |
transak | Card, bank transfer, UPI | Global | 30 USD |
binance, blockchaincom, topper, meld, alchemypay, sardine, particle | Card via partner | varies | 10–30 USD |
* Minimums and availability come from the payment network and change; the amount is converted to the minimum's currency before comparing. Some providers may ask the buyer for a quick one-time identity check.
Currencies & what you receive
currency is the currency the buyer pays in. Whatever it is, the money is converted by the provider and moves as USDC on Polygon.
GET /v1/currencies public
Codes accepted in currency | Own sub-balance (custody) |
|---|---|
USD EUR GBP CAD INR SEK | ✓ |
AUD NZD CHF NOK DKK PLN CZK HUF TRY UAH JPY SGD HKD AED MYR THB PHP IDR BRL MXN COP ZAR NGN | credited in USD |
Any other code returns 400. A provider may show the buyer the price converted into its own currency (e.g. Revolut in EUR) — the buyer still pays the equivalent of your amount. Some methods accept only certain currencies; for those, /v1/providers?currency=… leaves them out.
What arrives where
| Mode | You receive | Example: 100 EUR order |
|---|---|---|
No account (/v1/pay) | USDC on Polygon straight to payout_wallet, 95.5% of the USDC the provider delivered | ≈ 100 EUR in USD × 0.955 ≈ 107 USDC (at 1 EUR ≈ 1.12 USD) |
| Account · Direct | Same — USDC on Polygon to the project's wallet; the dashboard shows the order in EUR | ≈ 107 USDC on-chain |
| Account · Stored on UTOPAY | Internal balance in the order's currency (amount × 95.5%) if it has its own sub-balance; otherwise USD at the current rate | 95.50 EUR on the EUR balance (a 100 BRL order → ≈ 19 USD on the USD balance) |
The exact USDC that arrived is in the order's value_coin after payment; it can differ slightly from the estimate because the provider sets the conversion rate. Stored balances can be exchanged between currencies and withdrawn in USDC, USDT, BTC, ETH and more — see Stored on UTOPAY.
Payment status
GET /v1/payments/:id Bearer sk
The order in the same shape. While it is not paid, we also check the live status upstream, so polling is reliable even if a callback is delayed — the balance credit and your webhook happen exactly once either way. Only the key's own project is visible (404 otherwise).
GET /v1/payments?limit=&offset=&status= Bearer sk
The project's orders, newest first. limit 1–500 (default 50), status = pending | paid | failed.
GET /api/checkout/:id/status public
No key: { "id", "status", "amount", "currency", "updated_at" }. Use it for orders created without an account. Don't treat a visit to success_url as proof of payment — check the status.
Crypto gateway
The buyer pays directly in crypto — USDT, USDC, ETH, BTC and more — to a unique address of the order. Everything that arrives is forwarded on-chain to your wallet at once. Fee 1.5%, deducted from the received amount; the blockchain fee is extra. Always direct: crypto is never kept on the UTOPAY balance.
GET /v1/crypto/coins public
Every coin/network with ticker (what you pass as coin, e.g. polygon/usdc, bep20/usdt, erc20/usdt, base/usdc, eth, btc, trc20/usdt), network, family (address type), minimum and available. EVM networks are live; networks with available: false (Tron, Bitcoin, Solana, TON…) are being connected.
POST /v1/crypto/payments Bearer sk · POST /v1/crypto/pay no account
| Field | Notes |
|---|---|
coin ✓ | Ticker from /v1/crypto/coins |
amount + currency | Price in fiat (any of the currencies), converted to the coin at the current rate |
amount_coin | Or the exact amount in the coin (instead of amount) |
payout_address | Your wallet in that network. Required for /v1/crypto/pay; with a key it defaults to the project's Crypto gateway wallets (for EVM — the payout wallet). Validated per network. |
email, description, success_url, cancel_url | As in Create a payment |
curl -X POST https://api.utopay.app/v1/crypto/pay \ -H "content-type: application/json" \ -d '{ "coin": "bep20/usdt", "amount": 100, "currency": "USD", "payout_address": "0xYourWallet0000000000000000000000000000000" }' # → { "id": "ord_…", "kind": "crypto", "checkout_url": "https://api.utopay.app/pay/ord_…", # "crypto": { "coin": "bep20/usdt", "network": "BNB Smart Chain (BEP-20)", "symbol": "USDT", # "address": "0x…", "amount_coin": 100.02, "minimum": 1.66, "detected": false, "underpaid": false } }
- Redirect the buyer to
checkout_url(amount, network, address, QR, live status) — or showcrypto.addressandcrypto.amount_coinon your own page. - Statuses:
pending(withcrypto.detected: trueonce the transfer is seen, before confirmations) →paid. If less than the requested amount arrives (more than 1% short) the status isunderpaidand the event ispayment.underpaid— the money is already in your wallet, decide yourself. - Webhooks,
GET /v1/payments/:idand test keys work as for card payments. Transfers belowminimumor in another coin/network are not processed — the payment page warns the buyer.
Integration examples
Create the order on your server (never with a secret key in the browser), redirect the buyer to checkout_url, and mark the order paid from the webhook. Step-by-step guide: accept crypto on a website; networks and tickers for Tether: accept USDT.
const res = await fetch('https://api.utopay.app/v1/crypto/payments', { method: 'POST', headers: { authorization: `Bearer ${process.env.UTOPAY_SK}`, 'content-type': 'application/json' }, body: JSON.stringify({ coin: 'bep20/usdt', amount: 49.9, currency: 'EUR', description: 'Order #1042', success_url: 'https://shop.example/thanks', cancel_url: 'https://shop.example/cart' }) }); const order = await res.json(); if (!res.ok) throw new Error(order.error); // save order.id with your order, then redirect the buyer reply.redirect(order.checkout_url);
import os, requests r = requests.post('https://api.utopay.app/v1/crypto/payments', headers={'Authorization': f'Bearer {os.environ["UTOPAY_SK"]}'}, json={'coin': 'erc20/usdt', 'amount': 120, 'currency': 'USD', 'description': 'Invoice 77'}, timeout=20) order = r.json() r.raise_for_status() print(order['id'], order['checkout_url'], order['crypto']['address'], order['crypto']['amount_coin'])
$ch = curl_init('https://api.utopay.app/v1/crypto/payments'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('UTOPAY_SK'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['coin' => 'polygon/usdt', 'amount' => 25, 'currency' => 'USD']), ]); $order = json_decode(curl_exec($ch), true); header('Location: ' . $order['checkout_url']);
Direct settlement
The money never rests with us: every payment is split on-chain the moment the USDC arrives.
- Enable: dashboard → project → Funds & payouts → Direct to wallet, paste a USDC (Polygon) address. Or per request:
"settlement": "direct". - You receive USDC on every payment; history, stats, webhooks and Telegram alerts on top. Nothing to withdraw.
- One wallet per project — need several wallets, create several projects.
Stored on UTOPAY
The USDC lands on the UTOPAY platform wallet. On paid your share is credited to the internal balance — then you hold, exchange, swap or withdraw.
- Enable: Funds & payouts → Stored on UTOPAY (no wallet needed), or
"settlement": "custody"per request. - Credit:
amount × 95.5%in the order's currency (USD, EUR, GBP, CAD, INR, SEK), any other currency in USD at the current rate. Credited exactly once. - One balance per account: all projects in this mode add up; the ledger shows the source project.
| Endpoint (dashboard session) | Purpose |
|---|---|
GET /api/merchants/:id/balances | Balance per currency |
GET /api/merchants/:id/balances/transactions | Ledger |
POST /api/merchants/:id/balances/exchange | { from_currency, to_currency, amount } — at the live rate |
POST /api/merchants/:id/balances/withdraw | { currency, amount, network, to_address } |
GET /api/merchants/:id/balances/withdrawals | Withdrawal requests |
Withdrawal networks: USDC_POLYGON, USDT_POLYGON, USDT_TRC20, USDT_ERC20, ETH, BTC, BNB, TRX. The amount is reserved at once (pending); the UTOPAY team sends the transfer, usually within one business day (done with txid). Rejected requests are refunded. Withdrawals can't be started with an API key.
Payment links
No code: create a link, send it in a chat or email, get paid. Dashboard → project → Payment links → + Link.
- Amount fixed, or empty — the buyer types it. Currency — any from the list.
- Method (optional): pin a provider. With a fixed amount the link sends the buyer straight to it; with a free amount the buyer sees only the amount field and a "Pay with PayPal" button. If the method is unavailable, the normal method picker is shown.
- Return URL — where the buyer goes after payment. One-time links become
usedafter the first order (opening it again leads to that order); reusable links work until archived. - Funds follow the project's funds mode. Link orders appear in history and fire webhooks like API orders.
| Endpoint (dashboard session) | Body |
|---|---|
POST /api/merchants/:id/links | { amount?, currency?, description?, provider?, redirect_url?, reusable? } → link with url |
GET /api/merchants/:id/links | List |
PATCH /api/merchants/:id/links/:linkId | { status: "active" | "archived", amount, currency, description, provider, redirect_url, reusable } |
The public link is https://api.utopay.app/i/lnk_…. Without an account, use the form on /payment-links — it creates a /v1/pay order with the same method choice.
Webhooks & alerts
With an account. When an order changes state, UTOPAY POSTs it to every endpoint of the project, signed with HMAC-SHA256.
Register: dashboard → Webhooks → + Endpoint, or POST /api/merchants/:id/webhooks with { "url": "https://you.com/hooks/utopay" }. The whsec_… secret is shown in the dashboard.
| Event | When |
|---|---|
payment.succeeded | Funds received (direct and stored) |
payment.expired | Not paid within 24 h (sent to endpoints subscribed to payment.failed; not sent to Telegram). A late payment afterwards still sends payment.succeeded. |
payment.underpaid | Crypto order received less than requested (sent to endpoints subscribed to payment.succeeded) |
payment.failed | Failed payment (currently from test-mode simulations) |
POST https://you.com/hooks/utopay
content-type: application/json
x-utopay-event: payment.succeeded
x-utopay-signature: t=1759660123,v1=<hex hmac-sha256>
user-agent: UTOPAY-Webhooks/1.0
{
"event": "payment.succeeded",
"created_at": 1759660123456,
"data": { /* the Order object */ }
}
import crypto from 'node:crypto'; // use the RAW body, not re-serialised JSON function verify(rawBody, header, secret) { const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('='))); const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; const a = Buffer.from(v1 || ''), b = Buffer.from(expected); return fresh && a.length === b.length && crypto.timingSafeEqual(a, b); }
// raw body + x-utopay-signature header + your whsec_… secret $raw = file_get_contents('php://input'); parse_str(str_replace(',', '&', $_SERVER['HTTP_X_UTOPAY_SIGNATURE'] ?? ''), $sig); $expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, getenv('UTOPAY_WHSEC')); if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int)($sig['t'] ?? 0)) > 300) { http_response_code(400); exit; } $event = json_decode($raw, true); if ($event['event'] === 'payment.succeeded') { /* mark $event['data']['id'] as paid */ } http_response_code(200);
import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split('=', 1) for p in header.split(',')) t, v1 = parts.get('t', ''), parts.get('v1', '') expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest() fresh = t.isdigit() and abs(time.time() - int(t)) < 300 return fresh and hmac.compare_digest(expected, v1)
- Reply with any 2xx within 10 s. Retries after 30 s, 2 min, 15 min, 1 h, 6 h — 6 attempts. Deliveries are visible in the dashboard.
- Be idempotent — de-duplicate by
data.id. - Telegram: paid orders arrive in your chat with amount, description and id (Notifications).
Test mode
- Create a test secret key (
utopay_sk_test_…) and callPOST /v1/paymentsas usual — all validation (currency, provider) is the same. checkout_urlpoints to a sandbox page (/sandbox/ord_test_…). Press Simulate payment or Simulate failure.- Webhooks and Telegram fire as in production with
"is_test": true. No money moves, no balance is credited.
Team, branding, routing
Team: invite by email (Team). Roles admin, developer, finance, viewer; the creator is owner.
Branding: name, logo, background and button colour in Settings — applied to the payment page, link pages and the paid screen.
Project routing: in Routing list preferred providers by priority. For API payments without provider, the first one that is online and fits the amount and currency is used (routing.reason = "project_routing"); none fits — the buyer chooses on our page. An explicit provider always wins.
Order object
Returned by create/get/list and inside webhooks.
| Field | Meaning |
|---|---|
id | ord_… (ord_test_… in test mode) |
status | pending → paid (or failed; crypto: underpaid). Not paid within 24 h → expired: no new payment can be started, but a late payment still turns it into paid. |
is_test | Created with a test key |
amount, currency | What the buyer pays |
email, description | As sent |
checkout_url | Where to send the buyer (to the provider or to our page) |
payment_page_url | Always our payment page with all methods |
provider, routing | Chosen method and how the buyer is routed — see routing |
settlement, payout_wallet | direct (your wallet) or custody (platform wallet) |
payout_coin, payout_network | USDC, polygon |
fees | Split as fractions: merchant (your share, 0.955), affiliate, sub_affiliate, sub_affiliate2 |
value_coin, value_forwarded_coin, coin | USDC actually received / forwarded (after payment) |
txid_in, txid_out | Polygon transaction hashes |
polygon_address_in | One-time receiving address of this order |
expires_at | When an unpaid order becomes expired (Unix ms) |
success_url, cancel_url, created_at, updated_at | As sent / Unix ms |
All public endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /v1/pay | — | Payment without an account |
POST /v1/payments | sk | Create a payment |
GET /v1/payments/:id | sk | One payment (live status check) |
GET /v1/payments | sk | List payments |
GET /v1/providers?amount=¤cy= | — | Methods available now |
GET /v1/crypto/coins | — | Crypto gateway coins and networks |
POST /v1/crypto/payments / POST /v1/crypto/pay | sk / — | Crypto payment with / without an account |
GET /v1/currencies | — | Accepted currencies |
GET /api/checkout/:id/status | — | Status of any order |
GET /pay/:id, GET /pay/:id/go?provider= | — | Payment page / redirect to a method (for the buyer) |
GET /i/:linkId | — | Payment link page (for the buyer) |
Fees
- You receive 95.5% — the same with and without an account, in direct and stored modes, for any currency and method. No setup or monthly fees.
- Large merchants can get an individual rate — contact us. The exact split of each order is in
fees.
Referral program
- Level 1: 25% of our fee from merchants you invited; level 2: 10% from merchants they invited.
- Paid on-chain at each payment to the referral wallet set in Referrals. It comes out of our fee — referrals pay nothing extra.
Errors
Every error is { "error": "message" } with a matching HTTP status.
| Status | Meaning |
|---|---|
400 | Bad input: amount, unsupported currency, unknown provider, invalid payout_wallet, non-http(s) success_url, bad settlement, direct mode without a wallet, insufficient balance. |
401 | Missing, malformed or revoked key; no dashboard session. |
403 | pk key where sk is needed; inactive project; no access. |
404 | Not found, or belongs to another project. |
429 | Too many sign-in / sign-up attempts — retry in a minute. |
503 | Payment network temporarily unavailable — retry with backoff. |
500 | Server misconfiguration — contact support. |