Paying money out, without the platform ever being able to move it alone.
How it works
Payouts are non-custodial. The platform never holds the funds you pay out and never signs on your
behalf: we only build an EIP-712 payout order. You sign it with your own wallet, and we
relay the signed order to an on-chain router that pulls the tokens straight from your
payout wallet — on EVM networks your settlement wallet, on Tron a separate
T… address set in your merchant settings.
Two signatures with two different jobs, and you need both. The HMAC request
signature proves the API call is yours. The EIP-712 wallet signature
authorizes moving the money. A leaked API key on its own can never move funds, and neither can we.
Approve, once per token and router. Your payout wallet grants the router an allowance. The API builds that transaction for you — see the allowance section.
Create.POST /v1/payouts checks the balance and allowance, and returns the payout id and the EIP-712 typed_data.
Check and sign. Compare recipient and amount with your own record, then sign typed_data with the payout wallet's key — see signing the order. Your private key never leaves you.
Relay. A dispatcher broadcasts the router call off the request path; the payout moves to submitted with a tx_hash.
Confirm. The confirmer settles it to completed or failed, then sends you a webhook — to the create request's
callback_url when you sent one, otherwise to your merchant default. Until then you can read it with POST /v1/payouts/status.
An order is signable for 30 minutes — its EIP-712 deadline.
Authorizing after that returns 410 expired; create a new payout under a new
external_id. Repeating a create with the same external_id is
idempotent: it returns the existing payout with its order rebuilt, never a second one.
Payouts
Non-custodial withdrawals. We build an EIP-712 order, you sign it with your own wallet, we relay it. A leaked API key alone can never move money.
POST/v1/payoutssigned
Create a payout order
Builds a non-custodial payout order pulled from your settlement wallet, and returns the EIP-712 typed_data for you to sign. Requires a prior approve(router, cap) from that wallet — without it every payout fails on-chain.
The order is signable for 30 minutes. Idempotent on external_id.
What to do with the typed_data in the response: signing the order, with working code in JavaScript, Python and Go.
Request body
Field
Type
Required
Description
external_id
string
no
Your idempotency key: at most one payout per (merchant, external_id). A repeat returns the existing payout with its order rebuilt, never a second order.
network
string
yes
token
string
yes
amount
string
yes
Whole token units.
recipient
string
yes
Destination address: 0x + 40 hex chars on EVM networks, a T… address on Tron.
callback_url
string (uri)
no
Per-payout override of the webhook target configured for your merchant. Must be an absolute http(s) URL. Empty falls back to the merchant default; it cannot switch delivery on while webhooks are disabled for your merchant.
400unsupportedMalformed JSON, a missing recipient or amount, an unknown network or token, a native coin rather than an ERC-20, or a merchant/network with no payout router configured (codes: validation, unsupported). Also insufficient_allowance when the router may pull less than the amount from your payout wallet, and insufficient_funds when the wallet holds less than it — both with details saying how much is required and whom to approve.
{
"error": {
"code": "unsupported",
"message": "payouts are not enabled for this merchant/network (no router)"
}
}
401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403forbiddenThe credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
"error": {
"code": "forbidden",
"message": "client IP not allowed for this merchant"
}
}
503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Submits your EIP-712 signature over the order. We verify it recovers to your configured settlement wallet, move the payout to authorized, and relay it off the request path.
Request body
Field
Type
Required
Description
payout
string
yes
signature
string
yes
65-byte EIP-712 signature (eth_signTypedData_v4) over the typed_data returned by create.
401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403forbiddenThe signature is well-formed but recovers to a different address than your configured settlement wallet, so it does not authorize this payout. Also returned when the caller's IP is not allowlisted.
{
"error": {
"code": "forbidden",
"message": "signature does not authorize this payout"
}
}
410expiredThe order's 30-minute deadline has passed and it can no longer be signed. Create a new payout with a fresh external_id.
{
"error": {
"code": "expired",
"message": "payout order has expired; create a new one"
}
}
503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Builds the unsigned approve(router, amount) your payout wallet must send before payouts can pull from it. Add batch: true for the batch router. On EVM networks you get a ready transaction to sign and send back to broadcast, or a wallet_action to hand to a browser wallet. On Tron you get raw_transaction and tx_id: sign the 32-byte tx_id with the Tron payout wallet's key and send both to broadcast. See the allowance.
Request body
Field
Type
Required
Description
network
string
yes
token
string
yes
amount
string
yes
The cap to approve, whole token units.
batch
boolean
no
Approve the batch router instead of the single-payout router.
The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.
Response 200
The unsigned approval. EVM shape shown; on Tron transaction carries raw_transaction, tx_id, fee_limit and expiration instead of nonce, gas and chainId, and there is no wallet_action.
400An unknown network or token, no payout wallet or router for it, or an amount that is not a positive number. Codes: validation, unsupported.401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403forbiddenThe credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
"error": {
"code": "forbidden",
"message": "client IP not allowed for this merchant"
}
}
503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Sends your signed approval to the network. It is checked first: it must be exactly the approve(router, amount) that was built — same token, spender and amount, from your payout wallet and signed by it — or it is refused and never broadcast. EVM: send the signed raw transaction as signed_transaction. Tron: send raw_transaction and signature (65 bytes, over tx_id).
Request body
Field
Type
Required
Description
network
string
yes
token
string
yes
amount
string
yes
The same amount the approval was built for.
batch
boolean
no
signed_transaction
string
no
EVM: the signed raw transaction, 0x hex. Tron: optionally the whole signed transaction instead of the two fields below.
The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.
Response 200
Broadcast. Poll /v1/payouts/approval/status with this tx_hash until it is confirmed before creating the payout again — and do not build another approval while it is pending.
400Not the approval that was built, not a signed transaction, expired, or rejected by the network. Code: validation.401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403Signed by a key other than your payout wallet's. Code: forbidden.503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Poll this after broadcasting an approval, instead of guessing or approving again. The transaction must be an approve of this token to this router (batch: the batch router) from your payout wallet; anything else is refused. pending: known to the network, not mined yet. confirmed: mined and succeeded — create the payout again. failed: mined and reverted, it granted nothing. not_found: the network does not know it; right after a broadcast that can last a few seconds, after that it was dropped. allowance is what the router may pull from your payout wallet right now, in whole units.
Request body
Field
Type
Required
Description
network
string
yes
token
string
yes
batch
boolean
no
The approval was for the batch router.
tx_hash
string
yes
The tx_hash /v1/payouts/approval/broadcast returned.
400No tx_hash, an unknown network or token, or a transaction that is not this approval. Code: validation or unsupported.401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Up to 100 recipients in one order, all in one token on one network, pulled from your payout wallet. Returns one EIP-712 typed_data covering every item — you sign once for the whole batch. Each item becomes a payout of its own, with its own status and webhook; items are paid independently, so one that cannot be paid does not hold back the rest. Requires a prior approve of the batch router (typed_data.domain.verifyingContract) for at least the batch total.
The order is signable for 30 minutes. Idempotent on external_id. On Tron, recipients are T… addresses and the typed_data carries them as 0x hex — sign it exactly as on EVM: batch payouts.
Request body
Field
Type
Required
Description
external_id
string
no
Your idempotency key for the batch: a repeat returns the existing batch with its order rebuilt.
network
string
yes
token
string
yes
callback_url
string (uri)
no
Where each item's webhook is sent. Omit to use your merchant default.
400No items or more than 100, an item without a recipient or with a bad amount or address (the message names the item by index), duplicate item external_ids, an unknown network or token, or a network with no batch router (codes: validation, unsupported). Also insufficient_allowance or insufficient_funds when the batch router's allowance or your wallet's balance is below the batch total, with details.401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403forbiddenThe credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
"error": {
"code": "forbidden",
"message": "client IP not allowed for this merchant"
}
}
409An item's external_id is already used by another payout of yours. Code: conflict.503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Submits your EIP-712 signature over the batch's typed_data. We verify it recovers to your payout wallet for that network, move every item to authorized, and relay them off the request path.
Request body
Field
Type
Required
Description
batch
string
yes
signature
string
yes
65-byte EIP-712 signature over the batch's typed_data.
The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.
Response 200
The authorized batch.
Errors
400A missing batch id or signature, or a signature that is not well-formed. Code: validation.401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403The signature recovers to a different address than your payout wallet for this network. Code: forbidden.404No such batch for your merchant.409The batch was already authorized with a different signature. Code: conflict.410The batch's 30-minute deadline has passed. Create a new one with a fresh external_id.503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
Read-only and safe to poll. The batch status is derived from its items: pending, authorized, processing, then completed, failed, or partially_completed when some items were paid and some were not.
401unauthorizedThe signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
403forbiddenThe credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
"error": {
"code": "forbidden",
"message": "client IP not allowed for this merchant"
}
}
404No such batch for your merchant.503unavailableA dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
The typed_data in the create response is a complete EIP-712 payload. Hand it to any
wallet or signing library and you get back the 65-byte signature that
authorize expects. It is the only thing that can move your money, so
it is worth knowing what each part of it pins down.
Field
What it commits you to
domain.chainId, domain.verifyingContract
The network and the exact payout router allowed to execute this order. The same signature is worthless on any other chain or router.
message.merchant
Your payout wallet — the funds owner, and the address the signature must recover to. Anything else is 403.
message.token, message.recipient, message.amount
Exactly what moves and where. amount is in the token's base units: 125.50 USDT is 125500000 at 6 decimals.
message.nonce, message.deadline
Single use, and only for 30 minutes. The router records the nonce on-chain, so a captured signature cannot be replayed.
Check message.merchant, message.recipient and message.amount
against your own withdrawal record before you sign. That check is the point of a typed payload:
it is what leaves a stolen API key unable to pay anybody.
The signing function
Each one takes the typed_data object exactly as it came back and returns the
0x-prefixed signature with v as 27/28. The server-side versions take the
payout wallet's private key; the browser version asks the merchant's own wallet — MetaMask or a
hardware wallet behind it — to sign, for teams that approve withdrawals by hand.
from eth_account import Account
def _with_ints(types, type_name, value):
if type_name.endswith("[]"):
return [_with_ints(types, type_name[:-2], v) for v in value]
if type_name in types:
return {f["name"]: _with_ints(types, f["type"], value[f["name"]]) for f in types[type_name]}
if type_name.startswith(("uint", "int")):
return int(value)
return value
def sign_typed_data(typed_data, private_key):
types = typed_data["types"]
full = dict(typed_data)
full["message"] = _with_ints(types, typed_data["primaryType"], typed_data["message"])
full["domain"] = _with_ints(types, "EIP712Domain", typed_data["domain"])
signed = Account.sign_typed_data(private_key, full_message=full)
return "0x" + bytes(signed.signature).hex()
import { ethers } from "ethers";
async function signTypedData(typedData, privateKey) {
const wallet = new ethers.Wallet(privateKey);
const { EIP712Domain, ...types } = typedData.types;
return wallet.signTypedData(typedData.domain, types, typedData.message);
}
The browser version talks to your own backend: /api/payouts creates the payout with
the client and returns the response, /api/payouts/authorize forwards the signature.
Things that bite
The v byte, if you sign with go-ethereum.crypto.Sign
returns v as 0 or 1; the router requires 27 or 28 — hence
sig[64] += 27. Authorize accepts either, because recovery works both ways — so you
get a clean 200, and then the relay reverts on-chain and the payout settles
failed. ethers, eth-account and the PHP code above already return 27/28.
EIP712Domain goes in or out depending on the library. Out for
ethers' signTypedData (the Node.js function strips it), in for a wallet's
eth_signTypedData_v4. It is in the payload because wallets need it.
JSON types are not uniform.domain.chainId is a number;
amount, nonce and deadline are decimal strings, because a
uint256 does not survive a JSON number. eth-account wants real integers, which is what the
Python helper converts.
Sign with the payout wallet, not an operational key. The signer is compared
against merchant config, not against anything you send, so a signature from another key is
403 forbidden no matter how well-formed it is.
Do not re-serialize the order yourself. Sign the typed_data you were
given. Rebuilding it from your own fields is how field order, decimals and checksummed addresses
quietly drift into a digest that recovers to the wrong address.
Batch payouts
Paying many people at once? POST /v1/payouts/batch
takes up to 100 recipients in one token on one network and returns onetyped_data — a PayoutBatch that lists every recipient and amount. You sign
it once, with the same signing function as above, and send the signature to
POST /v1/payouts/batch/authorize.
Every item is a payout of its own, with its own id, status and webhook
(which carries batch_id and batch_index). Items are paid independently: one
that cannot be paid — a blacklisted recipient, say — does not hold back the others, and is retried
until the batch's 30-minute deadline, then settles failed. The router never pays an item
twice, however many times it is relayed. Read the whole batch with
POST /v1/payouts/batch/status.
Batch status
Meaning
pending
Built, waiting for your signature.
authorized
Signature verified, nothing relayed yet.
processing
Some items are on their way or waiting for a retry.
completed
Every item paid.
partially_completed
Every item settled; some paid, some failed. Each failed item says why in failure_reason.
failed
No item was paid.
Check every item before you sign
The one signature authorizes the whole list, so compare each message.items[i] with
your records — same order, same recipient, same base-unit amount. On Tron the recipients are in
0x form; tronToEvm converts yours
for the comparison. For an EVM batch, compare the addresses directly.
func checkBatch(typedData map[string]any, want []Withdrawal) error {
items := typedData["message"].(map[string]any)["items"].([]any)
if len(items) != len(want) {
return fmt.Errorf("batch has %d items, expected %d", len(items), len(want))
}
for i, raw := range items {
item := raw.(map[string]any)
recipient, err := TronToEVM(want[i].Address)
if err != nil {
return err
}
if !strings.EqualFold(item["recipient"].(string), recipient) || item["amount"] != want[i].BaseUnits {
return fmt.Errorf("item %d does not match withdrawal %s", i, want[i].ID)
}
}
return nil
}
function checkBatch(array $typedData, array $want): void
{
$items = $typedData['message']['items'];
if (count($items) !== count($want)) {
throw new RuntimeException('batch size does not match');
}
foreach ($items as $i => $item) {
if (strcasecmp($item['recipient'], tronToEvm($want[$i]['address'])) !== 0
|| $item['amount'] !== $want[$i]['base_units']) {
throw new RuntimeException("item $i does not match withdrawal {$want[$i]['id']}");
}
}
}
async function checkBatch(typedData, want) {
const { items } = typedData.message;
if (items.length !== want.length) throw new Error("batch size does not match");
for (const [i, item] of items.entries()) {
const recipient = await tronToEvm(want[i].address);
if (item.recipient.toLowerCase() !== recipient || item.amount !== want[i].baseUnits) {
throw new Error(`item ${i} does not match withdrawal ${want[i].id}`);
}
}
}
def check_batch(typed_data, want):
items = typed_data["message"]["items"]
if len(items) != len(want):
raise ValueError("batch size does not match")
for i, item in enumerate(items):
recipient = tron_to_evm(want[i]["address"])
if item["recipient"].lower() != recipient or item["amount"] != want[i]["base_units"]:
raise ValueError(f"item {i} does not match withdrawal {want[i]['id']}")
function checkBatch(typedData, want) {
const { items } = typedData.message;
if (items.length !== want.length) throw new Error("batch size does not match");
items.forEach((item, i) => {
const recipient = tronToEvm(want[i].address);
if (item.recipient.toLowerCase() !== recipient || item.amount !== want[i].baseUnits) {
throw new Error(`item ${i} does not match withdrawal ${want[i].id}`);
}
});
}
Things that bite
The batch router is its own contract. Batches go through a different router than single payouts — its address is typed_data.domain.verifyingContract — and it needs its own allowance, covering at least the batch total. Create refuses a batch whose total is above it.
Item order is part of the signature.index in the response is the position in message.items; do not re-sort the list before signing.
Item external_ids are global. They share one namespace with single payouts, so a withdrawal id cannot be paid twice by putting it in two batches.
The allowance
The router holds nothing. When a signed order is relayed it pulls the tokens from your payout
wallet with transferFrom, which only succeeds if that wallet has granted the router an
allowance. It is a one-time transaction per token and router, not per payout,
sent from the payout wallet itself — the single router and the batch router each need one.
You do not need to build it. POST /v1/payouts/approval
returns the unsigned approve for your payout wallet, the right token and the right
router ("batch": true for the batch router). You sign it where the key lives and hand
it back to POST /v1/payouts/approval/broadcast,
which checks it is exactly that approval, from that wallet, before sending it. amount
is the cap in whole units — a working cap you top up, or a large one; either way every payout still
needs your signature.
EVM
transaction is a legacy transaction with every field as a hex string —
nonce, gas, gasPrice, chainId,
value, to (the token) and data (the
approve call). Sign it and send the raw signed transaction as
signed_transaction. The response also carries a wallet_action: an
eth_sendTransaction request a browser wallet can send on its own, with no broadcast
call afterwards.
transaction.raw_transaction is the unsigned approve and
transaction.tx_id its 32-byte id. Sign the id itself — no extra hashing, no message
prefix — with the Tron payout wallet's key, and send raw_transaction and the 65-byte
signature back. The built transaction is valid for 30 minutes and burns TRX from your
payout wallet, fee limit 50 TRX.
The browser tab approves straight from TronLink, without the API: approve(router, cap)
on the token contract, from the Tron payout wallet, with the cap in base units.
Waiting for the approval
Broadcast is not mined. Before you create the payout again, poll
POST /v1/payouts/approval/status
with the tx_hash broadcast returned, every few seconds, until it says confirmed.
failed means it reverted and granted nothing; not_found for more than about a
minute means it was dropped — usually a payout wallet without TRX or gas.
Never build a second approval while one is pending. A chain that is merely slow
looks exactly like one that lost your transaction until the status endpoint says otherwise, and every
approval you send is a fee from your payout wallet.
func waitForApproval(ctx context.Context, client *time2pay.Client, network, token, txHash string, batch bool) error {
body, _ := json.Marshal(map[string]any{"network": network, "token": token, "tx_hash": txHash, "batch": batch})
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
st, err := client.Call(ctx, "POST", "/v1/payouts/approval/status", string(body))
if err != nil {
return err
}
switch st["status"] {
case "confirmed":
return nil
case "failed":
return fmt.Errorf("approval %s reverted; it granted nothing", txHash)
}
time.Sleep(3 * time.Second)
}
return fmt.Errorf("approval %s is still not confirmed; check it before approving again", txHash)
}
function waitForApproval(Time2payClient $client, string $network, string $token, string $txHash, bool $batch): void
{
$deadline = time() + 300;
while (time() < $deadline) {
$st = $client->call('POST', '/v1/payouts/approval/status', [
'network' => $network,
'token' => $token,
'tx_hash' => $txHash,
'batch' => $batch,
]);
if ($st['status'] === 'confirmed') {
return;
}
if ($st['status'] === 'failed') {
throw new RuntimeException("approval $txHash reverted; it granted nothing");
}
sleep(3);
}
throw new RuntimeException("approval $txHash is still not confirmed; check it before approving again");
}
async function waitForApproval(client, { network, token, txHash, batch = false }) {
const deadline = Date.now() + 5 * 60_000;
while (Date.now() < deadline) {
const st = await client.call("POST", "/v1/payouts/approval/status", { network, token, tx_hash: txHash, batch });
if (st.status === "confirmed") return;
if (st.status === "failed") throw new Error(`approval ${txHash} reverted; it granted nothing`);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`approval ${txHash} is still not confirmed; check it before approving again`);
}
import time
def wait_for_approval(client, network, token, tx_hash, batch=False):
deadline = time.time() + 300
while time.time() < deadline:
st = client.call("POST", "/v1/payouts/approval/status",
{"network": network, "token": token, "tx_hash": tx_hash, "batch": batch})
if st["status"] == "confirmed":
return
if st["status"] == "failed":
raise RuntimeError(f"approval {tx_hash} reverted; it granted nothing")
time.sleep(3)
raise TimeoutError(f"approval {tx_hash} is still not confirmed; check it before approving again")
async function waitForApproval(client, { network, token, txHash, batch = false }) {
const deadline = Date.now() + 5 * 60_000;
while (Date.now() < deadline) {
const st = await client.call("POST", "/v1/payouts/approval/status", { network, token, tx_hash: txHash, batch });
if (st.status === "confirmed") return;
if (st.status === "failed") throw new Error(`approval ${txHash} reverted; it granted nothing`);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`approval ${txHash} is still not confirmed; check it before approving again`);
}
When the allowance runs out
Creating a payout or a batch checks the allowance and the balance first, and refuses with
400 insufficient_allowance or 400 insufficient_funds. details
carries spender, required and the current allowance or
balance — enough to approve more and retry with the same external_id.
See handling errors for the code.
Things that bite
USDT on Ethereum will not change a non-zero allowance to another non-zero value — the approval reverts. Set it to zero first from your own tooling (cast send $USDT "approve(address,uint256)" $ROUTER 0), then approve the new cap. USDC, BSC and Tron tokens have no such restriction.
You can cut us off at any time by setting the allowance to zero. The routers are immutable — no owner, no upgrade — and can never move funds you did not sign for, allowance or not.
Allowance is per token, per router, per network. Approving USDT does not cover USDC, and the batch router does not share the single router's allowance.
The payout wallet needs gas. The approval is your transaction: ETH or BNB for gas on EVM, TRX on Tron. The payouts themselves are paid for by us.
Payouts on Tron
Tron works the same way, with the same endpoints, statuses and webhooks. The differences:
Addresses. Send recipients as ordinary T… addresses. The Tron payout wallet is set per merchant, separately from the EVM one.
Signing. The typed_data carries every address in its 20-byte 0x form and chainId728126428, so the same signing function signs it — with the private key of your Tron payout wallet. Convert your T… recipients to compare them.
Allowance. Built and broadcast through the API as above, or approved from TronLink.
Fees. We pay the energy the payout transactions burn; your wallet only needs the tokens, plus some TRX for the approval.
Payout statuses
Status
Set by
Meaning
pending
create
Order built, waiting for your signature.
authorized
authorize
Signature verified, waiting to be relayed.
submitted
the dispatcher
Router call broadcast, waiting for confirmations.
completed
the confirmer
Reached the required confirmation depth. Mark the withdrawal paid.
failed
the confirmer
The relay or the on-chain execution was unsuccessful. Nothing left your wallet; failure_reason says why.