Checkout
Your customer needs to see where to pay, how much, and whether it worked.
Send them to the hosted checkout page (checkout_url), or show it all on
your own payment page, in your own design: the
payment your server creates already carries everything the page needs.
checkout_url is only for the hosted page.
Hosted checkout
When the gateway has a hosted checkout configured, every payment carries a
checkout_url. Redirect the customer there, open it in a new tab, or put it
in an email:
const payment = await payitgo.payments.create({ order_id: "ORD-1001", amount_usd: "49.90" });
res.redirect(payment.checkout_url); // https://pay.example.com/pay/6f1e2a9c4d7b3e05a8c1f2d4
What the page does:
- If you created the payment without a
token, it lists the coins on offer and lets the customer choose one. The payment then gets its address and its 15 minutes. - It shows the amount (
crypto_amountandtoken), the deposit address with a QR code and a copy button, the XRP destination tag where there is one, and a countdown toexpires_at. For a payment priced in your own currency, the page can show that price (price_amountandprice_currency, such as "€45.50") next to the dollar amount. - It updates live as the payment moves through
partially_paid,detectingandconfirmed, and shows a "Test payment" banner in test mode. - When the payment is confirmed, it offers a button to your
success_url. When it closes unpaid (expired,underpaidorfailed), it offers a link back to yourcancel_url.
Neither link is followed automatically, and neither is proof of payment:
fulfil orders from the payment.confirmed webhook, never
from a customer landing on success_url.
Your own payment page
You do not have to send customers to the hosted page. The payment your
server creates is the page's data: payments.create (POST /v1/payments)
answers with the amount, coin, network, deposit address, QR code payload,
XRP destination tag and deadline, so your server renders them in your own
design straight from that response. No second call, and no checkout URL.
| The customer… | Your server calls | And renders |
|---|---|---|
…pays in a coin you already know (you chose it, or they picked it on your site from tokens.list({amount_usd}) before you created the payment) |
payments.create({…, token: "USDT_TRC20"}) |
The payment it returns |
| …picks the coin on your payment page | payments.create({…}) without a token, then shows its token_options; once they pick, payments.selectToken(payment.id, "USDT_TRC20") |
The payment selectToken returns |
Then follow the payment until it is paid:
- Your server hears about every change from its webhook,
and can read the payment at any time with
payments.retrieve(id)(GET /v1/payments/{id}). Fulfil the order from thepayment.confirmedwebhook, never from what the page shows: the page is for the customer. - The page shows progress live with the socket, using
the payment's
checkout_tokenfrom the same response (no extra call): each event carries the whole payment, so the page needs no other request.
Your secret key stays on your server throughout. A page with no server of its own (a static site, a Flutter app) uses the public checkout API instead.
What to show
| Show | From | Notes |
|---|---|---|
| The exact amount | crypto_amount and token, e.g. "49.9 USDT" |
The amount must arrive in full: the customer's wallet or exchange fee comes on top. An exchange that takes its withdrawal fee out of the amount makes the payment arrive short |
| The network | chain (names below), e.g. "on Tron (TRC-20)" |
Show it next to the amount, prominently: the same coin exists on several networks |
| The address | deposit_address, with a copy button |
One address per payment: never reuse it for another order |
| A QR code | qr_payload, drawn as a QR code |
The deposit address alone, so exchange apps accept it; the customer types the amount |
| The destination tag (XRP only) | destination_tag |
Required: show it as clearly as the address, with a copy button. Without it the payment cannot be matched |
| The deadline | A countdown to expires_at |
15 minutes from when the coin was set; the rate is locked until then |
| What is still missing | crypto_amount − received_amount |
Only while partially_paid: "Send the remaining 19.9 USDT to the same address" |
| A fee note | network_fee_note |
Optional, one line about the network |
| Your price in your currency | price_amount and price_currency, e.g. "€45.50", next to the dollars due (amount_due_usd) |
Only on a payment priced in another currency. For reference: the customer still sends crypto_amount, and amount_due_usd includes the fees when you pass them on |
Network names, as network gives them:
chain |
A token on it | The chain's own coin |
|---|---|---|
tron |
Tron (TRC-20): USDT_TRC20 |
Tron: TRX |
ethereum |
Ethereum (ERC-20): USDT_ERC20, USDC_ERC20 |
Ethereum: ETH |
bsc |
BNB Smart Chain (BEP-20): USDT_BEP20, USDC_BEP20 |
BNB Smart Chain: BNB |
bitcoin, litecoin, dogecoin |
Bitcoin, Litecoin, Dogecoin: BTC, LTC, DOGE |
|
xrp |
XRP Ledger: XRP |
The payment's network (and each of its token_options') says it for you:
the token standard is a token's, so a chain's own coin is named by the
chain alone.
In test mode ("livemode": false) show a "test payment" notice. Nothing
watches a test payment's address, so real coins sent to it are never seen:
the payment is paid by the outcome you
simulate instead.
What each status means on the page
status |
What to show |
|---|---|
pending |
The amount, network, address, QR code and the countdown |
partially_paid |
"We received 30 of 49.9 USDT. Send the remaining 19.9 USDT to the same address before the time runs out." Keep the countdown |
detecting |
"Payment received, confirming…" with confirmations of confirmations_required ("7 of 20"), from the socket's events (how many are needed). Stop the countdown: the customer must not pay again |
confirmed |
"Paid." Offer a link back to your order page |
expired |
"This payment expired." Offer to start again: create a new payment for the same order_id |
underpaid |
"Only 30 of 49.9 USDT arrived before the deadline." Point the customer to your support: what arrived is settled to you, so you can refund it or agree on the rest |
failed |
"The payment could not be confirmed." Point the customer to your support |
Keep the socket open after the countdown ends: money that arrives up to 24
hours after expires_at can still complete the payment
(late payments), and the socket then sends
payment.detecting and payment.confirmed.
A complete example
A server that creates the payment and renders your page (Node.js with
Express; the qrcode package draws
the QR code), and the page's script that follows the payment live:
// server.js: npm install express @payitgo/node qrcode
import express from "express";
import QRCode from "qrcode";
import { Payitgo } from "@payitgo/node";
const payitgo = new Payitgo(process.env.PAYITGO_SECRET_KEY, {
webhookUrl: "https://shop.example/payitgo/ipn", // where this server hears about payments
});
const app = express();
app.get("/orders/:id/pay", async (req, res) => {
const order = await findOrder(req.params.id); // your own order lookup
const p = await payitgo.payments.create({
order_id: order.id,
amount_usd: order.totalUsd, // "49.90"
token: "USDT_TRC20", // or create it without one, show p.token_options,
// then render payitgo.payments.selectToken(p.id, choice)
});
res.render("pay", { // your template, your design
amount: p.crypto_amount,
coin: p.token.split("_")[0], // "USDT"
network: { tron: "Tron (TRC-20)", ethereum: "Ethereum (ERC-20)", bsc: "BNB Smart Chain (BEP-20)" }[p.chain] ?? p.chain,
address: p.deposit_address,
tag: p.destination_tag, // XRP only
qr: await QRCode.toDataURL(p.qr_payload), // <img src="…">
expiresAt: p.expires_at,
checkoutToken: p.checkout_token, // for the live updates below
test: !p.livemode,
});
});
Calling it again for the same order while its payment is open returns that same payment, so a reload shows the same address and amount (safe retries).
<!-- pay.html: a Mustache-style template; render the values above with your own engine and markup -->
<div id="pay">
<p>Send exactly <b>{{amount}} {{coin}}</b> on <b>{{network}}</b></p>
<img src="{{qr}}" alt="QR code" width="220" height="220">
<code id="address">{{address}}</code> <button onclick="navigator.clipboard.writeText('{{address}}')">Copy</button>
<p>Time left: <span id="countdown"></span></p>
<p id="state"></p>
</div>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script>
const expiresAt = new Date("{{expiresAt}}");
const state = document.getElementById("state");
const tick = setInterval(() => {
const s = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
document.getElementById("countdown").textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}, 1000);
const socket = io("https://api.payitgo.co", { transports: ["websocket"] });
socket.on("connect", async () => { // again after every reconnect
const r = await socket.emitWithAck("subscribe", "{{checkoutToken}}");
if (r.ok) show(r.payment);
});
socket.on("payment:update", (e) => show(e.payment)); // the whole payment after each change
function show(p) {
switch (p.status) {
case "partially_paid": state.textContent = `Received ${p.received_amount}. Send the remaining ${(p.crypto_amount - p.received_amount).toFixed(6)} to the same address.`; break;
case "detecting": clearInterval(tick); state.textContent = `Payment received, confirming (${p.confirmations} of ${p.confirmations_required})…`; break;
case "confirmed": clearInterval(tick); state.textContent = "Paid. Thank you!"; socket.disconnect(); break;
case "expired": state.textContent = "This payment expired."; break; // keep listening: it can still be paid late
case "underpaid": state.textContent = `Only ${p.received_amount} arrived in time. Please contact us.`; break;
case "failed": state.textContent = "The payment could not be confirmed. Please contact us."; socket.disconnect(); break;
}
}
</script>
The same in any stack: create the payment on your server, show the fields
from What to show, and follow it with the socket. Your
server marks the order paid when its webhook receives payment.confirmed.
The public checkout API
You only need this for a payment page with no server of its own behind it
(a static site, a Flutter app): with a server, everything above comes from
payments.create and payments.selectToken. Everything the hosted page uses
is public and keyed by the payment's
checkout_token (also the last part of checkout_url). These routes
need no API key, so your page can call them straight from the browser: they
accept requests from any website (CORS). The token only reveals the payer's
view of one payment.
| Method | Path | What it does |
|---|---|---|
GET |
/checkout/{checkout_token} |
The payer's view of the payment |
POST |
/checkout/{checkout_token}/token |
The customer chooses the coin |
POST |
/checkout/{checkout_token}/simulate |
Plays an outcome on a test payment |
GET |
/tokens |
Every token payitgo supports, without an account |
| socket.io | subscribe, payment:update |
Live updates: the whole payment on every change |
In a Flutter app, use the Dart library's
PayitgoCheckout, which wraps these calls; in a
React app, the React checkout widget.
Read the payment
GET /checkout/{checkout_token} · no authentication
curl -s https://api.payitgo.co/checkout/6f1e2a9c4d7b3e05a8c1f2d4
200 OK with the payment object, minus
your private fields: merchant_id, metadata, customer_email,
webhook_url, checkout_token and checkout_url are left out. A payment
priced in another currency also
carries price_amount, price_currency and price_rate.
{
"id": "8f3a2c1e9b7d4a6f05e1c2d3",
"order_id": "ORD-1001",
"status": "pending",
"deposit_address": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "49.9",
"received_amount": "0",
"usd_amount": "49.9",
"amount_due_usd": "49.9",
"fees_paid_by_customer": false,
"network_fee": "3.212009",
"network_fee_usd": "3.22",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"deposits": [],
"expires_at": "2026-10-10T09:15:00.112Z",
"created_at": "2026-10-10T09:00:00.111Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"description": "2 x Acme T-shirt",
"success_url": "https://acme.example/thanks",
"cancel_url": "https://acme.example/cart"
}
Show the customer:
| Show | From |
|---|---|
| The amount to send | crypto_amount and token (for example "49.9 USDT on Tron") |
| Where to send it | deposit_address, and qr_payload as a QR code |
| The XRP tag | destination_tag, marked as required |
| The deadline | A countdown to expires_at |
| A note on fees and timing | network_fee_note |
| Progress | status, and received_amount against crypto_amount while partially_paid |
| The price in the merchant's currency (when there is one) | price_amount and price_currency ("€45.50"), next to the dollars due, amount_due_usd |
| Status | Message | Cause |
|---|---|---|
404 |
payment not found |
No payment has this checkout token |
Choosing the coin
A payment created without a token has "token": "", no address, and
token_options: the coins you accept that the amount is large enough for,
each a TokenInfo with its
network_fee_usd (and network_fee_bps for a coin that is converted) and,
when you pass the fees on, its amount_due_usd (what the customer pays in
that coin, everything included, so they can pick the cheapest). Show them, and
post the customer's choice:
POST /checkout/{checkout_token}/token · no authentication
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | yes | One of the payment's token_options[].token, such as USDT_TRC20 |
curl -s https://api.payitgo.co/checkout/b28d4f61e9a03c75d2e8f1a6/token \
-H 'Content-Type: application/json' \
-d '{"token": "USDT_TRC20"}'
200 OK with the payment, now with its coin, address, locked rate and a
deadline 15 minutes from now:
{
"id": "c41e7a9d2b05f3e8a6d17c90",
"order_id": "ORD-1002",
"status": "pending",
"deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "25",
"received_amount": "0",
"usd_amount": "25",
"amount_due_usd": "25",
"fees_paid_by_customer": false,
"network_fee": "3.211899",
"network_fee_usd": "3.22",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"deposits": [],
"expires_at": "2026-10-10T09:16:48.029135472Z",
"created_at": "2026-10-10T09:00:00.832Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU"
}
The coin can be chosen once.
| Status | Message | Cause |
|---|---|---|
400 |
token is required |
No token in the body, or the body is not JSON |
400 |
token is not one of this payment's options |
A coin not in token_options |
400 |
minimum payment in … is $… right now (it is converted on its way to the merchant, and the exchange takes no less) |
The coin needs a bigger payment than this one: it is converted through the exchange on its way to the merchant, which takes no less, and the merchant pays the fees or hides such coins (hide_below_minimum). Offer the other coins |
400 |
this payment can no longer be paid |
The payment is no longer pending, or its hour to choose has passed |
404 |
payment not found |
No payment has this checkout token |
409 |
the coin for this payment has already been chosen |
A second choice |
429 |
rate limit exceeded |
More than 5 choices a second from one IP address |
Simulate a test payment
POST /checkout/{checkout_token}/simulate · no authentication · test payments only
A test payment moves no coins: it is paid by an outcome you choose, and
payitgo plays its deposits, status changes and webhooks a few seconds apart
(Test mode). Your server can start one
with its key (POST /v1/payments/{id}/simulate); this route lets the payment
page itself do it, for example to put "simulate" buttons on a test payment
while you build your integration.
| Field | Type | Required | Description |
|---|---|---|---|
outcome |
string | yes | confirmed, overpaid, partially_paid, underpaid, expired, failed, wrong_asset or paid_late (what each does) |
curl -s https://api.payitgo.co/checkout/b28d4f61e9a03c75d2e8f1a6/simulate \
-H 'Content-Type: application/json' \
-d '{"outcome": "confirmed"}'
200 OK with the payment, as GET /checkout/{checkout_token}
returns it, with its simulation started:
{
"id": "c41e7a9d2b05f3e8a6d17c90",
"order_id": "ORD-1002",
"status": "pending",
"deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"simulation": { "outcome": "confirmed", "state": "running" },
"crypto_amount": "25",
"received_amount": "0",
"…": "…"
}
The payment then moves as it would for real coins: follow it with the
socket. A payment.simulation event comes when the
simulation starts (simulation.state running), a status event for each step
played (payment.detecting, payment.confirmed…), and payment.simulation
again when it is over (done): enable your buttons again then. Each event
carries the whole payment, simulation included.
The payment must be pending or partially_paid, have its coin (except for
expired), and run no other simulation.
| Status | Message | Cause |
|---|---|---|
400 |
outcome is required |
No outcome in the body, or the body is not JSON |
400 |
simulate must be one of: confirmed, overpaid, … |
An unknown outcome |
400 |
only test payments can be simulated: use a test key |
A live payment |
400 |
choose the payment's coin first |
No coin yet, for anything but expired |
400 |
this payment is confirmed: only a pending or partially paid payment can be simulated |
The payment is closed or confirming |
400 |
a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain |
wrong_asset on a coin that has no other |
404 |
payment not found |
No payment has this checkout token |
409 |
a simulation is already running for this payment |
Wait for its done |
429 |
rate limit exceeded |
More than 5 requests a second from one IP address |
Every token
GET /tokens?mode=test · no authentication
Every token payitgo supports, in live mode or (with mode=test) test mode,
priced for a default account. It is a catalogue for a public page. For what
your customers may pay with, use the payment's token_options or
GET /v1/tokens with your key.
curl -s 'https://api.payitgo.co/tokens?mode=test'
200 OK: {"tokens": [TokenInfo, …]}, the same shape as
GET /v1/tokens. Test mode lists the same coins
as live.
Follow it live
The gateway serves socket.io at https://api.payitgo.co/socket.io
(use socket.io-client 4.x). Connect to the default namespace and emit
subscribe with the checkout token, with an acknowledgement: it answers with
the payment as it is. Then every change arrives as a payment:update event
carrying the whole payment:
import { io } from "socket.io-client";
const socket = io("https://api.payitgo.co", { transports: ["websocket"] });
socket.on("connect", async () => { // again after every reconnect
const r = await socket.emitWithAck("subscribe", checkoutToken);
if (r.ok) render(r.payment);
else console.error(r.error); // "payment not found", "checkout token required"
});
socket.on("payment:update", (e) => render(e.payment)); // e.type says what happened
| You emit | With | The acknowledgement |
|---|---|---|
subscribe |
the checkout token | {"ok": true, "payment": {…}}: the payment as GET /checkout/{checkout_token} returns it. An unknown token: {"ok": false, "error": "payment not found"}, then the socket is disconnected |
unsubscribe |
the checkout token | {"ok": true}: no more events for that payment |
One socket can follow several payments: each subscribe adds one. Right after
subscribe you also get a payment:update of type payment.snapshot with the
current state.
A payment:update event, when the customer's deposit arrived (shortened):
{
"type": "payment.detecting",
"payment": {
"id": "5bdca6322582804b50abaadb",
"order_id": "RT-1",
"status": "detecting",
"deposit_address": "TK5aUacx7EGYu2bGqWZiezvLxxzcSVYsSC",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "20",
"received_amount": "20",
"tx_id": "34a5405206fe1dcf6002d738643f4109c30f4e86fdd075ff95ee88ca1828e231",
"deposits": [
{ "tx_id": "34a5405206fe1dcf6002d738643f4109c30f4e86fdd075ff95ee88ca1828e231", "amount": "20", "counted": true, "confirmations": 0, "confirmations_required": 20, "status": "pending", "simulated": true, "…": "…" }
],
"expires_at": "2026-10-12T16:37:33.759Z",
"confirmations": 0,
"confirmations_required": 20,
"…": "…"
},
"id": "5bdca6322582804b50abaadb",
"livemode": false,
"order_id": "RT-1",
"status": "detecting",
"tx_id": "34a5405206fe1dcf6002d738643f4109c30f4e86fdd075ff95ee88ca1828e231",
"crypto_amount": "20",
"token": "USDT_TRC20",
"chain": "tron"
}
| Field | Type | Description |
|---|---|---|
type |
string | What happened (below) |
payment |
object | The whole payment after the change, as GET /checkout/{checkout_token} returns it. Render from it |
id, livemode, order_id, status |
The payment's id, mode, your order id and its status | |
tx_id, confirmations, crypto_amount, token, chain, confirmed_at |
The event's older, flat form. Here crypto_amount is the amount received so far (in payment it is the amount asked for). Read payment instead |
type |
When |
|---|---|
payment.snapshot |
Right after subscribe: the payment as it is |
payment.coin_chosen |
The coin was chosen: the payment now has its address, amount and a new expires_at |
payment.partially_paid, payment.detecting, payment.confirmed, payment.underpaid, payment.expired, payment.failed, payment.wrong_asset, payment.late_deposit |
The webhook events of the same name |
payment.confirmations |
A deposit collected confirmations: show "7 of 20" from payment.confirmations and payment.confirmations_required |
payment.deposit |
A deposit became final, or failed, without changing the status |
payment.simulation |
A test payment's simulation started, and again when it is over (payment.simulation.state) |
Render any type you do not know from its payment like the others.
socket.io-client reconnects by itself after a network drop; a drop of under
2 minutes replays the events missed. Subscribe again on every connect (as
above): it brings the current state either way. Keep the socket open while the
page is open, also after expired and underpaid (late payments).
If WebSockets are blocked, drop the transports option: socket.io then uses
HTTP long-polling, which the gateway serves too. Only where both are blocked,
poll GET /checkout/{checkout_token} every few seconds
as a last resort.
Payment links in email or chat
checkout_url works anywhere. Remember the deadlines: a payment created with
a coin gives the customer 15 minutes, one created without a coin gives them an
hour to choose and then 15 minutes. Create the payment when the customer is
ready to pay, not when the invoice is drafted.