time2pay Developer Docs

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

HTTPCodeWhat happenedRetry
400validation Malformed JSON, a missing required field, or a value of the wrong shape — an address, a transaction hash, an amount. No — fix the request
400invalid_action The action does not belong to the step the session is on, or a terminal session was advanced. No — read the step first
400unsupported An unknown network or token, a token on a network that does not carry it, or a merchant/network with no payout router. No
400insufficient_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
400insufficient_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
400tx_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
401unauthorized 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
403forbidden The caller's IP is not in your allowlist, or a payout signature recovers to a different address than your payout wallet. No
404not_found No such session or payout for your merchant. Another merchant's id is indistinguishable from one that does not exist. No
409conflict An order reference reused with different parameters, or a link requested for an order that is already being paid. No — use a new reference
410expired A hosted payment link past its TTL, or a payout order past its 30-minute signing deadline. No — create a new one
503unavailable 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
500internal 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, new X-Nonce, recomputed X-Signature — the client does this on every call.
  • Same external_order_id (deposits) or external_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

StatusMeaning
pendingCreated; no payment transaction submitted yet.
processingPayment transaction submitted; waiting for confirmations.
completedConfirmed on-chain. Credit here.
failedSettlement was unsuccessful.

Payouts

StatusMeaning
pendingOrder built, waiting for your EIP-712 signature.
authorizedSignature verified, waiting to be relayed.
submittedRelay transaction broadcast, waiting for confirmations.
completedConfirmed on-chain.
failedThe relay or the on-chain execution was unsuccessful.

Payout batches

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.
failedNo item was paid.