How to set up a self-hosted payment processor using Bicycle
This guide shows one of the possible ways to set up Gram and USDT (on TON) payments in business applications. For more info and alternative approaches, see the payment processing overview.
Bicycle is a self-hosted payment processor for TON. It runs next to an exchange, custodial wallet, merchant backend, or payment service and provides a REST API for:
- Reusable per-user deposit addresses
- Gram deposits and withdrawals
- Jetton deposits and withdrawals, including USDT on TON
- Hot-wallet aggregation and optional cold-wallet sweeps
- Webhooks or RabbitMQ notifications
Use Bicycle to show one deposit address per user and currency, credit deposits automatically, and submit withdrawals through one backend API.
Bicycle controls a hot wallet from SEED. Do not withdraw manually from this hot wallet outside Bicycle. The processor tracks balances, internal sweeps, and withdrawal state in its database — bypassing it can make the service state inconsistent.
Architecture
Bicycle creates deposit addresses from the hot-wallet seed and stores the mapping to an application user_id. For Gram, users deposit to a TON wallet address. For USDT, users deposit to a proxy owner address that owns the user's USDT jetton wallet. Bicycle scans the relevant shard blocks, records deposits, sweeps balances to the hot wallet when thresholds are met, and sends withdrawals from the hot wallet.
The application does not need to parse comments, generate wallet contracts, scan blocks, or build jetton transfer messages. The backend calls Bicycle and stores Bicycle IDs in the application ledger.
Prerequisites
- Linux-based OS with
jqandcurlinstalled - Docker Engine 24 or later with Docker Compose v2
- A 24-word seed phrase for the Bicycle hot wallet
- Enough Gram on the hot wallet to pay fees and deploy/sweep deposit wallets
- A cold wallet address for automatic cold-wallet sweeps
Test the full flow on testnet first. Note that IS_TESTNET=true only controls address validation inside Bicycle — it does not prove that the liteserver is testnet.
Clone and build Bicycle
git clone https://github.com/gobicycle/bicycle.git
cd bicycle
make -f MakefileThe existing Bicycle docker-compose.yml can start PostgreSQL, the processor, Grafana, Prometheus, and RabbitMQ. For a minimal production-like setup, start only PostgreSQL and payment-processor.
Choose a liteserver
Bicycle connects to a liteserver over Abstract Datagram Network Layer (ADNL). Either use a public liteserver with proof verification enabled, or run a self-hosted liteserver next to Bicycle.
Option A: Public liteserver with proofs
Use this path for a short setup. Fetch the current public liteserver list from the network config and extract one endpoint into the Bicycle .env file:
curl -fsSL https://ton-blockchain.github.io/global.config.json \
| jq -r '
def ntoa:
(if . < 0 then . + 4294967296 else . end) as $ip
| [($ip / 16777216 | floor) % 256,
($ip / 65536 | floor) % 256,
($ip / 256 | floor) % 256,
$ip % 256]
| join(".");
.liteservers[0]
| "LITESERVER=\(.ip | ntoa):\(.port)\nLITESERVER_KEY='\''\(.id.key)'\''"
' >> .envThen enable proof checking:
cat >> .env <<'EOF'
PROOF_CHECK_ENABLED=true
NETWORK_CONFIG_URL=https://ton-blockchain.github.io/global.config.json
EOFFor testnet, use https://ton-blockchain.github.io/testnet-global.config.json and set IS_TESTNET=true.
Public liteservers are shared infrastructure. Keep PROOF_CHECK_ENABLED=true and set a conservative LITESERVER_RATE_LIMIT.
Consider setting up a dedicated liteserver for production payment volume.
Option B: Local liteserver with official Docker image
Ensure the server meets the minimal hardware requirements for running a liteserver node.
Use this path to run Bicycle and the liteserver on the same host. Put this compose file next to Bicycle's docker-compose.yml:
services:
ton-node:
image: ghcr.io/ton-blockchain/ton:latest
container_name: ton_liteserver
restart: unless-stopped
network_mode: host
volumes:
- /var/ton-work/db:/var/ton-work/db
environment:
PUBLIC_IP: ${PUBLIC_IP}
GLOBAL_CONFIG_URL: https://ton-blockchain.github.io/global.config.json
DUMP_URL: https://dump.ton.org/dumps/latest.tar.lz
LITESERVER: "true"
LITE_PORT: "30003"
VALIDATOR_PORT: "30001"
QUIC_PORT: "31001"
CONSOLE_PORT: "30002"
STATE_TTL: "86400"
ARCHIVE_TTL: "2592000"Start it:
# One of the ways to check the public IP of the server.
# If it is known ahead of time, manually specify it here.
export PUBLIC_IP=$(curl -4 -fsSL ifconfig.me)
docker compose -f docker-compose.liteserver.yml up -d ton-node
# Follow the logs to check the status.
docker logs -f ton_liteserverAfter /var/ton-work/db/config.json is created, append the local liteserver endpoint to Bicycle's .env:
jq -r '
.liteservers[0]
| "LITESERVER=host.docker.internal:\(.port)\nLITESERVER_KEY='\''\(.id.key)'\''"
' /var/ton-work/db/config.json >> .envAdd host access to the payment-processor service in Bicycle's compose file when Bicycle uses Docker's bridge network:
services:
payment-processor:
extra_hosts:
- "host.docker.internal:host-gateway"For node setup, hardware requirements, firewall, sync, and maintenance details, see Run a liteserver. The official Docker image uses the same node software. The key settings above are LITESERVER=true, LITE_PORT, a public PUBLIC_IP, and persistent /var/ton-work/db storage.
Configure assets
Create .env file in the Bicycle directory:
# PostgreSQL setup
POSTGRES_DB=payment_processor
POSTGRES_USER=payment_processor
POSTGRES_PASSWORD=<POSTGRES_PASSWORD>
POSTGRES_READONLY_PASSWORD=<POSTGRES_READONLY_PASSWORD>
DB_URI=postgres://payment_processor:<POSTGRES_PASSWORD>@payment_processor_db:5432/payment_processor
# Bicycle's REST API port
API_PORT=8081
# Bicycle's Bearer token for REST API
API_TOKEN=<API_TOKEN>
# Seed phrase for the main hot wallet, 12 or 24 word mnemonic compatible with standard TON wallets
SEED=<SEED_PHRASE>
# TON address of the cold wallet
COLD_WALLET=<COLD_WALLET_ADDRESS>
# Cutoffs in nanograms format:
# hot_wallet_min_balance:hot_wallet_max_balance:min_withdrawal_amount:hot_wallet_residual_balance
TON_CUTOFFS=1000000000:100000000000:1000000000:95000000000
# List of jettons to be processed by the service; set to USDTs only
JETTONS=USDT:EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs:100000000000:10000000:95000000000
# USDTs only exist on mainnet!
IS_TESTNET=false
# Miscellaneous, refer to Bicycle's README.md for details
DEPOSIT_SIDE_BALANCE=true
FORWARD_TON_AMOUNT=1
LITESERVER_RATE_LIMIT=25
LITESERVER_MAX_RETRIES=10
LITESERVER_BASE_RETRY_DELAY=100
ALLOWABLE_LAG=40Configure the processor container to receive all Bicycle .env vars, not only the variables already listed in the upstream compose file:
services:
payment-processor:
env_file:
- .envTON_CUTOFFS uses nanograms:
| Position | Meaning |
|---|---|
hot_wallet_min_balance | Minimum hot-wallet Gram balance required on startup |
hot_wallet_max_balance | Balance that triggers a cold-wallet sweep |
minimum_withdrawal_amount | Minimum deposit-wallet balance to sweep to the hot wallet |
hot_wallet_residual_balance | Balance left after a cold-wallet sweep |
JETTONS uses jetton base units. USDT on TON has 6 decimals, so 1000000 means 1 USDT:
| Position | Meaning |
|---|---|
USDT | Currency code used in Bicycle API requests |
EQCx...sDs | USDT jetton master address on mainnet |
100000000000 | Sweep hot-wallet excess above 100,000 USDT |
10000000 | Sweep a deposit wallet after it holds more than 10 USDT |
95000000000 | Leave 95,000 USDT after a cold-wallet sweep |
Verify the jetton master (minter)
Never accept jettons by symbol or metadata alone — only accept an allow-listed master contract address. Verify the USDT jetton master address before mainnet launch.
USDTs only exist on the mainnet.
For information about the other environment variables, refer to the Bicycle's README.md file.
Start Bicycle
For a minimal production setup, start only payment-postgres (PostgreSQL) and payment-processor (Bicycle itself):
docker compose up -d payment-postgres
docker compose up -d payment-processorOptional available services include: payment-grafana (Grafana), payment-prometheus (Prometheus), and payment-rabbitmq (RabbitMQ).
Check sync:
curl -sS http://127.0.0.1:8081/v1/system/sync | jqExample of an expected response when Bicycle is caught up:
{
"is_synced": true,
"last_block_gen_utime": 1719306580
}Create deposit addresses
Create one Gram deposit address for a user:
curl -sS http://127.0.0.1:8081/v1/address/new \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id":"user-1001","currency":"TON"}' | jqCreate one USDT deposit address for the same user:
curl -sS http://127.0.0.1:8081/v1/address/new \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id":"user-1001","currency":"USDT"}' | jqStore the returned address in the application database and show it to the user. Users do not need to enter comments or invoice IDs.
To list all addresses later:
curl -sS "http://127.0.0.1:8081/v1/address/all?user_id=user-1001" \
-H "Authorization: Bearer $API_TOKEN" | jqCredit deposits
If DEPOSIT_SIDE_BALANCE=true, Bicycle credits deposits when it observes income on the deposit address. Query the user's total credited income:
curl -sS "http://127.0.0.1:8081/v1/income?user_id=user-1001" \
-H "Authorization: Bearer $API_TOKEN" | jqFetch deposit history for reconciliation:
curl -sS "http://127.0.0.1:8081/v1/deposit/history?user_id=user-1001¤cy=USDT&limit=20&offset=0&sort_order=desc" \
-H "Authorization: Bearer $API_TOKEN" | jqLook up a deposit by transaction hash when handling support tickets:
curl -sS "http://127.0.0.1:8081/v1/deposit/income?tx_hash=<TX_HASH>" \
-H "Authorization: Bearer $API_TOKEN" | jqSend manual withdrawals
Bicycle controls a hot wallet from SEED. Do not withdraw manually from this hot wallet outside Bicycle. The processor tracks balances, internal sweeps, and withdrawal state in its database — bypassing it can make the service state inconsistent.
Use a unique query_id per user withdrawal. Bicycle rejects duplicate (user_id, query_id) pairs.
Withdraw 1 Gram:
# Replace <RECIPIENT_ADDRESS> with desired TON wallet address
curl -sS http://127.0.0.1:8081/v1/withdrawal/send \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user-1001",
"query_id": "wd-ton-000001",
"currency": "TON",
"amount": 1000000000,
"destination": "<RECIPIENT_ADDRESS>",
"comment": "withdrawal wd-ton-000001"
}' | jqWithdraw 25 USDT:
# Replace <RECIPIENT_ADDRESS> with desired TON wallet address
curl -sS http://127.0.0.1:8081/v1/withdrawal/send \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user-1001",
"query_id": "wd-usdt-000001",
"currency": "USDT",
"amount": 25000000,
"destination": "<RECIPIENT_ADDRESS>",
"comment": "withdrawal wd-usdt-000001"
}' | jqCheck the withdrawal status:
# Replace <WITHDRAWAL_ID> with the `query_id` previously submitted to `/v1/withdrawal/send`
curl -sS "http://127.0.0.1:8081/v1/withdrawal/status?id=<WITHDRAWAL_ID>" \
-H "Authorization: Bearer $API_TOKEN" | jqStatuses are pending, processing, processed, and failed. Treat processed as the terminal success state in the application ledger.
Enable notifications
Option A: Webhook endpoint
Extend the .env file with webhook variables before starting the payment-processor service:
cat >> .env <<'EOF'
# When set, Bicycle will send webhooks to the specified endpoint
WEBHOOK_ENDPOINT=https://payments.example.com/ton/bicycle/webhook
# Bearer token for the webhook requests — leave unset when unused
# WEBHOOK_TOKEN=<WEBHOOK_TOKEN>
EOFStart or restart the service:
docker compose up -d payment-processorBicycle sends deposit notifications as JSON:
{
"deposit_address": "<DEPOSIT_ADDRESS>",
"time": 1719306580,
"amount": "25000000",
"source_address": "<SOURCE_ADDRESS>",
"comment": "optional comment",
"tx_hash": "f9b9e7efd3a38da318a894576499f0b6af5ca2da97ccd15c5f1d291a808a0ebf",
"user_id": "user-1001"
}The webhook handler should be idempotent by tx_hash, verify user_id and deposit_address against application records, credit the internal ledger once, and return HTTP 200 with an empty body. Bicycle stops after repeated unsuccessful webhook delivery, so monitor processor logs and alerts.
Option B: RabbitMQ
Extend the .env file with RabbitMQ variables and start the payment-rabbitmq service before starting the payment-processor:
cat >> .env <<'EOF'
# When true, Bicycle will send incoming notiications to the RabbitMQ queue
QUEUE_ENABLED=true
# URI for client connections to the queue
QUEUE_URI=amqp://guest:guest@payment_rabbitmq:5672/
# Name of the exchange
QUEUE_NAME=<QUEUE_NAME>
EOFStart or restart the services:
docker compose up -d payment-rabbitmq
docker compose up -d payment-processorOperational checklist
- Keep
SEED,API_TOKEN, database credentials, and webhook tokens in a secret manager. - Expose Bicycle's API only to the application backend, not to the public Internet.
- Keep enough Gram on the hot wallet for fees, jetton notifications, and deposit sweeps.
- Keep a separate cold wallet whose seed is never loaded into Bicycle.
- Always use
PROOF_CHECK_ENABLED=truefor public or rented liteservers. - Monitor
/v1/system/sync,/metrics, hot-wallet balances, failed withdrawals, and webhook delivery. - Reconcile the application ledger with
/v1/deposit/historyand/v1/withdrawal/status. - Test mistaken-transfer recovery with
/v1/withdrawal/service/tonand/v1/withdrawal/service/jettonbefore production.