time2pay Developer Docs

Guide

Networks and amounts

EVM and Tron side by side, address formats, decimals, and amounts without floating point.

Two families

Every network belongs to one of two families, and the family decides the address format and a few details of signing. Network ids are lower-case strings such as eth, bsc and tron; which ones are switched on is set per merchant. The balance response and the network_select step list what you actually have.

EVM (eth, bsc, …)Tron (tron)
Address0x + 40 hex charsBase58 T…, 34 chars
Token standardERC-20 (BEP-20 on BSC)TRC-20
Redirect, iframe, self-hosted depositsyesno
Wallet address depositsyes, when switched onyes, when switched on
Payouts and batch payoutsyesyes
Payout signatureEIP-712, chain's own chainIdthe same EIP-712 payload, chainId 728126428 (Nile testnet 3448148188)
Payout allowancesigned transaction via the API, or from any walletsigned transaction id via the API, or from TronLink
Fees on the payout transactionspaid by usenergy paid by us
Fees on your allowancegas from your payout walletTRX from your payout wallet, fee limit 50 TRX
Balance endpointyesno

Amounts

Every amount you send is a decimal string in whole token units: "125.50" means 125.50 USDT. Never a JSON number and never a float — 0.1 + 0.2 is not a thing you want near a ledger. Amounts you receive come in two forms:

WhereFormExample
Request bodies, amount in responses and webhooks, credited, fee, error detailswhole units, decimal string"125.5"
typed_data.message.amount, items[].amountbase units, integer string"125500000" at 6 decimals
amount_raw in deposit.confirmed, raw in balancebase units, integer string"49500000"

Decimals differ by network

The same coin has different decimals on different chains: USDT is 6 on Ethereum and Tron and 18 on BSC. Do not hard-code them; the balance response carries decimals per token, and the typed data you sign is already in base units. Converting base units to a decimal string without floating point:

func ToAmount(minor int64, decimals int) string {
	r := new(big.Rat).SetFrac(big.NewInt(minor), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil))
	return r.FloatString(decimals)
}
function toAmount(string $minor, int $decimals): string
{
    return bcdiv($minor, bcpow('10', (string) $decimals), $decimals);
}
function toAmount(minor, decimals) {
  const value = BigInt(minor);
  const scale = 10n ** BigInt(decimals);
  const whole = value / scale;
  const fraction = (value % scale).toString().padStart(decimals, "0");
  return decimals === 0 ? whole.toString() : `${whole}.${fraction}`;
}
from decimal import Decimal


def to_amount(minor, decimals):
    return format(Decimal(minor).scaleb(-decimals).quantize(Decimal(1).scaleb(-decimals)), "f")
function toAmount(minor, decimals) {
  const value = BigInt(minor);
  const scale = 10n ** BigInt(decimals);
  const whole = value / scale;
  const fraction = (value % scale).toString().padStart(decimals, "0");
  return decimals === 0 ? whole.toString() : `${whole}.${fraction}`;
}
Store money as a decimal type or as integer base units with the decimals next to it. Compare the base units in typed_data against your own record converted the same way — not the other way round through a float.

Tron addresses in signed data

Everywhere in the API a Tron address is the familiar T… form. The one exception is what you sign: EIP-712 has no type for a Base58 address, so a Tron payout's typed_data carries every address — merchant, token, recipient, verifyingContract — as its 20-byte 0x form. It is the same address: Base58-decode the T… string, drop the 0x41 prefix and the 4-byte checksum.

You need this to do the one check that matters before signing a Tron payout: that each recipient in typed_data is the address in your own withdrawal record.

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math/big"
	"strings"
)

const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

