Developers

Payitgo API documentation

Create crypto payments priced in US dollars (or your own currency), send customers to checkout, and fulfil orders from signed webhooks. Payments are settled to your own wallet, or to a USDT balance if you prefer.

Overview#

Payitgo is a crypto payment gateway. Your server creates a payment priced in US dollars (or in your own currency, converted into dollars), your customer pays it in stablecoins or crypto on a checkout page, and Payitgo tells your server with a signed webhook (IPN) when the money has arrived and is final. The money is then sent to your own wallet, or kept in your Payitgo balance until you withdraw it.

  1. Your server creates a payment with POST /v1/payments: your order id, the amount in US dollars and, if you like, the coin. Payitgo answers with a one-time deposit address, the exact crypto amount at a locked rate, and a checkout_url: the hosted checkout page.
  2. You send the customer to checkout_url, or show the amount, address and QR code on your own page. They have 15 minutes to pay once the coin is set (and an hour to choose a coin first).
  3. The customer pays. The payment becomes detecting as soon as the money is seen, and confirmed once the network has enough confirmations.
  4. Payitgo notifies your server with a webhook at each step, signed with your webhook secret.
  5. Your server verifies the signature and, on payment.confirmed, marks the order paid.
  6. The money is settled, less Payitgo's service fee and the network fee: sent to your wallet within minutes (direct settlement, the default, with a settlement.completed webhook), or added to your balance (custody), which Payitgo can also pay out for you on a schedule (scheduled settlement).

Webhooks are Payitgo's IPN

Payitgo's webhooks are its IPN (instant payment notifications), and the webhook secret is the signing key. Every notification carries an X-Webhook-Signature header, an HMAC-SHA256 you check with that secret. Each mode has one webhook URL and one secret, and a payment can name its own webhook_url, a callback for that payment alone (Webhooks).
KeyModeMoneyWebhook
Test keyTestNo coins: you simulate what the customer pays, nothing real movesThe test webhook URL and its own secret
Live keyLiveReal coins on mainnetThe live webhook URL and its own secret

A key's text does not say its mode: the dashboard shows each key with a Test or Live badge, and the key object's livemode says it (Authentication). The API is the same in both: build and test with the test key, then swap in the live key and the live webhook secret. New accounts start in test mode; the owner turns live mode on when ready. Customers can pay with USDT (Tron, Ethereum, BNB Smart Chain), USDC (Ethereum, BNB Smart Chain), TRX, ETH, BNB, BTC, LTC, DOGE and XRP, in both modes (Currencies).

Your Payitgo API host

The examples use https://api.payitgo.example as a placeholder: replace it with your Payitgo API host. The libraries read it from the PAYITGO_BASE_URL environment variable (Dart and the React widget take it as their baseUrl option), then fall back to the address they were released with. Every request and response is JSON; amounts are decimal strings such as "49.9".
I want to…Read
Take my first test payment, end to endQuickstart
Use a library (Node.js, Python, PHP, Go, Dart) or the React widgetLibraries
Take crypto in a WooCommerce shop, with no codeWooCommerce plugin
Know every status and what to doPayment lifecycle
Show the payment on my own pageYour own payment page
Receive and verify notificationsWebhooks
Understand fees and where my money goesFees and settlement

Quickstart#

Take one test payment from start to finish: get your keys, create a payment, send the customer to checkout, and handle the webhook. Everything runs in test mode: no coins move, and you simulate what the customer pays.

1. Get your keys and webhook secret#

When you open an account, Payitgo issues a test key and a live key, each 64 hex characters and shown only once: the test key is the one with "livemode": false (the dashboard shows a Test or Live badge next to each key). Owners and developers can create more in Dashboard → Developers. Keep them on your server, and use the test key for now. Then read your test webhook's signing secret and tell Payitgo where to send notifications (a public https:// URL; on a laptop, use a tunnel such as ngrok):

Terminal
export PAYITGO_BASE_URL=https://api.payitgo.example
export PAYITGO_KEY=4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f   # your test key

# Your test webhook and its signing secret: keep it as PAYITGO_WEBHOOK_SECRET
curl -s $PAYITGO_BASE_URL/v1/webhook -H "Authorization: Bearer $PAYITGO_KEY"

# Where notifications go
curl -s -X PUT $PAYITGO_BASE_URL/v1/webhook \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://acme.example/payitgo/ipn"}'

# A signed "ping" arrives within seconds
curl -s -X POST $PAYITGO_BASE_URL/v1/webhook/test -H "Authorization: Bearer $PAYITGO_KEY"

2. Create a payment#

When your customer clicks “Pay with crypto”, your server creates a payment for the order. Without a token, the customer chooses the coin on the checkout page. Save id with your order. Sending the same order_id again while the payment is open returns that same payment (200), so a retried request never makes a second one.

curl -s https://api.payitgo.example/v1/payments \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"order_id": "ORD-1001", "amount_usd": "19.99", "success_url": "https://acme.example/thanks"}'
201 Created (shortened)
{
  "id": "c41e7a9d2b05f3e8a6d17c90",
  "order_id": "ORD-1001",
  "status": "pending",
  "token": "",
  "token_options": [
    { "token": "USDT_TRC20", "chain": "tron", "min_usd": "6.44", "network_fee_usd": "3.22", "fee_note": "Final in about a minute." },
    { "token": "USDT_BEP20", "chain": "bsc", "min_usd": "1", "network_fee_usd": "0.01", "fee_note": "Final in under a minute." }
  ],
  "livemode": false,
  "usd_amount": "19.99",
  "amount_due_usd": "19.99",
  "expires_at": "2026-10-10T10:00:00.832Z",
  "checkout_token": "b28d4f61e9a03c75d2e8f1a6",
  "checkout_url": "https://pay.payitgo.example/pay/b28d4f61e9a03c75d2e8f1a6",
  "…": "…"
}

3. Send the customer to checkout#

Redirect the customer to the payment's checkout_url: the hosted checkout, this site's /pay/<checkout_token> page. checkout_url is only present when the gateway has hosted checkout configured; without it, build the address yourself from checkout_token. The page lets the customer choose a coin, shows the amount, address and QR code, counts down the 15 minutes and updates live. Prefer your own design? See your own payment page.

Node.js
// checkout_url is the hosted page. It is absent when the gateway has no hosted checkout configured:
// this example then builds the address from the checkout token. PAYITGO_CHECKOUT_URL is its own
// convention (a variable you set, not part of the API): this site's address, e.g. https://pay.payitgo.example
const url = payment.checkout_url || `${process.env.PAYITGO_CHECKOUT_URL}/pay/${payment.checkout_token}`;
res.redirect(303, url);

Pay it: simulate the customer

No coins move in test mode: you choose what the customer does. Open checkout_url yourself and choose a coin (or set it with POST /v1/payments/{id}/token), then simulate the customer paying in full:

curl -s -X POST $PAYITGO_BASE_URL/v1/payments/c41e7a9d2b05f3e8a6d17c90/simulate \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"outcome": "confirmed"}'

200 OK with the payment, now with "simulation": {"outcome": "confirmed", "state": "running"}. A few seconds later the deposit arrives and the checkout page moves to detecting, then to confirmed, as it would for real coins. To start the simulation without calling again, create the payment with "simulate": "confirmed": it plays as soon as the coin is chosen. Partial payments, expiry, overpayment and the other outcomes: Test mode.

Never send real coins to a test payment's address: nothing watches it.

4. Handle the webhook#

Your URL receives payment.detecting (money seen, confirming), then payment.confirmed (final). In a simulation they come seconds apart; a live payment's come as fast as its network confirms (Currencies). Verify the signature on the raw body (v1 is the hex HMAC-SHA256 of t + "." + body, and t must be within 5 minutes), skip events whose id you already processed (a notification can arrive twice), mark the order paid on payment.confirmed, and answer 200 within 10 seconds. Anything else is retried for about a day and a half.

// express.raw keeps the body as bytes: register this route before express.json()
app.post("/payitgo/ipn", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = payitgo.webhooks.constructEvent(req.body, req.get("X-Webhook-Signature"));
  } catch {
    return res.sendStatus(400); // not from Payitgo, or tampered with
  }
  if (!(await events.seen(event.id))) {
    if (event.type === "payment.confirmed") {
      await orders.markPaid(event.data.order_id); // idempotent
    }
    await events.remember(event.id);
  }
  res.sendStatus(200);
});

5. Go live#

  1. The account owner turns live mode on in the dashboard (POST /merchant/live {"enabled": true}). Until then the live key gets 403.
  2. With the live key, read the live webhook's secret (GET /v1/webhook) and set its URL (PUT /v1/webhook). Test and live have separate URLs and secrets.
  3. Deploy the live key and the live webhook secret, and act only on events with "livemode": true in production. Remove any simulate from your payment requests: a live key refuses it.
  4. Add a live payout address for each coin you accept, so payments reach your wallet as they are. A coin you have none for is converted into one you have, which costs more. Your first live address of a coin is used at once; another, or a changed one, 24 hours after it is set.
  5. Make one small real payment end to end.

Authentication#

Your server authenticates every /v1 request with a secret API key. The key also decides the mode: there is no mode parameter.

API keys#

Send the key on every /v1 request, in either header (when both are present, X-API-Key wins):

Headers
Authorization: Bearer 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f
X-API-Key: 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f

A key is 64 hex characters. Treat it as an opaque string: it does not say its mode. Your first test key and live key are issued at signup and shown once. Owners and developers create more, and revoke old ones, in Dashboard → Developers. Payitgo stores only a hash: a lost secret cannot be shown again. To rotate, create the new key, deploy it, then revoke the old one.

Keep keys on your server

Never put a secret key in a browser, a mobile app or a public repository. An API key cannot withdraw money (payouts need a signed-in owner or finance member), but it can create payments and change your webhook URL. The checkout routes your customers' browsers call need no key.

Test keys and live keys#

KeyModeWhat it does
Test keyTestTest payments, paid by the outcomes you simulate (no coins, no real money), the test webhook and its signing secret
Live keyLiveReal payments on mainnet, the live webhook and its signing secret

You choose the mode when you create a key, and the dashboard shows it as a Test or Live badge next to the key; the key object's livemode is the only way to tell them apart. To ask Payitgo which mode a key is in, call GET /v1/webhook with it: the answer's livemode is the key's mode (the libraries' isLive()).

A test key gets 404 for a live payment, and the other way round. Every payment and every webhook carries "livemode": true or false, so your code can always tell which mode it is looking at.

Read-only keys#

A key created with read access can only call GET /v1/payments, GET /v1/payments/{id}, GET /v1/tokens and GET /v1/webhook. Anything else answers 403 this api key is read-only: it can only read (GET). Give one to a service that reports on payments and should never create them.

Storefront keys#

A key can belong to a store (one of your shops). It works like any other key, except that:

  • every payment it creates carries the store's store_id, and it only sees its own store's payments;
  • its order ids are its own: it creates and finds its own store's orders only (a key with no store, those made with no store's key), so two shops on one account can both number their orders from 1;
  • it can read the webhook and send a test, but cannot change the account's webhook URL (403): a store with its own backend sets webhook_url on each payment instead.

Live mode#

A new account starts in test mode. Until the account owner turns live mode on in the dashboard, every request with the live key is refused with 403 live mode is off for this account. Turning it off again stops new live payments; payments already open still complete.

StatusErrorCause
401missing api keyNeither header was sent
401invalid api keyUnknown, mistyped or revoked key
403live mode is off for this account: …A live key before the owner turned live mode on
403merchant account is suspendedPayitgo suspended the account
403this api key is read-only: it can only read (GET)A read-only key used to create or change something

Payments#

A payment is one request for money from one customer: an amount in US dollars (or in your own currency, converted into dollars), the coin they pay with, and a deposit address only this payment uses. Your server creates it with your secret key and learns the outcome from webhooks.

MethodPathWhat it does
POST/v1/paymentsCreate a payment (or get back the open payment of the same order)
POST/v1/payments/{id}/tokenSet the coin of a payment created without one
POST/v1/payments/{id}/simulateTest mode: play an outcome on a test payment
GET/v1/payments/{id}Read one payment, by payment id or by your order id
GET/v1/paymentsList payments, newest first
GET/v1/tokensThe coins your customers can pay with, with today's fees and minimums

Create a payment#

POST/v1/payments· API key

With a token, the payment gets its deposit address, locked rate and 15-minute deadline at once. Without one, the customer chooses the coin first (see below). Send Content-Type: application/json: without it the body is not read. Amounts are accepted as numbers or decimal strings, and always returned as decimal strings ("49.9"): parse them with a decimal type, not a float.

FieldTypeDescription
order_idstring, requiredYour id for the order, 1 to 128 bytes. An order has at most one open payment per mode and per store
amount_usdnumber or decimal string, required unless price_amount + price_currencyThe price in US dollars, more than 0, such as "49.90". It must reach the token's minimum
price_amount, price_currencydecimal string and string, togetherInstead of amount_usd: the price in another currency and its ISO 4217 code, such as "45.50" and EUR (prices in other currencies). Not both
tokenstringThe coin, such as USDT_TRC20 (case does not matter). Leave it out to let the customer choose
tokensarray of stringsWithout token: the coins to offer. Default: every coin you accept whose minimum the amount reaches
descriptionstringWhat the customer is paying for, up to 500 bytes. Shown on the hosted checkout
metadataobjectYour own data: up to 20 keys (40 bytes), string values only (500 bytes)
customer_emailstringThe customer's email address. Payments are filed under a customer per email; a blocked customer gets 403
success_urlstringhttp(s) URL the hosted checkout links to once the payment is confirmed
cancel_urlstringhttp(s) URL the hosted checkout links back to if the payment closes unpaid
webhook_urlstringhttps:// URL on a public host: this payment's notifications go there instead of your account's webhook URL
simulatestringTest keys only: the outcome the payment plays, such as confirmed (outcomes). With token (or for expired) it starts at once; without, when the coin is chosen. wrong_asset needs token. A live key gets 400
curl -s https://api.payitgo.example/v1/payments \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "order_id": "ORD-1001",
    "amount_usd": "49.90",
    "token": "USDT_TRC20",
    "description": "2 x Acme T-shirt",
    "metadata": {"customer_id": "cus_42"},
    "customer_email": "[email protected]",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart"
  }'

201 Created with the payment. While an order has an open payment (pending, partially_paid or detecting), creating it again returns that payment with 200 and ignores the other fields, so a request that timed out can simply be sent again. Once the payment is closed, the same order_id creates a new one. Order ids are unique per store: two shops can both send "order_id": "1234" and get two payments, as long as each uses its own store's key. The payment's main fields:

