time2pay Developer Docs

API reference

Payouts

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.

  1. 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.
  2. Create. POST /v1/payouts checks the balance and allowance, and returns the payout id and the EIP-712 typed_data.
  3. 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.
  4. Authorize. POST /v1/payouts/authorize with the signature; we verify it recovers to your payout wallet.
  5. Relay. A dispatcher broadcasts the router call off the request path; the payout moves to submitted with a tx_hash.
  6. 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/payouts signed

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

FieldTypeRequiredDescription
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.

Request

curl -X POST "$BASE_URL/v1/payouts" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"external_id":"withdrawal-5001","network":"eth","token":"USDT","amount":"125.50","recipient":"0x1111111111111111111111111111111111111111","callback_url":"https://merchant.example/webhooks/payouts"}'
res, err := client.Call(ctx, "POST", "/v1/payouts", `{
	"external_id": "withdrawal-5001",
	"network": "eth",
	"token": "USDT",
	"amount": "125.50",
	"recipient": "0x1111111111111111111111111111111111111111",
	"callback_url": "https://merchant.example/webhooks/payouts"
}`)
$res = $client->call('POST', '/v1/payouts', [
    'external_id' => 'withdrawal-5001',
    'network' => 'eth',
    'token' => 'USDT',
    'amount' => '125.50',
    'recipient' => '0x1111111111111111111111111111111111111111',
    'callback_url' => 'https://merchant.example/webhooks/payouts',
]);
const res = await client.call("POST", "/v1/payouts", {
    "external_id": "withdrawal-5001",
    "network": "eth",
    "token": "USDT",
    "amount": "125.50",
    "recipient": "0x1111111111111111111111111111111111111111",
    "callback_url": "https://merchant.example/webhooks/payouts"
});
res = client.call("POST", "/v1/payouts", {
    "external_id": "withdrawal-5001",
    "network": "eth",
    "token": "USDT",
    "amount": "125.50",
    "recipient": "0x1111111111111111111111111111111111111111",
    "callback_url": "https://merchant.example/webhooks/payouts",
})
const res = await client.call("POST", "/v1/payouts", {
    "external_id": "withdrawal-5001",
    "network": "eth",
    "token": "USDT",
    "amount": "125.50",
    "recipient": "0x1111111111111111111111111111111111111111",
    "callback_url": "https://merchant.example/webhooks/payouts"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The payout, with the payload to sign.

{
  "payout": "pay_abc123",
  "status": "pending",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0x1111111111111111111111111111111111111111",
  "typed_data": {
    "types": {
      "EIP712Domain": [
        {
          "name": "name",
          "type": "string"
        },
        {
          "name": "version",
          "type": "string"
        },
        {
          "name": "chainId",
          "type": "uint256"
        },
        {
          "name": "verifyingContract",
          "type": "address"
        }
      ],
      "PayoutOrder": [
        {
          "name": "token",
          "type": "address"
        },
        {
          "name": "merchant",
          "type": "address"
        },
        {
          "name": "recipient",
          "type": "address"
        },
        {
          "name": "amount",
          "type": "uint256"
        },
        {
          "name": "nonce",
          "type": "uint256"
        },
        {
          "name": "deadline",
          "type": "uint256"
        }
      ]
    },
    "primaryType": "PayoutOrder",
    "domain": {
      "name": "PSFP Payouts",
      "version": "1",
      "chainId": 1,
      "verifyingContract": "0x3333333333333333333333333333333333333333"
    },
    "message": {
      "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
      "merchant": "0x2222222222222222222222222222222222222222",
      "recipient": "0x1111111111111111111111111111111111111111",
      "amount": "125500000",
      "nonce": "10237456029384756123",
      "deadline": "1767225600"
    }
  }
}

Errors

400 unsupported Malformed 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)"
  }
}
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/authorize signed

Authorize a payout with your wallet 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

FieldTypeRequiredDescription
payout string yes
signature string yes 65-byte EIP-712 signature (eth_signTypedData_v4) over the typed_data returned by create.

Request

