Accept Payments
Taking money on Base is an ERC-20 problem with a handful of extra primitives layered on top. The token moves, a Transfer event lands, and your backend decides whether that constitutes payment. Everything else on this page — escrow, collectors, HTTP negotiation, channels — exists to control when that transfer happens and who triggers it.
Two rails carry almost all of it:
| Rail | Best for | Integration |
|---|---|---|
| Direct USDC | Wallet-agnostic ERC-20 checkout | viem |
| B20 with memo | Issuer tokens and onchain reconciliation | viem or Solidity |
USDC lives at 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base mainnet and 0x036CbD53842c5426634e7929541eC2318f3dCF7c on Base Sepolia. B20 adds issuer policy controls and an order memo written onchain beside the transfer.
Settle now, or approve now and settle later
Section titled “Settle now, or approve now and settle later”The first decision is whether the wallet interaction and the token movement happen together.
A plain transfer is the floor. The sender calls transfer on USDC, pays the gas, and the recipient holds the funds once it confirms. There is no escrow behind it, no authorization to revoke, and no protocol refund path, so check the network and the recipient before anyone signs. Treat it as paid only after the receipt succeeds and its Transfer event matches the sender, recipient, and amount you expected.
B20 with a memo keeps the one-prompt shape but writes an order reference onchain. The payer calls transferWithMemo, or a checkout contract pulls a pre-approved amount with transferFromWithMemo and refuses order IDs it has already seen:
function pay(bytes32 orderId, uint256 amount) external { if (paid[orderId]) revert OrderAlreadyPaid(orderId); paid[orderId] = true; bool transferred = token.transferFromWithMemo(msg.sender, merchant, amount, orderId); require(transferred, "B20 transfer failed");}That Solidity path has two prerequisites people miss: the payer has to approve the checkout contract, and any TRANSFER_EXECUTOR_POLICY on the token has to authorize it as well. Verifying the amount before you ship remains your job either way.
Anything with terms attached — a charge you may refund, a hold you capture after shipping, a recurring bill — now goes through the Commerce Payments Protocol. The rest of this page covers it.
The Commerce Payments Protocol
Section titled “The Commerce Payments Protocol”The protocol puts one contract, AuthCaptureEscrow, between payer and merchant. It keeps per-payment accounting onchain, so the questions a merchant ledger used to answer on its own — how much is still capturable, how much can still be refunded — become contract state you can read.
| v1.1.0 contract | Address |
|---|---|
AuthCaptureEscrow | 0xf96815976523E00e65Be8f34cA5e64b4f41EB19c |
ERC3009PaymentCollector | 0x8612dfdc421f80336cd14E8EF9cb1E765dB5ab88 |
Permit2PaymentCollector | 0xD69831Aed5bfe262067ec4c751f4F830EcdD446e |
PreApprovalPaymentCollector | 0xF1F9C408C787B2bC6CAEB91e5BbEc434a5c8d2Ea |
SpendPermissionPaymentCollector | 0xB508c1C0a13849693DC175307667653C5977a408 |
OperatorRefundCollector | 0x7a03443724d14798c4AB4622F1DAAcA761Fea486 |
These are CREATE2 deployments, so each address is identical on Base and Base Sepolia. The escrow holds bytecode at that address on both chains.
Payment terms
Section titled “Payment terms”Every payment starts from a PaymentInfo struct that never changes once used. Every later operation takes the whole struct and rehashes it, so store it exactly as submitted:
| Field | Role |
|---|---|
operator | The only account that may submit charge, authorize, capture, void, and refund |
payer | Where funds come from, and where refunds and returned holds go |
receiver | Where captured value goes, minus fees |
token | The ERC-20 being moved |
maxAmount | Ceiling on what this payment can collect |
preApprovalExpiry | Last moment the payer’s approval can be used to collect |
authorizationExpiry | Last moment a hold can be captured; the payer can reclaim from here on |
refundExpiry | Last moment a refund can be issued |
minFeeBps, maxFeeBps | Bounds each fee is checked against |
feeReceiver | Where fees go |
salt | Makes the hash unique |
The three expiries must satisfy preApprovalExpiry <= authorizationExpiry <= refundExpiry; otherwise the escrow reverts with InvalidExpiries. A payment can be collected exactly once. Draw a fresh random salt for every checkout attempt, and that includes the retry after a void or a reclaim.
How the payer approves
Section titled “How the payer approves”The escrow never pulls tokens itself. A collector contract does it, and you choose which one per payment:
| Collector | Use when |
|---|---|
| ERC-3009 | The token supports native transfer authorizations, as USDC does |
| Permit2 | You need general ERC-20 coverage and the payer has approved Permit2 |
| Pre-approval | A standard allowance plus a pre-approval scoped to this payment |
| Spend permission | A wallet has granted a spend permission for repeat billing |
B20 tokens and other ERC-20s can go through Permit2 or the pre-approval collector, but only when their transfer policies admit every party involved: the collector, the operator’s token store, the receiver, and the fee receiver.
Charge immediately
Section titled “Charge immediately”charge collects, pays out, and records the result in one transaction. The operator submits it with the terms, an amount, the collector, the collector’s data, and a fee:
const simulation = await publicClient.simulateContract({ account, address: AUTH_CAPTURE_ESCROW, abi: authCaptureEscrowAbi, functionName: "charge", args: [ payment.paymentInfo, payment.paymentInfo.maxAmount, ERC3009_PAYMENT_COLLECTOR, payment.collectorData, 0n, zeroAddress, ],});The receiver gets amount - feeAmount, the fee receiver gets the fee, and refundableAmount rises by the full gross amount. Look for PaymentCharged, match its paymentInfoHash to the order, and claim it once before you fulfil. Keep maxFeeBps tight, and pin feeReceiver in the terms unless your operator genuinely needs to swap out a recipient that has been denylisted.
Authorize and capture
Section titled “Authorize and capture”Use this path when fulfilment comes after checkout. It differs from the old signature-only hold in one decisive way: a successful authorize pulls the tokens into the operator’s escrow token store right away. The payer spending the balance afterwards cannot break a later capture.
const simulation = await publicClient.simulateContract({ account, address: AUTH_CAPTURE_ESCROW, abi: authCaptureEscrowAbi, functionName: "authorize", args: [ payment.paymentInfo, payment.paymentInfo.maxAmount, ERC3009_PAYMENT_COLLECTOR, payment.collectorData, ],});Once PaymentAuthorized is emitted, the state reads:
| State field | Value |
|---|---|
hasCollectedPayment | true, forever |
capturableAmount | Everything held in escrow |
refundableAmount | 0, until something is captured |
Record paymentInfoHash, the full terms, the collector, the authorization transaction hash, and PaymentAuthorized.amount against the order.
To settle, read paymentState(paymentInfoHash) and call capture with an amount that is nonzero and no more than capturableAmount:
const simulation = await publicClient.simulateContract({ account, address: AUTH_CAPTURE_ESCROW, abi: authCaptureEscrowAbi, functionName: "capture", args: [payment.paymentInfo, amount, 0n, zeroAddress],});The fee argument is an absolute feeAmount in raw token units, not basis points. The escrow checks it against minFeeBps and maxFeeBps from the terms. Each capture moves its gross amount out of capturableAmount and into refundableAmount. Confirm PaymentCaptured(paymentInfoHash, amount, feeAmount, feeReceiver) and the matching transfers before you mark that piece of the order settled.
Capture a partial amount
Section titled “Capture a partial amount”There is no separate function. Call capture again for each increment — a split shipment, or a final bill that came in lower. Successful captures can sum to at most PaymentAuthorized.amount, and fee bounds are checked on every one individually. Capture 64 from a 100 USDC hold, and 36 stays capturable while 64 becomes refundable. From there you can capture again or void the rest.
Re-read capturableAmount and refundableAmount after each capture, before any other worker moves the order forward.
Void or reclaim what is left
Section titled “Void or reclaim what is left”| Path | Caller | Available |
|---|---|---|
void(paymentInfo) | Operator | Whenever a capturable balance remains |
reclaim(paymentInfo) | Payer | At or after authorizationExpiry |
void hands back the entire capturableAmount. Nothing voids part of a hold, so capture the fulfilled amount first and void afterwards. Both paths zero capturableAmount and leave any refundableAmount from earlier captures alone. Match PaymentVoided or PaymentReclaimed against the capturable balance just before it fired.
Verify before you fulfil
Section titled “Verify before you fulfil”A wallet prompt that returned cleanly is not a payment, and neither is a transaction hash the client hands you. Decode confirmed AuthCaptureEscrow events on the backend, match their paymentInfoHash to the order, claim each one exactly once in durable storage, and only then reserve inventory, issue credentials, or return a paid resource.
PaymentAuthorized and PaymentCharged carry the complete PaymentInfo. Every later lifecycle event carries only the hash, so join those to the terms you stored at order creation.
The check should pass only for the first confirmed event with the right chain, escrow address, payment hash, operation, amount, and fee. Outside the protocol, bind orders like this:
| Rail | Bind the order with |
|---|---|
| Direct USDC | Stored transaction hash and expected checkout fields |
| Commerce Payments | paymentInfoHash from the escrow event |
| B20 | Adjacent Memo event containing the order reference |
For B20 the memo is emitted directly after its Transfer, so the join is logIndex + 1 — an ordering guarantee, not a search.
Pick a confirmation depth against the value at stake and how hard fulfilment is to undo. The transaction finality stages describe what each depth actually buys you.
Refunds
Section titled “Refunds”Protocol refunds run up to refundExpiry and always go to paymentInfo.payer; there is no argument for a different destination. The escrow enforces the ceiling: every charge or capture adds its gross amount to refundableAmount, fees do not reduce it, and every refund subtracts what it returned.
What the escrow does not supply is the money. Captured funds already left for the receiver, so the refund has to be funded fresh. The OperatorRefundCollector draws from paymentInfo.operator, which means the operator approves that collector for the refund amount first:
await walletClient.writeContract({ address: payment.paymentInfo.token, abi: refundApprovalAbi, functionName: "approve", args: [OPERATOR_REFUND_COLLECTOR, refundAmount],});// thenconst refund = await publicClient.simulateContract({ account, address: AUTH_CAPTURE_ESCROW, abi: authCaptureEscrowAbi, functionName: "refund", args: [payment.paymentInfo, refundAmount, OPERATOR_REFUND_COLLECTOR, "0x"],});Confirm PaymentRefunded(paymentInfoHash, amount, tokenCollector), the transfer to the stored payer, and the lowered refundableAmount before closing the return.
Outside the protocol nothing tracks this for you. Refund the address the chain says paid you, taken from the verified Transfer log, and reserve before you broadcast: atomically create the pending refund and decrement your own refundable balance so concurrent requests cannot overshoot. B20 can carry the original order ID into the refund via transferWithMemo.
Payouts and splits
Section titled “Payouts and splits”Both use a purpose-built contract that pulls each amount straight from the sender to its recipient. Nothing pools in the contract between transactions, which keeps the contract from becoming a balance worth attacking.
A payout batch is a list of recipients and amounts under one batchId, replay-guarded and length-bounded:
function sendPayouts(bytes32 batchId, address[] calldata recipients, uint256[] calldata amounts) external { if (processed[batchId]) revert BatchAlreadyProcessed(batchId); if (recipients.length == 0 || recipients.length != amounts.length) revert InvalidArrayLengths(); if (recipients.length > MAX_RECIPIENTS) revert TooManyRecipients(); processed[batchId] = true;
for (uint256 i; i < recipients.length; ++i) { require(token.transferFrom(msg.sender, recipients[i], amounts[i]), "transfer failed"); emit PayoutSent(batchId, msg.sender, recipients[i], amounts[i]); }}Size batches from measured gas and respect MAX_RECIPIENTS. One failing token transfer reverts the whole batch.
A split is the same shape with proportions instead of amounts. Shares are basis points, they must total 10_000, and integer division leaves a remainder that goes to one nominated recipient — so the legs sum to the input exactly and no dust is stranded. Every leg emits PayoutSent under a shared splitId, which gives both USDC and B20 an order reference without depending on token-level memo support.
Who gets the rounding remainder is a commercial question. Settle it in your terms, not implicitly in the loop.
Paying for APIs with x402
Section titled “Paying for APIs with x402”x402 moves the negotiation into HTTP. An unpaid request gets 402 Payment Required carrying the scheme, network, token, price, and recipient. The client signs what was advertised and retries with a PAYMENT-SIGNATURE header. Seller middleware verifies before your handler runs, and a facilitator settles. On Base USDC, the thing being carried through that handshake is an EIP-3009 authorization.
Three schemes cover three billing shapes:
exact— the price is known before the work. A fixed-price route.upto— you advertise a ceiling, then set the real charge after the handler succeeds. This is the agentic twin of the capped permit checkout above. Apply the settlement override only once you have computed successful usage, and keep it at or below the advertised maximum.batch-settlement— for many small requests where per-request settlement would cost more than the requests. Each call advances a cumulative voucher; the channel manager claims, settles, or refunds the latest state later. Onchain transactions happen only at those points.
app.use(paymentMiddleware({ "GET /metered": { accepts: [{ scheme: "upto", price: "$0.10", network, payTo }], description: "Usage-priced inference", mimeType: "application/json", },}, resourceServer));app.get("/metered", (_request, response) => { setSettlementOverrides(response, { amount: "$0.04" }); response.json({ tokens: 812, result: "Generated response" });});Treat the facilitator’s response and your own fulfilment record as two separate facts. A retried paid request must not deliver a one-time resource twice.
The buyer side
Section titled “The buyer side”An agent wraps its HTTP client so it can read a 402, pick a scheme it supports, sign, and retry. The important part is that policy runs before any signature exists — network, asset, per-request cap, and a session-cumulative cap:
client.onBeforePaymentCreation(async ({ selectedRequirements }) => { if (selectedRequirements.network !== "eip155:84532") return { abort: true, reason: "Wrong network" }; if (selectedRequirements.asset.toLowerCase() !== baseSepoliaUsdc.toLowerCase()) return { abort: true, reason: "Wrong asset" }; const amount = BigInt(selectedRequirements.amount); if (amount > 100_000n || authorizedThisSession + amount > 1_000_000n) { return { abort: true, reason: "Spend limit exceeded" }; } authorizedThisSession += amount;});The wrapper automates negotiation, not trust. Whatever the service returns is untrusted input: validate the schema and the content, and never let anything in the response steer wallet policy.
Charge on a schedule
Section titled “Charge on a schedule”Recurring billing runs through the SpendPermissionPaymentCollector. It rebuilds a spend permission out of the payment terms: the payer is the account, the collector is the spender, the token and allowance come from PaymentInfo, and preApprovalExpiry is where the permission ends. Pass the permission signature, plus an optional MagicSpend withdrawal request, as collectorData.
Each billing period is its own protocol payment with a fresh PaymentInfo and a fresh salt, because a payment collects only once. Key each one durably — subscriptionId:periodStart works — before you submit charge. The resulting PaymentCharged gives that period its own refundableAmount. Keep the billing key, paymentInfoHash, charge transaction, amount, and permission usage together in one durable workflow.
Some outcomes at a billing point are ordinary states, not errors:
- Revoked or expired permission — stop retrying and ask for a new one.
- Insufficient balance — notify the buyer and retry on your billing policy.
- Period allowance exhausted — wait for
nextPeriodStart, or collect a new permission.
Watching and reconciling
Section titled “Watching and reconciling”A WebSocket subscription gives you low-latency wakeups. It is not the source of truth. eth_getLogs across an overlapping window is, because it survives disconnects and reorgs — so use the subscription only to trigger a backfill, and let the backfill decide what is real.
For protocol payments, watch the v1.1.0 escrow for PaymentAuthorized, PaymentCharged, PaymentCaptured, PaymentVoided, PaymentReclaimed, and PaymentRefunded. All six index paymentInfoHash, which becomes the key of your ledger.
Make the overlap replacement one database transaction: delete the previously indexed rows in the window, insert the canonical logs returned now, rebuild the payment state they touch, and advance the cursor only if every write succeeds. Key rows on (blockHash, transactionHash, logIndex) so retries stay idempotent.
The ledger arithmetic per charge or capture is:
merchant net = gross amount - fee amountrefundable balance += gross amountA refund lowers the refundable balance by its gross amount. A void or reclaim lowers only the capturable balance and never unwinds an earlier capture. Two identities should hold for every payment:
- Authorized amount = captures + voided or reclaimed amount + current
capturableAmount. - Total charged and captured = refunds + current
refundableAmount.
For reporting, run accounting over a finalized range only. If you also surface recent activity, label it provisional and replace it after a reorg.
One reconciliation trap worth naming: an outgoing Transfer from the merchant address is ambiguous on its face. It could be a refund, a payout leg, a split leg, or a treasury movement. Join it to your refund ledger, to PaymentRefunded, or to the PayoutSent reference — direction alone does not classify it. On the incoming side, B20 memos join back through (transactionHash, logIndex - 1), the mirror of the adjacency rule used during verification. Direct USDC has no memo, so those rows join on the transaction hash or a contract event you emitted yourself.