Solutions · Payment acceptance
Accept stablecoin payments and settle them automatically
For payment service providers, merchants and checkout platforms. Customers pay to an address that belongs only to them. Your server learns about the payment from a signed webhook, and funds are swept to your treasury on a schedule.
Fastest path: hosted checkout. Checkouts do everything on this page for you: a wallet per payment, the payment page with coin choice and QR code, matching, under- and late payments, and checkout.completed when it's paid. Create one per order with valtu.checkouts.create({ amount, externalId }) and redirect to its url. Build it yourself, as below, when you want your own payment screen.
deposit.pending: show "processing".deposit.confirmed: mark the order paid.Wallet layout
| Wallet | Type | Purpose |
|---|---|---|
One per paying customer (externalId = your customer id) | user | Receives payments. The same address every time for that customer. |
| Settlement | treasury | Collects all sweeps. Kept off the API for sending. |
| Refunds | operational | Small float for refunds, and the gas wallet. |
One address per customer or per order? Per customer is simpler: one wallet, reused, and you match payments to open orders by amount and time. If your customers pay many invoices at once, or the amounts are ambiguous, create a user wallet per invoice instead (externalId = invoice id). Wallets are free to create, and sweeps collect from all of them.
Showing the payment details
import { depositAddress } from "@valtu/wallet-kit";
import { valtu } from "./valtu.ts";
export async function paymentDetails(order: { id: string; customerId: string; network: "tron" | "ethereum" | "bsc" | "base" | "solana"; asset: string; amount: string }) {
const wallet = await valtu.wallets.forCustomer(order.customerId);
const asset = await valtu.assets.get(order.asset); // decimals, closesAt
if (asset.closesAt) throw new Error(`${order.asset} is being retired; offer another network`);
await db.orders.expect(order.id, { walletId: wallet.id, asset: order.asset, amount: await valtu.assets.toBaseUnits(order.asset, order.amount) });
return { address: depositAddress(wallet, order.network), asset: order.asset, amount: order.amount, network: order.network };
}Show a QR code with the address, the exact network name and the amount, and a countdown if the price is only valid for a while.
Matching payments to orders
case "deposit.confirmed": {
const d = event.data;
const order = await db.orders.openFor(d.walletId, d.asset); // oldest unpaid order on this wallet
if (!order) { await db.unmatched.add(d); break; } // paid without an order: review
const paid = BigInt(d.amount), due = BigInt(order.amount);
if (paid === due) await db.orders.markPaid(order.id, d.depositId);
else if (paid < due) await db.orders.markUnderpaid(order.id, d.depositId, (due - paid).toString());
else await db.orders.markOverpaid(order.id, d.depositId, (paid - due).toString()); // refund the difference
break;
}- Wrong coin, right network (e.g. USDC sent to a USDT checkout): it still arrives as a deposit of that asset. Decide whether to accept, convert or refund.
- Late payments after the order expired: keep them as unmatched and refund or credit.
- Only
deposit.confirmedis final. Reverse anything marked paid ifdeposit.orphanedarrives.
Settlement and gas
- Gas station: make the refunds wallet the gas wallet and fund it with TRX, ETH, BNB and SOL. Turn on sponsorship with a daily cap. Sweeping a USDT payment out of a customer wallet needs gas there first; the gas station sends it automatically.
- Sweep task: Automations → New task. Source: all user wallets. Destination: settlement. Frequency: hourly (or daily for small volume, to spend less on fees). Minimum: for example $50, so tiny payments aren't swept at a loss.
- Alerts: email or Slack when the gas wallet runs low, and when a sweep fails.
Refunds under approval
Refunds are payouts from the refunds wallet. Two rules keep them safe:
| Rule | Effect |
|---|---|
principal.kind == 'api_key' && withdrawal.value_usd <= 200 | Skip approval: small refunds go automatically. |
wallet.outflow_24h > 5000 | Require approval: unusual refund volume needs a person. |
await valtu.transfers.create({
walletId: REFUNDS_WALLET_ID, asset: order.asset, amount: refundAmount, to: customerRefundAddress,
externalId: `refund_${order.id}`, category: "refund",
});Before going live
- Network name and coin shown next to every address and QR code
- Orders marked paid only on
deposit.confirmed - Under-, over- and unmatched payments go to a review queue
- Sweep minimum set so fees never exceed what's swept
- Gas wallet funded on every network you accept, with a low-balance alert
- Customer screening and refund policy in place