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.
- 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 acheckout_url: the hosted checkout page. - 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). - The customer pays. The payment becomes
detectingas soon as the money is seen, andconfirmedonce the network has enough confirmations. - Payitgo notifies your server with a webhook at each step, signed with your webhook secret.
- Your server verifies the signature and, on
payment.confirmed, marks the order paid. - 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.completedwebhook), or added to your balance (custody), which Payitgo can also pay out for you on a schedule (scheduled settlement).
Webhooks are Payitgo's IPN
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).| Key | Mode | Money | Webhook |
|---|---|---|---|
| Test key | Test | No coins: you simulate what the customer pays, nothing real moves | The test webhook URL and its own secret |
| Live key | Live | Real coins on mainnet | The 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
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 end | Quickstart |
| Use a library (Node.js, Python, PHP, Go, Dart) or the React widget | Libraries |
| Take crypto in a WooCommerce shop, with no code | WooCommerce plugin |
| Know every status and what to do | Payment lifecycle |
| Show the payment on my own page | Your own payment page |
| Receive and verify notifications | Webhooks |
| Understand fees and where my money goes | Fees 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):
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"}'{
"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.
// 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.
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#
- The account owner turns live mode on in the dashboard (
POST /merchant/live {"enabled": true}). Until then the live key gets403. - 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. - Deploy the live key and the live webhook secret, and act only on events with
"livemode": truein production. Remove anysimulatefrom your payment requests: a live key refuses it. - 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.
- 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):
Authorization: Bearer 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f
X-API-Key: 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8fA 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
Test keys and live keys#
| Key | Mode | What it does |
|---|---|---|
| Test key | Test | Test payments, paid by the outcomes you simulate (no coins, no real money), the test webhook and its signing secret |
| Live key | Live | Real 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 setswebhook_urlon 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.
| Status | Error | Cause |
|---|---|---|
| 401 | missing api key | Neither header was sent |
| 401 | invalid api key | Unknown, mistyped or revoked key |
| 403 | live mode is off for this account: … | A live key before the owner turned live mode on |
| 403 | merchant account is suspended | Payitgo suspended the account |
| 403 | this 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.
| Method | Path | What it does |
|---|---|---|
| POST | /v1/payments | Create a payment (or get back the open payment of the same order) |
| POST | /v1/payments/{id}/token | Set the coin of a payment created without one |
| POST | /v1/payments/{id}/simulate | Test 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/payments | List payments, newest first |
| GET | /v1/tokens | The 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.
| Field | Type | Description |
|---|---|---|
order_id | string, required | Your id for the order, 1 to 128 bytes. An order has at most one open payment per mode and per store |
amount_usd | number or decimal string, required unless price_amount + price_currency | The price in US dollars, more than 0, such as "49.90". It must reach the token's minimum |
price_amount, price_currency | decimal string and string, together | Instead 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 |
token | string | The coin, such as USDT_TRC20 (case does not matter). Leave it out to let the customer choose |
tokens | array of strings | Without token: the coins to offer. Default: every coin you accept whose minimum the amount reaches |
description | string | What the customer is paying for, up to 500 bytes. Shown on the hosted checkout |
metadata | object | Your own data: up to 20 keys (40 bytes), string values only (500 bytes) |
customer_email | string | The customer's email address. Payments are filed under a customer per email; a blocked customer gets 403 |
success_url | string | http(s) URL the hosted checkout links to once the payment is confirmed |
cancel_url | string | http(s) URL the hosted checkout links back to if the payment closes unpaid |
webhook_url | string | https:// URL on a public host: this payment's notifications go there instead of your account's webhook URL |
simulate | string | Test 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:
| Field | Meaning |
|---|---|
id | Payitgo's id: 24 hex characters. Treat it as an opaque string |
status | Where the payment is in its lifecycle |
crypto_amount | What the customer must send, in token: amount_due_usd ÷ rate_locked, rounded up |
token, chain | The coin and its network: tron, ethereum, bsc, bitcoin, litecoin, dogecoin or xrp ("" until chosen) |
token_name, network | The coin and its network for people: "Tether", "Tron (TRC-20)". Absent until a coin is chosen |
deposit_address | Where to send it, used by this payment only (except on XRP, see destination_tag) |
destination_tag | XRP only: the tag the customer must include |
qr_payload | What to encode in a QR code: the deposit address alone (QR codes). "" until a coin is chosen |
expires_at | The deadline: 15 minutes after the coin was set |
usd_amount, amount_due_usd | Your price in dollars, and what the customer pays (more when you pass the fees on) |
extra_usd | Optional: 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_customer | Whether the customer pays the service and network fees on top of your price (passing fees on) |
network_fee, network_fee_usd | The 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_rate | Only for a payment priced in another currency: your price, its currency and the dollar value of one unit of it |
received_amount, received_usd | What has arrived so far |
deposits | Every 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[].counted | Whether it counts toward the payment: always for the payment's own token; another asset only with accept other assets on |
deposits[].status | pending (confirming), confirmed (final; settled to you) or failed (never became final; nothing settled) |
deposits[].late | true when it arrived after the payment had closed (late payments) |
deposits[].failed_at, fail_reason | When and why it failed: transaction not found on chain, or confirmation timeout |
confirmations, confirmations_required | The latest deposit's confirmations when last checked (0 before any), and how many make a deposit final on the payment's network |
rate_locked | The dollar price of one token, locked when the coin was chosen |
checkout_token, checkout_url | The public key to the payer's view, and the hosted checkout page |
overpaid, paid_late | Set when more than 1% too much arrived, or when the payment completed late |
settlement | Where 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, channel | The 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 |
livemode | false for a test payment (no coins: paid by the outcome you simulate), true for a real one |
simulation | A 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:
| Status | Error | Cause |
|---|---|---|
| 400 | invalid request body | Not JSON, or a field of the wrong type (an amount that is not a number, a metadata value that is not a string) |
| 400 | order_id is required | Missing order_id, or no Content-Type: application/json (the body is then not read) |
| 400 | order_id is too long | More than 128 bytes |
| 400 | amount_usd must be > 0 | Zero, negative or missing amount (neither amount_usd nor price_amount + price_currency) |
| 400 | give amount_usd, or price_amount and price_currency: not both | Both kinds of price; or price_amount without price_currency (price_amount and price_currency go together) |
| 400 | price_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 |
| 400 | unsupported token in test mode | A token (or an entry of tokens) not offered in the key's mode |
| 400 | not enabled for your account: BTC (turn tokens on in your payment settings) | A token you have not enabled |
| 400 | minimum payment in USDT_TRC20 is $6.44 right now (network fees) | With token: amount_usd is under the token's current minimum |
| 400 | amount_usd is below the minimum of every token on offer | Without token: no coin on offer accepts so small an amount |
| 400 | none of the tokens on offer is available in test mode | Without token: none of tokens is offered in the key's mode |
| 400 | description is too long | More than 500 bytes |
| 400 | metadata key or value is too long | A key over 40 bytes or a value over 500 (and metadata may hold at most 20 keys) |
| 400 | invalid customer_email | Not an email address |
| 400 | webhook_url: url must use https | Also: not absolute, not a public host, or a host that does not resolve. success_url and cancel_url must be http(s) URLs |
| 403 | this customer is blocked from paying the merchant | You blocked the customer with this customer_email |
| 500 | payment creation failed | On Payitgo's side: retry with the same order_id, it is safe |
| 503 | exchange rate unavailable for price_currency: try again shortly | No exchange rate for price_currency. Nothing was created: retry shortly with the same order_id |
And simulate can be refused:
| Status | Error | Cause |
|---|---|---|
| 400 | only test payments can be simulated: use a test key | simulate with a live key |
| 400 | simulate must be one of: confirmed, overpaid, … | An unknown simulate outcome |
| 400 | simulate wrong_asset needs the payment's coin: pass token | "simulate": "wrong_asset" without token |
| 400 | a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain | wrong_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 -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"}'{
"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 convertedusd_amount. - The payment keeps your price.
price_amount,price_currency(upper case) andprice_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 shortlyand 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.
| Status | Error | Cause |
|---|---|---|
| 400 | token is required | No token in the body, or the body is not JSON |
| 400 | token is not one of this payment's options | A coin not in token_options |
| 400 | minimum payment in … is $… right now (it is converted on its way to the merchant, and the exchange takes no less) | The coin needs a bigger payment than this one (converted through the exchange, and you pay the fees or hide such coins): offer the other coins |
| 400 | this payment can no longer be paid | The payment is no longer pending (paid, expired), or its deadline has passed |
| 404 | payment not found | No such payment for your key's mode (or, for a storefront's key, for its store) |
| 409 | the coin for this payment can no longer be changed | The 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:
{
"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.
| Status | Error | Cause |
|---|---|---|
| 400 | outcome is required | No outcome in the body, or the body is not JSON |
| 400 | simulate must be one of: confirmed, overpaid, … | An unknown outcome |
| 400 | only test payments can be simulated: use a test key | A live key |
| 400 | choose the payment's coin first | No coin yet, for anything but expired |
| 400 | this payment is confirmed: only a pending or partially paid payment can be simulated | The payment is closed or confirming |
| 400 | a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain | wrong_asset on a coin that has no other on its network |
| 404 | payment not found | No such payment for your key's mode (or, for a storefront's key, for its store) |
| 409 | a simulation is already running for this payment | Wait for its done |
Lifecycle and statuses#
pending ─────────────────────────┐
│ ▼
├──▶ partially_paid ──▶ detecting ──▶ confirmed
│ │ │
▼ ▼ ▼
expired underpaid failed
│ │
└────┬─────┘
▼ the rest arrives within 24 hours
detecting ──▶ confirmed ("paid_late": true)| Status | Meaning | Webhook | What you do |
|---|---|---|---|
pending | Created; waiting for a coin (if not set) and the money | none | Send the customer to pay |
partially_paid | Part of the amount arrived; there is still time for the rest | payment.partially_paid | Optionally tell the customer |
detecting | Enough arrived; waiting for confirmations | payment.detecting | Show "confirming". Do not ship yet |
confirmed | Final. The money is yours | payment.confirmed | Fulfil the order |
expired | The deadline passed and nothing counted arrived | payment.expired | Cancel, or create a new payment |
underpaid | The deadline passed with part of the amount; what arrived is settled to you | payment.underpaid | Refund, or agree on the rest |
failed | Funds were seen but never became final | payment.failed | Treat 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_paidand the checkout asks for the rest. If it arrives beforeexpires_at, the payment goes on toconfirmed; if not, it becomesunderpaid, 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'sfrom.
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 assets | The deposit | The payment |
|---|---|---|
| Off (default) | Not counted; settled to you in the asset that arrived | Keeps waiting for the coin it asked for |
| On | Counted at its dollar value, if you accept that token | Completes 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:
- lists the coins on offer when you created the payment without a
token, and lets the customer choose one; - 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; - updates live through
partially_paid,detectingandconfirmed, and shows a “Test payment” banner in test mode; - offers a button to your
success_urlonce confirmed, or a link back to yourcancel_urlwhen the payment closes unpaid.
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.
| Show | From | Notes |
|---|---|---|
| The exact amount | crypto_amount + token | Must arrive in full: the customer's wallet or exchange fee comes on top |
| The network | chain | Show it prominently: the same coin exists on several networks |
| The address | deposit_address | With a copy button. One address per payment |
| A QR code | qr_payload | The deposit address alone, so exchange apps accept it; the customer types the amount |
| The destination tag | destination_tag | XRP only, and required: show it as clearly as the address, with a copy button (the QR code does not carry it) |
| The deadline | expires_at | 15 minutes from when the coin was set; the rate is locked until then |
| What is missing | crypto_amount − received_amount | Only while partially_paid |
| A fee note | network_fee_note | Optional, one line about the network |
| Your price in your currency | price_amount + price_currency | Only 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
"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.
| Method | Path | What it does |
|---|---|---|
| GET | /checkout/{checkout_token} | The payer's view of the payment |
| POST | /checkout/{checkout_token}/token | The customer chooses the coin: {"token": "USDT_TRC20"} (5 a second per IP). Same errors as POST /v1/payments/{id}/token, plus 429 |
| POST | /checkout/{checkout_token}/simulate | Plays an outcome on a test payment (below) |
| GET | /tokens?mode=test | Every token Payitgo supports, without an account. Test mode lists the same coins as live |
| socket.io | subscribe → payment:update | Live 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 emit | With | The acknowledgement |
|---|---|---|
subscribe | The checkout token | {"ok": true, "payment": {…}}: the payment as GET /checkout/{checkout_token} returns it. An unknown token: {"ok": false, "error": "payment not found"}, then the socket is disconnected |
unsubscribe | The checkout token | {"ok": true}: no more events for that payment |
One socket can follow several payments: each subscribe adds one. 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.)
| type | When |
|---|---|
payment.snapshot | Right after subscribe: the payment as it is |
payment.coin_chosen | The coin was chosen: the payment now has its address, amount and a new expires_at |
payment.partially_paid, payment.detecting, payment.confirmed, payment.underpaid, payment.expired, payment.failed, payment.wrong_asset, payment.late_deposit | The webhook events of the same name |
payment.confirmations | A deposit collected confirmations: show "7 of 20" from payment.confirmations and payment.confirmations_required |
payment.deposit | A deposit became final, or failed, without changing the status |
payment.simulation | A test payment's simulation started, and again when it is over (payment.simulation.state) |
Render any type you do not know from its payment like the others. socket.io-client reconnects by itself after a network drop; a drop of under 2 minutes replays the events missed. 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 -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.
| Status | Error | Cause |
|---|---|---|
| 400 | outcome is required | No outcome in the body, or the body is not JSON |
| 400 | simulate must be one of: confirmed, overpaid, … | An unknown outcome |
| 400 | only test payments can be simulated: use a test key | A live payment |
| 400 | choose the payment's coin first | No coin yet, for anything but expired |
| 400 | this payment is confirmed: only a pending or partially paid payment can be simulated | The payment is closed or confirming |
| 400 | a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain | wrong_asset on a coin that has no other on its network |
| 404 | payment not found | No payment has this checkout token |
| 409 | a simulation is already running for this payment | Wait for its done |
| 429 | rate limit exceeded | More than 5 requests a second from one IP address |
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.
| Method | Path | What it does |
|---|---|---|
| GET | /v1/webhook | Read the URL and the signing secret |
| PUT | /v1/webhook | Set the URL: {"url": "https://…"}. Public https only, no redirects. Safe to retry |
| DELETE | /v1/webhook | Remove the URL; the secret is kept |
| POST | /v1/webhook/test | Queue 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.
| Notification | Goes to |
|---|---|
payment.*, settlement.* of a payment created with webhook_url | That payment's webhook_url |
payment.*, settlement.* of any other payment | The webhook URL of the payment's mode |
payout.* | The live webhook URL (payouts are live only) |
ping | The webhook URL of the key's mode |
Events#
| Type | Sent when | data |
|---|---|---|
payment.partially_paid | Part of the amount arrived; the rest can still come before expires_at | Payment |
payment.detecting | The full amount arrived; waiting for confirmations. Not final | Payment |
payment.confirmed | Final: the money is yours. Fulfil the order | Payment |
payment.underpaid | Closed with only part of the amount (at the deadline, or a late deposit that was still not enough) | Payment |
payment.expired | The deadline passed and nothing arrived (or no coin was chosen) | Payment |
payment.failed | Funds were seen but never became final | Payment |
payment.wrong_asset | A deposit in another coin than the payment asked for | Payment |
payment.late_deposit | A deposit after the payment had closed | Payment |
settlement.completed | Direct 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_id | Settlement |
settlement.failed | Direct settlement: it could not be sent; Payitgo retries or returns it to your balance | Settlement |
payout.approved | Payitgo approved a payout you requested | Payout |
payout.rejected | Payitgo rejected a payout; the amount is back in your balance (data.review_note says why) | Payout |
payout.sent | A payout is on chain (data.tx_id) | Payout |
ping | You 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 /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{
"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).
| Object | In the coin | In dollars |
|---|---|---|
| Payment | crypto_amount | amount_due_usd |
| Payment | received_amount | received_usd |
| Payment | network_fee | network_fee_usd |
| Payment | deposits[].amount | deposits[].usd_value (at rate_locked, or at the market price for another coin) |
| Payment | rate_locked: the dollar price of one token, locked when the coin was chosen | |
| Settlement | amount | usd_value |
| Settlement | service_fee, network_fee, conversion_fee | service_fee_usd, network_fee_usd, conversion_fee_usd |
| Settlement | net | net_usd: usd_value less the three fees in dollars, so the dollar figures add up |
| Settlement | usd_rate: the dollar price of one token used (usd_value ÷ amount, to 8 decimals) | |
| Payout | amount | amount_usd |
| Payout | send_amount | send_amount_usd |
| Payout | network_fee | network_fee_usd |
| Payout | usd_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:
{
"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 400By hand, the recipe is:
X-Webhook-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )- Read the raw request body as bytes, before any JSON parsing (Express:
express.raw()before anyexpress.json(); Next.js:await request.text(); Fastify: aparseAs: '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)). - Split the header on
,and each part on the first=: taketandv1. - 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. - Compare it with
v1in constant time, and reject atmore than 5 minutes away. Answer400to 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
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:
| Attempt | When |
|---|---|
| 1 | Within about 5 seconds of the event |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
| 7 | 24 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.
| Symptom | Check |
|---|---|
| Nothing arrives | Is 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 3xx | A redirect: set the final URL (exact scheme, host and trailing slash) |
| Timeouts | Your handler takes over 10 seconds: answer first, work after |
| TLS or connection errors | A certificate valid for the host, and a firewall that accepts connections from the internet |
| Signature never matches | Raw 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 old | Your server's clock is off: sync it with NTP |
| PUT /v1/webhook answers 403 | A 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/testsends a signedpingto your test URL within seconds; check that your handler verifies it and answers200. - 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.expired15 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
500once and watch the retry arrive a minute later withX-Webhook-Attempt: 2; change one byte of a captured body and check that your handler answers400.
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_urlon its payments. - Test it with
POST /v1/webhook/testfor 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.
| Fee | What it is | How much |
|---|---|---|
| Service fee | Payitgo's fee | A percentage agreed with your account: 1% by default, and at least $0.20 |
| Network fee | Moving 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 fee | Only 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 balance | 0.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.
Payments paid in parts#
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).
| Field | Meaning |
|---|---|
id, payment_id, store_id | The settlement, the payment the deposit was made to, and the payment's store (when it has one) |
deposit_tx, deposit_txs | The deposit's transaction id (the payment's deposits[].tx_id). Settled together, deposit_txs lists every deposit and deposit_tx is the first |
livemode | false for a test settlement |
mode | Where the money actually went: direct (your wallet) or custodial (your balance). A direct payment kept in custody says why in note |
token, chain | The coin settled: what arrived, or what a swap on its own network bought |
amount, service_fee, network_fee, conversion_fee, net | What is settled, each fee, and what is yours (amount less the three fees), in token; in dollars below |
destination, destination_tag | Direct settlement: the payout address it was sent to, and on XRP the tag it was sent with |
tx_id | Direct settlement: the transaction that paid you, once sent (converted across networks, the exchange's payout to your wallet) |
status | pending, completed, held or failed (statuses) |
note | Why 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) |
conversion | When 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_at | When 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.
| Field | Meaning |
|---|---|
usd_value | amount 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_rate | The 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_usd | Each fee in dollars at usd_rate, to the cent |
net_usd | What 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.
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 status | Meaning |
|---|---|
pending | The transfers are being made |
completed | Done. In direct settlement, tx_id is the transfer to your wallet (converted across networks, the exchange's payout to it) |
held | Nothing to move, and note says why: the deposit was smaller than its fees, or it is already where it needs to be |
failed | It 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 (asecuritynotification). 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, withawaits_address.until(when the address is used) and anote, 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. instantrequests 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) withrequested_by: "scheduler". The merchant object showspayout_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- 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);
- otherwise USDT on Tron;
- 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 choosecheapestto 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 in | Into USDT on Tron, $20 / $100 | Into 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).
| Network | Swapped there |
|---|---|
| Ethereum | ETH, USDC and USDT into one another |
| BNB Smart Chain | BNB, USDC and USDT into one another |
| Tron | TRX 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 notx_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
| Token | Network (chain) | Lowest minimum | Final after |
|---|---|---|---|
USDT_TRC20 | Tron (tron) | $1 | 20 blocks (about 1 min) |
TRX | Tron (tron) | $1 | 20 blocks (about 1 min) |
ETH | Ethereum (ethereum) | $5 | 12 blocks (about 3 min) |
USDT_ERC20 | Ethereum (ethereum) | $5 | 12 blocks (about 3 min) |
USDC_ERC20 | Ethereum (ethereum) | $5 | 12 blocks (about 3 min) |
BNB | BNB Smart Chain (bsc) | $1 | 12 blocks (about 1 min) |
USDT_BEP20 | BNB Smart Chain (bsc) | $1 | 12 blocks (about 1 min) |
USDC_BEP20 | BNB Smart Chain (bsc) | $1 | 12 blocks (about 1 min) |
BTC | Bitcoin (bitcoin) | $10 | 3 blocks (30 to 60 min) |
LTC | Litecoin (litecoin) | $2 | 6 blocks (about 15 min) |
DOGE | Dogecoin (dogecoin) | $1 | 6 blocks (about 6 min) |
XRP | XRP Ledger (xrp) | $1 | 1 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_usdinGET /v1/tokensis the minimum right now, and a payment under it is refused with400 minimum payment in … is $… right now (network fees). - Choosing. Stablecoins on BNB Smart Chain cost cents to move.
USDT_TRC20is 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
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 key | Live key | |
|---|---|---|
| Payments | Test payments only; "livemode": false | Live payments only; "livemode": true |
| Order ids | A test order_id never collides with a live one | |
| Coins | None: you simulate what the customer sends | Real coins on mainnet |
| Webhook | The test webhook: its own URL and its own secret | The live webhook: its own URL and secret |
| Money | Your test balance, never paid out; direct settlement to a test payout address completes on paper | Your balance, or your live payout addresses once in use (another or a changed one after 24 hours) |
| Dashboard | Add ?mode=test to dashboard routes | The 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 atoken, the simulation starts at once, and so doesexpired. Otherwise it starts when the customer chooses the coin on the checkout page (or you set it withPOST /v1/payments/{id}/token). - Later, on a payment you already have:
POST /v1/payments/{id}/simulate, where{id}is the payment id or yourorder_id. The answer is the payment, with itssimulationstarted. The libraries call itpayments.simulate(id, outcome). - From a payment page:
POST /checkout/{checkout_token}/simulatetakes 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.
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:
{
"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#
| outcome | What happens | Webhooks, in order |
|---|---|---|
confirmed | The exact amount arrives and confirms | payment.detecting, payment.confirmed |
overpaid | 5% more than due arrives | payment.detecting, payment.confirmed with "overpaid": true |
partially_paid | Half of what is still due arrives. The payment stays open: simulate again to pay the rest, or let it run out | payment.partially_paid |
underpaid | Half arrives, then the deadline passes | payment.partially_paid, payment.underpaid |
expired | Nothing arrives, and the deadline passes now | payment.expired (payment.underpaid if it was partially paid) |
failed | The amount arrives, then its transaction is dropped | payment.detecting, payment.failed, with the deposit's fail_reason (deposits[].fail_reason) |
wrong_asset | The 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_late | Half arrives, the deadline passes, then the rest arrives | payment.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
pendingorpartially_paid, and have its coin (except forexpired). - One simulation runs at a time: another request gets
409. Once it isdone, you can run another, for examplepartially_paidand thenconfirmedto pay the rest. wrong_assetneeds 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 needstoken.
| Status | Error | Cause |
|---|---|---|
| 400 | outcome is required | No outcome in the body, or the body is not JSON |
| 400 | simulate must be one of: confirmed, overpaid, … | An unknown outcome |
| 400 | only test payments can be simulated: use a test key | A live payment, or simulate with a live key |
| 400 | choose the payment's coin first | No coin yet, for anything but expired |
| 400 | this payment is confirmed: only a pending or partially paid payment can be simulated | The payment is closed or confirming |
| 400 | a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain | wrong_asset on a coin that has no other on its network |
| 400 | simulate wrong_asset needs the payment's coin: pass token | wrong_asset on create without token |
| 404 | payment not found | No such payment for your key's mode (or, for a storefront's key, for its store) |
| 409 | a simulation is already running for this payment | Wait 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": [
{ "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,overpaidandpaid_lateare 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#
Scenarios to test#
Create a test payment for each, and check what your server receives.
| Scenario | How | What you should see |
|---|---|---|
| Normal payment | confirmed | payment.detecting, then payment.confirmed |
| Partial, then completed | partially_paid, then confirmed once it is done | payment.partially_paid, then payment.detecting, payment.confirmed |
| Underpaid | underpaid | payment.partially_paid, then payment.underpaid |
| Expired | expired, or create a payment and simulate nothing | payment.expired (15 minutes later without a simulation, an hour if no coin was chosen) |
| Overpaid | overpaid | payment.confirmed with "overpaid": true |
| Late completion | paid_late | payment.underpaid, then payment.late_deposit and payment.detecting, then payment.confirmed with "paid_late": true |
| Dropped transaction | failed | payment.detecting, then payment.failed |
| Wrong asset | wrong_asset | payment.wrong_asset; the deposit counts only with accept other assets on |
| XRP | Create with "token": "XRP" and simulate | The payment has its destination_tag; show it as prominently as the address |
| Customer chooses the coin | Create without token, with simulate; choose on the checkout page | token_options, then an address after the choice, then the outcome's webhooks |
| Retry safety | Create the same order_id twice | The second call returns the first payment with 200 |
| Per-payment URL | Create a payment with webhook_url and simulate | Its notifications go to that URL, not the account's |
Test your webhook handler too:
| Scenario | How | What you should see |
|---|---|---|
| Signature check | POST /v1/webhook/test, and verify the ping | Your handler answers 200 |
| Tampering | Change one byte of a captured body and send it to your handler | Your handler answers 400 |
| Retries | Answer 500 once | The same notification again a minute later, with X-Webhook-Attempt: 2 |
| Duplicates | Redeliver a delivery from the dashboard | Your handler recognises the event id and does nothing twice |
| Test vs live | Look at livemode | Your production handler ignores "livemode": false |
Going live#
- The account owner turns live mode on (
POST /merchant/live {"enabled": true}, or in the dashboard). - With the live key, read the live webhook secret (
GET /v1/webhook) and set the live URL (PUT /v1/webhook). - Put the live key and the live webhook secret on your production server. Remove any
simulatefrom your payment requests: a live key refuses it. - Check that your handler fulfils only
"livemode": trueevents in production. - 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.
- 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.
| Role | Can |
|---|---|
owner | Everything: team, settings, live mode, the audit log, rolling the webhook secret, payouts, the referral program |
developer | API keys, stores, the webhook URL and test, redelivering webhooks |
finance | Payout addresses and payouts, the referral program |
viewer | Reads 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.
| Setting | Default | What it does |
|---|---|---|
underpaid_tolerance_bps | 50 (0.5%) | How far short a payment may fall and still count as paid, 0 to 2000 (20%) (wrong amounts) |
accept_other_assets | false | Whether a deposit in another accepted coin counts toward the payment at its dollar value (wrong asset) |
settlement | direct | direct, custodial or scheduled (where the money goes) |
payout_schedule | daily | Scheduled settlement: instant, daily or weekly (on Mondays) |
fees_paid_by_customer | true (a new account) | Whether the customer pays the service and network fees on top of your price (passing fees on) |
convert_into | cheapest | Direct 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_minimum | false | Direct 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_tokens | every token | The 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, orrejected(by Payitgo) orcanceled(by you, while still requested), which return the amount to your balance. Each step Payitgo takes raises apayout.*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_tokenandfrom_chainsay 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 hasrequested_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/affiliateshows 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 liveUSDT_TRC20payout addresses (your default unless you giveaddress_id), or to any other Tron address you give with your password. The network fee, fixed when you ask, comes out of it (send_amountis what arrives), and Payitgo pays it within 5 business days (due_at). - One open at a time. A payout is
requested, thenprocessingandpaid(with itstx_id). Onerejectedby Payitgo (with anote), orcanceledby you while stillrequested, gives the amount back toavailable. Owners and finance are notified of each request, payment and rejection.
| Method | Path | What it does |
|---|---|---|
| GET | /merchant/affiliate | Your code, link, terms, figures, referral balance and payout terms (the open payout, if any) |
| GET | /merchant/affiliate/referrals, /merchant/affiliate/commissions | The merchants you referred, and each commission credited, newest first |
| POST | /merchant/affiliate/payouts | Request a payout: amount (absent: all of it), and address_id or address with password. 1 a second per user |
| GET | /merchant/affiliate/payouts | Your referral payouts, newest first; filter by status. One: /merchant/affiliate/payouts/{id} |
| POST | /merchant/affiliate/payouts/{id}/cancel | Cancel 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.
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.
| Status | Meaning | Typical causes | Retry? |
|---|---|---|---|
| 200 | Done | A read, an update, or POST /v1/payments returning the order's open payment | |
| 201 | Created | A new payment, key, user, store or payout | |
| 202 | Queued | A webhook test or a redelivery: it is sent within seconds | |
| 400 | The request is invalid | A missing or malformed field, an unsupported token, an amount under the minimum, a webhook URL that is not https | No: fix it |
| 401 | Not authenticated | missing api key, invalid api key (unknown or revoked); in the dashboard, a missing or expired access token | No |
| 403 | Not allowed | Live 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 password | No |
| 404 | No such thing | An unknown id, a payment of the other mode, an order with no open payment, an unknown route | No |
| 409 | Clashes with the current state | An 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 requested | No |
| 413 | Body over 1 MiB | No | |
| 429 | Over the rate limit | See below | Yes, after a short wait |
| 500 | Something failed on Payitgo's side | payment creation failed, could not set the webhook… | Yes, with backoff |
| 503 | Temporarily unavailable | exchange rate unavailable for price_currency (a price in another currency): nothing was created | Yes, shortly |
Rate limits#
| Routes | Limit | Counted per |
|---|---|---|
/v1/* | 20 requests a second, bursts up to 40 | API key |
POST /checkout/{checkout_token}/token | 5 requests a second, bursts up to 10 | IP address |
POST /checkout/{checkout_token}/simulate | 5 requests a second, bursts up to 10 | IP address |
/merchant/login, /merchant/signup | 5 requests a second, bursts up to 10 | IP 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.
- Requests are JSON with
Content-Type: application/json: without that header the body is not read. Unknown fields in requests are ignored. - Amounts are decimal strings in responses (
"49.9"): parse them with a decimal type, not a float. Requests accept a number or a string. - Times are RFC 3339 in UTC, with a varying number of fractional digits.
- Optional fields are left out when empty, and new fields may appear at any time: parse leniently.
- 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'slivemodedoes). - Lists take
limit(1 to 200, default 50) andoffset, and returntotal.
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.
| Language | Install | Import |
|---|---|---|
| Node.js / TypeScript | npm install @payitgo/node | import { Payitgo } from "@payitgo/node" |
| Python | pip install payitgo | import payitgo |
| PHP | composer require payitgo/payitgo-php | use Payitgo\Client; |
| Go | go get github.com/payitgo/go-sdk | import payitgo "github.com/payitgo/go-sdk" |
| Dart / Flutter | dart pub add payitgo | import 'package:payitgo/payitgo.dart'; |
| React (widget) | npm install @payitgo/react | import { PayitgoButton, payitgoCheckout } from "@payitgo/react" |
| Call | Node.js and Dart | Python | PHP | Go |
|---|---|---|---|---|
| Create | payments. | payments. | payments-> | Payments. |
| Read one | payments. | payments. | payments-> | Payments. |
| List | payments. | payments. | payments-> | Payments. |
| Choose the coin | payments. | payments. | payments-> | Payments. |
| Simulate (test keys) | payments. | payments. | payments-> | Payments. |
| Coins on offer | tokens. | tokens. | tokens-> | Tokens. |
| Webhook URL | webhooks. | webhooks. | webhooks-> | Webhooks. |
| Verify | webhooks. | webhooks. | webhooks-> | Webhooks. |
| Test or live (asks once) | isLive() | is_live() | isLive() | IsLive(ctx) |
| Payer view (no key) | checkout. | checkout. | checkout-> | Checkout. |
| Payer chooses the coin (no key) | checkout. | checkout. | checkout-> | Checkout. |
- 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'slivemode, 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:Errorin Node,ValueErrorin Python,InvalidArgumentExceptionin PHP, an error fromNewClientin Go,ArgumentErrorin Dart. - Base URL. Without the base URL option, the libraries read
PAYITGO_BASE_URLfrom the environment, then fall back to the address they were released with. Dart reads no environment variable: pass it asbaseUrl. - Timeouts. Each attempt may take 30 s by default:
timeoutMsin Node,timeoutin seconds in Python and PHP, aDurationin Dart; in Go, thehttp.Client'sTimeoutand yourctx. Go (WithHTTPClient) and Dart (httpClient) also take your own HTTP client. - Retries. Network errors, timeouts,
429and5xxare retried up to twice (maxRetries,max_retries,WithMaxRetries;0turns 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 sameorder_idreturns its open payment, choosing a coin twice answers409(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 (
webhookUrlin Node and Dart,webhook_urlin Python and PHP,payitgo.WithWebhookURL(u)in Go) and every payment the client creates gets that webhook_url; a payment's ownwebhook_urlwins. - 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 call | Status, message | A bad signature | |
|---|---|---|---|
| Node.js | PayitgoError | .status, .message | SignatureVerificationError |
| Python | payitgo.PayitgoError | .status, .message | payitgo.SignatureVerificationError |
| PHP | PayitgoException | ->status, ->getMessage() | SignatureVerificationException (both in Payitgo\Exception) |
| Go | *payitgo.Error (errors.As) | .Status, .Message | payitgo.ErrSignature (errors.Is) |
| Dart | PayitgoException | .status, .message | SignatureVerificationException |
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 client | Another tolerance | |
|---|---|---|
| Node.js | Payitgo.webhooks.constructEvent(body, sig, secret) | 4th argument, in seconds |
| Python | payitgo.Webhook.construct_event(body, sig, secret) | tolerance= |
| PHP | \Payitgo\Webhook::constructEvent($body, $sig, $secret) | 4th argument, in seconds |
| Go | payitgo.ConstructEvent(body, sig, secret) | payitgo.ConstructEventWithTolerance(…, d) |
| Dart | Webhook.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
npm install @payitgo/nodeCreate a client
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 remembersCreate a payment
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
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
// 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
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
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
pip install payitgoCreate a client
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 remembersCreate a payment
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
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
# 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
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 "", 200Handle errors
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, 5xxPHP#
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
composer require payitgo/payitgo-phpCreate a client
<?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
$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
$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
// 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
// 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
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
go get github.com/payitgo/go-sdkCreate a client
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 remembersCreate a payment
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
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
// 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
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
_, 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
dart pub add payitgo # Dart
flutter pub add payitgo # FlutterCreate a client
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 doneCreate a payment
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
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
// 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
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
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.
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 moreWooCommerce 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 | |
|---|---|
| Download | The zip of the latest release: payitgo-for-woocommerce-X.Y.Z.zip |
| Source | github.com/payitgo/woocommerce |
| Needs | WordPress 6.3+, WooCommerce 8.0+, PHP 7.4+ |
| Checkout | The block checkout and the classic checkout |
| Orders | WooCommerce's order tables (HPOS) or posts |
| Shop currency | Any. 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
- Download
payitgo-for-woocommerce-X.Y.Z.zipfrom the latest release and upload it in WordPress (Plugins > Add New > Upload Plugin). Activate it. - 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).
- In WooCommerce > Settings > Payments > Payitgo (crypto), paste the key and enable it.
No webhook to set up
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
| Setting | Default | |
|---|---|---|
| Title, Description | "Crypto (USDT, BTC, ETH and more)" | What customers see at checkout |
| Secret API key | None | A 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 secret | Read with the key | Pin one only if you must; otherwise the plugin reads it, and reads it again once if you roll it |
| API address | https://api.payitgo.example | Your Payitgo API host |
| Debug log | Off | WooCommerce > Status > Logs, source payitgo. Errors are always logged |
What it sends
When the customer places the order, the plugin creates a payment:
| Field | From the order |
|---|---|
order_id | The order number, as it is: 1234 |
amount_usd | The total, for a shop in US dollars |
price_amount, price_currency | The total and the shop's currency, for any other shop |
description | The items: 2 x Acme T-shirt, Mug |
customer_email | The billing email |
success_url, cancel_url | The order-received page, and WooCommerce's cancel link |
webhook_url | The shop's notification address, https://<shop>/wc-api/payitgo/, when the shop is on https |
metadata | wc_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:
| Payment | Order |
|---|---|
detecting | On hold: the money arrived, confirmations are pending |
confirmed | Paid (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_paid | A note: the customer can still send the rest |
underpaid | On hold, with what arrived: refund the customer or agree on the rest |
expired, failed | Failed: 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 |
|---|---|
| Install | npm install @payitgo/react (0.4.0 or later lets the customer change the coin) |
| Source | github.com/payitgo/react-sdk |
| Needs | React 18 or 19; it brings qrcode.react and socket.io-client |
| Module | ESM, with TypeScript types. The components are marked 'use client' |
Show a payment
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:
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. onSuccessruns in the customer's browser and is not proof of payment: fulfil orders from thepayment.confirmedwebhook.- Next.js: render the widget from a client component. The adapter and
onSuccessare 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:
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.
| Option | Required | Description |
|---|---|---|
createPayment | yes | Called when the widget opens (and on "Start again"): returns the payment's checkout_token, as it is |
baseUrl | no | The gateway’s address. Default: the address the package was released with. Trailing slashes are removed |
pollIntervalMs | no | How often to read the payment while the live connection is down. Default 4000 |
fetch | no | For 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.
| Prop | Required | Description |
|---|---|---|
open | yes | PayitgoCheckout only |
onClose | yes | Escape, the close button, a click outside, or “Done” |
payment | yes | Display text: title, amount: { value, currency: "USD" }, and optionally description, period, badge, lineItem, due. Once loaded, the amount shown is the payment's own |
adapter | no | payitgoCheckout({…}). Default: demoAdapter, an offline simulation for previews and design work |
onSuccess | no | Once, when the payment is confirmed: { paymentId, status: "confirmed", token, amount, txId }, amount a decimal string in token |
theme | no | { mode?: "dark" | "light", accent?: string }: dark by default; accent is any CSS colour |
returnLinks | no | Show 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 |
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:.
demoAdapter, so you can click through every screen without paying.