To let customers top up an account with crypto, give each customer their own permanent deposit address, and credit their balance when your server receives a signed "deposit confirmed" webhook, once per deposit ID. Unlike an invoice, a top-up has no set amount and no expiry, so the customer can send any amount at any time. Crypto Merchant provides this as sticky wallets, chosen at sign-up, with a standard fee of 1% of each deposit and no monthly fee. It does not hold your customers' balances for you: that ledger lives in your own system.
Last checked: 6 November 2026.
When do top-ups fit better than invoices?
Use top-ups when the customer holds a balance with you and spends it later. Use invoices when each payment belongs to one order.
| Invoice | Payment link | Sticky wallet (top-ups) | |
|---|---|---|---|
| Amount | Fixed by you | Fixed or chosen by the customer | Whatever the customer sends |
| Address | Its own, while the invoice is open | Its own for each payment, while the invoice it creates is open | Permanent, for that customer only |
| Expiry | 30 minutes by default | The invoice it creates expires | None |
| Who is identified | Your order ID | Whoever pays | Your customer ID, on every deposit |
| Underpaid or overpaid | Handled on the invoice page | Handled on the invoice page | Doesn't apply: every amount is a deposit |
| Refund link | Yes, once paid | Yes, once paid | No: see below |
| Good for | Shops, one-off orders | No website, small sellers | Prepaid credits, hosting and ad balances, marketplaces, usage-based SaaS |
A business on sticky wallets can send invoices too. For the invoice route, see accepting crypto through the API.
How does a crypto top-up work?
- 1A customer opens the deposit page on your site.
- 2Your server calls
POST /sticky-walletswith your own ID for that customer. The first call makes the addresses. Every later call returns the same ones. - 3You show the address, with a QR code, for the coin the customer picks.
- 4When the coins arrive, you get
deposit.pending. Once the network has confirmed them, you getdeposit.confirmed. - 5On
deposit.confirmed, you credit the customer named insticky_wallet.reference.
curl https://cryptomerchant.online/api/v1/sticky-wallets \
-H "Authorization: Bearer $CM_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "reference": "user-1042" }'The response lists one entry per network, each with the address and the coins it takes. The call is safe to repeat, so you don't have to store the addresses. Call it from your server whenever the deposit page opens, and take the customer ID from your own session, never from the browser.
Pick your reference with care. It can be 1 to 120 letters, digits or . _ : @ + -, and each reference has one wallet for good. An internal user ID is a better choice than an email address, which a customer may change.
When should I credit the customer?
Credit on deposit.confirmed only. The other two events are for your interface.
| Event | What it means | What to do |
|---|---|---|
deposit.pending | Seen on the network, waiting for confirmations | Show "deposit on its way". Don't credit it |
deposit.confirmed | Final, and added to your Crypto Merchant balance | Credit the customer once |
deposit.dropped | Seen, but it never confirmed | Remove the "on its way" message. Nothing to reverse, because you didn't credit it |
The wait depends on the network. These are the times from our network status page on 6 November 2026:
| Network | Confirmations | Typical wait |
|---|---|---|
| BNB Smart Chain, Base, Arbitrum, Tron, Solana | 15–32 (varies by network) | About 1 minute |
| Ethereum, Polygon | 12 on Ethereum, 64 on Polygon | About 2 minutes |
| Litecoin | 6 | About 15 minutes |
| Bitcoin | 2 | About 20 minutes |
How do I avoid crediting a deposit twice?
Make the deposit ID a unique key in your own ledger, and write the credit and the balance change in one database transaction. Webhooks are delivered at least once, so the same event can arrive twice. A customer balance is the worst place for a duplicate, because it turns directly into spendable value.
CREATE TABLE account_credits (
deposit_id text PRIMARY KEY,
customer_id text NOT NULL,
coin text NOT NULL,
amount numeric NOT NULL,
tx_hash text NOT NULL,
credited_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE balances (
customer_id text NOT NULL,
coin text NOT NULL,
amount numeric NOT NULL DEFAULT 0,
PRIMARY KEY (customer_id, coin)
);const API = "https://cryptomerchant.online/api/v1";
// Runs for each saved deposit.confirmed event, and from the catch-up job. Safe to run twice.
export async function creditDeposit(deposit, { db }) {
if (deposit.status !== "confirmed" || !deposit.sticky_wallet) return false;
if (deposit.net_amount === null) return false; // not in your balance yet: the next run picks it up
const client = await db.connect();
try {
await client.query("BEGIN");
// The primary key on deposit_id means a repeat inserts nothing and credits nothing.
const { rowCount } = await client.query(
`INSERT INTO account_credits (deposit_id, customer_id, coin, amount, tx_hash)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (deposit_id) DO NOTHING`,
[deposit.id, deposit.sticky_wallet.reference, deposit.coin.id, deposit.net_amount, deposit.tx_hash],
);
if (rowCount === 1) {
await client.query(
`INSERT INTO balances (customer_id, coin, amount) VALUES ($1, $2, $3)
ON CONFLICT (customer_id, coin) DO UPDATE SET amount = balances.amount + EXCLUDED.amount`,
[deposit.sticky_wallet.reference, deposit.coin.id, deposit.net_amount],
);
}
await client.query("COMMIT");
return rowCount === 1;
} catch (err) {
await client.query("ROLLBACK");
throw err; // the job tries again later
} finally {
client.release();
}
}Check the webhook's signature and save the event before you run this. The webhook retries and reconciliation guide shows how. Store amounts as numeric or as strings, as the API sends them. Floating-point numbers will drift.
What if my server misses a webhook?
Crypto Merchant retries a failed webhook up to eight times over about two days. For longer outages, list your confirmed deposits on a schedule and credit any you haven't handled. The unique key makes this safe.
// Scheduled catch-up: walks confirmed deposits, newest first, and credits any you missed.
export async function catchUp({ db, fetchFn = fetch, maxPages = 50 }) {
let after = null;
let credited = 0;
for (let page = 0; page < maxPages; page++) {
const query = new URLSearchParams({ status: "confirmed", limit: "100" });
if (after) query.set("starting_after", after);
const res = await fetchFn(`${API}/deposits?${query}`, {
headers: { Authorization: `Bearer ${process.env.CM_SECRET_KEY}` },
});
if (!res.ok) throw new Error(`Deposit list failed: ${res.status}`);
const list = await res.json();
for (const deposit of list.data) {
if (await creditDeposit(deposit, { db })) credited++;
}
if (!list.has_more || list.data.length === 0) break;
after = list.data[list.data.length - 1].id;
}
return credited;
}The default of 50 pages covers your latest 5,000 confirmed deposits. Set maxPages to match how far back you could have missed something. A deposit in the list can show as confirmed just before it's added to your balance. Its net_amount is null until then, so the code skips it and a later run or the webhook credits it.
Should I credit in the coin or in dollars?
That depends on what your balance is for. Each deposit gives you the amount in the coin and its value in USD when it arrived.
| Your balance is in | Credit | Watch out for |
|---|---|---|
| The coin itself | net_amount, per coin.id such as USDT.TRON | Keep one balance per coin and network, or decide that USDT on different networks counts as the same |
| USD or credits | amount_usd × (net_amount ÷ amount) | Decide the rule once and show it. The value is fixed when the deposit arrives, so you carry any price move until you pay out or swap |
If you don't want to carry price movement, accept stablecoins only, or choose a holding coin so that what you receive is swapped automatically. Swaps use the live market rate, which includes our 1% service margin, plus a gas fee.
What does a top-up cost?
Crypto Merchant's standard fee is 1% of each deposit, with no setup or monthly fee. At 1%, a 250 USDT deposit costs 2.5 USDT, and 247.5 USDT is added to your balance. The fee field on each deposit shows what was charged. You choose who bears it: credit net_amount to pass it to the customer, or amount to cover it yourself.
Moving coins carries a gas fee too, from the gas balance or the coins being moved. It's higher on some networks than others, as today's network fees show, so small deposits on an expensive network leave you less. Showing a minimum top-up per network on your deposit page is a sensible protection. See pricing for the full list.
What do customers need to be told?
Show each address together with the coins listed for it, and say this plainly on the deposit page:
- Send only the coins listed. Coins on another network, or tokens that aren't listed, are not credited automatically. The customer has to contact you, and you contact support.
- One address can serve several networks. The
evmaddress is the same on Ethereum, BNB Smart Chain, Polygon, Base and Arbitrum. Each network still appears as its own entry in thecoinslist. - The address can be saved. It never changes, so regular customers can save it. That is a large part of the appeal.
- Exchanges deduct their own fee. A customer who withdraws 50 USDT from an exchange may deliver less. You credit what arrives.
XRP and TON aren't available for sticky wallets, because every new address on those networks costs money to open. If pending_networks lists a network, its address is still being prepared. Call again a few minutes later.
One more point for privacy-minded customers: a permanent address links that customer's deposits to each other on a public blockchain. For most top-up products that's an acceptable trade-off, but it's worth knowing.
What do I have to build myself?
The customer's balance and everything that uses it. Crypto Merchant holds the coins in custody and tells you who sent what. It doesn't keep a balance per customer for you to spend from.
| Part | Crypto Merchant | You |
|---|---|---|
| Deposit addresses and watching the networks | Yes | |
| Signed webhooks and the deposit list | Yes | |
| Keys and custody of the coins | Yes: Crypto Merchant is custodial | |
| Customer balances and spending | Yes | |
| Deposit page, minimums and messages | Yes | |
| Withdrawals back to your customers | Yes | |
| Tax, licensing and reporting for your business | Yes |
Withdrawals deserve thought before you launch. The API has no endpoint for sending coins to your customers, and payouts go to addresses an Admin has saved in the portal. So if customers can withdraw, you pay them from your own wallet or by hand. Refund links belong to paid invoices, so a sticky wallet deposit doesn't have one.
Holding money for customers can be regulated. In many countries, keeping balances that people can withdraw or spend with third parties is treated differently from selling your own prepaid service. Ask a lawyer before you offer anything beyond credits for your own product. This article is not legal advice. The Terms prohibit illegal and sanctioned uses, and the eligibility page has the full list.
How do I get sticky wallets?
Choose "Customer wallets" when you sign up. If you already have an account, ask support to switch them on. No identity documents are needed to create an account. Then:
- 1Finish setup and choose the coins you accept. Each customer gets an address on those coins' networks.
- 2Create a secret key under Developers, and keep it on your server.
- 3Add a webhook endpoint with the three deposit events, and keep its signing secret.
Until sticky wallets are on and setup is finished, these endpoints answer 403 sticky_wallets_unavailable. The full reference is in the sticky wallet docs. Crypto Merchant is custodial; see custodial vs non-custodial. For taking payments without a website, see crypto payment links.