func TronToEVM(address string) (string, error) {
	n := new(big.Int)
	for _, c := range address {
		i := strings.IndexRune(base58Alphabet, c)
		if i < 0 {
			return "", fmt.Errorf("not a Tron address: %q", address)
		}
		n.Mul(n, big.NewInt(58)).Add(n, big.NewInt(int64(i)))
	}
	if n.BitLen() > 200 {
		return "", fmt.Errorf("not a Tron address: %q", address)
	}
	raw := n.FillBytes(make([]byte, 25))
	first := sha256.Sum256(raw[:21])
	check := sha256.Sum256(first[:])
	if raw[0] != 0x41 || !bytes.Equal(check[:4], raw[21:]) {
		return "", fmt.Errorf("not a Tron address: %q", address)
	}
	return "0x" + hex.EncodeToString(raw[1:21]), nil
}
function tronToEvm(string $address): string
{
    $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
    $n = gmp_init(0);
    foreach (str_split($address) as $c) {
        $i = strpos($alphabet, $c);
        if ($i === false) {
            throw new InvalidArgumentException("not a Tron address: $address");
        }
        $n = gmp_add(gmp_mul($n, 58), $i);
    }
    $raw = str_pad(gmp_export($n), 25, "\0", STR_PAD_LEFT);
    $check = substr(hash('sha256', hash('sha256', substr($raw, 0, 21), true), true), 0, 4);
    if (strlen($raw) !== 25 || $raw[0] !== "\x41" || $check !== substr($raw, 21)) {
        throw new InvalidArgumentException("not a Tron address: $address");
    }
    return '0x' . bin2hex(substr($raw, 1, 20));
}
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

export async function tronToEvm(address) {
  let n = 0n;
  for (const c of address) {
    const i = BASE58.indexOf(c);
    if (i < 0) throw new Error(`not a Tron address: ${address}`);
    n = n * 58n + BigInt(i);
  }
  const hex = n.toString(16).padStart(50, "0");
  const raw = Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16));
  const first = await crypto.subtle.digest("SHA-256", raw.slice(0, 21));
  const check = new Uint8Array(await crypto.subtle.digest("SHA-256", first));
  if (hex.length !== 50 || raw[0] !== 0x41 || check.slice(0, 4).some((b, i) => b !== raw[21 + i])) {
    throw new Error(`not a Tron address: ${address}`);
  }
  return "0x" + hex.slice(2, 42);
}
import hashlib

BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def tron_to_evm(address):
    n = 0
    for c in address:
        i = BASE58.find(c)
        if i < 0:
            raise ValueError(f"not a Tron address: {address}")
        n = n * 58 + i
    if n.bit_length() > 200:
        raise ValueError(f"not a Tron address: {address}")
    raw = n.to_bytes(25, "big")
    check = hashlib.sha256(hashlib.sha256(raw[:21]).digest()).digest()[:4]
    if raw[0] != 0x41 or check != raw[21:]:
        raise ValueError(f"not a Tron address: {address}")
    return "0x" + raw[1:21].hex()
import { createHash } from "node:crypto";

const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest();

export function tronToEvm(address) {
  let n = 0n;
  for (const c of address) {
    const i = BASE58.indexOf(c);
    if (i < 0) throw new Error(`not a Tron address: ${address}`);
    n = n * 58n + BigInt(i);
  }
  const raw = Buffer.from(n.toString(16).padStart(50, "0"), "hex");
  const check = sha256(sha256(raw.subarray(0, 21))).subarray(0, 4);
  if (raw.length !== 25 || raw[0] !== 0x41 || !check.equals(raw.subarray(21))) {
    throw new Error(`not a Tron address: ${address}`);
  }
  return "0x" + raw.subarray(1, 21).toString("hex");
}
TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t  →  0xa614f803b6fd780986a42c78ec9c7f77e6ded13c   (USDT-TRC20)

Compare case-insensitively: typed_data uses checksummed (mixed-case) hex.

Confirmations and finality

A deposit is credited only once it is final: confirmation depth is amount-based on EVM networks — larger deposits wait for more blocks — and on Tron a transfer counts once the block containing it is solidified. You do not track any of this yourself; the webhook is sent only when the money can no longer disappear.