FieldMeaning
idPayitgo's id: 24 hex characters. Treat it as an opaque string
statusWhere the payment is in its lifecycle
crypto_amountWhat the customer must send, in token: amount_due_usd ÷ rate_locked, rounded up
token, chainThe coin and its network: tron, ethereum, bsc, bitcoin, litecoin, dogecoin or xrp ("" until chosen)
token_name, networkThe coin and its network for people: "Tether", "Tron (TRC-20)". Absent until a coin is chosen
deposit_addressWhere to send it, used by this payment only (except on XRP, see destination_tag)
destination_tagXRP only: the tag the customer must include
qr_payloadWhat to encode in a QR code: the deposit address alone (QR codes). "" until a coin is chosen
expires_atThe deadline: 15 minutes after the coin was set
usd_amount, amount_due_usdYour price in dollars, and what the customer pays (more when you pass the fees on)
extra_usdOptional: what of amount_due_usd the customer pays only to reach the exchange's minimum (a payment too small for its coin's conversion, the fees passed on): you receive it
fees_paid_by_customerWhether the customer pays the service and network fees on top of your price (passing fees on)
network_fee, network_fee_usdThe network fee quoted for this payment, in token and in dollars, what converting it costs included (fees). "0" until a coin is chosen
price_amount, price_currency, price_rateOnly for a payment priced in another currency: your price, its currency and the dollar value of one unit of it
received_amount, received_usdWhat has arrived so far
depositsEvery transfer to the payment, oldest first, with tx_id, token, amount, usd_value, confirmations, the sender address (from) and explorer_url. A simulated deposit has "simulated": true and no explorer_url: its tx_id looks like a transaction hash but is on no chain
deposits[].countedWhether it counts toward the payment: always for the payment's own token; another asset only with accept other assets on
deposits[].statuspending (confirming), confirmed (final; settled to you) or failed (never became final; nothing settled)
deposits[].latetrue when it arrived after the payment had closed (late payments)
deposits[].failed_at, fail_reasonWhen and why it failed: transaction not found on chain, or confirmation timeout
confirmations, confirmations_requiredThe latest deposit's confirmations when last checked (0 before any), and how many make a deposit final on the payment's network
rate_lockedThe dollar price of one token, locked when the coin was chosen
checkout_token, checkout_urlThe public key to the payer's view, and the hosted checkout page
overpaid, paid_lateSet when more than 1% too much arrived, or when the payment completed late
settlementWhere the money goes once final: direct (your payout address) or custodial (your balance). Your account setting when the payment was created (fees and settlement)
store_id, customer_id, channelThe store whose key created it; the customer its customer_email is filed under; where the customer paid from: mobile or desktop (the hosted checkout), api (without opening it), "" until then
livemodefalse for a test payment (no coins: paid by the outcome you simulate), true for a real one
simulationA test payment's simulation, once one is asked for: outcome and state (requested, waiting for the coin; running; done)

For a BTC or ETH payment, read the dollars from amount_due_usd, received_usd and each deposit's usd_value; rate_locked is the dollar price of one coin. Settlements and payouts carry their dollar values too (amounts in dollars).

Every error is {"error": "…"}. The ones you are most likely to meet, besides the key errors and 429:

StatusErrorCause
400invalid request bodyNot JSON, or a field of the wrong type (an amount that is not a number, a metadata value that is not a string)
400order_id is requiredMissing order_id, or no Content-Type: application/json (the body is then not read)
400order_id is too longMore than 128 bytes
400amount_usd must be > 0Zero, negative or missing amount (neither amount_usd nor price_amount + price_currency)
400give amount_usd, or price_amount and price_currency: not bothBoth kinds of price; or price_amount without price_currency (price_amount and price_currency go together)
400price_currency must be the ISO 4217 code of a currency (EUR, GBP, NGN…)Not a currency in use, such as BTC or EURO; price_amount must be > 0 and worth at least a cent
400unsupported token in test modeA token (or an entry of tokens) not offered in the key's mode
400not enabled for your account: BTC (turn tokens on in your payment settings)A token you have not enabled
400minimum payment in USDT_TRC20 is $6.44 right now (network fees)With token: amount_usd is under the token's current minimum
400amount_usd is below the minimum of every token on offerWithout token: no coin on offer accepts so small an amount
400none of the tokens on offer is available in test modeWithout token: none of tokens is offered in the key's mode
400description is too longMore than 500 bytes
400metadata key or value is too longA key over 40 bytes or a value over 500 (and metadata may hold at most 20 keys)
400invalid customer_emailNot an email address
400webhook_url: url must use httpsAlso: not absolute, not a public host, or a host that does not resolve. success_url and cancel_url must be http(s) URLs
403this customer is blocked from paying the merchantYou blocked the customer with this customer_email
500payment creation failedOn Payitgo's side: retry with the same order_id, it is safe
503exchange rate unavailable for price_currency: try again shortlyNo exchange rate for price_currency. Nothing was created: retry shortly with the same order_id

And simulate can be refused:

StatusErrorCause
400only test payments can be simulated: use a test keysimulate with a live key
400simulate must be one of: confirmed, overpaid, …An unknown simulate outcome
400simulate wrong_asset needs the payment's coin: pass token"simulate": "wrong_asset" without token
400a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chainwrong_asset with a coin that has no other on its network

Prices in other currencies#

To price a payment in your own currency, send price_amount and price_currency (an ISO 4217 code such as EUR, GBP or NGN; case does not matter, and USD works too) instead of amount_usd. The currency is only how you state the price: the customer always pays in crypto.

curl
curl -s https://api.payitgo.example/v1/payments \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"order_id": "ORD-1004", "price_amount": "45.50", "price_currency": "EUR", "token": "USDT_TRC20"}'
Response (shortened)
{
  "usd_amount": "49.23",
  "price_amount": "45.5",
  "price_currency": "EUR",
  "price_rate": "1.082",
  "amount_due_usd": "49.23",
  "crypto_amount": "49.23",
  "token": "USDT_TRC20"
}
  • Converted at creation. Payitgo converts the price into dollars at the exchange rate of the moment and rounds it to the cent. The rate is fixed: the payment does not follow the currency afterwards, and a retry for an order with an open payment returns it unchanged.
  • Everything else is in dollars. usd_amount, amount_due_usd, crypto_amount, the fees, the minimums and the underpayment tolerance all work from the converted usd_amount.
  • The payment keeps your price. price_amount, price_currency (upper case) and price_rate (the dollar value of one unit) are on the payment, its webhooks and the checkout view, so a page can show “€45.50 (≈ $49.23)”. They are absent for a payment priced in dollars.
  • The rates come from two market data sources (the second when the first does not answer), refreshed every 10 minutes. When neither answers, a rate up to 6 hours old is used; with none at all, the request fails with 503 exchange rate unavailable for price_currency: try again shortly and nothing is created.

Other errors: give amount_usd, or price_amount and price_currency: not both, price_amount and price_currency go together, price_currency must be the ISO 4217 code of a currency (EUR, GBP, NGN…), price_amount must be > 0 and price_amount is worth less than a cent, all 400.

QR codes#

qr_payload is the deposit address alone, on every network: the same as deposit_address. Show it as a QR code next to the address and the amount.

It carries no amount, token contract or XRP destination tag, on purpose: exchange apps, where many customers pay from, reject payment URIs (ethereum:…, bitcoin:…, xrpl:…) as a malformed address. The customer types the amount, and for XRP the tag, so show both as text with copy buttons.

Let the customer choose the coin#

Create the payment with only order_id and amount_usd. It comes back with "token": "", no address, an expires_at one hour away, and token_options: the coins on offer, each with min_usd, network_fee_usd (and network_fee_bps for a coin that is converted), fee_note and, when you pass fees on, amount_due_usd (what the customer pays in it, everything included) and, for a coin the payment is too small to convert, extra_usd (what of it only reaches the exchange's minimum: yours). The hosted checkout shows them. On your own page, set the customer's choice:

POST/v1/payments/{id}/token· API key

curl -s https://api.payitgo.example/v1/payments/c41e7a9d2b05f3e8a6d17c90/token \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"token": "USDT_TRC20"}'

{id} is the payment id or your order_id, and token one of the payment's token_options[].token. The answer is the whole payment, now with its address, locked rate, amount, QR payload and a deadline 15 minutes away.

Changing the coin. A customer who picked a coin can pick another before paying: send another of its token_options the same way, by your server or on the hosted checkout. The payment gets a new address (or XRP tag), amount and locked rate, and a new deadline 15 minutes away. token_options is on the payment exactly while this is possible: it was created without a token (a coin you set stays), it is pending, nothing has arrived and its deadline has not passed, the coin was changed fewer than three times, and a test payment's simulation is not running. Sending the coin it already has changes nothing.

Every address a payment was given stays watched as long as its own, so nothing is lost. When the first deposit arrives at a previous coin's address, in that coin, the payment is that coin again, on the terms it was chosen on; a later deposit there is credited in that coin at its locked rate. The coins chosen before are in previous_coins, and each deposit's to says which address it was sent to.

StatusErrorCause
400token is requiredNo token in the body, or the body is not JSON
400token is not one of this payment's optionsA coin not in token_options
400minimum 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 (converted through the exchange, and you pay the fees or hide such coins): offer the other coins
400this payment can no longer be paidThe payment is no longer pending (paid, expired), or its deadline has passed
404payment not foundNo such payment for your key's mode (or, for a storefront's key, for its store)
409the coin for this payment can no longer be changedThe payment was created with its coin, something arrived, a test payment's simulation runs, or the coin was changed three times already

Retrieve and list payments#

GET/v1/payments/{id}· API key

GET/v1/payments· API key

Retrieve takes the payment's id or your order_id (the payment id is tried first): by order id you get the order's open payment of the key's store, or of no store for a key with none (a closed one is found by its payment id; otherwise 404). List filters by status (one, or several separated by commas), token, chain, store_id, customer_id, link_id, q (search), from and to, and pages with limit (1 to 200, default 50) and offset; it returns payments, total, limit and offset. from and to take RFC 3339 times or YYYY-MM-DD dates (to excluded); q matches a payment id, order id, transaction hash or deposit address exactly, or part of the customer email or description. An unknown status, token or chain matches nothing. A storefront key only sees its own store's payments.

# One payment, by payment id or by your order id (the order's open payment)
curl -s https://api.payitgo.example/v1/payments/8f3a2c1e9b7d4a6f05e1c2d3 -H "Authorization: Bearer $PAYITGO_KEY"
curl -s https://api.payitgo.example/v1/payments/ORD-1001 -H "Authorization: Bearer $PAYITGO_KEY"

# A page of payments, newest first
curl -s 'https://api.payitgo.example/v1/payments?status=confirmed&limit=50&offset=0' -H "Authorization: Bearer $PAYITGO_KEY"

Webhooks tell you about every change, so you rarely need to poll. When you do, fetch the payment from your server and trust it over the browser.

List tokens#

GET/v1/tokens?amount_usd=40· API key

The coins your customers can pay with in the key's mode, with each one's network fee and minimum right now. With amount_usd, only the coins a payment of that amount would offer. Use it to show coins before you create a payment, then create it with the customer's choice as token. Libraries: tokens.list().

Fees and minimums are quoted for where a payment in the coin will really settle. A coin that is converted (into one of your payout coins, or bought by Payitgo for your balance) also has network_fee_bps, the share of the payment converting adds on top of network_fee_usd (fees).

Simulate a test payment#

POST/v1/payments/{id}/simulate· API key (test keys only)

A test payment moves no coins. Choose what happens to it instead: Payitgo plays the deposits, status changes and webhooks a few seconds apart, through the same processing as live coins. {id} is the payment id or your order_id; the body is {"outcome": …}, one of confirmed, overpaid, partially_paid, underpaid, expired, failed, wrong_asset or paid_late (what each does).

curl -s -X POST https://api.payitgo.example/v1/payments/c41e7a9d2b05f3e8a6d17c90/simulate \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"outcome": "partially_paid"}'

200 OK with the payment, its simulation started:

Response (shortened)
{
  "id": "c41e7a9d2b05f3e8a6d17c90",
  "order_id": "ORD-1002",
  "status": "pending",
  "token": "USDT_TRC20",
  "livemode": false,
  "simulation": { "outcome": "partially_paid", "state": "running" },
  "…": "…"
}

The payment must be pending or partially_paid, and have its coin (except for expired). One simulation runs at a time; once it is done, you can run another (confirmed after partially_paid, to pay the rest). To choose the outcome when you create the payment, pass simulate to POST /v1/payments.

StatusErrorCause
400outcome is requiredNo outcome in the body, or the body is not JSON
400simulate must be one of: confirmed, overpaid, …An unknown outcome
400only test payments can be simulated: use a test keyA live key
400choose the payment's coin firstNo coin yet, for anything but expired
400this payment is confirmed: only a pending or partially paid payment can be simulatedThe payment is closed or confirming
400a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chainwrong_asset on a coin that has no other on its network
404payment not foundNo such payment for your key's mode (or, for a storefront's key, for its store)
409a simulation is already running for this paymentWait for its done

Lifecycle and statuses#

Payment lifecycle
pending ─────────────────────────┐
   │                             ▼
   ├──▶ partially_paid ──▶ detecting ──▶ confirmed
   │          │                 │
   ▼          ▼                 ▼
expired    underpaid          failed
   │          │
   └────┬─────┘
        ▼   the rest arrives within 24 hours
    detecting ──▶ confirmed   ("paid_late": true)
StatusMeaningWebhookWhat you do
pendingCreated; waiting for a coin (if not set) and the moneynoneSend the customer to pay
partially_paidPart of the amount arrived; there is still time for the restpayment.partially_paidOptionally tell the customer
detectingEnough arrived; waiting for confirmationspayment.detectingShow "confirming". Do not ship yet
confirmedFinal. The money is yourspayment.confirmedFulfil the order
expiredThe deadline passed and nothing counted arrivedpayment.expiredCancel, or create a new payment
underpaidThe deadline passed with part of the amount; what arrived is settled to youpayment.underpaidRefund, or agree on the rest
failedFunds were seen but never became finalpayment.failedTreat as unpaid

A payment in detecting never expires; it becomes failed if a deposit is not found on chain within 6 hours or not confirmed within 48. Only confirmed means you have been paid: never fulfil from detecting, from a customer landing on your success_url, or from anything the browser says.

Wrong amounts#

  • Too little. The payment becomes partially_paid and the checkout asks for the rest. If it arrives before expires_at, the payment goes on to confirmed; if not, it becomes underpaid, and what arrived is still settled to you.
  • A little too little. A shortfall within your underpayment tolerance (0.5% by default, 0 to 20%, set by the owner) still counts as paid, so a wallet that rounds does not leave the customer stuck.
  • Too much. The payment confirms with "overpaid": true (more than 1% over) and all of it is settled to you. Refund the difference from your own wallet if you wish: the sender is in the deposit's from.

