Back to Blog
BlockchainPaymentsStablecoinWeb3Full-Stack

Building a Crypto Payment Gateway: Accept Stablecoin (USDC/USDT) Payments

A developer's guide to building a stablecoin payment gateway—the payment flow, custodial vs non-custodial trade-offs, watching for on-chain payments with viem, and the edge cases (reorgs, underpayments, gas sweeping) that break naive implementations.

Nawab Khairuzzaman9 min read
Share:
BLOG POST

Accepting crypto used to mean accepting volatility—nobody wants an invoice that's worth 12% less by the time it settles. Stablecoins fixed that. With USDC or USDT, a $100 invoice is $100 when it's paid, settlement is global and near-instant, and fees are a fraction of card processing. That's why "can you build us a crypto payment gateway?" has become one of my most common project requests.

This guide is the architecture and the hard parts. I'll show the payment flow end to end, where naive implementations break, and real code for the piece everyone underestimates: reliably detecting that a payment arrived.

Why stablecoins specifically

CardsVolatile crypto (BTC/ETH)Stablecoins (USDC/USDT)
Settlement1–3 daysMinutesMinutes
ChargebacksYes (risk)NoNo
Price riskNoneHighNone
Fees2.9% + 30¢Network gasNetwork gas
Global reachLimitedYesYes

The pitch is simple: card economics without the chargebacks or the FX headaches, and none of the "did we just lose 10%?" risk of accepting raw ETH. The trade-off is that you now own the on-chain engineering—which is the rest of this article.


The payment flow

Every stablecoin gateway, whether you build or buy, follows the same lifecycle:

plaintext
1. Customer checks out        → create invoice (amount, currency, chain)
2. Gateway issues a deposit   → unique address OR unique amount/memo
3. Customer sends USDC/USDT   → on-chain transfer
4. Gateway detects the tx     → watch chain for incoming Transfer
5. Wait for confirmations     → guard against reorgs
6. Mark invoice paid          → fire webhook to your app
7. Sweep funds to treasury    → consolidate from deposit addresses

Steps 4–7 are where the engineering lives. Steps 1–2 are a database row.


Custodial vs non-custodial (decide this first)

This single decision shapes everything—your code, your risk, and your legal exposure.

  • Non-custodial: funds flow straight to the merchant's own wallet. You're software, not a money transmitter. Simpler compliance, but you can't offer refunds, balances, or fiat off-ramps on the merchant's behalf.
  • Custodial: you hold funds, then settle to merchants. Enables balances, refunds, and fiat conversion—but you're now responsible for key security and almost certainly need money-transmitter licensing.

For most businesses just wanting to accept payments, start non-custodial. You get 90% of the value with a fraction of the risk. Go custodial only when the product genuinely requires holding balances. The same logic applies when designing a crypto exchange—custody is the line where engineering becomes regulation.


Issuing deposit addresses

You need to tell which invoice a payment belongs to. Two approaches:

  1. Unique address per invoice (recommended): derive a fresh address from an HD wallet for each invoice. Clean attribution, standard practice.
  2. Shared address + unique amount/memo: one address, distinguish payments by an exact amount or memo tag. Simpler, but amount collisions and missing memos cause headaches.

For the HD approach, derive each address deterministically from a single seed so you can always recompute it:

ts
import { mnemonicToAccount } from 'viem/accounts';
 
// Each invoice gets a deterministic address from its index.
// Store invoice.id ↔ addressIndex so you can re-derive and sweep later.
export function depositAddressForInvoice(addressIndex: number): `0x${string}` {
  const account = mnemonicToAccount(process.env.TREASURY_MNEMONIC!, { addressIndex });
  return account.address;
}

Keep that mnemonic in a secret manager or HSM—never in code or env files committed to git. It is the master key to every deposit address.


Detecting the payment (the part that's harder than it looks)

A USDC/USDT transfer is an ERC-20 Transfer event. To know a payment arrived, you watch for Transfer events where to is your deposit address. Here's a working watcher with viem:

ts
import { createPublicClient, http, parseAbiItem, formatUnits } from 'viem';
import { mainnet } from 'viem/chains';
 
const client = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });
 
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // mainnet USDC
const transferEvent = parseAbiItem(
  'event Transfer(address indexed from, address indexed to, uint256 value)'
);
 
export function watchInvoice(depositAddress: `0x${string}`, expected: bigint) {
  const unwatch = client.watchEvent({
    address: USDC,
    event: transferEvent,
    args: { to: depositAddress },
    onLogs: async (logs) => {
      for (const log of logs) {
        const amount = log.args.value!;          // USDC has 6 decimals
        const txHash = log.transactionHash!;
        await handleIncomingPayment({
          txHash,
          depositAddress,
          amount,
          underpaid: amount < expected,
          human: formatUnits(amount, 6),
        });
      }
    },
  });
  return unwatch; // call to stop watching
}

