Guide
Errors and statuses
Every code the API returns, what causes it, and whether retrying can help.
The envelope
Every error, on every endpoint, has the same shape:
{
"error": {
"code": "insufficient_allowance",
"message": "the router's allowance on the payout wallet is 40 USDT, less than the 125.5 requested — approve more first",
"details": {
"network": "tron",
"token": "USDT",
"owner": "TFbu1gKVsrs6c96r3FyuD9aUs3Gdi1HmH3",
"spender": "TMyZJJ1AQjnkCVEr7bXjJNHmJ5x3VhhGPL",
"required": "125.5",
"allowance": "40"
}
}
}
Branch on code. It is stable and part of the contract. message is for
your logs and for a human reading them — the wording can change at any time.
details is present only on the codes that say so below, with string values.
Every code
| HTTP | Code | What happened | Retry |
|---|---|---|---|
| 400 | validation |
Malformed JSON, a missing required field, or a value of the wrong shape — an address, a transaction hash, an amount. | No — fix the request |
| 400 | invalid_action |
The action does not belong to the step the session is on, or a terminal session was advanced. | No — read the step first |
| 400 | unsupported |
An unknown network or token, a token on a network that does not carry it, or a merchant/network with no payout router. | No |
| 400 | insufficient_funds |
The payer's wallet does not hold enough of the token to cover the deposit. Raised when the wallet is connected, or when the amount is submitted — before the payer is asked to sign anything. On a payout or batch create: your payout wallet holds less than the amount (for a batch, its total); details carries required and balance. |
Yes — once the wallet is funded |
| 400 | insufficient_allowance |
The approval was mined and succeeded, but grants the router less than the deposit needs — the player edited the spend limit on their wallet's approval screen. On a payout or batch create, EVM or Tron: the payout router may pull less than the amount (for a batch, its total) from your payout wallet; details carries spender, required and allowance, enough to approve and retry. |
Yes — after you approve enough |
| 400 | tx_reverted |
A transaction the player reported was mined and reverted, so it did nothing — an approval that never granted the allowance. Their gas is spent; their tokens are not. | Yes — after they sign it again |
| 401 | unauthorized |
A missing header, an unknown key, a timestamp outside the 5-minute window, a signature that does not match, or a nonce already used. | Only after rebuilding — new timestamp, new nonce, new signature |
| 403 | forbidden |
The caller's IP is not in your allowlist, or a payout signature recovers to a different address than your payout wallet. | No |
| 404 | not_found |
No such session or payout for your merchant. Another merchant's id is indistinguishable from one that does not exist. | No |
| 409 | conflict |
An order reference reused with different parameters, or a link requested for an order that is already being paid. | No — use a new reference |
| 410 | expired |
A hosted payment link past its TTL, or a payout order past its 30-minute signing deadline. | No — create a new one |
| 503 | unavailable |
Something we depend on is degraded, so the request was refused rather than answered wrongly — an unreachable anti-replay store, a missing RPC endpoint for a balance read or an allowance build, or an approval the chain has not made visible yet. | Yes, with backoff |
| 500 | internal |
Something unexpected on our side. | Yes, with backoff |
503 and 500 are different on purpose. 503 means we
deliberately refused because we could not answer safely, and it will clear on its own —
retry with backoff and a fresh nonce. 500 means we did not expect it. Everything
4xx is about the request you sent; sending it again unchanged gets the same answer.
Handling errors in code
The client raises one error type carrying status,
code and details. A network failure raises something else — treat it like
a 5xx. Creating a batch payout, for example:
res, err := client.Call(ctx, "POST", "/v1/payouts/batch", body)
var apiErr *time2pay.Error
switch {
case err == nil:
return handleCreated(res)
case !errors.As(err, &apiErr), apiErr.Status >= 500:
return retryLater(body)
case apiErr.Code == "insufficient_allowance":
return approveMore(apiErr.Details["spender"], apiErr.Details["required"])
case apiErr.Code == "insufficient_funds":
return topUpPayoutWallet(apiErr.Details["required"], apiErr.Details["balance"])
default:
return fmt.Errorf("refused: %w", err)
}try {
$created = $client->call('POST', '/v1/payouts/batch', $batch);
handleCreated($created);
} catch (Time2payError $e) {
match (true) {
$e->status >= 500 => retryLater($batch),
$e->errorCode() === 'insufficient_allowance' => approveMore($e->details()['spender'], $e->details()['required']),
$e->errorCode() === 'insufficient_funds' => topUpPayoutWallet($e->details()['required'], $e->details()['balance']),
default => throw $e,
};
} catch (RuntimeException $e) {
retryLater($batch);
}let created;
try {
created = await client.call("POST", "/v1/payouts/batch", batch);
} catch (e) {
if (!(e instanceof Time2payError) || e.status >= 500) return retryLater(batch);
if (e.code === "insufficient_allowance") return approveMore(e.details.spender, e.details.required);
if (e.code === "insufficient_funds") return topUpPayoutWallet(e.details.required, e.details.balance);
throw e;
}
await handleCreated(created);try:
created = client.call("POST", "/v1/payouts/batch", batch)
except requests.RequestException:
return retry_later(batch)
except Time2payError as e:
if e.status >= 500:
return retry_later(batch)
if e.code == "insufficient_allowance":
return approve_more(e.details["spender"], e.details["required"])
if e.code == "insufficient_funds":
return top_up_payout_wallet(e.details["required"], e.details["balance"])
raise
handle_created(created)let created;
try {
created = await client.call("POST", "/v1/payouts/batch", batch);
} catch (e) {
if (!(e instanceof Time2payError) || e.status >= 500) return retryLater(batch);
if (e.code === "insufficient_allowance") return approveMore(e.details.spender, e.details.required);
if (e.code === "insufficient_funds") return topUpPayoutWallet(e.details.required, e.details.balance);
throw e;
}
await handleCreated(created);
approveMore is the allowance flow with
amount at or above required; afterwards, retry the create with the same
external_id.
What a correct retry looks like
A retry is a new signed request carrying the same business reference. Both halves matter: reusing the old nonce is rejected as a replay, and changing the order reference opens a second order.
- New
X-Timestamp, newX-Nonce, recomputedX-Signature— the client does this on every call. - Same
external_order_id(deposits) orexternal_id(payouts), byte for byte, with the same parameters.
Done that way, a retry after a timeout is safe: you get back the order you already created rather than a second one. See Retries, nonces and idempotency.
Statuses
Not errors — the states a deposit or a payout moves through.
Session deposits
| Status | Meaning |
|---|---|
pending | Created; no payment transaction submitted yet. |
processing | Payment transaction submitted; waiting for confirmations. |
completed | Confirmed on-chain. Credit here. |
failed | Settlement was unsuccessful. |
Payouts
| Status | Meaning |
|---|---|
pending | Order built, waiting for your EIP-712 signature. |
authorized | Signature verified, waiting to be relayed. |
submitted | Relay transaction broadcast, waiting for confirmations. |
completed | Confirmed on-chain. |
failed | The relay or the on-chain execution was unsuccessful. |
Payout batches
| 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. |
failed | No item was paid. |