curl -X POST "$BASE_URL/v1/payouts/authorize" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"payout":"pay_abc123","signature":"0x<65-byte EIP-712 signature>"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/authorize", `{
	"payout": "pay_abc123",
	"signature": "0x<65-byte EIP-712 signature>"
}`)
$res = $client->call('POST', '/v1/payouts/authorize', [
    'payout' => 'pay_abc123',
    'signature' => '0x<65-byte EIP-712 signature>',
]);
const res = await client.call("POST", "/v1/payouts/authorize", {
    "payout": "pay_abc123",
    "signature": "0x<65-byte EIP-712 signature>"
});
res = client.call("POST", "/v1/payouts/authorize", {
    "payout": "pay_abc123",
    "signature": "0x<65-byte EIP-712 signature>",
})
const res = await client.call("POST", "/v1/payouts/authorize", {
    "payout": "pay_abc123",
    "signature": "0x<65-byte EIP-712 signature>"
});

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 payout.

{
  "payout": "pay_abc123",
  "status": "authorized",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0x1111111111111111111111111111111111111111"
}

Errors

400 validation A missing payout id or signature, or a signature that is not well-formed. Code: validation.
{
  "error": {
    "code": "validation",
    "message": "signature is required"
  }
}
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
404 not_found No such payout for your merchant.
{
  "error": {
    "code": "not_found",
    "message": "payout not found"
  }
}
410 expired The 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"
  }
}
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/approval signed

Build the allowance transaction

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

FieldTypeRequiredDescription
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.

Request

curl -X POST "$BASE_URL/v1/payouts/approval" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"network":"bsc","token":"USDT","amount":"100000","batch":false}'
res, err := client.Call(ctx, "POST", "/v1/payouts/approval", `{
	"network": "bsc",
	"token": "USDT",
	"amount": "100000",
	"batch": false
}`)
$res = $client->call('POST', '/v1/payouts/approval', [
    'network' => 'bsc',
    'token' => 'USDT',
    'amount' => '100000',
    'batch' => false,
]);
const res = await client.call("POST", "/v1/payouts/approval", {
    "network": "bsc",
    "token": "USDT",
    "amount": "100000",
    "batch": false
});
res = client.call("POST", "/v1/payouts/approval", {
    "network": "bsc",
    "token": "USDT",
    "amount": "100000",
    "batch": False,
})
const res = await client.call("POST", "/v1/payouts/approval", {
    "network": "bsc",
    "token": "USDT",
    "amount": "100000",
    "batch": false
});

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.

{
  "network": "bsc",
  "token": "USDT",
  "amount": "100000",
  "spender": "0x4444444444444444444444444444444444444444",
  "source": "0x2222222222222222222222222222222222222222",
  "transaction": {
    "from": "0x2222222222222222222222222222222222222222",
    "to": "0x55d398326f99059fF775485246999027B3197955",
    "data": "0x095ea7b30000000000000000000000004444444444444444444444444444444444444444000000000000000000000000000000000000000000000152d02c7e14af6800000",
    "value": "0x0",
    "nonce": "0x7",
    "gas": "0x186a0",
    "gasPrice": "0x3b9aca00",
    "chainId": "0x38"
  },
  "wallet_action": {
    "method": "eth_sendTransaction",
    "params": [
      {
        "from": "0x2222222222222222222222222222222222222222",
        "to": "0x55d398326f99059fF775485246999027B3197955",
        "data": "0x095ea7b3…",
        "value": "0x0",
        "chainId": "0x38"
      }
    ]
  }
}

Errors

400 An unknown network or token, no payout wallet or router for it, or an amount that is not a positive number. Codes: validation, unsupported.
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/approval/broadcast signed

Broadcast the signed allowance transaction

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

FieldTypeRequiredDescription
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.
raw_transaction string no Tron: raw_transaction exactly as built.
signature string no Tron: 65-byte signature over tx_id, hex.

Request

curl -X POST "$BASE_URL/v1/payouts/approval/broadcast" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"network":"tron","token":"USDT","amount":"100000","batch":true,"raw_transaction":"0a02b40f2208ab…","signature":"0x<65-byte signature over tx_id>"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/approval/broadcast", `{
	"network": "tron",
	"token": "USDT",
	"amount": "100000",
	"batch": true,
	"raw_transaction": "0a02b40f2208ab…",
	"signature": "0x<65-byte signature over tx_id>"
}`)
$res = $client->call('POST', '/v1/payouts/approval/broadcast', [
    'network' => 'tron',
    'token' => 'USDT',
    'amount' => '100000',
    'batch' => true,
    'raw_transaction' => '0a02b40f2208ab…',
    'signature' => '0x<65-byte signature over tx_id>',
]);
const res = await client.call("POST", "/v1/payouts/approval/broadcast", {
    "network": "tron",
    "token": "USDT",
    "amount": "100000",
    "batch": true,
    "raw_transaction": "0a02b40f2208ab…",
    "signature": "0x<65-byte signature over tx_id>"
});
res = client.call("POST", "/v1/payouts/approval/broadcast", {
    "network": "tron",
    "token": "USDT",
    "amount": "100000",
    "batch": True,
    "raw_transaction": "0a02b40f2208ab…",
    "signature": "0x<65-byte signature over tx_id>",
})
const res = await client.call("POST", "/v1/payouts/approval/broadcast", {
    "network": "tron",
    "token": "USDT",
    "amount": "100000",
    "batch": true,
    "raw_transaction": "0a02b40f2208ab…",
    "signature": "0x<65-byte signature over tx_id>"
});

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.

{
  "network": "tron",
  "token": "USDT",
  "tx_hash": "5f1c0b7e9a3d2c4b6a8e0f1d3c5b7a9e2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c"
}

Errors

400 Not the approval that was built, not a signed transaction, expired, or rejected by the network. Code: validation.
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 Signed by a key other than your payout wallet's. Code: forbidden.
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/approval/status signed

What became of an allowance transaction

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

FieldTypeRequiredDescription
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.

Request

curl -X POST "$BASE_URL/v1/payouts/approval/status" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"network":"tron","token":"USDT","batch":true,"tx_hash":"13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/approval/status", `{
	"network": "tron",
	"token": "USDT",
	"batch": true,
	"tx_hash": "13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce"
}`)
$res = $client->call('POST', '/v1/payouts/approval/status', [
    'network' => 'tron',
    'token' => 'USDT',
    'batch' => true,
    'tx_hash' => '13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce',
]);
const res = await client.call("POST", "/v1/payouts/approval/status", {
    "network": "tron",
    "token": "USDT",
    "batch": true,
    "tx_hash": "13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce"
});
res = client.call("POST", "/v1/payouts/approval/status", {
    "network": "tron",
    "token": "USDT",
    "batch": True,
    "tx_hash": "13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce",
})
const res = await client.call("POST", "/v1/payouts/approval/status", {
    "network": "tron",
    "token": "USDT",
    "batch": true,
    "tx_hash": "13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The approval's state.

{
  "network": "tron",
  "token": "USDT",
  "spender": "THTCz1YJ7A5Q3k8GHu5ihLSCrZcvSxS4Gp",
  "source": "TEDAYYWeLtPbHwfbMB9Pr4Y2eqhs2huW23",
  "tx_hash": "13fbbc8d17050da72ec9cf265e104b971a810d5e5d648f74707e80f3bde152ce",
  "status": "confirmed",
  "block": 71362524,
  "allowance": "200"
}

Errors

400 No tx_hash, an unknown network or token, or a transaction that is not this approval. Code: validation or unsupported.
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/status signed

Read a payout

Read-only and safe to poll while the payout is relayed and confirmed. Only your own payouts are visible.

Request body

FieldTypeRequiredDescription
payout string yes

Request

curl -X POST "$BASE_URL/v1/payouts/status" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"payout":"pay_abc123"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/status", `{
	"payout": "pay_abc123"
}`)
$res = $client->call('POST', '/v1/payouts/status', [
    'payout' => 'pay_abc123',
]);
const res = await client.call("POST", "/v1/payouts/status", {
    "payout": "pay_abc123"
});
res = client.call("POST", "/v1/payouts/status", {
    "payout": "pay_abc123",
})
const res = await client.call("POST", "/v1/payouts/status", {
    "payout": "pay_abc123"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The payout's current state.

{
  "payout": "pay_abc123",
  "status": "submitted",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0x1111111111111111111111111111111111111111",
  "tx_hash": "0xabc…"
}

Errors

401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
404 not_found No such payout for your merchant. Another merchant's payout id is indistinguishable from one that does not exist.
{
  "error": {
    "code": "not_found",
    "message": "payout not found"
  }
}
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/batch signed

Create a batch payout order

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

FieldTypeRequiredDescription
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.
items array yes

Request

curl -X POST "$BASE_URL/v1/payouts/batch" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"external_id":"payroll-2026-09-26","network":"tron","token":"USDT","callback_url":"https://merchant.example/webhooks/payouts","items":[{"external_id":"WD-1","recipient":"TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S","amount":"12.5"},{"external_id":"WD-2","recipient":"TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6","amount":"25"}]}'
res, err := client.Call(ctx, "POST", "/v1/payouts/batch", `{
	"external_id": "payroll-2026-09-26",
	"network": "tron",
	"token": "USDT",
	"callback_url": "https://merchant.example/webhooks/payouts",
	"items": [
		{
			"external_id": "WD-1",
			"recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
			"amount": "12.5"
		},
		{
			"external_id": "WD-2",
			"recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
			"amount": "25"
		}
	]
}`)
$res = $client->call('POST', '/v1/payouts/batch', [
    'external_id' => 'payroll-2026-09-26',
    'network' => 'tron',
    'token' => 'USDT',
    'callback_url' => 'https://merchant.example/webhooks/payouts',
    'items' => [
        [
            'external_id' => 'WD-1',
            'recipient' => 'TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S',
            'amount' => '12.5',
        ],
        [
            'external_id' => 'WD-2',
            'recipient' => 'TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6',
            'amount' => '25',
        ],
    ],
]);
const res = await client.call("POST", "/v1/payouts/batch", {
    "external_id": "payroll-2026-09-26",
    "network": "tron",
    "token": "USDT",
    "callback_url": "https://merchant.example/webhooks/payouts",
    "items": [
        {
            "external_id": "WD-1",
            "recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
            "amount": "12.5"
        },
        {
            "external_id": "WD-2",
            "recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
            "amount": "25"
        }
    ]
});
res = client.call("POST", "/v1/payouts/batch", {
    "external_id": "payroll-2026-09-26",
    "network": "tron",
    "token": "USDT",
    "callback_url": "https://merchant.example/webhooks/payouts",
    "items": [
        {
            "external_id": "WD-1",
            "recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
            "amount": "12.5",
        },
        {
            "external_id": "WD-2",
            "recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
            "amount": "25",
        },
    ],
})
const res = await client.call("POST", "/v1/payouts/batch", {
    "external_id": "payroll-2026-09-26",
    "network": "tron",
    "token": "USDT",
    "callback_url": "https://merchant.example/webhooks/payouts",
    "items": [
        {
            "external_id": "WD-1",
            "recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
            "amount": "12.5"
        },
        {
            "external_id": "WD-2",
            "recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
            "amount": "25"
        }
    ]
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The batch and its items, with the one payload to sign.

{
  "batch": "pb_01J9A3",
  "external_id": "payroll-2026-09-26",
  "status": "pending",
  "network": "tron",
  "token": "USDT",
  "total": "37.5",
  "items": [
    {
      "payout": "pay_01J9A4",
      "index": 0,
      "external_id": "WD-1",
      "recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
      "amount": "12.5",
      "status": "pending"
    },
    {
      "payout": "pay_01J9A5",
      "index": 1,
      "external_id": "WD-2",
      "recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
      "amount": "25",
      "status": "pending"
    }
  ],
  "typed_data": {
    "types": {
      "EIP712Domain": [
        {
          "name": "name",
          "type": "string"
        },
        {
          "name": "version",
          "type": "string"
        },
        {
          "name": "chainId",
          "type": "uint256"
        },
        {
          "name": "verifyingContract",
          "type": "address"
        }
      ],
      "PayoutBatch": [
        {
          "name": "token",
          "type": "address"
        },
        {
          "name": "merchant",
          "type": "address"
        },
        {
          "name": "items",
          "type": "PayoutItem[]"
        },
        {
          "name": "nonce",
          "type": "uint256"
        },
        {
          "name": "deadline",
          "type": "uint256"
        }
      ],
      "PayoutItem": [
        {
          "name": "recipient",
          "type": "address"
        },
        {
          "name": "amount",
          "type": "uint256"
        }
      ]
    },
    "primaryType": "PayoutBatch",
    "domain": {
      "name": "PSFP Payouts",
      "version": "1",
      "chainId": 728126428,
      "verifyingContract": "0x83b273deb148942a21316e5048932afceacddf0c"
    },
    "message": {
      "token": "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c",
      "merchant": "0xba63ee50c2475fa1ab18139e9fb69cc7db0ce3c4",
      "items": [
        {
          "recipient": "0x9ec89e629612fa65b7b74b9bdad529e5278b1265",
          "amount": "12500000"
        },
        {
          "recipient": "0xba63ee50c2475fa1ab18139e9fb69cc7db0ce3c5",
          "amount": "25000000"
        }
      ],
      "nonce": "10237456029384756123",
      "deadline": "1790430000"
    }
  }
}

Errors

400 No 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.
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
409 An item's external_id is already used by another payout of yours. Code: conflict.
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/batch/authorize signed

Authorize a batch with one wallet 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

FieldTypeRequiredDescription
batch string yes
signature string yes 65-byte EIP-712 signature over the batch's typed_data.

Request

curl -X POST "$BASE_URL/v1/payouts/batch/authorize" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"batch":"pb_01J9A3","signature":"0x<65-byte EIP-712 signature>"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/batch/authorize", `{
	"batch": "pb_01J9A3",
	"signature": "0x<65-byte EIP-712 signature>"
}`)
$res = $client->call('POST', '/v1/payouts/batch/authorize', [
    'batch' => 'pb_01J9A3',
    'signature' => '0x<65-byte EIP-712 signature>',
]);
const res = await client.call("POST", "/v1/payouts/batch/authorize", {
    "batch": "pb_01J9A3",
    "signature": "0x<65-byte EIP-712 signature>"
});
res = client.call("POST", "/v1/payouts/batch/authorize", {
    "batch": "pb_01J9A3",
    "signature": "0x<65-byte EIP-712 signature>",
})
const res = await client.call("POST", "/v1/payouts/batch/authorize", {
    "batch": "pb_01J9A3",
    "signature": "0x<65-byte EIP-712 signature>"
});

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

400 A missing batch id or signature, or a signature that is not well-formed. Code: validation.
401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 The signature recovers to a different address than your payout wallet for this network. Code: forbidden.
404 No such batch for your merchant.
409 The batch was already authorized with a different signature. Code: conflict.
410 The batch's 30-minute deadline has passed. Create a new one with a fresh external_id.
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/payouts/batch/status signed

Read a batch

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.

Request body

FieldTypeRequiredDescription
batch string yes

Request

curl -X POST "$BASE_URL/v1/payouts/batch/status" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  -d '{"batch":"pb_01J9A3"}'
res, err := client.Call(ctx, "POST", "/v1/payouts/batch/status", `{
	"batch": "pb_01J9A3"
}`)
$res = $client->call('POST', '/v1/payouts/batch/status', [
    'batch' => 'pb_01J9A3',
]);
const res = await client.call("POST", "/v1/payouts/batch/status", {
    "batch": "pb_01J9A3"
});
res = client.call("POST", "/v1/payouts/batch/status", {
    "batch": "pb_01J9A3",
})
const res = await client.call("POST", "/v1/payouts/batch/status", {
    "batch": "pb_01J9A3"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The batch and every item's state.

{
  "batch": "pb_01J9A3",
  "external_id": "payroll-2026-09-26",
  "status": "processing",
  "network": "tron",
  "token": "USDT",
  "total": "37.5",
  "items": [
    {
      "payout": "pay_01J9A4",
      "index": 0,
      "external_id": "WD-1",
      "recipient": "TQSn6biF59oVzq1qi2sqvPgndGo1Zw6k9S",
      "amount": "12.5",
      "status": "completed",
      "tx_hash": "1a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b"
    },
    {
      "payout": "pay_01J9A5",
      "index": 1,
      "external_id": "WD-2",
      "recipient": "TSxkQ4X6pds2SE58UC5LSJz61xKVjtPEP6",
      "amount": "25",
      "status": "submitted",
      "tx_hash": "7c9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b1a3b5c"
    }
  ]
}

Errors

401 unauthorized The 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.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The 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"
  }
}
404 No such batch for your merchant.
503 unavailable A 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.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}

Signing the order

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.

FieldWhat 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.

import (
	"crypto/ecdsa"
	"encoding/hex"
	"encoding/json"

	ethcrypto "github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/signer/core/apitypes"
)

func SignTypedData(typedData json.RawMessage, key *ecdsa.PrivateKey) (string, error) {
	var td apitypes.TypedData
	if err := json.Unmarshal(typedData, &td); err != nil {
		return "", err
	}
	digest, _, err := apitypes.TypedDataAndHash(td)
	if err != nil {
		return "", err
	}
	sig, err := ethcrypto.Sign(digest, key)
	if err != nil {
		return "", err
	}
	sig[64] += 27
	return "0x" + hex.EncodeToString(sig), nil
}
use kornrunner\Keccak;
use kornrunner\Secp256k1;

final class Eip712
{
    public static function sign(array $typedData, string $privateKeyHex): string
    {
        $digest = self::hash($typedData);
        $signature = (new Secp256k1())->sign(bin2hex($digest), preg_replace('/^0x/i', '', $privateKeyHex));
        $r = str_pad(gmp_strval($signature->getR(), 16), 64, '0', STR_PAD_LEFT);
        $s = str_pad(gmp_strval($signature->getS(), 16), 64, '0', STR_PAD_LEFT);
        return '0x' . $r . $s . dechex(27 + $signature->getRecoveryParam());
    }

    public static function hash(array $typedData): string
    {
        $types = $typedData['types'];
        return self::keccak("\x19\x01"
            . self::hashStruct('EIP712Domain', $typedData['domain'], $types)
            . self::hashStruct($typedData['primaryType'], $typedData['message'], $types));
    }

    private static function keccak(string $data): string
    {
        return hex2bin(Keccak::hash($data, 256));
    }

    private static function hashStruct(string $type, array $data, array $types): string
    {
        $encoded = self::keccak(self::encodeType($type, $types));
        foreach ($types[$type] as $field) {
            $encoded .= self::encodeValue($field['type'], $data[$field['name']], $types);
        }
        return self::keccak($encoded);
    }

    private static function encodeType(string $primary, array $types): string
    {
        $deps = array_values(array_diff(self::dependencies($primary, $types), [$primary]));
        sort($deps);
        $out = '';
        foreach (array_merge([$primary], $deps) as $type) {
            $fields = array_map(fn (array $f) => $f['type'] . ' ' . $f['name'], $types[$type]);
            $out .= $type . '(' . implode(',', $fields) . ')';
        }
        return $out;
    }

    private static function dependencies(string $type, array $types, array $found = []): array
    {
        $type = preg_replace('/\[\]$/', '', $type);
        if (in_array($type, $found, true) || !isset($types[$type])) {
            return $found;
        }
        $found[] = $type;
        foreach ($types[$type] as $field) {
            $found = self::dependencies($field['type'], $types, $found);
        }
        return $found;
    }

    private static function encodeValue(string $type, mixed $value, array $types): string
    {
        if (str_ends_with($type, '[]')) {
            $inner = substr($type, 0, -2);
            return self::keccak(implode('', array_map(fn ($v) => self::encodeValue($inner, $v, $types), $value)));
        }
        if (isset($types[$type])) {
            return self::hashStruct($type, $value, $types);
        }
        return match (true) {
            $type === 'string' => self::keccak($value),
            $type === 'address' => str_pad(hex2bin(substr(strtolower($value), 2)), 32, "\0", STR_PAD_LEFT),
            str_starts_with($type, 'uint') => hex2bin(str_pad(gmp_strval(gmp_init((string) $value, 10), 16), 64, '0', STR_PAD_LEFT)),
            default => throw new InvalidArgumentException("unsupported EIP-712 type $type"),
        };
    }
}
async function signTypedData(typedData, account) {
  return window.ethereum.request({
    method: "eth_signTypedData_v4",
    params: [account, JSON.stringify(typedData)],
  });
}
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);
}

Create, sign, authorize

created, err := client.Call(ctx, "POST", "/v1/payouts", body)
if err != nil {
	return err
}
typedData, err := json.Marshal(created["typed_data"])
if err != nil {
	return err
}
signature, err := SignTypedData(typedData, merchantKey)
if err != nil {
	return err
}
authorizeBody, _ := json.Marshal(map[string]string{"payout": created["payout"].(string), "signature": signature})
_, err = client.Call(ctx, "POST", "/v1/payouts/authorize", string(authorizeBody))
return err
$created = $client->call('POST', '/v1/payouts', $withdrawal);
$signature = Eip712::sign($created['typed_data'], getenv('MERCHANT_PRIVATE_KEY'));
$client->call('POST', '/v1/payouts/authorize', ['payout' => $created['payout'], 'signature' => $signature]);
const created = await fetch("/api/payouts", { method: "POST", body: JSON.stringify(withdrawal) }).then((r) => r.json());
const [account] = await window.ethereum.request({ method: "eth_requestAccounts" });
const signature = await signTypedData(created.typed_data, account);
await fetch("/api/payouts/authorize", {
  method: "POST",
  body: JSON.stringify({ payout: created.payout, signature }),
});
created = client.call("POST", "/v1/payouts", withdrawal)
signature = sign_typed_data(created["typed_data"], os.environ["MERCHANT_PRIVATE_KEY"])
client.call("POST", "/v1/payouts/authorize", {"payout": created["payout"], "signature": signature})
const created = await client.call("POST", "/v1/payouts", withdrawal);
const signature = await signTypedData(created.typed_data, process.env.MERCHANT_PRIVATE_KEY);
await client.call("POST", "/v1/payouts/authorize", { payout: created.payout, signature });

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 one typed_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.

created, err := client.Call(ctx, "POST", "/v1/payouts/batch", body)
if err != nil {
	return err
}
typedData, err := json.Marshal(created["typed_data"])
if err != nil {
	return err
}
signature, err := SignTypedData(typedData, merchantKey)
if err != nil {
	return err
}
authorizeBody, _ := json.Marshal(map[string]string{"batch": created["batch"].(string), "signature": signature})
_, err = client.Call(ctx, "POST", "/v1/payouts/batch/authorize", string(authorizeBody))
return err
$created = $client->call('POST', '/v1/payouts/batch', $batch);
$signature = Eip712::sign($created['typed_data'], getenv('MERCHANT_PRIVATE_KEY'));
$client->call('POST', '/v1/payouts/batch/authorize', ['batch' => $created['batch'], 'signature' => $signature]);
const created = await fetch("/api/payouts/batch", { method: "POST", body: JSON.stringify(batch) }).then((r) => r.json());
const [account] = await window.ethereum.request({ method: "eth_requestAccounts" });
const signature = await signTypedData(created.typed_data, account);
await fetch("/api/payouts/batch/authorize", {
  method: "POST",
  body: JSON.stringify({ batch: created.batch, signature }),
});
created = client.call("POST", "/v1/payouts/batch", batch)
signature = sign_typed_data(created["typed_data"], os.environ["MERCHANT_PRIVATE_KEY"])
client.call("POST", "/v1/payouts/batch/authorize", {"batch": created["batch"], "signature": signature})
const created = await client.call("POST", "/v1/payouts/batch", batch);
const signature = await signTypedData(created.typed_data, process.env.MERCHANT_PRIVATE_KEY);
await client.call("POST", "/v1/payouts/batch/authorize", { batch: created.batch, signature });

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 statusMeaning
pendingBuilt, waiting for your signature.
authorizedSignature verified, nothing relayed yet.
processingSome items are on their way or waiting for a retry.
completedEvery item paid.
partially_completedEvery item settled; some paid, some failed. Each failed item says why in failure_reason.
failedNo 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.

import (
	"crypto/ecdsa"
	"encoding/hex"
	"math/big"
	"strings"

	ethcommon "github.com/ethereum/go-ethereum/common"
	ethtypes "github.com/ethereum/go-ethereum/core/types"
)

func hexInt(s string) *big.Int {
	v, _ := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16)
	return v
}

func SignEVMApproval(tx map[string]string, key *ecdsa.PrivateKey) (string, error) {
	to := ethcommon.HexToAddress(tx["to"])
	signed, err := ethtypes.SignTx(ethtypes.NewTx(&ethtypes.LegacyTx{
		Nonce:    hexInt(tx["nonce"]).Uint64(),
		GasPrice: hexInt(tx["gasPrice"]),
		Gas:      hexInt(tx["gas"]).Uint64(),
		To:       &to,
		Value:    hexInt(tx["value"]),
		Data:     ethcommon.FromHex(tx["data"]),
	}), ethtypes.LatestSignerForChainID(hexInt(tx["chainId"])), key)
	if err != nil {
		return "", err
	}
	raw, err := signed.MarshalBinary()
	if err != nil {
		return "", err
	}
	return "0x" + hex.EncodeToString(raw), nil
}
use Web3p\EthereumTx\Transaction;

function signEvmApproval(array $tx, string $privateKey): string
{
    $transaction = new Transaction([
        'nonce' => $tx['nonce'],
        'gasPrice' => $tx['gasPrice'],
        'gas' => $tx['gas'],
        'to' => $tx['to'],
        'value' => $tx['value'],
        'data' => $tx['data'],
        'chainId' => hexdec($tx['chainId']),
    ]);
    return '0x' . $transaction->sign(preg_replace('/^0x/i', '', $privateKey));
}
const built = await fetch("/api/payouts/approval", {
  method: "POST",
  body: JSON.stringify({ network: "bsc", token: "USDT", amount: "100000" }),
}).then((r) => r.json());
const txHash = await window.ethereum.request({
  method: built.wallet_action.method,
  params: built.wallet_action.params,
});
from eth_account import Account


def sign_evm_approval(tx, private_key):
    signed = Account.sign_transaction({
        "to": tx["to"],
        "data": tx["data"],
        "value": int(tx["value"], 16),
        "nonce": int(tx["nonce"], 16),
        "gas": int(tx["gas"], 16),
        "gasPrice": int(tx["gasPrice"], 16),
        "chainId": int(tx["chainId"], 16),
    }, private_key)
    return "0x" + bytes(signed.raw_transaction).hex()
import { ethers } from "ethers";

async function signEvmApproval(tx, privateKey) {
  return new ethers.Wallet(privateKey).signTransaction({
    type: 0,
    to: tx.to,
    data: tx.data,
    value: BigInt(tx.value),
    nonce: Number(tx.nonce),
    gasLimit: BigInt(tx.gas),
    gasPrice: BigInt(tx.gasPrice),
    chainId: BigInt(tx.chainId),
  });
}
built, err := client.Call(ctx, "POST", "/v1/payouts/approval", `{"network":"bsc","token":"USDT","amount":"100000"}`)
if err != nil {
	return err
}
tx := map[string]string{}
for k, v := range built["transaction"].(map[string]any) {
	tx[k], _ = v.(string)
}
signed, err := SignEVMApproval(tx, merchantKey)
if err != nil {
	return err
}
body, _ := json.Marshal(map[string]string{"network": "bsc", "token": "USDT", "amount": "100000", "signed_transaction": signed})
_, err = client.Call(ctx, "POST", "/v1/payouts/approval/broadcast", string(body))
return err
$built = $client->call('POST', '/v1/payouts/approval', ['network' => 'bsc', 'token' => 'USDT', 'amount' => '100000']);
$signed = signEvmApproval($built['transaction'], getenv('MERCHANT_PRIVATE_KEY'));
$client->call('POST', '/v1/payouts/approval/broadcast', [
    'network' => 'bsc',
    'token' => 'USDT',
    'amount' => '100000',
    'signed_transaction' => $signed,
]);
built = client.call("POST", "/v1/payouts/approval", {"network": "bsc", "token": "USDT", "amount": "100000"})
signed = sign_evm_approval(built["transaction"], os.environ["MERCHANT_PRIVATE_KEY"])
client.call("POST", "/v1/payouts/approval/broadcast", {
    "network": "bsc", "token": "USDT", "amount": "100000", "signed_transaction": signed,
})
const built = await client.call("POST", "/v1/payouts/approval", { network: "bsc", token: "USDT", amount: "100000" });
const signed = await signEvmApproval(built.transaction, process.env.MERCHANT_PRIVATE_KEY);
await client.call("POST", "/v1/payouts/approval/broadcast", {
  network: "bsc", token: "USDT", amount: "100000", signed_transaction: signed,
});

Tron

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.

import (
	"crypto/ecdsa"
	"encoding/hex"

	ethcrypto "github.com/ethereum/go-ethereum/crypto"
)

func SignTronTxID(txID string, key *ecdsa.PrivateKey) (string, error) {
	digest, err := hex.DecodeString(txID)
	if err != nil {
		return "", err
	}
	sig, err := ethcrypto.Sign(digest, key)
	if err != nil {
		return "", err
	}
	sig[64] += 27
	return hex.EncodeToString(sig), nil
}
use kornrunner\Secp256k1;

function signTronTxId(string $txId, string $privateKey): string
{
    $signature = (new Secp256k1())->sign($txId, preg_replace('/^0x/i', '', $privateKey));
    return str_pad(gmp_strval($signature->getR(), 16), 64, '0', STR_PAD_LEFT)
        . str_pad(gmp_strval($signature->getS(), 16), 64, '0', STR_PAD_LEFT)
        . dechex(27 + $signature->getRecoveryParam());
}
const usdt = await window.tronWeb.contract().at("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t");
await usdt.approve(PAYOUT_ROUTER_T_ADDRESS, "100000000000").send();
from eth_account import Account


def sign_tron_tx_id(tx_id, private_key):
    return bytes(Account.unsafe_sign_hash(bytes.fromhex(tx_id), private_key).signature).hex()
import { ethers } from "ethers";

function signTronTxId(txId, privateKey) {
  return new ethers.SigningKey(privateKey).sign("0x" + txId).serialized;
}
built, err := client.Call(ctx, "POST", "/v1/payouts/approval", `{"network":"tron","token":"USDT","amount":"100000","batch":true}`)
if err != nil {
	return err
}
tx := built["transaction"].(map[string]any)
signature, err := SignTronTxID(tx["tx_id"].(string), merchantKey)
if err != nil {
	return err
}
body, _ := json.Marshal(map[string]any{
	"network": "tron", "token": "USDT", "amount": "100000", "batch": true,
	"raw_transaction": tx["raw_transaction"], "signature": signature,
})
_, err = client.Call(ctx, "POST", "/v1/payouts/approval/broadcast", string(body))
return err
$request = ['network' => 'tron', 'token' => 'USDT', 'amount' => '100000', 'batch' => true];
$built = $client->call('POST', '/v1/payouts/approval', $request);
$client->call('POST', '/v1/payouts/approval/broadcast', $request + [
    'raw_transaction' => $built['transaction']['raw_transaction'],
    'signature' => signTronTxId($built['transaction']['tx_id'], getenv('TRON_PAYOUT_PRIVATE_KEY')),
]);
request = {"network": "tron", "token": "USDT", "amount": "100000", "batch": True}
built = client.call("POST", "/v1/payouts/approval", request)
client.call("POST", "/v1/payouts/approval/broadcast", {
    **request,
    "raw_transaction": built["transaction"]["raw_transaction"],
    "signature": sign_tron_tx_id(built["transaction"]["tx_id"], os.environ["TRON_PAYOUT_PRIVATE_KEY"]),
})
const request = { network: "tron", token: "USDT", amount: "100000", batch: true };
const built = await client.call("POST", "/v1/payouts/approval", request);
await client.call("POST", "/v1/payouts/approval/broadcast", {
  ...request,
  raw_transaction: built.transaction.raw_transaction,
  signature: signTronTxId(built.transaction.tx_id, process.env.TRON_PAYOUT_PRIVATE_KEY),
});

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 chainId 728126428, 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

StatusSet byMeaning
pendingcreateOrder built, waiting for your signature.
authorizedauthorizeSignature verified, waiting to be relayed.
submittedthe dispatcherRouter call broadcast, waiting for confirmations.
completedthe confirmerReached the required confirmation depth. Mark the withdrawal paid.
failedthe confirmerThe relay or the on-chain execution was unsuccessful. Nothing left your wallet; failure_reason says why.