Take a payment with Payaider
Payaider is a non-custodial crypto payment gateway. Your customer pays USDT or USDC from their own wallet straight into yours, on Tron, BNB Smart Chain or Base. We watch the chain, match the payment to the intent you created, and tell your server with a signed webhook. We never hold your funds, and we hold no key for any wallet you list.
How it works, in four steps
In none of these steps does an account of ours hold your money. You price the order in ordinary money, your customer sends stablecoin from their own wallet to yours, and we watch the chain and tell you when it has arrived.
- Create a payment intent on your server. One POST with your secret key, priced in fiat — 125.00 USD below. You get back an id to store against your order, and a checkout URL.
- Send the customer to the hosted checkout URL. There they choose USDT or USDC and a network: Tron, BNB Smart Chain or Base. That locks a rate for fifteen minutes and reserves an exact amount, which is what lets us tell one payment to your address apart from another.
- They pay from their own wallet, directly to yours. The address on the checkout page is your address. We hold no key for it, so there is no balance on this platform, no withdrawal step, and nothing of yours to release.
- You receive a signed webhook. We match the transfer to the intent, follow it to the confirmation depth that network and amount require, and post a signed event to your endpoint. Our fee is per transaction and recorded against your account, not taken out of the transfer.
The redirect back to your site is not proof of payment. A customer returning to your success URL has proved only that they can visit a URL. The signed webhook is what separates a real payment from a forged one.
Authentication
Every request carries one API key as a bearer token. There is no other credential:
curl https://api.payaider.com/v1/payment_intents/pi_7Fk2Qd9RmTs4Vb \
-H "Authorization: Bearer $PAYAIDER_SECRET_KEY"The prefix says what a key is, and where it is allowed to be:
- sk_test_… — a secret key for test mode. Server-side only.
- sk_live_… — a secret key for live mode, issued once KYB is approved. Server-side only.
- pk_test_… and pk_live_… — publishable keys: browser credentials, which can do nothing but drive a checkout page.
A publishable key must never be used server-side. It holds none of the scopes a server needs, so an integration built on one fails at the first real call rather than degrading quietly. A pk_ key in your server configuration means something was copied from the wrong box.
A secret key must never reach a browser. Not in page JavaScript, not in a mobile bundle, not in a query string. Anyone holding it can create, read and cancel payments as you. A secret is shown once, when it is created; if one is exposed, revoke it and issue another rather than hoping it was not read.
Creating a payment intent
On your server, at the moment the customer commits to the order:
curl https://api.payaider.com/v1/payment_intents \
-H "Authorization: Bearer $PAYAIDER_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_10432" \
-d '{
"amount": 12500,
"currency": "USD",
"metadata": { "order_id": "order_10432" }
}'The response carries the id to store against the order, and the URL to send the customer to:
{
"id": "pi_7Fk2Qd9RmTs4Vb",
"object": "payment_intent",
"mode": "test",
"status": "created",
"amount": "12500",
"currency": "USD",
"currency_decimals": 2,
"received_amount": "0",
"metadata": { "order_id": "order_10432" },
"expires_at": null,
"checkout_url": "https://checkout.payaider.com/c/pi_7Fk2Qd9RmTs4Vb",
"payment_method": null
}Then send the customer there — the last thing your server does until the webhook arrives:
res.redirect(303, intent.checkout_url);Amounts are always integers in minor units. 125.00 USD is 12500. A fiat minor amount is small, so a JSON integer is accepted on the way in — but every amount comes back as a JSON string, and you read it as a bigint rather than a float. The reason is the chain side of the same payment: 125 USDT on BNB Smart Chain is 125000000000000000000 base units, because that token has 18 decimals, and a number that long is far past the largest integer a floating-point double holds exactly, so a float would silently round the figure you reconcile against.
Put your own order id in metadata. It comes back on every read and inside every webhook, so the event that tells you a payment confirmed also tells you which of your orders it was for. There is no separate reference field to keep in step with it.
Send an Idempotency-Key. A create that times out and is retried without one can mint two intents for a single order, and a customer who pays both cannot be refunded by anyone, including us. Key it off your own order id, as above, so a retry is safe across a process restart.
The payment lifecycle
An intent moves through these states, and every externally visible move sends a webhook of the matching name: confirmed sends payment.confirmed, expired sends payment.expired.
created— The intent exists. No asset chosen, so no address and no amount due yet.awaiting_payment— Asset and network chosen. A rate is locked for fifteen minutes and an exact amount is reserved against your wallet.detected— A matching transfer is on-chain at zero confirmations. Show the customer that payment is processing. Do not ship.confirming— It has at least one confirmation and is working towards the depth this network and amount require.confirmed— Terminal success, and where you ship.underpaid— Confirmed for less than the amount due. The money is in your wallet; a further transfer can complete it, or you can accept it as paid.overpaid— Terminal success with a surplus recorded. It counts as paid, so ship on it as you would on confirmed.paid_late— A matching transfer arrived after the quote expired, inside the 24 hours we keep watching. It goes to a review queue — money is never silently ignored.expired— The quote elapsed with nothing detected. We watch the address for 24 hours.canceled— Cancelled before any transfer was detected, and unreachable once money is in flight.failed— The matched transaction reverted, or was reorged out and not re-included. No funds moved.
Ship on payment.confirmed. Never on payment.detected. A detected transfer has zero confirmations and can still be reverted by a reorg: the transaction disappears from the chain and the money was never yours. Confirmed means the transfer has reached the depth that network and that amount require. Crypto does not reverse and there is no chargeback behind you, so stock shipped against a detected payment is stock given away.
New states can be added, so treat one you do not recognise as “not finished yet” rather than as an error. Nothing you already handle changes meaning.
Webhooks
We POST a JSON event to the endpoint you register, and sign it:
POST /webhooks/payaider HTTP/1.1
Content-Type: application/json
Payaider-Signature: t=1756722672,v1=4a7d0c39e1b2c8f0...
{
"id": "evt_2Ay0Zt7Rm9Kq",
"type": "payment.confirmed",
"created": 1756722672,
"api_version": "2026-08-27",
"data": {
"id": "pi_7Fk2Qd9RmTs4Vb",
"object": "payment_intent",
"status": "confirmed",
"amount": "12500",
"currency": "USD",
"metadata": { "order_id": "order_10432" }
}
}Verify the signature before you trust a single field of the body. Your endpoint is a public URL, so anyone can post a plausible payment.confirmed to it. The v1 value is an HMAC-SHA256, in hex, over the timestamp, a full stop, and the raw request body, keyed with that endpoint’s signing secret. More than one v1 element can appear while a secret is being rolled: accept if any of them matches, and ignore any scheme that is not v1.
import crypto from 'node:crypto';
// rawBody must be the RAW bytes — in Express, express.raw({ type: 'application/json' }).
// The signature covers the bytes we sent; re-serialising the JSON produces different ones.
function verifyPayaiderSignature(header, rawBody, secret) {
let timestamp = null;
const candidates = [];
for (const element of String(header).split(',')) {
const [scheme, value] = element.trim().split('=');
if (scheme === 't') timestamp = value;
else if (scheme === 'v1') candidates.push(value); // every other scheme is ignored
}
if (timestamp === null || candidates.length === 0) return false;
// Replay protection: five minutes, in both directions. A t that is not a number is refused
// rather than compared: Math.abs(NaN) > 300 is false, so comparing it would wave it through.
const seconds = Number(timestamp);
if (!Number.isFinite(seconds)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > 300) return false;
const signed = Buffer.concat([Buffer.from(timestamp + '.', 'utf8'), rawBody]);
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(signed).digest('hex'));
let valid = false;
for (const candidate of candidates) {
const received = Buffer.from(candidate, 'utf8');
// Constant time, never ===, and no early return: the work must not reveal
// which element matched, one byte at a time.
if (received.length === expected.length && crypto.timingSafeEqual(expected, received)) {
valid = true;
}
}
return valid;
}Only once that returns true do you parse the body and act on it:
const event = JSON.parse(rawBody.toString('utf8'));
if (alreadyHandled(event.id)) return res.status(200).end();
if (event.type === 'payment.confirmed') ship(event.data.metadata.order_id);
res.status(200).end();Delivery is at-least-once, unordered, and retried:
- Only a 2xx counts as delivered. A redirect is a failure, and so are 4xx, 5xx, a TLS error and a timeout.
- You have ten seconds to respond. Acknowledge first and do your own work afterwards; holding the connection open while you talk to your own database is how an endpoint starts timing out under load.
- Failures are retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours. Eight attempts, the last about 45 hours after the first.
- The same event can arrive twice and two events can arrive out of order, so dedupe on event.id — identical on every retry — and re-read the payment intent when you need the authoritative status.
Test mode
A sk_test_ key runs the same code as a live one, against test networks. What you build in test behaves the same in live; the difference is whose money moves.
- No real money moves in test mode, ever. Test payments are testnet tokens, which cost nothing and are worth nothing.
- Test and live are separate universes with separate data. A test key cannot read a live object, and a live key cannot read a test one.
- Test payments never appear in live reporting. Not in your live totals, not in your fees, and not in anything you export for your accountant.
- Webhooks behave the same in test, signature included, so the endpoint you verify against a test key is the endpoint that will run live.
Going live
Three things, in this order, and none of them can be skipped:
- Pass KYB. Submit your business details and documents in the portal. A person reviews them — approval is never automatic — and we say what is missing rather than leaving it silent.
- Add a payout wallet and prove you control it. We hold no key for any wallet you list, so before one can receive a payment you prove ownership by signing the challenge we give you with that wallet’s own key. Checkout will not quote to an unverified wallet, which is what stands between a mistyped address and money nobody can recover.
- Issue live keys. With KYB approved and a verified wallet on file, you can create an sk_live_ key. Your test keys keep working alongside it.
Your integration needs no other change. The endpoints, payloads, states and signature scheme are identical in both modes — going live is swapping one key for another and pointing your webhook endpoint at your production server.