API Documentation
Integrate custodial multi-chain wallets in minutes. Read the guide, then try any endpoint live with your API key.
Integration guide
Everything you need to build a wallet system on TOKOPAY: authenticate, provision users, derive multi-chain addresses, move funds, and reconcile billing. Base URL is your deployment host.
Prefer Postman? Download the Postman collection — every endpoint below, pre-configured. Set the baseUrl and apiKey variables and run.
1. Get an API key
In API Keys, create a key and choose its permissions — Read (queries), Write (create users/wallets, transfers), Contract (raw calls). Use a Read-write key for most backends. The secret (ws_live_…) is shown once — store it server-side.
Authorization: Bearer ws_live_xxxxxxxx...2. Create a user
Each end user gets their own encrypted HD seed automatically. Pass your own externalId so you can map it back.
curl -X POST https://api.tokopay.io/api/v1/users \
-H "Authorization: Bearer ws_live_..." \
-H "Content-Type: application/json" \
-d '{ "externalId": "your-user-123", "label": "alice@app.com" }'
# → { "id": "ckuser...", "externalId": "your-user-123" }3. Discover networks & derive a wallet
List the networks available to you, then derive an address for a user on one. EVM and Solana both derive from that user's single seed.
curl https://api.tokopay.io/api/v1/networks -H "Authorization: Bearer ws_live_..."
# pick a network "id", then:
curl -X POST https://api.tokopay.io/api/v1/wallets \
-H "Authorization: Bearer ws_live_..." \
-H "Content-Type: application/json" \
-d '{ "userId": "ckuser...", "chainId": "ckNetworkId..." }'
# → { "wallet": { "id": "ckw...", "address": "0x...", "derivationPath": "m/44'/60'/0'/0/0" } }4. Check balances
# native balance
curl "https://api.tokopay.io/api/v1/wallets/balance?walletId=ckw..." -H "Authorization: Bearer ws_live_..."
# ERC-20 token balance
curl "https://api.tokopay.io/api/v1/wallets/token-balance?walletId=ckw...&contract=0x..." -H "Authorization: Bearer ws_live_..."5. Move funds
Native, tokens (listed by id or a custom contract), NFTs, and SPL all use the same shape. Keys never leave the server — you authorize the action, we sign.
# native
POST /api/v1/wallets/send { "walletId", "to", "amount" }
# ERC-20 / SPL (listed token by id, or custom contract/mint)
POST /api/v1/wallets/transfer-token
{ "walletId", "to", "amount", "tokenId" } # listed
{ "walletId", "to", "amount", "contract", "decimals" } # custom EVM
{ "walletId", "to", "amount", "mint" } # custom Solana
# NFTs
POST /api/v1/wallets/transfer-nft
{ "walletId", "to", "tokenId", "standard": "ERC721", "contract" }
# arbitrary contract call (needs Contract permission + tenant feature)
POST /api/v1/wallets/contract-call
{ "walletId", "to", "abi": ["function mint(address,uint256)"], "method": "mint", "args": ["0x...", 1] }Optional on every transaction endpoint: add notes (a free-form string) and tags (an array of short labels, or a comma-separated string) to any send / transfer / contract-call / paymaster / meta / asset-recovery body. Both are optional and fully backward-compatible — omit them and nothing changes. Use them to annotate a transaction (e.g. an order id, a campaign, a customer) and find it again later: filter your history with GET /api/v1/transactions?tags=order,vip¬es=invoice, and the same tags/notes are searchable and broken down on your dashboard's report.
# any transaction body may carry notes + tags
POST /api/v1/wallets/transfer-token
{ "walletId", "to", "amount", "tokenId",
"notes": "Invoice #4821 settlement",
"tags": ["invoice", "vip", "q3"] }
# retrieve everything you tagged
GET /api/v1/transactions?tags=invoice,vip # match ANY tag
GET /api/v1/transactions?notes=Invoice%20%234821 # notes substring6. Paymaster — submitter pays gas
For sponsored transfers two wallets cooperate: a SIGNER (the token holder / NFT owner / call author — walletId) and a SUBMITTER (the gas payer — submitterWalletId). The signer remains the EVM msg.sender of the actual transfer (so on-chain semantics are identical to a non-sponsored call), and the submitter ends up paying for the gas.
Server-side, each paymaster call runs a deterministic three-step ritual in one request:
1. Fund. Submitter sends the signer just enough native to cover the upcoming transfer's gas plus a sweep tx.
2. Execute. Signer broadcasts the actual transfer / contract call.
3. Sweep. Signer sends any leftover funded native back to the submitter, leaving the signer's pre-existing native balance untouched.
Net effect: the submitter is out exactly gas_used + 21000 × gasPrice (one sweep tx). The signer's native balance is unchanged. Three on-chain txs are produced and all three show up in the transaction explorer — the EXECUTE row is tagged sponsored: true with submitterWalletId / submitterAddress; the FUND and SWEEP rows carry meta.purpose and meta.forTxRecordId linking back to the EXECUTE row.
Both wallets must be EVM, on the same chain, and under the same tenant. The signer does not need to hold any native ahead of time. Works on any EVM chain regardless of zeroBaseFee configuration — ClubMOS, BSC, and standard chains are all fine.
# sponsored ERC-20 transfer
POST /api/v1/paymaster/transfer-token
{ "walletId": "ckSigner...", # token holder
"submitterWalletId":"ckSubmitter...", # pays gas
"to": "0x...", "amount": "10",
"tokenId": "ckListedTokenId..." } # or { "contract": "0x...", "decimals": 18 }
# response
{ "hash": "0xExecuteHash...", # the ERC-20 transfer itself
"sponsored": true,
"submitterWalletId": "ckSubmitter...",
"submitterAddress": "0xSubmitter...",
"fundingHash": "0xFundingHash...", # submitter → signer native top-up
"sweepHash": "0xSweepHash..." } # signer → submitter refund (null if dust only)
# sponsored NFT transfer (signer is the NFT owner)
POST /api/v1/paymaster/transfer-nft
{ "walletId", "submitterWalletId", "to", "tokenId",
"standard": "ERC721", "contract" } # or "ERC1155" + amount
# sponsored arbitrary contract call — msg.sender at the contract is the SIGNER
POST /api/v1/paymaster/contract-call
{ "walletId", "submitterWalletId", "to",
"abi": ["function mint(address,uint256)"], "method": "mint",
"args": ["0x...", 1] }Native transfers cannot be sponsored. Sponsoring a native move is meaningless: if you want one wallet to fund a third party with another wallet's native, send it directly from that other wallet via POST /api/v1/wallets/send. Only transfer-token, transfer-nft, and contract-call have paymaster variants.
7. Meta transactions — relayer pattern
Same submitter / signer roles as paymaster, but a different mechanism: the standard EVM allowance pattern. The first call between a given (signer, submitter, token) triple sets up an on-chain approval; every subsequent transfer is a SINGLE tx broadcast by the submitter. Cheaper than paymaster for repeated transfers — 1 tx vs 3 — at the cost of the target contract seeing msg.sender = submitter instead of the signer.
First call (ERC-20):
a. Signer issues approve(submitter, MaxUint256) on the token. Gas for this approval is covered automatically by an internal fund → approve → sweep so the signer doesn't need to hold any native — same as paymaster, but applied only this once.
b. Submitter calls transferFrom(signer, recipient, amount).
Every subsequent call for the same triple: just step b. One tx, submitter pays the gas, no signer involvement on-chain.
NFTs use setApprovalForAll(submitter, true) + safeTransferFrom with the same pattern. The approval is per-collection, not per-token, so one setup covers every NFT in that collection.
# meta-tx ERC-20 transfer
POST /api/v1/meta/transfer-token
{ "walletId": "ckSigner...", # token holder
"submitterWalletId":"ckSubmitter...", # pays gas, becomes spender
"to": "0x...", "amount": "10",
"tokenId": "ckListedTokenId..." } # or { "contract": "0x...", "decimals": 18 }
# response on the FIRST call (one-time approval ran)
{ "hash": "0xTransferFromHash...", # the actual transfer
"sponsored": true,
"submitterWalletId": "ckSubmitter...",
"submitterAddress": "0xSubmitter...",
"approvalHash": "0xApprovalHash...",
"approvalFundingHash": "0xFundingForApprovalHash...",
"approvalSweepHash": "0xSweepAfterApprovalHash..." }
# response on every subsequent call (approval already on-chain)
{ "hash": "0xTransferFromHash...",
"sponsored": true,
"submitterWalletId": "ckSubmitter...",
"submitterAddress": "0xSubmitter...",
"approvalHash": null,
"approvalFundingHash": null,
"approvalSweepHash": null }
# meta-tx NFT transfer (signer is the NFT owner)
POST /api/v1/meta/transfer-nft
{ "walletId", "submitterWalletId", "to", "tokenId",
"standard": "ERC721", "contract" } # or "ERC1155" + amount
# meta-tx arbitrary contract call — msg.sender at target = SUBMITTER
# Use paymaster/contract-call instead if you need msg.sender = signer.
POST /api/v1/meta/contract-call
{ "walletId", "submitterWalletId", "to",
"abi": ["function setText(string)"], "method": "setText",
"args": ["hello"] }When to use which: paymaster/* if you only do one transfer or if the target contract gates on msg.sender = signer. meta/* if the same (signer, submitter, token) triple transfers many times — the per-transfer cost drops to one tx after the one-time approval.
8. Asset recovery — cross-chain misroute
Users routinely send ChainA tokens to a wallet you created “for” ChainB. Both EVM addresses are the same (same HD key produces the same address on every EVM chain), so the assets sit on ChainA at that address. Standard transfer endpoints route through the wallet's stored chainId and would try to spend on ChainB (nothing there) and fail.
Asset recovery decouples the wallet from its stored chain. You pass sourceChainId — the chain where the misrouted asset actually lives — and the service uses the wallet's HD key on that chain's RPC. The optional destinationChainId is informational metadata (the chain context the recipient address “belongs to”, since an EVM address is the same on every chain).
Mechanism mirrors meta-tx for tokens/NFTs (one-time approve on the source chain, then submitter calls transferFrom) and a fund→send for native. The submitter must hold native on the SOURCE chain to pay gas there. All recovery TxRecords are tagged meta.assetRecovery: true with the source/destination chain ids, so the transaction explorer shows them clearly as recovery actions.
# recover misrouted BEP-20 USDT to a fresh address on BSC
POST /api/v1/asset-recovery/transfer-token
{ "walletId": "ckMistakeWallet...", # any of the user's EVM wallets
"submitterWalletId": "ckGasPayer...", # must hold BNB on BSC
"sourceChainId": "ckBscChainId...", # where the USDT actually lives
"destinationChainId":"ckEthChainId...", # optional, informational
"contract": "0x55d398...", # BEP-20 USDT on BSC
"to": "0xRescueAddress...",
"amount": "100" }
# recover misrouted native (drain mode)
POST /api/v1/asset-recovery/send-native
{ "walletId", "submitterWalletId", "sourceChainId",
"to": "0xRescueAddress...",
"amount": "all" } # or a specific number
# recover an NFT
POST /api/v1/asset-recovery/transfer-nft
{ "walletId", "submitterWalletId", "sourceChainId",
"contract", "to", "tokenId",
"standard": "ERC721" } # or "ERC1155" + amountPre-checks run before any tx: ERC-20 reads balanceOf on source chain, ERC-721 reads ownerOf, ERC-1155 reads balanceOf(account, id). If the signer doesn't actually hold the assets on source chain you get a clean 502 with detail before any gas is spent.
9. Balance lookups
Two new endpoints alongside the existing /api/v1/wallets/balance (which stays unchanged): native and ERC-20 balance lookups that accept an optional chainId override. With no override, behavior matches the existing endpoint; with a chainId, the same wallet address is checked on that chain instead — needed to triage asset-recovery cases.
# native balance on the wallet's own chain
GET /api/v1/balances/native?walletId=ck...
# native balance on a DIFFERENT chain (asset-recovery triage)
GET /api/v1/balances/native?walletId=ck...&chainId=ckBscChainId...
# ERC-20 balance (decimals/symbol optional hints)
GET /api/v1/balances/token?walletId=ck...&contract=0x...&chainId=ck...
# batch — used by the dashboard's users page to populate balance grid
POST /api/v1/balances/batch
{ "queries": [
{ "kind": "native", "walletId": "ck..." },
{ "kind": "token", "walletId": "ck...", "contract": "0x...",
"decimals": 18, "symbol": "USDT" }
] }The batch endpoint caches providers per chain — 50 wallets on the same chain share one RPC connection — and isolates errors per query. Capped at 200 queries per request.
10. Per-user sign-in (Google)
Send your users to Google sign-in; on return they're provisioned with a seed and a login is recorded. Mint a scoped ws_usr_… token for their session from your server.
GET /api/auth/google?tenant=YOUR_SLUG11. Billing & your wallet
The first 100 active users each month are free; beyond that you're charged per active user and per transaction, deducted from your prepaid balance. Fund it from Wallet — each of your billing deposit addresses (EVM + Solana) accepts the platform's settlement token (USDC/USDT). View charges in Billing.
Errors
401 invalid token · 403 insufficient permission or disabled feature · 404 not found · 422 unsupported op · 502 chain/RPC failure. Bodies are { "error": "...", "detail"?: "..." }.
Try the API
Paste an API key and send real requests from your browser.
api.tokopay.io (this deployment) from your browser. The key stays in this page only and is never stored.