How to Accept USDC Payments Without Custody: A Production Architecture
Accept USDC payments into merchant-controlled wallets with payment orders, chain monitoring, finality, and reliable webhook fulfillment.
You can accept USDC payments without giving a payment provider control of your receiving funds. The merchant supplies and controls the receiving wallets; the customer sends USDC directly to one of those addresses.
That custody model does not remove the payment operations layer. A non-custodial USDC payment integration still needs an explicit business order, exact chain-and-asset instructions, a way to match the transfer, confirmation and finality tracking, and a verified event that can safely trigger fulfillment. StableOps operates that order and event layer while your application remains the authority for the customer order and the merchant remains in control of the wallets.
The production architecture separates funds from payment state
A wallet answers “where did the tokens arrive?” It does not answer “which cart did this transfer pay, was it on time, and may we ship the order?” Keep those responsibilities separate:
Control: Merchant backend -> StableOps API -> Payment Order -> Checkout or custom UI
Funds: Payer wallet -> blockchain transfer -> merchant-controlled address
Events: Blockchain -> StableOps scanner -> signed webhook -> merchant backend
|
v
Business order / fulfillmentYour backend creates and owns the business order. StableOps creates a Payment Order, allocates a candidate from the merchant's receiving-address pool, and returns chain-specific payment instructions. Hosted Checkout or your own UI presents those instructions. After the payer transfers USDC, StableOps observes and normalizes the chain event, advances it through confirmation states, and sends signed webhooks back to your server.
Funds do not pass through StableOps in this model. StableOps also does not decide whether to ship, grant access, or post a ledger entry. Your application makes that decision from its durable business state after verifying the payment event.
Before starting, import enough receiving addresses and register a webhook endpoint as described in the Quickstart. Address capacity is an operating dependency: if no eligible address is available for a requested chain and asset, the system cannot issue complete payment instructions.
Create the business order before the payment order
Persist the cart, invoice, or subscription charge first. Its immutable identifier becomes merchantOrderId, and its persisted amount and expiry become inputs to the Payment Order. This gives retries a stable identity and prevents a browser refresh from creating a second collection attempt with different terms.
import { StableOps } from '@stableops/api-sdk'
const stableops = new StableOps({
apiKey: process.env.STABLEOPS_API_KEY!,
})
export async function createUsdcPayment(order: {
id: string
amount: string
paymentExpiresAt: string
}) {
return stableops.paymentOrders.create(
{
merchantOrderId: order.id,
amount: order.amount,
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: order.paymentExpiresAt,
},
{ idempotencyKey: `payment-order:${order.id}` },
)
}On every retry, reuse the same merchantOrderId, amount, accepted assets, expiry, and idempotency key. Do not calculate a new expiry or generate a random key for each request. Use order.amount together with the selected paymentInstructions entry to render the exact amount, chain, asset, and address. This distinction also matters if you later use amountMode: 'auto', because the returned order.amount can differ from the requested base amount. These values are instructions, not evidence that payment occurred.
The Payment Orders guide covers allocation and lifecycle details. If you want StableOps to host the checkout and wallet-interaction UI, create a Hosted Checkout Session over the same order model. For a framework-specific walkthrough, see How to Accept USDC Payments in Next.js.
Match the full instruction, not a wallet balance
A production matcher needs the order's chain, asset, receiving address, and expected amount. Checking only whether a wallet balance increased loses the business relationship between a transfer and an order. It also becomes ambiguous when multiple customers pay the same amount, when USDC exists on several supported chains, or when a transfer arrives after expiry.
Treat the displayed instruction as one coherent tuple:
(payment order, chain, asset, address, amount, expiry)The customer must send the specified asset on the specified chain. Your support policy should separately cover wrong networks, wrong assets, underpayments, overpayments, and late transfers. Do not silently reinterpret an unmatched transfer as payment for a convenient open order.
Use a minimal payment state machine
Chain observation is a sequence, not a Boolean paid value. Each state supports different application behavior.
| Event | Meaning | Safe application behavior | Unsafe assumption |
|---|---|---|---|
payment.detected | A matching transfer was observed | Show “payment received, confirming” and record the tx hash | The transfer is irreversible |
payment.confirmed | The configured confirmation level was reached | Update progress or perform deliberately reversible work | Every chain risk has passed |
payment.finalized | The payment reached the final fulfillment state | Fulfill idempotently and post the final receipt record | The event may be processed without verification |
payment.reverted | A previously observed payment was rolled back | Stop pending work and run defined compensation or review | The rare path can be ignored |
Confirmation policy depends on the chain and the risk of the product. The confirmation model explains how confirmations, finality, and reorganizations relate.
A Checkout success URL is only a browser return path. A wallet's “submitted” result and a transaction hash are useful progress signals and investigation tools. None of them prove final payment. For irreversible fulfillment, wait for a verified, deduplicated payment.finalized webhook.
Make the webhook boundary durable
Read the exact raw request body and verify its signature before parsing JSON. In the same database transaction, insert X-Event-Id into a table with a unique constraint and either update durable business state or enqueue an outbox/fulfillment job. Return 2xx only after that transaction commits. A uniqueness conflict then means the event and its durable next step were already committed, so the endpoint can return success without enqueuing the work again.
Event deduplication and fulfillment idempotency solve different failures. The event ID prevents a retry or replay from applying the same event twice. An idempotent fulfillment operation keyed by your internal order ID prevents two valid code paths from shipping or granting the same entitlement twice.
External fulfillment cannot usually run inside that database transaction. Let a worker claim the committed outbox job, perform the side effect idempotently by internal order ID, and record completion. A reconciliation worker should recover jobs left in a retryable state after a crash. Keep the Webhook handler short enough to return 2xx reliably; use the Webhook guide for verification, retry, replay, and delivery-audit details.
Keep a reconciliation trail
For each collection attempt, retain the relationship among:
- your internal business order ID;
- the StableOps Payment Order ID;
- the selected chain, asset, address, amount, and expiry;
- each
X-Event-Idand event type; and - the on-chain transaction hash.
This trail lets a worker recover after an outage and lets support answer what happened without treating a wallet balance as the source of truth. A scheduled reconciliation job should find finalized payments whose fulfillment is incomplete, pending orders beyond their expected window, and recorded events whose downstream job failed.
Production checklist
- Confirm the exact chain-and-USDC pairs your product accepts; “USDC” alone is not a network selection.
- Maintain enough eligible receiving addresses; for single-use address pools, alert before available capacity is exhausted. Shared pools need collision and exact-amount monitoring instead.
- Persist order amount, expiry, accepted assets, and the idempotency key before calling StableOps.
- Define policies for expired, late, wrong-chain, wrong-asset, underpaid, and overpaid transfers.
- Map reversible product actions to
confirmedonly when justified; reserve irreversible fulfillment forfinalized. - Verify Webhooks from the raw body, store secrets server-side, and plan secret rotation.
- Enforce uniqueness on
X-Event-Idand make fulfillment idempotent by the business order ID. - Test duplicate delivery, replay, process crashes, and
payment.revertedhandling. - Reconcile business orders, Payment Orders, events, and transaction hashes on a schedule.
FAQ
Do I need to custody customer or merchant wallets to accept USDC payments?
No. The payer sends from a wallet they control, and the merchant supplies the receiving addresses. StableOps manages payment orders, address allocation, chain observation, and events without holding the merchant's receiving funds or the customer's private keys.
Can one wallet address receive every USDC payment?
It can receive transfers, but it makes reliable order matching and support harder. Separate allocated addresses provide a clearer relationship between an order and an incoming transfer. If your address policy uses shared addresses, the matching model still needs an unambiguous chain, asset, amount, and order association.
When is a USDC payment safe to fulfill?
For irreversible fulfillment, after your server verifies and deduplicates a payment.finalized webhook. Use payment.detected and payment.confirmed to show progress or perform carefully reversible work, not as substitutes for final payment proof.
Start with one complete payment loop
Follow the Quickstart to create one Sandbox business order, create its Payment Order with a stable idempotency key, complete a test USDC transfer, and connect one verified payment.finalized handler to an idempotent fulfillment job. Once that loop survives a duplicate delivery and a replay, expand to more chains, assets, and checkout surfaces.
Related articles
Compare custodial gateways, direct wallet transfers, and non-custodial payment infrastructure for reliable stablecoin collection.
A practical model for confirmations, reorgs, finality, and merchant fulfillment.
Create a hosted USDC Checkout Session in Next.js, redirect securely, and fulfill only from verified webhooks.