Wrong asset#

A payment's address only receives coins of its own network, but customers do send another token on the same chain (USDC or ETH to a USDT on Ethereum payment, TRX to a USDT on Tron one) or use the other EVM chain (the address is the same on Ethereum and BNB Smart Chain). Each such deposit raises payment.wrong_asset.

Accept other assetsThe depositThe payment
Off (default)Not counted; settled to you in the asset that arrivedKeeps waiting for the coin it asked for
OnCounted at its dollar value, if you accept that tokenCompletes when the dollar total is reached

Either way the money is yours to keep or refund. It is settled like any payment in the coin that arrived: with direct settlement, to your payout address for that coin, or converted into one of your payout coins when you have none for it.

Late payments#

A payment's address is watched for 24 hours after it expires, falls short or confirms. A deposit in that time is marked "late": true, settled to you once final, and reported with payment.late_deposit. If it completes an expired or underpaid payment, the payment moves on to detecting and confirmed with "paid_late": true, at the rate locked at creation. A late deposit that is still not enough makes the payment underpaid. A deposit after a payment confirmed (a customer paying twice) marks it overpaid.

XRP#

XRP payments share one deposit address and are told apart by destination_tag. The customer must include the tag: a transfer without it cannot be matched to the payment and needs Payitgo's support to recover. The hosted checkout shows the tag next to the address; qr_payload does not carry it, so the customer types it in.

Checkout#

Your customer needs to see where to pay, how much, and whether it worked. Send them to the hosted checkout page, or show it all on your own page: the payment your server creates already carries everything the page needs.

Hosted checkout#

Every payment has a checkout_token, and its hosted checkout is this site's /pay/<checkout_token> page. When the gateway has hosted checkout configured, the payment carries it as checkout_url (absent otherwise). Redirect the customer there, open it in a new tab, or put it in an email. The page:

  1. lists the coins on offer when you created the payment without a token, and lets the customer choose one;
  2. shows the exact amount, the network, 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, and, for a payment priced in another currency, that price (“€45.50”) next to the dollars;
  3. updates live through partially_paid, detecting and confirmed, and shows a “Test payment” banner in test mode;
  4. offers a button to your success_url once confirmed, or a link back to your cancel_url when the payment closes unpaid.
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.

In an email or a chat, checkout_url works anywhere, but mind the deadlines: a payment created with a coin gives the customer 15 minutes, one created without a coin an hour to choose and then 15 minutes. Create the payment when the customer is ready to pay, not when the invoice is drafted.

Your own payment page#

The response of payments.create (or payments.selectToken, when the customer picks the coin on your page) holds everything to render in your own design. Keep your secret key on your server; the page follows progress live with the socket.

ShowFromNotes
The exact amountcrypto_amount + tokenMust arrive in full: the customer's wallet or exchange fee comes on top
The networkchainShow it prominently: the same coin exists on several networks
The addressdeposit_addressWith a copy button. One address per payment
A QR codeqr_payloadThe deposit address alone, so exchange apps accept it; the customer types the amount
The destination tagdestination_tagXRP only, and required: show it as clearly as the address, with a copy button (the QR code does not carry it)
The deadlineexpires_at15 minutes from when the coin was set; the rate is locked until then
What is missingcrypto_amount − received_amountOnly while partially_paid
A fee notenetwork_fee_noteOptional, one line about the network
Your price in your currencyprice_amount + price_currencyOnly for a payment priced in another currency: show it (“€45.50”) next to the dollars due, amount_due_usd. For reference only: the customer still sends crypto_amount

Keep the socket open after the countdown ends: money that arrives up to 24 hours after expires_at can still complete the payment, and the socket then sends payment.detecting and payment.confirmed. While a deposit confirms, show confirmations of confirmations_required (“7 of 20”).

Test payments

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.

Public checkout API#

For a page with no server of its own (a static site, a Flutter app), the routes the hosted page uses are public, keyed by the payment's checkout_token, and callable from any website (CORS). They need no API key and only reveal the payer's view of one payment: your private fields merchant_id, metadata, customer_email, webhook_url, checkout_token and checkout_url are left out.

MethodPathWhat it does
GET/checkout/{checkout_token}The payer's view of the payment
POST/checkout/{checkout_token}/tokenThe customer chooses the coin: {"token": "USDT_TRC20"} (5 a second per IP). Same errors as POST /v1/payments/{id}/token, plus 429
POST/checkout/{checkout_token}/simulatePlays an outcome on a test payment (below)
GET/tokens?mode=testEvery token Payitgo supports, without an account. Test mode lists the same coins as live
socket.iosubscribe → payment:updateLive updates: the whole payment on every change

In a Flutter app, the Dart library's PayitgoCheckout wraps these calls; in React, the Payitgo widget does.

Follow it live#

The gateway serves socket.io (use socket.io-client 4.x) on your Payitgo API host. 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. Subscribe again on every connect: it brings the current state after a reconnect.

import { io } from "socket.io-client";

