Skip to content
BaseHub by wbnns Updated

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 — authorizations, permits, HTTP negotiation, channels — exists to control when that transfer happens and who triggers it.

Two rails carry almost all of it:

RailBest forIntegration
Direct USDCWallet-agnostic ERC-20 checkoutviem
B20 with memoIssuer tokens and onchain reconciliationviem 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.

Auto-settle is one prompt. The wallet approves and the transfer executes in the same flow. For plain USDC that is an ordinary transfer. For B20 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.

Deferred settlement splits the two. The buyer signs offchain; you submit the transaction when you are ready to fulfil. Which primitive to reach for depends on what you know at signing time:

NeedPrimitiveSection
Exact wallet payment captured laterEIP-3009Authorize and capture
Variable total known laterEIP-2612 plus a constrained checkoutCapture a partial amount
Repeated scheduled chargesSmart-account spend permissionCharge on a schedule
Payment negotiated over HTTPx402Paying for APIs with x402

An EIP-3009 signature commits the payer, recipient, amount, a validity window, and a single-use nonce. No transaction is sent, so nothing hits the chain until you capture.

FieldPurpose
valueExact amount that can settle
validAfterEarliest valid capture time
validBeforeCapture deadline
nonceOne-time identifier consumed by capture or cancellation

Generate the nonce from a cryptographic random source — 32 bytes, fresh for every attempt, never recycled across orders. Store the payload and the signature against the order server-side.

Capture with receiveWithAuthorization rather than transferWithAuthorization. The difference matters: the token requires msg.sender to equal to, so an unrelated account cannot front-run your capture by submitting the signature first.

const simulation = await publicClient.simulateContract({
account,
address: USDC,
abi: usdcAbi,
functionName: "receiveWithAuthorization",
args: [
authorization.from,
authorization.to,
authorization.value,
authorization.validAfter,
authorization.validBefore,
authorization.nonce,
Number(v),
r,
s,
],
});

Read authorizationState(payer, nonce) before you submit — it stays false until the authorization is either captured or cancelled. Check the payer’s balance too. Both reads cut down on avoidable reverts, but neither one reserves anything, so keep fulfilment idempotent and drive it off the receipt.

Walking away is free. No funds were held, so a merchant that simply stops using a signature and lets validBefore pass has cancelled it in every practical sense. Making the nonce unusable immediately costs one transaction, and only the buyer can authorize it.

PathCostUse when
Mark void and let validBefore passNo transactionThe short validity window is acceptable
Submit cancelAuthorizationOne transactionYou need the nonce to become unusable immediately

The buyer signs a CancelAuthorization typed-data message; you or any relayer submits it. Afterwards the original transfer authorization reverts on capture.

EIP-3009 pins one exact value, so it has no partial path. When the final total is not known until fulfilment — an open tab, a tip added afterwards, shipping worked out during packing — sign a capped EIP-2612 permit instead and charge the real amount underneath the cap.

The cap only means something if the spender is a contract that cannot do anything else with it. The checkout below takes the permit and moves the actual amount in a single transaction. It has no general-purpose transfer function, it insists on the merchant as caller, and it burns each order ID once:

function capture(
bytes32 orderId,
address payer,
uint256 actualAmount,
uint256 authorizedMaximum,
uint256 permitDeadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
if (msg.sender != merchant) revert NotMerchant();
if (captured[orderId]) revert OrderAlreadyCaptured(orderId);
if (actualAmount > authorizedMaximum) revert AmountExceedsMaximum();
captured[orderId] = true;
token.permit(payer, address(this), authorizedMaximum, permitDeadline, v, r, s);
require(token.transferFrom(payer, merchant, actualAmount), "transfer failed");
emit PaymentCaptured(orderId, payer, actualAmount, authorizedMaximum);
}

A wallet prompt that returned cleanly is not a payment. Verify against confirmed chain data on the backend, then claim the transaction hash exactly once in durable storage, and only then reserve inventory, issue credentials, or return a paid resource.

The check itself is rail-agnostic. Direct transfer, an EIP-3009 capture, and a transferFrom after a permit all emit the same ERC-20 Transfer event. What differs is how you bind that event back to an order:

RailBind the order with
Direct USDCStored transaction hash and expected checkout fields
EIP-3009 captureStored authorization nonce plus the capture hash
Permit checkoutCheckout contract’s order event plus the token transfer
B20Adjacent 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.

Refund the address the chain says paid you, taken from the verified Transfer log — never an address supplied by the client. ERC-20 tokens keep no order-level refundable balance, so captured - refunded is a number your ledger has to track.

Reserve before you broadcast. The reservation must atomically create the pending refund and decrement the refundable balance, so that concurrent requests cannot together exceed what you captured. If a worker dies after broadcasting and loses the receipt, reconcile that pending row against chain data — releasing it invites a second transfer for the same refund.

B20 can carry the original order ID into the refund itself via transferWithMemo. Plain USDC uses transfer, and the linkage between order ID, capture hash, refund hash, and amount lives entirely in your records.

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.

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.

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.

Recurring charges use a smart-account spend permission. The permission bounds the spender, the token, the amount per period, and the start and end times. When each charge fires is your backend’s decision.

Check status at every billing point before preparing calls — confirm the connected account is the approved spender, that the permission is active rather than revoked or expired, and that remainingSpend covers the charge. Three outcomes are ordinary billing states rather than errors:

  • Revoked or expired — stop retrying and ask for a new permission.
  • Insufficient balance — notify the buyer and retry on your billing policy.
  • Period allowance exhausted — wait for nextPeriodStart, or collect a new permission.

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.

Make the overlap replacement one database transaction: delete the previously indexed rows in the window, insert the canonical logs returned now, and advance the cursor only if both succeed. Key rows on (blockHash, transactionHash, logIndex) so retries stay idempotent.

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 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. USDC has no memo, so those rows join on the transaction hash, the authorization nonce, or a contract event you emitted yourself.