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:

  1. 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.
  2. It shows the amount (crypto_amount and token), the deposit address with a QR code and a copy button, the XRP destination tag where there is one, and a countdown to expires_at. For a payment priced in your own currency, the page can show that price (price_amount and price_currency, such as "€45.50") next to the dollar amount.
  3. It updates live as the payment moves through partially_paid, detecting and confirmed, and shows a "Test payment" banner in test mode.
  4. When the payment is confirmed, it offers a button to your success_url. When it closes unpaid (expired, underpaid or failed), it offers a link back to your cancel_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 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.

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.