const socket = io("https://api.payitgo.example", { 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 emitWithThe acknowledgement
subscribeThe 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
unsubscribeThe checkout token{"ok": true}: no more events for that payment

One socket can follow several payments: each subscribe adds one. Each payment:update has a type (what happened) and payment, the whole payment after the change: render from it. (The event also keeps an older, flat form, where crypto_amount is the amount received so far; read payment instead.)

typeWhen
payment.snapshotRight after subscribe: the payment as it is
payment.coin_chosenThe 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_depositThe webhook events of the same name
payment.confirmationsA deposit collected confirmations: show "7 of 20" from payment.confirmations and payment.confirmations_required
payment.depositA deposit became final, or failed, without changing the status
payment.simulationA 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. 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.

Simulate from a payment page#

POST/checkout/{checkout_token}/simulate· no authentication · test payments only

A test payment moves no coins: it is paid by an outcome you choose (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. The body is {"outcome": "confirmed"}, or any other outcome.

curl
curl -s https://api.payitgo.example/checkout/b28d4f61e9a03c75d2e8f1a6/simulate \
  -H 'Content-Type: application/json' \
  -d '{"outcome": "confirmed"}'

200 OK with the payment, as GET /checkout/{checkout_token} returns it, with "simulation": {"outcome": "confirmed", "state": "running"}. The payment then moves as it would for real coins: follow it with the socket. A payment.simulation event comes when the simulation starts (running), a status event for each step played, and payment.simulation again when it is over (done): enable your buttons again then. The payment must be pending or partially_paid, have its coin (except for expired), and run no other simulation.

StatusErrorCause
400outcome is requiredNo outcome in the body, or the body is not JSON
400simulate must be one of: confirmed, overpaid, …An unknown outcome
400only test payments can be simulated: use a test keyA live payment
400choose the payment's coin firstNo coin yet, for anything but expired
400this payment is confirmed: only a pending or partially paid payment can be simulatedThe payment is closed or confirming
400a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chainwrong_asset on a coin that has no other on its network
404payment not foundNo payment has this checkout token
409a simulation is already running for this paymentWait for its done
429rate limit exceededMore than 5 requests a second from one IP address

Webhooks#

Whenever one of your payments changes, Payitgo sends an HTTPS POST to your server (an IPN). Every notification is signed with your webhook secret, retried until your server acknowledges it, and arrives even if the customer closed the browser. Fulfil orders from these notifications.

Set up your endpoint#

Your account has two webhooks, one per mode, each with a URL (optional, null until you set it) and a signing secret (64 hex characters). Your API key picks the mode: with your test key you read and change the test webhook, with your live key the live one (the answer's livemode says which). Notifications with nowhere to go are not sent, and not replayed later: set your URL before you take payments.

MethodPathWhat it does
GET/v1/webhookRead the URL and the signing secret
PUT/v1/webhookSet the URL: {"url": "https://…"}. Public https only, no redirects. Safe to retry
DELETE/v1/webhookRemove the URL; the secret is kept
POST/v1/webhook/testQueue a signed ping to the URL (202 with the delivery, sent within seconds). With no URL for the mode: 400 no webhook url is set for this mode: set one first

Libraries: webhooks.get(), webhooks.set(url), webhooks.remove(), webhooks.test(). The account owner can roll the secret in the dashboard: the new secret signs every notification from then on, retries of older ones included, and the old one stops working at once. Deploy code that accepts both, roll, store the new one, then remove the old.

NotificationGoes to
payment.*, settlement.* of a payment created with webhook_urlThat payment's webhook_url
payment.*, settlement.* of any other paymentThe webhook URL of the payment's mode
payout.*The live webhook URL (payouts are live only)
pingThe webhook URL of the key's mode

Events#

TypeSent whendata
payment.partially_paidPart of the amount arrived; the rest can still come before expires_atPayment
payment.detectingThe full amount arrived; waiting for confirmations. Not finalPayment
payment.confirmedFinal: the money is yours. Fulfil the orderPayment
payment.underpaidClosed with only part of the amount (at the deadline, or a late deposit that was still not enough)Payment
payment.expiredThe deadline passed and nothing arrived (or no coin was chosen)Payment
payment.failedFunds were seen but never became finalPayment
payment.wrong_assetA deposit in another coin than the payment asked forPayment
payment.late_depositA deposit after the payment had closedPayment
settlement.completedDirect settlement: your share reached your wallet (data.tx_id; for a deposit converted across networks, the exchange's payout to your wallet, and data.conversion says what it became, with "via": "exchange"). A test settlement completes on paper, with no tx_idSettlement
settlement.failedDirect settlement: it could not be sent; Payitgo retries or returns it to your balanceSettlement
payout.approvedPayitgo approved a payout you requestedPayout
payout.rejectedPayitgo rejected a payout; the amount is back in your balance (data.review_note says why)Payout
payout.sentA payout is on chain (data.tx_id)Payout
pingYou sent a test{"message": "pong"}

No event is sent when a payment is created. Payout events go to the live webhook URL (payouts are live only).

The notification#

POST to your URL
POST /payitgo/ipn HTTP/1.1
Content-Type: application/json
User-Agent: payitgo-webhooks/1.0
X-Webhook-Id: 8e3c1a7f5d9b2e4c6a0f1d3b
X-Webhook-Event: payment.confirmed
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1791619398,v1=37098bbd98dba570cf350275781003849426785222f9c6dc68d6193870059846
Body (one line of JSON, shown indented)
{
  "created_at": "2026-10-10T09:04:03.771240512Z",
  "data": {
    "id": "c41e7a9d2b05f3e8a6d17c90",
    "order_id": "ORD-1001",
    "status": "confirmed",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": false,
    "crypto_amount": "19.99",
    "usd_amount": "19.99",
    "…": "…"
  },
  "id": "2b8f4d6a0c1e3f5a7b9d2c4e",
  "livemode": false,
  "type": "payment.confirmed"
}

id is the event's id (24 hex characters, an opaque string), the same on every retry and redelivery: deduplicate on it. data is the payment as GET /v1/payments/{id} returns it (but never with checkout_token or token_options), the settlement, or the payout; every amount in a coin has its dollar value next to it. A simulated test payment's carries its simulation, and its deposits "simulated": true. Never fulfil a real order from an event with "livemode": false.

Amounts in dollars#

The amounts in a coin in data come with their dollar values, so you can book a BTC or ETH payment in dollars without looking up a price. Dollar values are at the rate fixed at the time (the payment's locked rate, the settlement, the payout request), not today's price. Like every amount, they are decimal strings without trailing zeros ("0.5" is $0.50).

ObjectIn the coinIn dollars
Paymentcrypto_amountamount_due_usd
Paymentreceived_amountreceived_usd
Paymentnetwork_feenetwork_fee_usd
Paymentdeposits[].amountdeposits[].usd_value (at rate_locked, or at the market price for another coin)
Paymentrate_locked: the dollar price of one token, locked when the coin was chosen
Settlementamountusd_value
Settlementservice_fee, network_fee, conversion_feeservice_fee_usd, network_fee_usd, conversion_fee_usd
Settlementnetnet_usd: usd_value less the three fees in dollars, so the dollar figures add up
Settlementusd_rate: the dollar price of one token used (usd_value ÷ amount, to 8 decimals)
Payoutamountamount_usd
Payoutsend_amountsend_amount_usd
Payoutnetwork_feenetwork_fee_usd
Payoutusd_rate: the coin's dollar price when the payout was requested

Payouts requested before these fields existed have no usd_rate, amount_usd or send_amount_usd. For USDT and USDC, usd_rate is "1" and each dollar figure is the coin amount rounded to cents. A BTC deposit of 0.00085 to a payment locked at $62,400 settles with:

Settlement (other fields left out)
{
  "token": "BTC",
  "amount": "0.00085",
  "service_fee": "0.0000085",
  "network_fee": "0.0000185",
  "conversion_fee": "0",
  "net": "0.000823",
  "usd_value": "53.04",
  "usd_rate": "62400",
  "service_fee_usd": "0.53",
  "network_fee_usd": "1.15",
  "conversion_fee_usd": "0",
  "net_usd": "51.36"
}

Verify the signature#

The libraries verify a notification in one call, in constant time, and reject a timestamp more than 5 minutes from your clock:

const event = payitgo.webhooks.constructEvent(req.body, req.get("X-Webhook-Signature"));
// throws SignatureVerificationError: answer 400

By hand, the recipe is:

Recipe
X-Webhook-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )
  1. Read the raw request body as bytes, before any JSON parsing (Express: express.raw() before any express.json(); Next.js: await request.text(); Fastify: a parseAs: 'buffer' content-type parser; Flask: request.get_data(); Django: request.body; FastAPI: await request.body(); Laravel: $request->getContent(); Rails: request.raw_post; Go: io.ReadAll(r.Body)).
  2. Split the header on , and each part on the first =: take t and v1.
  3. HMAC-SHA256 with your mode's secret as the key (the whole string as you read it, as UTF-8 bytes) over t, then ., then the raw body, as lowercase hex.
  4. Compare it with v1 in constant time, and reject a t more than 5 minutes away. Answer 400 to anything that fails.
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries((header || "").split(",").map((p) => {
    const i = p.indexOf("=");
    return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
  }));
  if (!parts.t || !parts.v1) return false;
  const expected = crypto.createHmac("sha256", secret).update(parts.t + ".").update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
  return Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300; // 5 minutes
}

Check your code against this example

With the secret a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48, t = 1791623565 and the body {"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}, your code must produce 1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b (skip the timestamp check for this test).

Answering and retries#

Answer any 2xx within 10 seconds; do slow work after answering. The body of your answer is ignored (its first 500 characters are kept in the delivery history). Anything else is a failure: another status, a timeout, a connection or TLS error, and redirects, which are not followed (a 3xx fails, so set the exact final URL). A failed delivery is tried 7 times in all:

AttemptWhen
1Within about 5 seconds of the event
21 minute after attempt 1 failed
35 minutes later
430 minutes later
52 hours later
66 hours later
724 hours later; then the delivery is marked failed (about 33 hours after the event)

Every attempt is signed afresh (a new t) with the same X-Webhook-Id and body, and an increasing X-Webhook-Attempt. Notifications can arrive twice or out of order: deduplicate on the event id, make fulfilment idempotent, and treat data.status as the truth. A notification to the account's URL goes to the URL as it is at each attempt, so pending retries follow a changed URL.

SymptomCheck
Nothing arrivesIs a URL set for that mode (GET /v1/webhook with the key you create payments with)? Was the payment created with its own webhook_url? No delivery in the history means there was nowhere to send it
endpoint answered 3xxA redirect: set the final URL (exact scheme, host and trailing slash)
TimeoutsYour handler takes over 10 seconds: answer first, work after
TLS or connection errorsA certificate valid for the host, and a firewall that accepts connections from the internet
Signature never matchesRaw bytes before any JSON parsing? The secret of the right mode, not rolled since? The "." between t and the body? Hex compared with hex?
Timestamp too oldYour server's clock is off: sync it with NTP
PUT /v1/webhook answers 403A storefront key: use an account-wide key, or set webhook_url on the store's payments

Delivery logs and redelivery#

Every notification is kept with each attempt, the URL it went to, your server's status code and the start of its answer. Each delivery carries livemode (false for a delivery of a test event), so test and live deliveries are told apart. See them in Dashboard → Developers, and redeliver any delivery in one click: it starts again with up to seven tries, signed with the current secret and sent to the current URL.

Test your handler#

  • The signature: POST /v1/webhook/test sends a signed ping to your test URL within seconds; check that your handler verifies it and answers 200.
  • Real payment events: create a test payment and simulate an outcome: its webhooks arrive a few seconds apart, sent by the same processing as live coins (outcomes and their events). Create one and simulate nothing to get payment.expired 15 minutes later.
  • On your laptop: Payitgo only posts to public https:// URLs. Expose your local server with a tunnel (ngrok, Cloudflare Tunnel or similar) and set its URL as your test webhook URL.
  • Your error handling: answer 500 once and watch the retry arrive a minute later with X-Webhook-Attempt: 2; change one byte of a captured body and check that your handler answers 400.

Per-payment webhook URL#

Pass webhook_url when you create a payment, and that payment's payment.* and settlement.* notifications go there instead of the account's URL, signed with the same secret. It must be public https://, and is fixed at creation. Use it for one backend per store or tenant. The libraries take a default for every payment a client creates (webhookUrl, webhook_url, WithWebhookURL).

  • A storefront key cannot change the account's URL, so a store sets webhook_url on its payments.
  • Test it with POST /v1/webhook/test for the mode's URL, or a simulated test payment for a per-payment URL.

Fees & settlement#

Every deposit that becomes final is settled: Payitgo takes its fees and moves the rest where your account says. You choose where, and who pays the fees. Payitgo handles crypto only: there are no cards, no bank payouts and no fiat.

Fees#

The fees come out of each deposit, in the asset that arrived. Nothing is invoiced.

FeeWhat it isHow much
Service feePayitgo's feeA percentage agreed with your account: 1% by default, and at least $0.20
Network feeMoving the deposit out of its address on the blockchain (and converting it, when it is)Priced from the network right now: cents on most networks, a dollar or two for USDT on Tron (Payitgo rents the energy a Tron transfer needs, well under the cost of burning it). For a converted coin, the quote also covers converting it: the swap or the transfers to the exchange, what the exchange keeps, and the conversion fee
Conversion feeOnly for a deposit converted into another coin: into one of your payout coins on its way to your wallet, or bought by Payitgo for your custody balance0.5% of what is swapped or converted (the exchange's own fee is in its rate); 1.5% of what Payitgo buys

On Tron, sending USDT to a payout address that holds no USDT burns about twice the energy (its balance is created): a direct settlement to such an address pays that too, and says so in its note. Keep a little USDT on your Tron payout address to avoid it.

When you pay the fees yourself (fees_paid_by_customer off), a payment must be worth at least twice its token's network fee (and never less than the token's own minimum), so the fees never eat it. GET /v1/tokens shows each coin's min_usd and network fee right now: network_fee_usd is the fixed part, whatever the amount, and network_fee_bps, for a coin that is converted, the share of the payment added on top (180 = 1.8%). Each payment records the fee quoted when its coin was chosen, what converting it costs included; a settlement breaks it down into network_fee (the transfers) and conversion_fee.

A coin converted across networks on its way to your wallet (which coins) must also leave the exchange its minimum, with a tenth to spare: about 13 USDT from BNB Smart Chain to Tron. With the fees passed on, that is reached from a price of about $7.70 (the customer pays the exchange's cut on top). A smaller payment is still offered in that coin: the customer is asked for enough to reach the minimum, and you receive the extra (extra_usd on the payment and on each of its token_options). A $5 order, say, asks the customer for about $13.50, and you receive about $7.70. To offer such a coin only from the price that reaches the minimum, turn on hide_below_minimum in your payment settings; when you pay the fees yourself, that is always so. The minimums move with the fees and the exchange: min_usd is the current one.

A payment paid in parts is settled once, not once per part. While it is still short (pending or partially_paid), and while another deposit of the same coin is confirming, its final deposits wait. When it confirms, falls short at its deadline (underpaid), expires or fails, the deposits of each coin are settled together: one transfer out of the address, one network fee and one service fee, on their total. The settlement's deposit_txs lists them (deposit_tx is the first), and their total is its amount. A deposit that arrives after that (a late one) is moved by a transfer of its own, and pays its own fees.

The settlement object#

Every final deposit gets a settlement: what arrived, the fees taken, what you received and where it went. It is the data of the settlement.* webhooks, and the dashboard lists them (GET /merchant/settlements, any role).

FieldMeaning
id, payment_id, store_idThe settlement, the payment the deposit was made to, and the payment's store (when it has one)
deposit_tx, deposit_txsThe deposit's transaction id (the payment's deposits[].tx_id). Settled together, deposit_txs lists every deposit and deposit_tx is the first
livemodefalse for a test settlement
modeWhere the money actually went: direct (your wallet) or custodial (your balance). A direct payment kept in custody says why in note
token, chainThe coin settled: what arrived, or what a swap on its own network bought
amount, service_fee, network_fee, conversion_fee, netWhat is settled, each fee, and what is yours (amount less the three fees), in token; in dollars below
destination, destination_tagDirect settlement: the payout address it was sent to, and on XRP the tag it was sent with
tx_idDirect settlement: the transaction that paid you, once sent (converted across networks, the exchange's payout to your wallet)
statuspending, completed, held or failed (statuses)
noteWhy it was held, kept in custody or converted the way it was, or what it waits for
awaits_address{"until": "…"}: waiting for your new or changed payout address for its coin, used from until; then it is sent there as it is. Its fees are 0 until then (why)
conversionWhen the deposit was converted: via (swap, exchange or platform), from and amount_in, to and amount_out, status (pending, done, or skipped: settled as it arrived) and a note
created_at, completed_atWhen the deposit became final, and when the settlement completed

Settlements in dollars#

Each settlement carries its amounts in dollars next to the coin, so the dollar figures add up exactly. Payouts carry usd_rate, amount_usd, network_fee_usd and send_amount_usd, at the coin's price when you requested the payout. Every field: amounts in dollars.

FieldMeaning
usd_valueamount in dollars, to the cent: at the payment's locked rate for its own coin, at the market price for another coin, or what a conversion bought
usd_rateThe dollar price of one token used: usd_value ÷ amount, to 8 decimals. "1" for USDT and USDC
service_fee_usd, network_fee_usd, conversion_fee_usdEach fee in dollars at usd_rate, to the cent
net_usdWhat is yours, in dollars: usd_value less the three fees in dollars

Passing the fees on to the customer#

With fees paid by customer on, the default for a new account, the customer pays the fees on top of your price, so you receive the full price. Turn it off (owner, in the dashboard's payment settings, or PATCH /merchant/settings) and the customer pays your price alone, and the fees come out of what you receive. Accounts created before this default keep their own setting: read fees_paid_by_customer on the payment rather than assuming one. A $100 order in USDT_TRC20 with a $1.39 network fee and the 1% service fee asks the customer for $102.42. The payment's usd_amount stays your price; amount_due_usd and crypto_amount are what the customer pays, and each of the payment's token_options shows its own amount_due_usd so they can pick the cheapest network. For a coin that is converted, the customer pays for converting it too, and your price still reaches you.

Formula
amount_due_usd = (usd_amount + network_fee_usd) / (1 − service fee − network_fee_bps ÷ 10 000)
               (and at least usd_amount + network_fee_usd + $0.20)

With the token's network_fee_usd and network_fee_bps (0 when it is absent) from GET /v1/tokens. With fees passed on, the minimums are just each token's own.

Direct settlement (default)#

Payitgo sends what remains of each deposit to your own payout address for that token, minutes after the deposit is final. A coin you have no payout address for is first converted into one you have (see below). Nothing stays with Payitgo. It is the cheapest option when you take few payments: a coin you have a payout address for is not converted, and the network fee covers two transfers (yours, and Payitgo's fee). On XRP, Payitgo's fee stays on its own address, so a direct XRP settlement is a single transfer.

Add a payout address for every coin you accept. It costs nothing, and it makes each coin's payments the cheapest they can be: they are sent to you as they arrived, on their own network, with no swap, and your customers pay only that network's transfers. A coin without one is converted first, and its quote carries the conversion: a TRX payment swapped into USDT costs your customer several dollars more than one sent to your TRX address, and TRX sent as it is costs nothing to move. You can have an address for each coin on each network: USDT on Tron and USDT on BNB Smart Chain are two coins, each with its own address.

Each direct settlement raises settlement.completed once your share is on chain (data.tx_id is the transfer that paid you; for a deposit converted across networks, the exchange's payout to your wallet), or settlement.failed if it could not be sent after repeated tries (Payitgo's team retries it, or returns it to your balance). A payment created with its own webhook_url gets its settlement events there. Custody settlements raise none: the money is in your balance once the payment is confirmed.

Settlement statusMeaning
pendingThe transfers are being made
completedDone. In direct settlement, tx_id is the transfer to your wallet (converted across networks, the exchange's payout to it)
heldNothing to move, and note says why: the deposit was smaller than its fees, or it is already where it needs to be
failedIt could not be sent after repeated tries: Payitgo retries it, or returns it to your balance
  • Add payout addresses in the dashboard (owner or finance; your password is asked again). Test payments settle to test addresses, on paper: nothing is ever sent to them.
  • Each token is paid to its default payout address in the payment's mode. The first address you add for a token is its default; make another one the default with PATCH /merchant/payout-addresses/{id} {"default": true}.
  • Your first live address of a coin is used at once: there was nowhere else for those payments to go. Any address that could redirect payments waits 24 hours (active_at): another address of a coin, a changed address or tag, or one added after the coin's last address was deleted. So whoever might take over your account cannot redirect your payments at once, and your team is told of every change (a security notification). Test addresses work immediately.
  • While the address for a deposit's coin is in its 24 hours, the deposit waits on its deposit address until the address is used, and is then sent there as it is, never converted: its settlement is pending, with awaits_address.until (when the address is used) and a note, and its fees are worked out when it is sent. Changing the address again restarts the wait, for up to 48 hours from the deposit; after that, or when the address is deleted, it is settled as if there were none.
  • A deposit with no address of its own coin at all is converted into a coin you have a ready payout address for and sent there. When it cannot be, it is kept in your balance instead, in USDT on Tron, and its settlement says why in note: never lost. Payitgo pays that balance out by itself to your USDT address on BNB Smart Chain (or Ethereum) once you have one in use (payouts); without one, request a payout of it as usual.
  • On XRP, give the payout address the destination tag your exchange asks for.

Custody (optional)#

Switch to custodial settlement and Payitgo keeps the money as your balance, always in USDT on Tron, so what Payitgo owes you never moves with a coin's price. A custody deposit in any other coin is bought by Payitgo at the market price as it settles (1.5% conversion fee). Request payouts when you like: to any of your live USDT payout addresses, on BNB Smart Chain, Ethereum or Tron, $10 or more, each reviewed by Payitgo before it is sent (payout.approved, payout.rejected, payout.sent). A payout carries the network fee of sending it: about a cent for USDT on BNB Smart Chain, a few dollars on Tron. Test balances are never paid out.

Switch with PATCH /merchant/settings {"settlement": "custodial"} (owner), or in the dashboard's payment settings. The setting is captured when a payment is created: a payment keeps the mode it was created under.

Scheduled payouts (optional)#

Scheduled settlement works like custody, and Payitgo pays your whole balance out for you, in one payout, to your payout address for USDT_TRC20: as each payment is credited, every day, or every week on Mondays (payout_schedule: instant, daily, weekly). Set it with PATCH /merchant/settings {"settlement": "scheduled", "payout_schedule": "daily"} (owner), or in the dashboard's payment settings.

  • It is the cheapest when you take several USDT on Tron payments a day: each pays the network fee of one transfer instead of the two of direct settlement on Tron (about $1.39 against $2.40), and one payout a day or week pays the rest.
  • Payouts go out at a fixed hour, once the payout address is in use (active_at: another or a changed address waits 24 hours). A balance under the $10 minimum waits for the next one.
  • instant requests a payout the moment each payment is credited: one per payment, so it suits payments in other coins (they reach you as USDT on Tron without an exchange's fees) and large payments.
  • Each payout follows the usual steps (requested, approved, sent) with requested_by: "scheduler". The merchant object shows payout_scheduled_at, when the balance was last paid out.

Coins you have no payout address for#

The cheapest is not to need this: add a payout address for each coin you accept (why). With direct settlement, a payment in a coin you have no payout address for (say a customer pays in BTC and you only have a USDT on Tron address) is converted into one of your payout coins and sent to your payout address for it. There is nothing to turn on. A wrong-asset deposit is settled the same way.

Which payout coin

You choose it with convert_into in your payment settings (PATCH /merchant/settings {"convert_into": "cheapest"}, owner), or in the dashboard under your payout addresses:

  • cheapest (the default; "" sets it too): Payitgo compares your payout coins for each payment and converts into the one that costs the least at that payment's amount: the swap or the exchange's take, the transfers, and the 0.5% conversion fee.
  • order: the first of
    1. a coin on its own network that it is swapped into there, a stablecoin first (BNB or USDC into your USDT on BNB Smart Chain, say);
    2. otherwise USDT on Tron;
    3. otherwise the coin of your oldest payout address it can be converted into.
  • A coin, any coin you have a payout address for (BTC, USDT_BEP20, ETH…): your preferred payout coin. Every coin you have no address for is converted into it and sent to your default address for it, whenever the payment can be converted into it at its amount (the exchange takes about $5 at least to pay out BTC, a few cents for USDT on BNB Smart Chain); otherwise the fixed order above decides. It is used even where another of your coins would cost less (TRX into your USDT on Tron, say), so choose cheapest to always save.

The difference can be large. Receiving a conversion as USDT on Tron costs a few dollars (the exchange pays the Tron network to send it), as USDT on BNB Smart Chain a few cents. Converting a payment, as quoted on 2026-10-15:

Paid inInto USDT on Tron, $20 / $100Into USDT on BNB Smart Chain, $20 / $100
BTC$5.37 / $6.65$0.53 / $1.73
XRP$4.85 / $6.21$0.33 / $1.60
ETH$5.08 / $6.46$0.35 / $1.68
TRX$14.82 / $15.46 (swapped on Tron)$0.33 / $1.66

A payment is quoted for the coin chosen when your customer picks theirs, and converted into that coin when it settles, unless you have since added an address for the coin it was paid in (it is then sent as it is) or removed the one it was quoted for (one is chosen again). A coin the exchange would not take that little of is passed over; when none would, the payment is kept in your balance (below).

NetworkSwapped there
EthereumETH, USDC and USDT into one another
BNB Smart ChainBNB, USDC and USDT into one another
TronTRX into USDT

A swap's price must be within 3% of the market price, and it is refused on chain if it falls more than 0.5% (between stablecoins, 1% otherwise) below the quote; the exchange's own fee is in its price. Across networks (BTC, XRP or ETH into USDT on Tron, say), an exchange converts it and pays your coin out to your payout address: its own rate and network fees apply, and its fixed part (a few dollars) makes converting dear on small payments. What converting costs is quoted in the payment's network fee, and paid by the customer when you pass the fees on. The settlement's conversion says what happened (via: swap, exchange or platform), and GET /merchant/tokens shows the coin each coin's payments are converted into (paid_out_in). A payment too small for the exchange's minimum pays up to it, and you receive the extra (extra_usd), unless you offer such coins only from the price that reaches it (hide_below_minimum, minimums).

A deposit is kept in your balance instead, in USDT on Tron like any custody deposit, with its settlement's note saying why, when:

  • it converts into none of your payout coins;
  • it is too small for the exchange's minimum (a few dollars, with a margin for the fees): too small to convert into USDT_TRC20: kept in your balance instead;
  • a swap is not worth it (its network fees would be over a tenth of its value: small deposits, and on Tron deposits under about $65), the exchange's price is off the market, or the exchange refuses the swap twice;
  • the exchange refunds it (its conversion fee is then 1.5% in all).

Nothing is lost: it is paid out to you automatically once you have a USDT address on BNB Smart Chain or Ethereum in use, or request a payout when you like.

  • Accept other assets: a deposit in another coin you accept counts toward the payment at its dollar value (wrong asset).
  • Add a payout address for a coin to receive its next payments as they are. To accept only coins you can be paid out in, enable only those.
  • Conversion runs in live mode only: in test mode a coin you have no test payout address for is credited to your test balance as it arrived.

In test mode#

Test payments are simulated and move no coins, but they are settled like live ones, on paper:

  • Each simulated deposit is settled when it is final (a payment paid in parts once, when it closes), with the same fees as live, and gets its settlement ("livemode": false).
  • Coins are never converted: a test deposit settles in the coin that arrived, in direct settlement and in custody alike.
  • With direct settlement and a test payout address, the settlement completes at once and sends settlement.completed. Nothing is sent to the address, so it has no tx_id.
  • In custody, or with no test payout address for the coin, the net amount is credited to your test balance. Test balances are never paid out.

Currencies#

A coin is named by its token symbol: USDT_TRC20 is USDT on Tron, USDT_BEP20 is USDT on BNB Smart Chain, BTC is Bitcoin. Use the symbol in a payment's token or tokens; case does not matter.

What your customers may pay with is the list below, narrowed to the tokens you accept. GET /v1/tokens returns that list for your key's mode, with each token's network fee and minimum right now.

Live mode

TokenNetwork (chain)Lowest minimumFinal after
USDT_TRC20Tron (tron)$120 blocks (about 1 min)
TRXTron (tron)$120 blocks (about 1 min)
ETHEthereum (ethereum)$512 blocks (about 3 min)
USDT_ERC20Ethereum (ethereum)$512 blocks (about 3 min)
USDC_ERC20Ethereum (ethereum)$512 blocks (about 3 min)
BNBBNB Smart Chain (bsc)$112 blocks (about 1 min)
USDT_BEP20BNB Smart Chain (bsc)$112 blocks (about 1 min)
USDC_BEP20BNB Smart Chain (bsc)$112 blocks (about 1 min)
BTCBitcoin (bitcoin)$103 blocks (30 to 60 min)
LTCLitecoin (litecoin)$26 blocks (about 15 min)
DOGEDogecoin (dogecoin)$16 blocks (about 6 min)
XRPXRP Ledger (xrp)$11 ledger (seconds)

"Final after" is when a deposit counts as final: the payment moves from detecting to confirmed once its deposits have that many confirmations.

  • Prices. Stablecoins (USDT_*, USDC_*) are priced at exactly $1. Every other token is priced from live market data when the coin is set, and that rate (rate_locked) holds for the payment's 15 minutes.
  • Minimums. The minimums above are each token's lowest. Unless your customers pay the fees, a payment must also be worth twice the token's network fee, which moves with the network. min_usd in GET /v1/tokens is the minimum right now, and a payment under it is refused with 400 minimum payment in … is $… right now (network fees).
  • Choosing. Stablecoins on BNB Smart Chain cost cents to move. USDT_TRC20 is what most customers hold, but every Tron token transfer burns energy, so its network fee is a few dollars. Ethereum's fee varies the most with how busy the network is. The customer also pays their own wallet's fee to send.
  • XRP payments must carry their destination_tag.

Test mode

Test mode offers the same coins as live, DOGE included, with the same minimums, network fees, fee notes, network names and confirmations, so your integration moves to live unchanged. No coins move in test mode, and no faucet is needed: a test payment is paid by the outcome you simulate, and Payitgo plays its deposits and confirmations in seconds.

Never send real coins to a test address

Test deposit addresses on Tron, Ethereum, BNB Smart Chain, Dogecoin and XRP look like live ones. Bitcoin and Litecoin ones are in the testnets' format (they start with m or n), which real wallets refuse. Nothing watches a test address (why).

Test mode#

Test mode is a full copy of Payitgo with no coins: the same API, the same webhooks and the same checkout. Build your whole integration with your test key, then go live by swapping in the live key and the live webhook secret. The dashboard shows each key with a Test or Live badge (its livemode): the key's text does not say.

A test payment gets an address, an amount and a deadline like a live one, but nothing watches a blockchain for it. You choose what happens to it instead: simulate an outcome, and Payitgo plays the deposits, status changes and webhooks a few seconds apart, exactly as live coins would. You never need test coins, a faucet or a wallet.

What is separate#

Test keyLive key
PaymentsTest payments only; "livemode": falseLive payments only; "livemode": true
Order idsA test order_id never collides with a live one
CoinsNone: you simulate what the customer sendsReal coins on mainnet
WebhookThe test webhook: its own URL and its own secretThe live webhook: its own URL and secret
MoneyYour test balance, never paid out; direct settlement to a test payout address completes on paperYour balance, or your live payout addresses once in use (another or a changed one after 24 hours)
DashboardAdd ?mode=test to dashboard routesThe default

A test key reading a live payment gets 404, and the other way round.

Simulate a payment#

There are three ways to choose an outcome:

  • When you create the payment, add "simulate". With a token, the simulation starts at once, and so does expired. Otherwise it starts when the customer chooses the coin on the checkout page (or you set it with POST /v1/payments/{id}/token).
  • Later, on a payment you already have: POST /v1/payments/{id}/simulate, where {id} is the payment id or your order_id. The answer is the payment, with its simulation started. The libraries call it payments.simulate(id, outcome).
  • From a payment page: POST /checkout/{checkout_token}/simulate takes the same body, with no key, for test payments only. Use it from your own payment page to put “simulate” buttons on a test payment.
On create, and later
curl -s https://api.payitgo.example/v1/payments \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"order_id": "ORD-2001", "amount_usd": "20", "token": "USDT_TRC20", "simulate": "confirmed"}'

curl -s -X POST https://api.payitgo.example/v1/payments/c41e7a9d2b05f3e8a6d17c90/simulate \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"outcome": "partially_paid"}'

The payment shows its simulation, and so does every webhook it sends:

A test payment (shortened)
{
  "id": "9e3a858c7701308142d545f9",
  "order_id": "ORD-2001",
  "status": "pending",
  "token": "USDT_TRC20",
  "deposit_address": "TPk2bpQqWRpXiaUHS9SXnZCDgvwrsuJ71o",
  "livemode": false,
  "simulation": { "outcome": "confirmed", "state": "running" },
  "…": "…"
}

state is requested (asked for on create, waiting for the coin), running or done.

Outcomes#

outcomeWhat happensWebhooks, in order
confirmedThe exact amount arrives and confirmspayment.detecting, payment.confirmed
overpaid5% more than due arrivespayment.detecting, payment.confirmed with "overpaid": true
partially_paidHalf of what is still due arrives. The payment stays open: simulate again to pay the rest, or let it run outpayment.partially_paid
underpaidHalf arrives, then the deadline passespayment.partially_paid, payment.underpaid
expiredNothing arrives, and the deadline passes nowpayment.expired (payment.underpaid if it was partially paid)
failedThe amount arrives, then its transaction is droppedpayment.detecting, payment.failed, with the deposit's fail_reason (deposits[].fail_reason)
wrong_assetThe amount arrives in another coin on the same network (TRX for USDT on Tron, USDC for USDT on BSC…)payment.wrong_asset. With accept other assets on: payment.detecting, payment.wrong_asset, payment.confirmed
paid_lateHalf arrives, the deadline passes, then the rest arrivespayment.partially_paid, payment.underpaid, payment.detecting, payment.late_deposit, payment.confirmed with "paid_late": true

Each step comes about 5 seconds after the one before, and its webhook is posted within about 5 seconds of it, so a whole simulation is over in less than half a minute. Two events of the same step (payment.detecting and payment.late_deposit, say) can reach you in either order, as live ones can.

The rules

  • Only test payments can be simulated: a live key gets 400 only test payments can be simulated.
  • The payment must be pending or partially_paid, and have its coin (except for expired).
  • One simulation runs at a time: another request gets 409. Once it is done, you can run another, for example partially_paid and then confirmed to pay the rest.
  • wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain: a Bitcoin, Litecoin, Dogecoin or XRP address only receives its own coin. Asked for on create, it needs token.
StatusErrorCause
400outcome is requiredNo outcome in the body, or the body is not JSON
400simulate must be one of: confirmed, overpaid, …An unknown outcome
400only test payments can be simulated: use a test keyA live payment, or simulate with a live key
400choose the payment's coin firstNo coin yet, for anything but expired
400this payment is confirmed: only a pending or partially paid payment can be simulatedThe payment is closed or confirming
400a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chainwrong_asset on a coin that has no other on its network
400simulate wrong_asset needs the payment's coin: pass tokenwrong_asset on create without token
404payment not foundNo such payment for your key's mode (or, for a storefront's key, for its store)
409a simulation is already running for this paymentWait for its done

What a simulated payment looks like#

A simulated deposit is like a live one, with "simulated": true. Its tx_id looks like a transaction hash (64 hex characters) but is on no chain, so it has no explorer_url. Tell a simulated deposit by its simulated, never by its tx_id:

deposits
"deposits": [
  { "tx_id": "f916492ece53d1cb5bab9515cfed47d17648649cbd8e52b5a45f6e4da6e6fdf9", "token": "USDT_TRC20", "chain": "tron",
    "amount": "20", "usd_value": "20", "counted": true, "status": "confirmed", "simulated": true, "…": "…" }
]

Everything after it works as live:

  • The payment's confirmations, received_amount, overpaid and paid_late are worked out the same way.
  • Each deposit is settled when it is final (the parts of a payment paid in parts together, once it closes), into your test balance with the same fees as live (settlement in test mode). With direct settlement and a test payout address, the settlement completes at once and sends settlement.completed; nothing is sent to the address.
  • Coins are never converted in test mode: a deposit settles in the coin that arrived, into your test balance when you have no test payout address for it.
  • Payment links, customers, analytics and notifications all count test payments in the dashboard's test mode.

Never send real coins to a test address#

Nothing watches a test payment's address, so coins sent to it are never seen. Test addresses on Tron, Ethereum, BNB Smart Chain, Dogecoin and XRP look exactly like live ones, and a wallet will pay them: the coins would stay on the address until Payitgo's support recovers them. Bitcoin and Litecoin test addresses use the testnet formats, which real wallets refuse.

Scenarios to test#

Create a test payment for each, and check what your server receives.

ScenarioHowWhat you should see
Normal paymentconfirmedpayment.detecting, then payment.confirmed
Partial, then completedpartially_paid, then confirmed once it is donepayment.partially_paid, then payment.detecting, payment.confirmed
Underpaidunderpaidpayment.partially_paid, then payment.underpaid
Expiredexpired, or create a payment and simulate nothingpayment.expired (15 minutes later without a simulation, an hour if no coin was chosen)
Overpaidoverpaidpayment.confirmed with "overpaid": true
Late completionpaid_latepayment.underpaid, then payment.late_deposit and payment.detecting, then payment.confirmed with "paid_late": true
Dropped transactionfailedpayment.detecting, then payment.failed
Wrong assetwrong_assetpayment.wrong_asset; the deposit counts only with accept other assets on
XRPCreate with "token": "XRP" and simulateThe payment has its destination_tag; show it as prominently as the address
Customer chooses the coinCreate without token, with simulate; choose on the checkout pagetoken_options, then an address after the choice, then the outcome's webhooks
Retry safetyCreate the same order_id twiceThe second call returns the first payment with 200
Per-payment URLCreate a payment with webhook_url and simulateIts notifications go to that URL, not the account's

Test your webhook handler too:

ScenarioHowWhat you should see
Signature checkPOST /v1/webhook/test, and verify the pingYour handler answers 200
TamperingChange one byte of a captured body and send it to your handlerYour handler answers 400
RetriesAnswer 500 onceThe same notification again a minute later, with X-Webhook-Attempt: 2
DuplicatesRedeliver a delivery from the dashboardYour handler recognises the event id and does nothing twice
Test vs liveLook at livemodeYour production handler ignores "livemode": false

Going live#

  1. The account owner turns live mode on (POST /merchant/live {"enabled": true}, or in the dashboard).
  2. With the live key, read the live webhook secret (GET /v1/webhook) and set the live URL (PUT /v1/webhook).
  3. Put the live key and the live webhook secret on your production server. Remove any simulate from your payment requests: a live key refuses it.
  4. Check that your handler fulfils only "livemode": true events in production.
  5. Add a live payout address for each coin you accept, so payments reach your wallet as they are. A coin you have none for is converted into one you have, which costs more. Your first live address of a coin is used at once; another, or a changed one, after 24 hours.
  6. Make one small real payment end to end.

Account and team#

Your team, stores, settings, balances and payouts are managed in the dashboard, signed in as a person. Your servers only ever need an API key and the /v1 routes.

Team and roles#

The owner invites colleagues with the role they need; each signs in with their own email and password.

RoleCan
ownerEverything: team, settings, live mode, the audit log, rolling the webhook secret, payouts, the referral program
developerAPI keys, stores, the webhook URL and test, redelivering webhooks
financePayout addresses and payouts, the referral program
viewerReads payments, the dashboard, balances, the ledger, settlements and webhook deliveries; changes nothing

Every role can read payments, balances, settlements, payouts and webhook deliveries. Selling from several shops? Create a store for each and give each its own API keys. Each store has its own order ids, so two shops on one account can both number their orders from 1.

Payment settings#

The owner changes them in the dashboard, or with PATCH /merchant/settings, sending only the settings that change. A payment keeps the settings in force when it was created.

SettingDefaultWhat it does
underpaid_tolerance_bps50 (0.5%)How far short a payment may fall and still count as paid, 0 to 2000 (20%) (wrong amounts)
accept_other_assetsfalseWhether a deposit in another accepted coin counts toward the payment at its dollar value (wrong asset)
settlementdirectdirect, custodial or scheduled (where the money goes)
payout_scheduledailyScheduled settlement: instant, daily or weekly (on Mondays)
fees_paid_by_customertrue (a new account)Whether the customer pays the service and network fees on top of your price (passing fees on)
convert_intocheapestDirect settlement: what a coin you have no payout address for is converted into: cheapest, order or a coin (which coin); "" sets the default
hide_below_minimumfalseDirect settlement, fees passed on: false offers a coin converted across networks even for a payment too small for the exchange's minimum (the customer pays up to it, you receive the extra: extra_usd); true offers it only from the price that reaches the minimum (minimums)
enabled_tokensevery tokenThe coins you accept, at least one

Accepted tokens. Only the coins in enabled_tokens are offered to your customers, listed by GET /v1/tokens and allowed in a payment's token or tokens (others get 400 not enabled for your account: …). A deposit in a coin you have not enabled still reaches you, but never counts toward the payment. Enabling every token also accepts tokens Payitgo adds later. GET /merchant/tokens (any role; ?mode=test for test) lists every supported token, marked enabled or not, with its fee and minimum right now and where its payments go: settles_to (wallet or balance) and, for a coin converted on its way to your wallet, paid_out_in.

Balances and payouts#

  • Balances. In custody (and scheduled settlement), what a payment leaves after the fees is credited to your balance, always in USDT on Tron. Test payments credit your test balance, which is never paid out. Your ledger lists every movement. Referral commissions are not part of this balance: they make your referral balance, paid out on its own.
  • Payout addresses are your wallets: where payouts go and, with direct settlement, where each coin's payments are sent. Add one for each coin you accept: that coin's payments are then sent to you as they arrive, with no conversion, at the lowest fee for your customers; a coin without one is converted into one of your payout coins first, and costs them more (why). Owners and finance add them (the password is asked again). A coin's first live address is used at once; another, or a changed one, 24 hours later (active_at), and payments due to it wait for it meanwhile (how); test addresses at once, and nothing is ever sent to them.
  • Payouts are live only: request one to a live payout address, $10 or more. The amount is held from your balance at once and the network fee of sending it comes out of it. Payitgo reviews each payout: requested → approved → sent, or rejected (by Payitgo) or canceled (by you, while still requested), which return the amount to your balance. Each step Payitgo takes raises a payout.* webhook to your live URL.
  • USDT on any network. USDT is worth $1 on every network, so a USDT balance is paid out to any of your USDT payout addresses: your custody balance, held in USDT on Tron, to your USDT address on BNB Smart Chain or Ethereum as well. The server picks the balance (the address's own coin when it has enough, else another USDT balance that has, USDT on Tron first), and the payout's from_token and from_chain say which. Sending USDT costs about a cent on BNB Smart Chain and a few dollars on Tron; on Ethereum it varies with how busy the network is. A payout in USDT on BNB Smart Chain or Ethereum is sent by Payitgo itself, usually within minutes of its approval. Before requesting one, GET /merchant/payouts/quote?address_id=…&amount=… answers the network fee now, the minimum, what would arrive and the balance it would draw on (nothing is held).
  • Automatic payouts. With direct settlement, what stays in your balance is paid out automatically: Payitgo pays a USDT balance, all of it, to your USDT address on BNB Smart Chain that direct settlement uses (past its active_at), else to the one on Ethereum, for a network fee of about a cent on BNB Smart Chain. The payout has requested_by: "auto". A balance under $10 waits, and without such an address you request payouts as usual. Custody is paid out on request, and scheduled settlement on its schedule.

Referral program#

Share your referral link (GET /merchant/affiliate, owner or finance): a business that opens an account through it is your referral, and each of its live payments earns you 25% of Payitgo's service fee on it, 30% once you have 5 active referrals (whose payments earned you a commission in the last 30 days), for 5 years from each referral's signup. Only the service fee is shared, never the network or conversion fee. Test payments earn nothing, and nor does referring yourself: an account sharing a live payout address with yours has its referral voided.

  • Referral balance. Each commission is credited in USDT on Tron once the payment's fee is final, to a referral balance apart from your account's own: GET /merchant/balances, the ledger and your payouts never include it. GET /merchant/affiliate shows it (available, pending_payout, paid_out, earned) with your code, link, terms and figures.
  • Referral payouts. Ask for all of available, or part of it, from $50: to one of your live USDT_TRC20 payout addresses (your default unless you give address_id), or to any other Tron address you give with your password. The network fee, fixed when you ask, comes out of it (send_amount is what arrives), and Payitgo pays it within 5 business days (due_at).
  • One open at a time. A payout is requested, then processing and paid (with its tx_id). One rejected by Payitgo (with a note), or canceled by you while still requested, gives the amount back to available. Owners and finance are notified of each request, payment and rejection.
MethodPathWhat it does
GET/merchant/affiliateYour code, link, terms, figures, referral balance and payout terms (the open payout, if any)
GET/merchant/affiliate/referrals, /merchant/affiliate/commissionsThe merchants you referred, and each commission credited, newest first
POST/merchant/affiliate/payoutsRequest a payout: amount (absent: all of it), and address_id or address with password. 1 a second per user
GET/merchant/affiliate/payoutsYour referral payouts, newest first; filter by status. One: /merchant/affiliate/payouts/{id}
POST/merchant/affiliate/payouts/{id}/cancelCancel a payout while it is requested (409 after)

The program is live only: these routes take no mode, and are for owners and finance (others get 403). A dashboard kept open gets affiliate:balance whenever the referral balance changes and affiliate:payout on every change of a payout.

Errors & rate limits#

Every error, on every route, is JSON with one field, error, and an HTTP status that says what kind of error it is. Branch on the status and log the message: messages are for developers and may be reworded, so do not match on their text or show them to customers.

An error
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-Id: Rz8vKm3pWq6tXc1nLb9sHd4fJg7yAe2M

{"error": "minimum payment in USDT_TRC20 is $6.44 right now (network fees)"}

Every response carries an X-Request-Id header: quote it when you contact support.

StatusMeaningTypical causesRetry?
200DoneA read, an update, or POST /v1/payments returning the order's open payment
201CreatedA new payment, key, user, store or payout
202QueuedA webhook test or a redelivery: it is sent within seconds
400The request is invalidA missing or malformed field, an unsupported token, an amount under the minimum, a webhook URL that is not httpsNo: fix it
401Not authenticatedmissing api key, invalid api key (unknown or revoked); in the dashboard, a missing or expired access tokenNo
403Not allowedLive key while live mode is off, a suspended account, a role without the permission, a read-only key writing, a storefront key changing the webhook, a blocked customer, a wrong passwordNo
404No such thingAn unknown id, a payment of the other mode, an order with no open payment, an unknown routeNo
409Clashes with the current stateAn email already registered, removing the last owner, choosing a payment's coin twice, simulating a test payment while its simulation runs, cancelling a payout that is no longer requestedNo
413Body over 1 MiBNo
429Over the rate limitSee belowYes, after a short wait
500Something failed on Payitgo's sidepayment creation failed, could not set the webhook…Yes, with backoff
503Temporarily unavailableexchange rate unavailable for price_currency (a price in another currency): nothing was createdYes, shortly

Rate limits#

RoutesLimitCounted per
/v1/*20 requests a second, bursts up to 40API key
POST /checkout/{checkout_token}/token5 requests a second, bursts up to 10IP address
POST /checkout/{checkout_token}/simulate5 requests a second, bursts up to 10IP address
/merchant/login, /merchant/signup5 requests a second, bursts up to 10IP address

Over the limit you get 429 rate limit exceeded: wait a second and retry. Webhooks make polling unnecessary for almost every integration.

Retrying safely#

On a 5xx or a network error, retry with exponential backoff (1 s, 2 s, 4 s… up to a minute); the libraries retry twice on their own. These are safe to repeat: POST /v1/payments with the same order_id and key (you get the open payment back), PUT and DELETE /v1/webhook, and every GET.

  1. Requests are JSON with Content-Type: application/json: without that header the body is not read. Unknown fields in requests are ignored.
  2. Amounts are decimal strings in responses ("49.9"): parse them with a decimal type, not a float. Requests accept a number or a string.
  3. Times are RFC 3339 in UTC, with a varying number of fractional digits.
  4. Optional fields are left out when empty, and new fields may appear at any time: parse leniently.
  5. Ids are opaque strings: store and compare them whole, and never read meaning into their shape. An id is 24 lowercase hex characters (6710c3a1f2b4e5d6a7b8c9d0), whatever it is: a payment, a settlement, an event, an API key. API keys and webhook signing secrets are 64 hex characters; they do not say their mode either (a key's livemode does).
  6. Lists take limit (1 to 200, default 50) and offset, and return total.

Libraries#

Official libraries wrap the API for Node.js, Python, PHP, Go and Dart. They all create and read payments, simulate test payments, list coins, manage your webhook URL, verify webhook notifications in one call, and call the public checkout API. Field names are the API's own, so everything above applies. Use them on your server: they need your secret key. Two exceptions need none: Dart's PayitgoCheckout, made for Flutter apps, and the React checkout widget. A WooCommerce shop needs no code: use the WooCommerce plugin.

LanguageInstallImport
Node.js / TypeScriptnpm install @payitgo/nodeimport { Payitgo } from "@payitgo/node"
Pythonpip install payitgoimport payitgo
PHPcomposer require payitgo/payitgo-phpuse Payitgo\Client;
Gogo get github.com/payitgo/go-sdkimport payitgo "github.com/payitgo/go-sdk"
Dart / Flutterdart pub add payitgoimport 'package:payitgo/payitgo.dart';
React (widget)npm install @payitgo/reactimport { PayitgoButton, payitgoCheckout } from "@payitgo/react"
CallNode.js and DartPythonPHPGo
Createpayments.createpayments.createpayments->createPayments.Create
Read onepayments.retrievepayments.retrievepayments->retrievePayments.Get
Listpayments.listpayments.listpayments->listPayments.List
Choose the coinpayments.selectTokenpayments.select_tokenpayments->selectTokenPayments.SelectToken
Simulate (test keys)payments.simulate(id, outcome)payments.simulate(payment_id, outcome)payments->simulate($id, $outcome)Payments.Simulate(ctx, id, outcome)
Coins on offertokens.listtokens.listtokens->listTokens.List
Webhook URLwebhooks.get / set / remove / testwebhooks.get / set / remove / testwebhooks->get / set / remove / testWebhooks.Get / Set / Remove / Test
Verifywebhooks.constructEventwebhooks.construct_eventwebhooks->constructEventWebhooks.ConstructEvent
Test or live (asks once)isLive()is_live()isLive()IsLive(ctx)
Payer view (no key)checkout.retrieve (Dart also watch and simulate)checkout.retrievecheckout->retrieveCheckout.Get
Payer chooses the coin (no key)checkout.selectTokencheckout.select_tokencheckout->selectTokenCheckout.SelectToken
  • Mode. The key decides it; pair each key with the webhook secret of the same mode. The key's text does not say which: isLive() (is_live() in Python, IsLive(ctx) in Go) asks Payitgo on its first call (GET /v1/webhook's livemode, allowed for a read-only key) and keeps the answer for the client's life; an error is not kept. A missing or empty key is refused when the client is created, before any request: Error in Node, ValueError in Python, InvalidArgumentException in PHP, an error from NewClient in Go, ArgumentError in Dart.
  • Base URL. Without the base URL option, the libraries read PAYITGO_BASE_URL from the environment, then fall back to the address they were released with. Dart reads no environment variable: pass it as baseUrl.
  • Timeouts. Each attempt may take 30 s by default: timeoutMs in Node, timeout in seconds in Python and PHP, a Duration in Dart; in Go, the http.Client's Timeout and your ctx. Go (WithHTTPClient) and Dart (httpClient) also take your own HTTP client.
  • Retries. Network errors, timeouts, 429 and 5xx are retried up to twice (maxRetries, max_retries, WithMaxRetries; 0 turns it off), after 0.5 s, then 1 s; other statuses are never retried. Every call is safe to repeat: creating a payment again for the same order_id returns its open payment, choosing a coin twice answers 409 (read the payment: it has its coin), and setting or removing the webhook URL twice changes nothing.
  • A default webhook URL. Set it on the client (webhookUrl in Node and Dart, webhook_url in Python and PHP, payitgo.WithWebhookURL(u) in Go) and every payment the client creates gets that webhook_url; a payment's own webhook_url wins.
  • WooCommerce. A WooCommerce shop needs no code: the Payitgo for WooCommerce plugin adds crypto to its checkout and marks orders paid by itself.

A failed call throws (Go: returns) the library's error, with the API's HTTP status (0 when no answer came, after the retries) and its error message. Branch on the status; log the message. A notification that fails verification throws its own error: answer 400.

A failed API callStatus, messageA bad signature
Node.jsPayitgoError.status, .messageSignatureVerificationError
Pythonpayitgo.PayitgoError.status, .messagepayitgo.SignatureVerificationError
PHPPayitgoException->status, ->getMessage()SignatureVerificationException (both in Payitgo\Exception)
Go*payitgo.Error (errors.As).Status, .Messagepayitgo.ErrSignature (errors.Is)
DartPayitgoException.status, .messageSignatureVerificationException

Each library verifies a notification in constant time against the raw body and refuses a timestamp more than 5 minutes from your clock. To verify without a client, or with another tolerance:

Without a clientAnother tolerance
Node.jsPayitgo.webhooks.constructEvent(body, sig, secret)4th argument, in seconds
Pythonpayitgo.Webhook.construct_event(body, sig, secret)tolerance=
PHP\Payitgo\Webhook::constructEvent($body, $sig, $secret)4th argument, in seconds
Gopayitgo.ConstructEvent(body, sig, secret)payitgo.ConstructEventWithTolerance(…, d)
DartWebhook.constructEvent(body, sig, secret)tolerance:, a Duration

Node.js#

The @payitgo/node package: Node 18+, no dependencies, TypeScript types included, CommonJS and ES modules. Methods return promises.

Install

Terminal
npm install @payitgo/node

Create a client

Node.js
import { Payitgo, PayitgoError } from "@payitgo/node";

const payitgo = new Payitgo(process.env.PAYITGO_KEY!, { // your test key or live key
  baseUrl: process.env.PAYITGO_BASE_URL,                // your Payitgo API host (also read from PAYITGO_BASE_URL)
  webhookSecret: process.env.PAYITGO_WEBHOOK_SECRET,    // the signing secret of the key's mode
  timeoutMs: 15_000,                                    // per attempt (default 30 s)
  maxRetries: 2,                                        // network errors, 429 and 5xx (default 2)
});
await payitgo.isLive(); // false for a test key: asks Payitgo once (GET /v1/webhook), then remembers

Create a payment

Node.js
const payment = await payitgo.payments.create({
  order_id: "ORD-1001",
  amount_usd: "49.90",
  token: "USDT_TRC20",            // or leave it out: payment.token_options lists the choices
  customer_email: "[email protected]",
  success_url: "https://acme.example/thanks",
});
// Later, on your own page: await payitgo.payments.selectToken(payment.id, "USDT_BEP20")

Retrieve and list

Node.js
const one = await payitgo.payments.retrieve("8f3a2c1e9b7d4a6f05e1c2d3"); // or your order_id
const page = await payitgo.payments.list({ status: "confirmed", limit: 100, offset: 0 });
const coins = await payitgo.tokens.list({ amount_usd: "49.90" });

Simulate a test payment

Node.js
// payments.simulate(id: string, outcome: SimulationOutcome): Promise<Payment>
// A test payment that pays itself: payment.detecting, then payment.confirmed, within half a minute
const payment = await payitgo.payments.create({
  order_id: "TEST-1", amount_usd: 10, token: "USDT_TRC20", simulate: "confirmed",
});
console.log(payment.livemode, payment.simulation?.state); // false "running"

// Another outcome on a payment you already have (payment id or order_id):
await payitgo.payments.simulate("TEST-2", "underpaid");

Verify a webhook

Node.js
import express from "express";

const app = express();
// Register before any app.use(express.json()), or the body is parsed and will not verify
app.post("/payitgo/ipn", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = payitgo.webhooks.constructEvent(req.body, req.get("X-Webhook-Signature"));
  } catch {
    return res.sendStatus(400); // SignatureVerificationError
  }
  if (event.livemode === (await payitgo.isLive()) && event.type === "payment.confirmed") {
    await markOrderPaid(event.data.order_id, event.data.id); // idempotent, deduplicated on event.id
  }
  res.sendStatus(200);
});

Handle errors

Node.js
try {
  await payitgo.payments.create({ order_id: "ORD-1", amount_usd: 1, token: "BTC" });
} catch (err) {
  if (err instanceof PayitgoError && err.status === 400) {
    console.warn("Payitgo refused:", err.message); // e.g. an amount under the coin's minimum
  } else {
    throw err; // status 0 = no answer (after the retries), 401, 403, 404, 409, 429, 5xx
  }
}

Python#

Published as payitgo, imported as payitgo: Python 3.8+, standard library only. Responses are PayitgoObjects: dicts with the API's own keys that also read as attributes (use .get() for optional ones). The client is synchronous and safe to share across threads; in async code call it from a plain def route or with asyncio.to_thread.

Install

Terminal
pip install payitgo

Create a client

Python
import os
import payitgo

client = payitgo.Client(
    os.environ["PAYITGO_KEY"],                           # your test key or live key
    base_url=os.environ.get("PAYITGO_BASE_URL"),         # your Payitgo API host
    webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # the signing secret of the key's mode
    timeout=15,                                          # seconds per attempt (default 30)
    max_retries=2,                                       # network errors, 429 and 5xx (default 2)
)
client.is_live()  # False for a test key: asks Payitgo once (GET /v1/webhook), then remembers

Create a payment

Python
payment = client.payments.create(
    order_id="ORD-1001",
    amount_usd="49.90",
    token="USDT_TRC20",  # or leave it out: payment.token_options lists the choices
    customer_email="[email protected]",
    success_url="https://acme.example/thanks",
)
# Later, on your own page: client.payments.select_token(payment.id, "USDT_BEP20")

Retrieve and list

Python
one = client.payments.retrieve("8f3a2c1e9b7d4a6f05e1c2d3")  # or your order_id
page = client.payments.list(status="confirmed", limit=100, offset=0)
coins = client.tokens.list(amount_usd="49.90")

Simulate a test payment

Python
# client.payments.simulate(payment_id, outcome) -> PayitgoObject
# A test payment that pays itself: payment.detecting, then payment.confirmed, within half a minute
payment = client.payments.create(order_id="TEST-1", amount_usd=10, token="USDT_TRC20", simulate="confirmed")
print(payment.livemode, payment.simulation.state)  # False running

# Another outcome on a payment you already have (one of payitgo.SIMULATION_OUTCOMES):
client.payments.simulate("TEST-2", "underpaid")

Verify a webhook

Python
from flask import Flask, request

app = Flask(__name__)

@app.post("/payitgo/ipn")
def ipn():
    try:
        event = client.webhooks.construct_event(
            request.get_data(),  # the raw bytes, before any JSON parsing
            request.headers.get("X-Webhook-Signature"),
        )
    except payitgo.SignatureVerificationError:
        return "", 400
    if event.livemode == client.is_live() and event.type == "payment.confirmed":
        mark_order_paid(event.data.order_id, event.data.id)  # idempotent, deduplicated on event.id
    return "", 200

Handle errors

Python
try:
    client.payments.create(order_id="ORD-1", amount_usd=1, token="BTC")
except payitgo.PayitgoError as e:
    if e.status == 400:
        log.warning("Payitgo refused: %s", e.message)  # e.g. an amount under the coin's minimum
    else:
        raise  # status 0 = no answer (after the retries), 401, 403, 404, 409, 429, 5xx

PHP#

The payitgo/payitgo-php Composer package: PHP 8.1+ with the curl and json extensions, PSR-4 namespace Payitgo\. Responses are arrays with the API's own keys.

Install

Terminal
composer require payitgo/payitgo-php

Create a client

PHP
<?php
require __DIR__ . '/vendor/autoload.php';

$payitgo = new \Payitgo\Client(getenv('PAYITGO_KEY'), [   // your test key or live key
    'base_url' => getenv('PAYITGO_BASE_URL'),             // your Payitgo API host
    'webhook_secret' => getenv('PAYITGO_WEBHOOK_SECRET'), // the signing secret of the key's mode
    'timeout' => 15,                                      // seconds per attempt (default 30)
    'max_retries' => 2,                                   // network errors, 429 and 5xx (default 2)
]);
$payitgo->isLive(); // false for a test key: asks Payitgo once per client (GET /v1/webhook)

Create a payment

PHP
$payment = $payitgo->payments->create([
    'order_id' => 'ORD-1001',
    'amount_usd' => '49.90',
    'token' => 'USDT_TRC20',  // or leave it out: $payment['token_options'] lists the choices
    'customer_email' => '[email protected]',
    'success_url' => 'https://acme.example/thanks',
]);
// Later, on your own page: $payitgo->payments->selectToken($payment['id'], 'USDT_BEP20');

Retrieve and list

PHP
$one = $payitgo->payments->retrieve('8f3a2c1e9b7d4a6f05e1c2d3'); // or your order_id
$page = $payitgo->payments->list(['status' => 'confirmed', 'limit' => 100, 'offset' => 0]);
$coins = $payitgo->tokens->list('49.90');

Simulate a test payment

PHP
// public function simulate(string $id, string $outcome): array
// A test payment that pays itself: payment.detecting, then payment.confirmed, within half a minute
$payment = $payitgo->payments->create([
    'order_id' => 'TEST-1', 'amount_usd' => 10, 'token' => 'USDT_TRC20', 'simulate' => 'confirmed',
]);
var_dump($payment['livemode'], $payment['simulation']['state']); // bool(false) string(7) "running"

// Another outcome on a payment you already have (one of \Payitgo\Payments::SIMULATION_OUTCOMES):
$payitgo->payments->simulate('TEST-2', 'underpaid');

Verify a webhook

PHP
<?php
// ipn.php
use Payitgo\Exception\SignatureVerificationException;

try {
    $event = $payitgo->webhooks->constructEvent(
        file_get_contents('php://input'),               // the raw body (Laravel: $request->getContent(), and exempt the route from CSRF)
        $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
    );
} catch (SignatureVerificationException) {
    http_response_code(400);
    exit;
}
if ($event['livemode'] === $payitgo->isLive() && $event['type'] === 'payment.confirmed') {
    markOrderPaid($event['data']['order_id'], $event['data']['id']); // idempotent, deduplicated on $event['id']
}
http_response_code(200);

Handle errors

PHP
use Payitgo\Exception\PayitgoException;

try {
    $payitgo->payments->create(['order_id' => 'ORD-1', 'amount_usd' => 1, 'token' => 'BTC']);
} catch (PayitgoException $e) {
    if ($e->status !== 400) {
        throw $e; // status 0 = no answer (after the retries), 401, 403, 404, 409, 429, 5xx
    }
    error_log('Payitgo refused: ' . $e->getMessage()); // e.g. an amount under the coin's minimum
}

Go#

Go 1.21+, standard library only. Import it with the name payitgo (the path ends in go). Every call takes a context.Context, which bounds the whole call, retries included, and returns an error last: check it before the result. A *Client is safe for concurrent use.

Install

Terminal
go get github.com/payitgo/go-sdk

Create a client

Go
import payitgo "github.com/payitgo/go-sdk"

client, err := payitgo.NewClient(os.Getenv("PAYITGO_KEY"),           // your test key or live key
	payitgo.WithBaseURL(os.Getenv("PAYITGO_BASE_URL")),              // your Payitgo API host
	payitgo.WithWebhookSecret(os.Getenv("PAYITGO_WEBHOOK_SECRET")),  // the signing secret of the key's mode
	payitgo.WithHTTPClient(&http.Client{Timeout: 15 * time.Second}), // per attempt (default 30 s)
	payitgo.WithMaxRetries(2),                                       // network errors, 429 and 5xx (default 2)
)
if err != nil {
	log.Fatal(err)
}
live, err := client.IsLive(ctx) // false for a test key: asks Payitgo once (GET /v1/webhook), then remembers

Create a payment

Go
payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID:       "ORD-1001",
	AmountUSD:     "49.90",
	Token:         "USDT_TRC20", // or leave it out: payment.TokenOptions lists the choices
	CustomerEmail: "[email protected]",
	SuccessURL:    "https://acme.example/thanks",
})
// Later, on your own page: client.Payments.SelectToken(ctx, payment.ID, "USDT_BEP20")

Retrieve and list

Go
one, err := client.Payments.Get(ctx, "8f3a2c1e9b7d4a6f05e1c2d3") // or your order_id
page, err := client.Payments.List(ctx, &payitgo.ListParams{Status: payitgo.StatusConfirmed, Limit: 100})
coins, err := client.Tokens.List(ctx, &payitgo.TokenListParams{AmountUSD: "49.90"})

Simulate a test payment

Go
// func (s *PaymentsService) Simulate(ctx context.Context, id, outcome string) (*Payment, error)
// A test payment that pays itself: payment.detecting, then payment.confirmed, within half a minute
payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID: "TEST-1", AmountUSD: "10", Token: "USDT_TRC20", Simulate: payitgo.OutcomeConfirmed,
})
if err != nil {
	return err
}
fmt.Println(payment.Livemode, payment.Simulation.State) // false running

// Another outcome on a payment you already have (a payitgo.Outcome… constant):
if _, err := client.Payments.Simulate(ctx, "TEST-2", payitgo.OutcomeUnderpaid); err != nil {
	return err
}

Verify a webhook

Go
func ipn(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the raw bytes
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	event, err := client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest) // errors.Is(err, payitgo.ErrSignature)
		return
	}
	live, err := client.IsLive(r.Context()) // asks Payitgo once, then remembers
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError) // Payitgo retries
		return
	}
	if event.Livemode == live && event.Type == payitgo.EventPaymentConfirmed {
		p, err := event.Payment()
		if err == nil {
			err = markOrderPaid(r.Context(), p.OrderID, p.ID) // idempotent, deduplicated on event.ID
		}
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError) // Payitgo retries
			return
		}
	}
	w.WriteHeader(http.StatusOK)
}

Handle errors

Go
_, err := client.Payments.Create(ctx, &payitgo.PaymentParams{OrderID: "ORD-1", AmountUSD: "1", Token: "BTC"})
var perr *payitgo.Error
if errors.As(err, &perr) && perr.Status == http.StatusBadRequest {
	log.Printf("Payitgo refused: %s", perr.Message) // e.g. an amount under the coin's minimum
} else if err != nil {
	return err // Status 0 = no answer (after the retries), 401, 403, 404, 409, 429, 5xx
}

Dart & Flutter#

The payitgo package for Dart 3 (Flutter 3.10+), on every platform (it does not import dart:io); it depends on http and crypto. Two clients: Payitgo with your secret key, for your server; and PayitgoCheckout, with no key, for Flutter apps.

Install

Terminal
dart pub add payitgo      # Dart
flutter pub add payitgo   # Flutter

Create a client

Dart & Flutter
import 'dart:io';
import 'package:payitgo/payitgo.dart';

final payitgo = Payitgo(
  Platform.environment['PAYITGO_KEY']!,                          // your test key or live key
  baseUrl: Platform.environment['PAYITGO_BASE_URL'],             // your Payitgo API host (read from no env variable)
  webhookSecret: Platform.environment['PAYITGO_WEBHOOK_SECRET'], // the signing secret of the key's mode
  timeout: const Duration(seconds: 15),                          // per attempt (default 30 s)
  maxRetries: 2,                                                 // network errors, 429 and 5xx (default 2)
  // httpClient: your own package:http client (an IOClient, a MockClient in tests); you close it
);
await payitgo.isLive(); // false for a test key: asks Payitgo once, then remembers; call payitgo.close() when done

Create a payment

Dart & Flutter
final payment = await payitgo.payments.create(
  orderId: 'ORD-1001',
  amountUsd: '49.90',
  token: 'USDT_TRC20', // or leave it out: payment.tokenOptions lists the choices
  customerEmail: '[email protected]',
  successUrl: 'https://acme.example/thanks',
);
// Later, on your own page: await payitgo.payments.selectToken(payment.id, 'USDT_BEP20');

Retrieve and list

Dart & Flutter
final one = await payitgo.payments.retrieve('8f3a2c1e9b7d4a6f05e1c2d3'); // or your order_id
final page = await payitgo.payments.list(status: 'confirmed', limit: 100, offset: 0);
final coins = await payitgo.tokens.list(amountUsd: '49.90');

Simulate a test payment

Dart & Flutter
// Future<Payment> simulate(String id, String outcome)
// A test payment that pays itself: payment.detecting, then payment.confirmed, within half a minute
final payment = await payitgo.payments.create(
    orderId: 'TEST-1', amountUsd: '10', token: 'USDT_TRC20', simulate: 'confirmed');
print('${payment.livemode} ${payment.simulation?.state}'); // false running

// Another outcome on a payment you already have (one of Payments.simulationOutcomes):
await payitgo.payments.simulate('TEST-2', 'underpaid');

Verify a webhook

Dart & Flutter
import 'package:shelf/shelf.dart';

Future<Response> ipn(Request request) async {
  final WebhookEvent event;
  try {
    event = payitgo.webhooks.constructEvent(
      await request.readAsString(), // the raw body, before any JSON parsing
      request.headers['x-webhook-signature'],
    );
  } on SignatureVerificationException {
    return Response(400);
  }
  if (event.livemode == await payitgo.isLive() && event.type == 'payment.confirmed') {
    final p = Payment(event.data);
    await markOrderPaid(p.orderId, p.id); // idempotent, deduplicated on event.id
  }
  return Response.ok('');
}

Handle errors

Dart & Flutter
try {
  await payitgo.payments.create(orderId: 'ORD-1', amountUsd: '1', token: 'BTC');
} on PayitgoException catch (e) {
  if (e.status != 400) rethrow; // 0 = no answer (after the retries), 401, 403, 404, 409, 429, 5xx
  print('Payitgo refused: ${e.message}'); // e.g. an amount under the coin's minimum
}

Flutter apps: PayitgoCheckout

Never ship a secret key in an app. Your backend creates the payment and hands the app its checkoutToken; PayitgoCheckout needs no key. It reads the payment and lets the customer choose the coin (selectToken). For updates the moment they happen, follow the payment with the socket (subscribe with the checkout token and an acknowledgement). watch is the fallback for apps that cannot use socket.io: it polls every 5 seconds and emits the payment each time its status changes, including a late payment within 24 hours. For a test payment, checkout.simulate(checkoutToken, outcome) plays an outcome with no key, so the app can show “simulate” buttons under its “Test payment” banner (never for a live payment, which answers 400; beyond 5 requests a second per IP it answers 429). Your backend still fulfils the order from the webhook.

Flutter
final checkout = PayitgoCheckout(baseUrl: 'https://api.payitgo.example'); // your Payitgo API host

// The customer picks a coin from payment.tokenOptions:
final chosen = await checkout.selectToken(checkoutToken, 'USDT_TRC20');
// show chosen.cryptoAmount, chosen.depositAddress, chosen.qrPayload (qr_flutter), chosen.expiresAt

// Test payments only: play an outcome from the app, no key needed
if (!chosen.livemode && chosen.simulation?.state != 'running') {
  await checkout.simulate(checkoutToken, 'confirmed');
}

// The fallback when the app cannot use socket.io: polls every 5 seconds
await for (final p in checkout.watch(checkoutToken)) {
  if (p.isConfirmed) showPaid();
}
checkout.close(); // when no screen needs it any more

WooCommerce plugin#

Payitgo for WooCommerce adds crypto to a WooCommerce shop's checkout, with no code to write. The customer chooses it, pays on Payitgo's hosted checkout, and comes back to the shop; the order is marked paid once the payment is final.

Plugin
DownloadThe zip of the latest release: payitgo-for-woocommerce-X.Y.Z.zip
Sourcegithub.com/payitgo/woocommerce
NeedsWordPress 6.3+, WooCommerce 8.0+, PHP 7.4+
CheckoutThe block checkout and the classic checkout
OrdersWooCommerce's order tables (HPOS) or posts
Shop currencyAny. A shop in US dollars sends amount_usd; any other sends price_amount and price_currency, converted at the rate of the moment (prices in other currencies)

Install

  1. Download payitgo-for-woocommerce-X.Y.Z.zip from the latest release and upload it in WordPress (Plugins > Add New > Upload Plugin). Activate it.
  2. Copy a secret key from your dashboard (Developers > API keys), one with full access: the plugin creates payments, and a read-only key cannot. A test key to try it out, a live key for real payments. Several shops on one account: give each the key of its own store (below).
  3. In WooCommerce > Settings > Payments > Payitgo (crypto), paste the key and enable it.

No webhook to set up

When you save the settings, the plugin asks Payitgo whether the key is a test or a live key (GET /v1/webhook's livemode) and reads your webhook's signing secret with it; an unknown key is reported (“payitgo does not know this secret API key: copy it again from your payitgo dashboard”). When the shop is on https, every payment it creates carries the shop's own notification address as its webhook_url, so each payment tells the shop how it went; otherwise the order-received page reads the payment when the customer comes back. There is nothing to change in your account's webhook settings.

Settings

SettingDefault
Title, Description"Crypto (USDT, BTC, ETH and more)"What customers see at checkout
Secret API keyNoneA test key to try it out, a live key for real payments. Its mode is the plugin's: a test key's payments are simulated
Signing secretRead with the keyPin one only if you must; otherwise the plugin reads it, and reads it again once if you roll it
API addresshttps://api.payitgo.exampleYour Payitgo API host
Debug logOffWooCommerce > Status > Logs, source payitgo. Errors are always logged

What it sends

When the customer places the order, the plugin creates a payment:

FieldFrom the order
order_idThe order number, as it is: 1234
amount_usdThe total, for a shop in US dollars
price_amount, price_currencyThe total and the shop's currency, for any other shop
descriptionThe items: 2 x Acme T-shirt, Mug
customer_emailThe billing email
success_url, cancel_urlThe order-received page, and WooCommerce's cancel link
webhook_urlThe shop's notification address, https://<shop>/wc-api/payitgo/, when the shop is on https
metadatawc_order_id (how the plugin finds the order), wc_order_number, shop

The customer is then sent to the payment's checkout_url, where they choose the coin. Paying an order again (after its payment expired) makes a new payment; while one is open, the customer gets that one.

Several shops on one account

Order ids are unique per store. Create a store for each shop (Stores in the dashboard) and give each shop the key of its own store: two shops can then both have an order 1234. Shops that share one key would be given each other's open payment for the same order number: the plugin refuses such a payment, and logs why.

How orders follow the payment

The plugin verifies each notification, ignores one it handled already, one of the other mode, or one for a payment the order did not make, and moves the order by the payment's status:

PaymentOrder
detectingOn hold: the money arrived, confirmations are pending
confirmedPaid (processing, or completed for virtual goods), with the transaction id. On hold instead when the payment was for another amount than the order's total
partially_paidA note: the customer can still send the rest
underpaidOn hold, with what arrived: refund the customer or agree on the rest
expired, failedFailed: the customer can pay again from their account

A deposit in another coin (payment.wrong_asset), or after the payment closed (payment.late_deposit), adds a note. The order-received page also reads the payment, so a shop that notifications cannot reach (not on https, or behind a firewall) learns the outcome when the customer comes back.

Test it

With a test key, the checkout page lets you choose how the payment goes: place an order, choose a coin, and play confirmed: the order goes on hold, then to processing, within seconds. Play expired to see an order fail, then pay it again from the customer's account.

Refunds

Not from WooCommerce yet: send the coins back from your wallet, then mark the order refunded.

React checkout widget (@payitgo/react)#

Shows a payment on your own page, in a modal: the customer chooses the coin, sees the exact amount, network, QR code, address (and XRP destination tag), pays from their wallet, and watches the payment confirm. It needs no key: your server creates the payment and hands the browser its checkout_token; the widget calls the public checkout API and follows the payment live.

From 0.4.0, the customer can also change the coin before paying: while the payment still has token_options (a coin chosen, nothing arrived), the pay screen has a Change coin button that reopens the list with the chosen coin selected, and “Back”. Another coin gets a new address, amount and 15 minutes. A change refused because a payment arrived meanwhile says so. The offline demo follows the same rules.

Package@payitgo/react
Installnpm install @payitgo/react (0.4.0 or later lets the customer change the coin)
Sourcegithub.com/payitgo/react-sdk
NeedsReact 18 or 19; it brings qrcode.react and socket.io-client
ModuleESM, with TypeScript types. The components are marked 'use client'

Show a payment

PayButton.tsx
import { PayitgoButton, payitgoCheckout } from "@payitgo/react";

const adapter = payitgoCheckout({
  baseUrl: "https://api.payitgo.example",
  // Ask your server for the payment and return its checkout_token.
  createPayment: async () =>
    (await fetch("/orders/42/payitgo", { method: "POST" }).then((r) => r.json())).checkout_token,
});

export function PayButton() {
  return (
    <PayitgoButton
      adapter={adapter}
      payment={{ title: "Order #42", amount: { value: 49.9, currency: "USD" } }}
      onSuccess={(result) => showThanks(result.paymentId)}
    >
      Pay with crypto
    </PayitgoButton>
  );
}

On your server, the route the widget calls creates the payment with your secret key and answers with its checkout_token:

server.ts (Node.js)
app.post("/orders/:id/payitgo", async (req, res) => {
  const order = await orders.get(req.params.id);
  // No token: the customer chooses the coin in the widget.
  const payment = await payitgo.payments.create({ order_id: order.id, amount_usd: order.total });
  res.json({ checkout_token: payment.checkout_token });
});
  • Never put a secret key in browser code. The checkout token only shows the payer's view of one payment.
  • Each time the widget opens it calls createPayment() again. While the order has an open payment, creating it again returns that same payment, so a customer who closes and reopens the widget sees the same address and amount.
  • onSuccess runs in the customer's browser and is not proof of payment: fulfil orders from the payment.confirmed webhook.
  • Next.js: render the widget from a client component. The adapter and onSuccess are functions, which a server component cannot pass down. Nothing renders on the server; the modal opens in the browser.

Without a server: a payment link

A payment link needs no key to pay, so a page without a server of its own can start its payment from the browser:

ts
const adapter = payitgoCheckout({
  baseUrl: "https://api.payitgo.example",
  createPayment: async () => {
    const r = await fetch(`https://api.payitgo.example/links/${linkId}/checkout`, { method: "POST" });
    const body = await r.json();
    if (!r.ok) throw new Error(body.error); // "this payment link can no longer be paid"…
    return body.checkout_token;
  },
});

The adapter

payitgoCheckout(options) connects the widget to Payitgo.

OptionRequiredDescription
createPaymentyesCalled when the widget opens (and on "Start again"): returns the payment's checkout_token, as it is
baseUrlnoThe gateway’s address. Default: the address the package was released with. Trailing slashes are removed
pollIntervalMsnoHow often to read the payment while the live connection is down. Default 4000
fetchnoFor tests, or older runtimes. Default: the global fetch

Everything it calls (GET /checkout/{token}, POST /checkout/{token}/token, POST /checkout/{token}/simulate and socket.io at /socket.io/, the default namespace) answers any origin, so no proxy is needed. A POST answered 429 is sent again once, a second later. If you put a proxy in front of the gateway anyway, it must forward WebSocket upgrades on its /socket.io/ path: a plain HTTP rewrite cannot carry the WebSocket (the widget then polls instead).

What the adapter throws is a PayitgoError: the API's message (capitalised) and the HTTP status, 0 when the gateway could not be reached. The widget shows them itself, with a way to try again.

Components

PayitgoCheckout is the modal itself, controlled: render it anywhere, it portals to document.body while open. PayitgoButton is a button that opens it: it takes every PayitgoCheckout prop except open, plus children (the label, default "Pay with crypto"), className, style, disabled and onClose.

PropRequiredDescription
openyesPayitgoCheckout only
onCloseyesEscape, the close button, a click outside, or “Done”
paymentyesDisplay text: title, amount: { value, currency: "USD" }, and optionally description, period, badge, lineItem, due. Once loaded, the amount shown is the payment's own
adapternopayitgoCheckout({…}). Default: demoAdapter, an offline simulation for previews and design work
onSuccessnoOnce, when the payment is confirmed: { paymentId, status: "confirmed", token, amount, txId }, amount a decimal string in token
themeno{ mode?: "dark" | "light", accent?: string }: dark by default; accent is any CSS colour
returnLinksnoShow the payment's success_url and cancel_url as "Back to the shop" links. Default false: on your own page, closing the modal is the way back
Controlled modal with the demo adapter
const [open, setOpen] = useState(false);

<button onClick={() => setOpen(true)}>Pay</button>
<PayitgoCheckout
  open={open}
  onClose={() => setOpen(false)}
  adapter={demoAdapter}
  payment={{ title: "Pro plan", amount: { value: 49, currency: "USD" } }}
  theme={{ accent: "#5b3df5", mode: "dark" }}
  onSuccess={(result) => console.log(result.paymentId)}
/>

Test payments

A test payment shows a "Test payment" banner and buttons that play what the customer does, through the real processing: deposits, status changes and your webhooks (pay in full, overpay, pay half, underpay, let it expire, drop the transaction, pay in another coin, pay late). Live payments never show them.

Custom adapters

PayitgoAdapter is an interface: start(payment) returns a checkout token; get(checkoutToken) and chooseToken(checkoutToken, token) return the payment as GET /checkout/{token} does. Two optional methods turn on more: subscribe(checkoutToken, { onPayment, onConnection, onNotFound }) follows the payment live (it returns the function that stops), and simulate(checkoutToken, outcome) shows the simulate buttons on test payments. Without subscribe, the widget polls get() every pollIntervalMs.

Content Security Policy

The widget adds one <style> element the first time it opens and styles some elements inline: style-src must allow 'unsafe-inline'. connect-src must allow the gateway, over https: and wss:.

Try the widget demo: it runs on demoAdapter, so you can click through every screen without paying.