USDT Payment API Tutorial: Create Invoices with PHP and Node.js
October 10, 2026 · 7 min read
Accepting USDT through an API takes three pieces of code: create an invoice when the customer checks out, redirect them to the hosted payment page, and fulfil the order when a signed webhook says it was paid. This tutorial builds all three in PHP and Node.js with the Clearqo REST API. Customers pay by Binance Pay, TRC20 or BEP20 straight into your own Binance account.
Before you start
- A Clearqo account with Binance connected and at least one payment method (read-only key guide).
- An API key from Dashboard → Developers → Create API key. It starts with
cq_live_and is shown only once. - The webhook Signing secret, from the same page.
- A public HTTPS URL for your webhook. For local development, use a tunnel such as ngrok or Cloudflare Tunnel.
Keep both secrets in environment variables on your server (CLEARQO_KEY, CLEARQO_WEBHOOK_SECRET). Never put the API key in browser or mobile-app code.
The API in one minute
| Request | Purpose |
|---|---|
POST /invoices |
Create a payment for an order. Returns checkout_url and the exact amount_to_pay. |
GET /invoices/{id} |
Current status, freshly checked against Binance. |
GET /invoices?status=paid&page=1 |
List invoices, 50 per page. |
GET /me |
Your plan, balance and Binance status. A handy connection test. |
Base URL: https://clearqo.com/wp-json/clearqo/v1. Authenticate with the header X-Api-Key. Full reference: API documentation.
PHP
1. A small API helper
<?php
// clearqo.php
function clearqo(string $method, string $path, ?array $body = null): array
{
$opts = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['X-Api-Key: ' . getenv('CLEARQO_KEY'), 'Content-Type: application/json'],
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
}
$ch = curl_init('https://clearqo.com/wp-json/clearqo/v1' . $path);
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode((string) $raw, true) ?: [];
if ($raw === false || $status >= 300) {
throw new RuntimeException(($data['code'] ?? 'http_' . $status) . ': ' . ($data['message'] ?? curl_error($ch)));
}
return $data;
}
2. Create an invoice at checkout
<?php
require 'clearqo.php';
// $order comes from your database
$invoice = clearqo('POST', '/invoices', [
'amount' => $order['total'], // a string, e.g. "49.00"
'currency' => 'USD', // or USDT, or any currency you set a rate for
'order_id' => (string) $order['id'],
'description' => 'Order #' . $order['id'],
'success_url' => 'https://shop.example/orders/' . $order['id'],
'cancel_url' => 'https://shop.example/cart',
]);
$db->prepare('UPDATE orders SET clearqo_invoice = ? WHERE id = ?')
->execute([$invoice['id'], $order['id']]);
header('Location: ' . $invoice['checkout_url'], true, 303);
exit;
Calling this again with the same order_id and amount while the invoice is still open returns the same invoice, so a double-click or a retry never creates a second payment.
3. Handle the webhook
<?php
require 'clearqo.php';
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_CLEARQO_SIGNATURE'] ?? ''), $sig);
$t = (string) ($sig['t'] ?? '');
$expected = hash_hmac('sha256', $t . '.' . $body, getenv('CLEARQO_WEBHOOK_SECRET'));
if (!hash_equals($expected, (string) ($sig['v1'] ?? '')) || abs(time() - (int) $t) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
if (($event['type'] ?? '') === 'invoice.paid') {
// Never trust the event body for money: fetch the invoice from the API.
$invoice = clearqo('GET', '/invoices/' . rawurlencode($event['data']['id']));
$paidEnough = (float) $invoice['paid_price_amount'] >= (float) $invoice['price_amount'];
if ($invoice['status'] === 'paid' && $paidEnough) {
// Atomic: only the first delivery of this event changes the row.
$stmt = $db->prepare("UPDATE orders SET status = 'paid', txid = ? WHERE id = ? AND clearqo_invoice = ? AND status = 'pending'");
$stmt->execute([$invoice['txid'], $invoice['order_id'], $invoice['id']]);
if ($stmt->rowCount() === 1) {
fulfil_order($invoice['order_id']); // email, licence key, download...
}
}
}
http_response_code(200);
Node.js (Express 5, Node 18+)
1. A small API helper
// clearqo.mjs
const API = 'https://clearqo.com/wp-json/clearqo/v1';
export async function clearqo(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { 'X-Api-Key': process.env.CLEARQO_KEY, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20_000),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${data.code ?? res.status}: ${data.message ?? 'request failed'}`);
return data;
}
2. Checkout route and webhook
// server.mjs
import crypto from 'node:crypto';
import express from 'express';
import { clearqo } from './clearqo.mjs';
const app = express();
// The webhook needs the RAW body to check the signature, so register it before express.json().
app.post('/webhooks/clearqo', express.raw({ type: 'application/json' }), async (req, res) => {
if (!validSignature(req.body, req.get('X-Clearqo-Signature') ?? '')) return res.sendStatus(400);
try {
const event = JSON.parse(req.body);
if (event.type === 'invoice.paid') {
// Never trust the event body for money: fetch the invoice from the API.
const invoice = await clearqo('GET', `/invoices/${encodeURIComponent(event.data.id)}`);
const paidEnough = Number(invoice.paid_price_amount) >= Number(invoice.price_amount);
if (invoice.status === 'paid' && paidEnough) await fulfilOnce(invoice); // your code, idempotent
}
res.sendStatus(200);
} catch (err) {
console.error(err);
res.sendStatus(500); // Clearqo retries later
}
});
app.use(express.json());
app.post('/checkout/:orderId', async (req, res) => {
const order = await findOrder(req.params.orderId); // your code
const invoice = await clearqo('POST', '/invoices', {
amount: order.total, // a string, e.g. "49.00"
currency: 'USD',
order_id: String(order.id),
success_url: `https://shop.example/orders/${order.id}`,
});
await saveInvoiceId(order.id, invoice.id); // your code
res.redirect(303, invoice.checkout_url);
});
function validSignature(raw, header) {
const sig = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const expected = crypto.createHmac('sha256', process.env.CLEARQO_WEBHOOK_SECRET)
.update(`${sig.t}.`).update(raw).digest('hex');
return typeof sig.v1 === 'string' && sig.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig.v1), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
}
app.listen(3000);
For fulfilOnce, use the same idea as the PHP example: a conditional update (... WHERE status = 'pending') or a unique constraint on the invoice ID, and deliver only when that write succeeds.
Five rules for a safe integration
- Verify the signature with the raw body. It is HMAC-SHA256 of
timestamp + "." + body. Parsing and re-serialising the JSON first breaks it. - Reject old timestamps. Anything older than 5 minutes may be a replay.
- Re-fetch before fulfilling. Act on
GET /invoices/{id}, not on the event body. - Fulfil exactly once. Webhooks are retried (after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h) and can arrive twice.
- Don’t fulfil on the redirect.
success_urlreceives?invoice=…&status=paidfor display only. Anyone can type that URL.
Errors you should handle
| Status | Meaning | What to do |
|---|---|---|
| 400 | Invalid parameters, or no rate for the currency (clq_no_rate) |
Fix the request or add the rate in Settings → Currencies |
| 401 | Missing or wrong API key | Check X-Api-Key |
| 402 | Balance too low for fees, or monthly limit reached | Top up in Billing; show customers “temporarily unavailable” |
| 409 | Binance not connected or no payment method | Fix in Dashboard → Binance |
| 429 | More than 120 requests per minute per key | Back off and retry |
Testing
- Call
GET /me. It should return your business name andbinancestatus. - In Dashboard → Developers, set your Endpoint URL and click Send test event. Your endpoint receives a signed
test.pingand should answer 200. - Create an invoice for 1 USDT and pay it from a second Binance account. Watch
invoice.paidarrive and your order get fulfilled once.
FAQ
Can I build my own payment page instead of using checkout_url?
Yes. The invoice includes amount_to_pay and methods (Pay ID and addresses). The hosted page already handles the countdown, copy buttons, live status and transaction-ID submission, so it is usually the faster choice.
How do I price in EUR, PKR or INR?
Send currency with the amount, after setting your rate in the dashboard. See local-currency pricing.
Is there a WordPress plugin instead?
Yes. For WooCommerce, use the free plugin. It does all of the above for you: WooCommerce setup guide.
Accept USDT on your website
Paid straight to your own Binance, verified automatically.