In production you wouldn't keep a websocket open per invoice. You'd run one indexer that filters Transfer logs for all your deposit addresses (or use a provider's address-activity webhooks), then route each hit to the right invoice. But the event shape above is exactly what you're matching on.


The edge cases that break naive gateways

This is the difference between a weekend demo and something you trust with real money:

  • Reorgs. A transaction can appear, then vanish if the chain reorganizes. Never mark an invoice paid on first sight—wait for confirmations (a handful on L2s, more on mainnet for large amounts) before settling.
  • Idempotency. The same Transfer log can be delivered more than once (retries, restarts, reorg replays). Use the transaction hash as a unique key and process each payment exactly once.
  • Underpayments and overpayments. Customers fat-finger amounts. Decide policy up front: credit partial payments, auto-refund overages, or hold for manual review. Don't let an off-by-a-cent transfer leave an invoice stuck.
  • Wrong chain / wrong token. USDC exists on Ethereum, Base, Polygon, Arbitrum, Solana, and more. A customer paying on the wrong network is the #1 support ticket. Show the exact chain and token contract, and detect common wrong-chain deposits.
  • The gas-sweeping trap. To move USDC out of a deposit address, that address needs native gas (ETH) to pay the transfer fee—but deposit addresses start empty. You must fund each with a little gas before sweeping, or use account abstraction / a paymaster to sponsor it. Budget for this; it surprises everyone.

Cheap chains make this dramatically easier. Sweeping on Ethereum mainnet can cost more than a small invoice; on an L2 like Base or Arbitrum it's cents. For optimizing the contract-level costs when you do operate on mainnet, see my Solidity gas optimization guide.


Confirming and settling

Once a payment has enough confirmations, the flow is ordinary backend work:

  1. Mark the invoice paid in a single transaction (guarded by the tx-hash idempotency key).
  2. Fire a signed webhook to the merchant's app—include the invoice ID, amount, tx hash, and a signature they can verify.
  3. Queue a sweep of the funds from the deposit address to your treasury (gas permitting).

Treat the webhook like Stripe does: sign it, retry with backoff, and make the receiver idempotent. Merchants will build their fulfillment on it, so it has to be reliable.


Security and compliance checklist

  • Keys: master seed in an HSM or secret manager; never in the repo or plain env. Sweep keys segregated from hot operational keys.
  • Confirmations scale with value: more confirmations for larger invoices.
  • Webhook signing: HMAC or asymmetric signatures so merchants can't be spoofed.
  • Monitoring: alert on stuck invoices, failed sweeps, and balance mismatches between expected and on-chain.
  • Compliance: non-custodial keeps you lighter, but sanctions screening and regional rules still apply once real money moves. Get legal advice before launch—this is not optional.

Build it yourself or use a provider?

Use an existing provider (Coinbase Commerce, a BitPay-style service) if you need to accept payments tomorrow and standard terms are fine. You trade flexibility and margin for speed.

Build your own when you need custom settlement logic, your own branding and UX, multi-chain control, tighter fees, or features a provider won't offer (subscriptions, marketplaces, split payments). That's the work behind my payment gateway development service—and if you're combining payments with trading or custody, it overlaps heavily with crypto exchange development.


Frequently asked questions

How does a crypto payment gateway work? It issues a unique deposit address (or amount) per invoice, watches the blockchain for an incoming stablecoin transfer to that address, waits for confirmations to rule out reorgs, then marks the invoice paid and notifies your app via webhook—mirroring how a card processor confirms and settles, but on-chain.

Which stablecoin should I accept, USDC or USDT? Both are widely used. USDC is favored for its transparency and regulatory posture; USDT has the deepest liquidity, especially outside the US. Many gateways accept both and let the customer choose. The integration code is nearly identical—only the token contract address changes.

What does it cost to accept stablecoin payments? There's no percentage fee like cards (2.9% + 30¢). You pay network gas: cents on L2s like Base or Arbitrum, more on Ethereum mainnet. The real cost is engineering and operations—building or buying the gateway, plus gas for sweeping funds.

Is building a crypto payment gateway legal? A non-custodial gateway (funds go straight to the merchant) is generally treated as software and avoids most money-transmitter obligations. The moment you hold customer funds (custodial), licensing and compliance requirements usually apply. Always confirm with a lawyer for your jurisdiction before launch.

Which blockchain is best for stablecoin payments? Layer-2s like Base, Arbitrum, and Polygon offer the best mix of low fees and fast confirmations for everyday payments. Ethereum mainnet is best reserved for high-value transfers where its security premium is worth the gas.


Need a stablecoin gateway built for your stack and chains? See my payment gateway development service or reach out to talk through the architecture.

N

Nawab Khairuzzaman

Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.

Comments

Related Posts