Xoibit Pay for developers
Accept USDT on the TRON (TRC-20) network on your website, app or shop. Payments land in your Xoibit wallet like any deposit, with no gateway fee on our side.
Nasıl Çalışır
- You create a payment (dashboard, shop QR or API). Your price gets a tiny unique tail, e.g. 25 becomes 25.0037.
- The customer sends exactly that amount of USDT TRC-20 to your own Xoibit deposit address (QR on the checkout page).
- Xoibit credits your wallet as a normal deposit, marks the payment paid and calls your webhook.
1. Get set up
- Open Deposit > USDT > TRON (TRC-20) once so your deposit address exists, then turn on Xoibit Pay in the merchant dashboard.
- Save the webhook secret shown once, and set your webhook URL (https).
- For the API: create an API key with only the
pay_invoicepermission at API anahtarları. Keep its secret on your server only, never in a browser or app.
2. REST API
Base URL: https://gateway.xoibit.com. Headers: x-public-key, x-timestamp (ms, within 60 s), x-nonce (UUID v4, new for every POST), x-signature = hex HMAC-SHA256 of METHOD + PATH (no query string) + BODY + TIMESTAMP (+ NONCE for POST). BODY is your compact JSON, or {} for GET.
Body fields: amount (string, at most 2 decimals, required), order_id (unique per merchant: sending it again returns the same payment), description, customer_email, return_url (https), expires_in_min (15–1440), metadata (JSON object up to 2 kB), test (true = never matched to real money), fee_payer ('merchant' or 'customer'; empty = your default). With fee_payer customer the amount to pay is amount + fee_amount; the response shows both.
curl# 1. sign: HMAC-SHA256(secret, METHOD + PATH + BODY + TIMESTAMP + NONCE), hex
BODY='{"amount":"25.00","order_id":"ORDER-1001","description":"T-shirt"}'
TS=$(date +%s%3N); NONCE=$(uuidgen | tr 'A-Z' 'a-z')
SIG=$(printf '%s' "POST/api/v1/pay/invoices${BODY}${TS}${NONCE}" | openssl dgst -sha256 -hmac "$XOIBIT_SECRET" -hex | sed 's/^.* //')
# 2. call
curl -s -X POST 'https://gateway.xoibit.com/api/v1/pay/invoices' \
-H 'Content-Type: application/json' \
-H "x-public-key: $XOIBIT_PUBLIC_KEY" -H "x-timestamp: $TS" -H "x-nonce: $NONCE" -H "x-signature: $SIG" \
--data "$BODY"Node.js// Node 18+ (no packages needed)
const crypto = require("crypto");
const API = "https://gateway.xoibit.com";
const PUBLIC_KEY = process.env.XOIBIT_PUBLIC_KEY;
const SECRET = process.env.XOIBIT_SECRET;
async function xoibit(method, path, body) {
const json = body ? JSON.stringify(body) : "{}"; // GET requests sign "{}"
const ts = Date.now().toString();
const nonce = crypto.randomUUID();
const msg = method + path + json + ts + (method === "GET" ? "" : nonce);
const sig = crypto.createHmac("sha256", SECRET).update(msg).digest("hex");
const res = await fetch(API + path, {
method,
headers: { "Content-Type": "application/json", "x-public-key": PUBLIC_KEY, "x-timestamp": ts, "x-nonce": nonce, "x-signature": sig },
body: method === "GET" ? undefined : json,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// create a payment and send the customer to checkout_url
const inv = await xoibit("POST", "/api/v1/pay/invoices", {
amount: "25.00", order_id: "ORDER-1001", description: "T-shirt",
return_url: "https://shop.example.com/thanks",
});
console.log(inv.checkout_url, inv.amount_due, inv.address);
// read it back (the query string is NOT part of the signed path)
const again = await xoibit("GET", "/api/v1/pay/invoices/" + inv.uid);PHP<?php
$api = 'https://gateway.xoibit.com';
$public = getenv('XOIBIT_PUBLIC_KEY');
$secret = getenv('XOIBIT_SECRET');
function xoibit($method, $path, $body = null) {
global $api, $public, $secret;
// compact JSON, no escaped slashes / unicode: the server re-encodes the body the same way
$json = $body === null ? '{}' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ts = (string) round(microtime(true) * 1000);
$nonce = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff),
random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
$msg = $method . $path . $json . $ts . ($method === 'GET' ? '' : $nonce);
$sig = hash_hmac('sha256', $msg, $secret);
$ch = curl_init($api . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-public-key: $public", "x-timestamp: $ts", "x-nonce: $nonce", "x-signature: $sig"],
CURLOPT_POSTFIELDS => $method === 'GET' ? null : $json,
]);
$out = curl_exec($ch);
return json_decode($out, true);
}
$inv = xoibit('POST', '/api/v1/pay/invoices', ['amount' => '25.00', 'order_id' => 'ORDER-1001']);
header('Location: ' . $inv['checkout_url']);Pythonimport hashlib, hmac, json, os, time, uuid, requests
API = "https://gateway.xoibit.com"
PUBLIC_KEY = os.environ["XOIBIT_PUBLIC_KEY"]
SECRET = os.environ["XOIBIT_SECRET"].encode()
def xoibit(method, path, body=None):
# compact JSON exactly like the server re-encodes it
data = "{}" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False)
ts = str(int(time.time() * 1000))
nonce = str(uuid.uuid4())
msg = method + path + data + ts + ("" if method == "GET" else nonce)
sig = hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest()
headers = {"Content-Type": "application/json", "x-public-key": PUBLIC_KEY,
"x-timestamp": ts, "x-nonce": nonce, "x-signature": sig}
r = requests.request(method, API + path, headers=headers, data=None if method == "GET" else data.encode())
r.raise_for_status()
return r.json()
inv = xoibit("POST", "/api/v1/pay/invoices", {"amount": "25.00", "order_id": "ORDER-1001"})
print(inv["checkout_url"])Response{
"uid": "inv_5f2c...",
"order_id": "ORDER-1001",
"status": "open", // open | paid | paid_late | paid_manual | expired | cancelled
"amount": "25", // your price
"amount_due": "25.0037", // what the customer must send (unique tail)
"currency": "USDT",
"network": "TRC20",
"address": "T...", // your own Xoibit deposit address
"checkout_url": "https://pay.xoibit.com/inv_5f2c...",
"paid_amount": null,
"txid": null,
"paid_at": null,
"expires_at": "2026-11-11T10:30:00.000Z",
"created_at": "2026-11-11T10:00:00.000Z",
"test": false,
"metadata": { "cart_id": "abc" }
}3. Webhooks
We POST JSON to your webhook URL with the headers X-Xoibit-Event, X-Xoibit-Delivery and X-Xoibit-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of t + '.' + raw body, keyed with your webhook secret>. Answer 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h; you can also resend from the dashboard.
Events: invoice.paid, invoice.paid_late (paid after the time limit), invoice.paid_manual (you linked a deposit by hand), invoice.expired, invoice.test_paid and invoice.test (dashboard tests: never ship goods for these). Always check the signature, ignore data.test = true, and process each order once.
Node.js (Express)// Express: keep the RAW body, the signature is over the exact bytes we sent
const crypto = require("crypto");
app.post("/xoibit-pay/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Xoibit-Signature") || ""; // t=1731318000,v1=hex
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
const body = req.body.toString("utf8");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
const want = crypto.createHmac("sha256", process.env.XOIBIT_WEBHOOK_SECRET).update(m[1] + "." + body).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(want, "hex"), Buffer.from(m[2], "hex"))) return res.sendStatus(400);
const evt = JSON.parse(body); // { event, created_at, data: { uid, order_id, status, ... } }
if (evt.data.test) return res.sendStatus(200); // never ship a TEST payment
if (evt.event === "invoice.paid" || evt.event === "invoice.paid_late" || evt.event === "invoice.paid_manual") {
// mark evt.data.order_id paid ONCE (the same event can arrive again: be idempotent)
}
res.sendStatus(200); // any 2xx = delivered; anything else is retried
});PHP<?php
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_XOIBIT_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300) { http_response_code(400); exit; }
$want = hash_hmac('sha256', $m[1] . '.' . $body, getenv('XOIBIT_WEBHOOK_SECRET'));
if (!hash_equals($want, $m[2])) { http_response_code(400); exit; }
$evt = json_decode($body, true);
if (!empty($evt['data']['test'])) { http_response_code(200); exit; }
if (in_array($evt['event'], ['invoice.paid', 'invoice.paid_late', 'invoice.paid_manual'], true)) {
// mark $evt['data']['order_id'] paid once
}
http_response_code(200);Python (Flask)import hashlib, hmac, os, re, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/xoibit-pay/webhook")
def hook():
body = request.get_data() # raw bytes
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("X-Xoibit-Signature", ""))
if not m or abs(time.time() - int(m.group(1))) > 300:
abort(400)
want = hmac.new(os.environ["XOIBIT_WEBHOOK_SECRET"].encode(), m.group(1).encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, m.group(2)):
abort(400)
evt = request.get_json()
if evt["data"].get("test"):
return "", 200
if evt["event"] in ("invoice.paid", "invoice.paid_late", "invoice.paid_manual"):
pass # mark evt["data"]["order_id"] paid once
return "", 2004. No code: links, shop QR and a button
- Payment links: create one in the dashboard and send it on chat or e-mail.
- Shop QR: one printed QR for your counter; the customer types the amount.
- Button: paste this into any website.
<!-- a pay button for a fixed checkout link (create it from your server with the API) --> <script src="https://pay.xoibit.com/button.js" async></script> <a class="xoibit-pay-button" href="https://pay.xoibit.com/inv_5f2c...">Pay with USDT</a> <!-- or a simple button to your shop page with an amount filled in (no server needed) --> <a class="xoibit-pay-button" href="https://pay.xoibit.com/s/your-shop?amount=25.00¬e=Order%201001">Pay 25 USDT</a>
Good to know
- Ship goods only after a signed paid webhook or after GET shows status paid. The checkout page is for the customer, not proof of payment.
- Customers paying from an exchange must make sure the RECEIVED amount (after that exchange's fee) is exact.
- An amount stays reserved for 24 hours after a payment expires, so a late payment is never confused with a newer order.
- Test payments use the same flow without real money: create one with test = true and mark it paid in the dashboard.