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.

1CheckoutYour page shows the customer's address and amount.
2Payment seendeposit.pending: show "processing".
3Confirmeddeposit.confirmed: mark the order paid.
4SettleA sweep task moves funds to treasury.
5RefundA payout from the operational wallet, with approval.

Wallet layout

WalletTypePurpose
One per paying customer (externalId = your customer id)userReceives payments. The same address every time for that customer.
SettlementtreasuryCollects all sweeps. Kept off the API for sending.
RefundsoperationalSmall 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.confirmed is final. Reverse anything marked paid if deposit.orphaned arrives.

Settlement and gas

  1. 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.
  2. 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.
  3. 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:

RuleEffect
principal.kind == 'api_key' && withdrawal.value_usd <= 200Skip approval: small refunds go automatically.
wallet.outflow_24h > 5000Require 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