VetoVetoDocs
Guides

Go live with x402 + Coinbase CDP

Settle real USDC over HTTP-402 with a Coinbase CDP facilitator — from Base Sepolia testnet to Base mainnet, the EIP-712 domain trap, the txHash-is-authoritative rule, funding, and the live cutover checklist.

The checkout core is zero-dependency — it never imports a chain library. To settle real USDC it does the cheap, structural part itself (verify the EIP-3009 envelope matches the quote) and hands the on-chain tail to an injected Facilitator that owns the viem / Coinbase CDP code. This guide takes you from a testnet settle to a live mainnet one, with the traps that bite everyone called out explicitly.

On the hosted control plane the facilitator is wired for you: a veto_live_ key (mint it in Dashboard → Developers at https://merchants.veto-ai.com) settles real USDC on Base mainnet; a veto_test_ key settles on a deterministic mock (see test mode). Production runs the rail in url-mode against a self-hosted x402-rs facilitator (FACILITATOR_MODE=url + FACILITATOR_URL) — no Coinbase CDP dependency. If you just want the hosted, copy-paste path, see Set up Veto end-to-end (hosted). This guide is what runs under the hosted plane — and exactly what you implement when you self-host, where the CDP facilitator below is one supported backend.

How settlement splits

agent ──signed EIP-3009 transferWithAuthorization──▶  POST /agent/checkout/{id}/settle

                          SDK x402 rail (zero-dep)        │  structural verify:
                          ├─ to === payTo                 │  value === amountBaseUnits
                          └─ validBefore in the future    │  signature present

                          injected Facilitator (your viem/CDP code)
                          ├─ ecrecover signer === authorization.from
                          └─ broadcast → CONFIRMED on-chain txHash  ◀── authoritative

The split is deliberate: the SDK stays installable with nothing, and all the secret-bearing, network-touching crypto lives in your facilitator where the dependencies already are.

1. Configure the x402 rail

server.ts
import { createCheckout, x402Rail, BALANCED } from '@veto-protocol/checkout';

const checkout = createCheckout({
  merchant: { id: 'acme', name: 'Acme', domain: 'shop.acme.example' },
  catalog: [/* … */],
  receiving: { x402: { chain: 'base-sepolia', address: '0xYourReceivingAddr…', asset: 'USDC' } },
  policy: BALANCED(),
  rails: [x402Rail({ facilitator: myFacilitator })], // inject the on-chain settler
});

Start with chain: 'base-sepolia' (testnet). You'll flip it to base in the cutover.

2. The facilitator contract

A Facilitator receives the already-structurally-validated envelope plus the quote it must match, does the real verify + settle, and returns the outcome. It must never throw for an expected on-chain failure — it returns ok: false with reason codes so the gate can surface them as a clean 402 the agent can act on.

import type { Facilitator } from '@veto-protocol/checkout';

const myFacilitator: Facilitator = {
  async settle({ payment, requirement, session }) {
    // 1. resolve the network from requirement.chain (else UNSUPPORTED_CHAIN — never guess)
    // 2. ecrecover the EIP-3009 signer; assert it === authorization.from (else SIGNER_MISMATCH)
    // 3. broadcast transferWithAuthorization via CDP, idempotencyKey = the EIP-3009 nonce
    // 4. return the CONFIRMED txHash as the AUTHORITATIVE reference
    return { ok: true, txHash: '0x…', reasonCodes: ['OK'] };
  },
};

Coinbase ships the heavy lifting. The reference CDP facilitator wraps @coinbase/x402 + @x402/core, mints a per-request CDP JWT, and broadcasts transferWithAuthorization. You provide CDP_API_KEY_ID and CDP_API_KEY_SECRET; everything below is about getting the details right.

3. The traps (these are the ones that actually bite)

These are ported verbatim from a proven end-to-end harness. Each one fails silently if you get it wrong — a transfer that looks signed but never authorizes, or a "failure" that already moved money. Treat them as load-bearing.

Trap 1 — the EIP-712 domain name: "USD Coin" vs "USDC"

USDC reports a different EIP-712 domain name per chain:

NetworkchainIdUSDC contractdomain name
Base mainnet84530x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"USD Coin"
Base Sepolia845320x036CbD53842c5426634e7929541eC2318f3dCF7e"USDC"

If you sign (or verify) transferWithAuthorization with the wrong domain name, the signature recovers to the wrong address and the transfer silently fails to authorize.

Never hardcode one name for both chains.

The authoritative source is the live 402's extra.{name,version} — the buyer's signature is bound to whatever the 402 advertised. Read name/version off the wire, and fall back to a per-chain table only when the 402 didn't carry them. Verify and settle against the name the signature was actually bound to, never a guess.

Trap 2 — the txHash is authoritative; never blind-retry

The facilitator can report success: false while the transaction still confirms on-chain, or throw an error that nonetheless carries a txHash (broadcast happened, confirmation timed out). The rule:

  • If a txHash exists, value moved (or is moving) — return it regardless of the success flag, and flag the outcome as "verify on-chain."
  • Never auto-retry when a hash exists. A fresh broadcast risks a double-pay.
// On an ambiguous facilitator response:
if (txHash) {
  return { ok: false, txHash, reasonCodes: ['SETTLEMENT_FAILED'] /* verify the chain; do NOT retry */ };
}

Trap 3 — idempotency = the EIP-3009 nonce

Use the EIP-3009 nonce as the settle idempotency key. The USDC contract tracks authorizationState[from][nonce], so a given signed authorization settles at most once — a replay is a chain no-op, not a double-pay. This is also why a re-submitted settle is safe.

Trap 4 — integer base units, never floats

USDC has 6 decimals: $0.01 == 10000 base units. Do all amount math in integer/string base units. The quote carries amountBaseUnits as an exact string — settle against that, never a re-derived float.

4. Settle on testnet first

Stay on Base Sepolia until a real testnet USDC transfer confirms end to end.

Get CDP credentials

Create an API key in the Coinbase Developer Platform and set both halves:

.env
CDP_API_KEY_ID=
CDP_API_KEY_SECRET=
X402_NETWORK=base-sepolia   # default; the live rail reads this when receiving pins no chain

Fund the buyer wallet with testnet USDC

x402 is paid up front by the buyer — your receiving wallet doesn't need a balance to receive. For an end-to-end test the payer (the agent's wallet) needs:

  • a little Base Sepolia ETH for gas (a Base Sepolia faucet), and
  • Base Sepolia USDC at 0x036CbD…CF7e (a USDC testnet faucet).

Your merchant receiving address needs nothing to accept a transfer.

Run a settle and read the txHash

Drive a checkout to settle. On success the facilitator returns a real Sepolia txHash; open it on the explorer to confirm value landed at your receiving address:

https://sepolia.basescan.org/tx/<txHash>

If you get SIGNER_MISMATCH, you almost certainly hit trap 1 — the domain name didn't match what the signature was bound to.

5. The mainnet cutover

Once a Sepolia settle confirms cleanly, flip to Base mainnet:

Point receiving at mainnet

receiving: { x402: { chain: 'base', address: '0xYourMainnetReceivingAddr…', asset: 'USDC' } }

Mainnet USDC is 0x833589fCD6…02913 with domain name "USD Coin" — the network table and the live 402 handle this; you only change chain.

Use a live key / live facilitator

On the hosted plane, switch from a veto_test_ to a veto_live_ key (mint it under Dashboard → Developers) — a live key is the only thing that reaches the real facilitator that moves USDC. In production that's the url-mode x402-rs facilitator (FACILITATOR_MODE=url + FACILITATOR_URL), so no Coinbase CDP credentials are needed. A test key can never touch a real chain; it's rejected with TEST_MODE_RAIL_FORBIDDEN. Self-hosting, this is just your live facilitator — x402-rs in url-mode, or the reference CDP facilitator with production credentials.

Refuse to settle live on a fake rail

A live merchant must never silently fall through to a mock. If CDP credentials are missing, settlement must fail loud rather than mint a fake txHash. The hosted plane enforces this; self-hosting, guard it yourself — never default a live key to the mock facilitator.

Verify a real mainnet txHash

Run one real, small purchase and confirm the txHash on https://basescan.org/tx/<txHash>. That on-chain hash is the settlement reference embedded in the order — the authoritative proof value moved.

Facilitator failure modes

When settlement fails, the facilitator surfaces a specific reason code. These appear only with a real injected facilitator — the default mock does structural validation and never emits them:

ReasonMeaningWhat to do
SIGNER_MISMATCHRecovered signer ≠ authorization.from.Usually trap 1; verify the domain name.
UNSUPPORTED_CHAINChain/network not in the supported table.Pin a supported chain (base, base-sepolia); never guess a domain.
INSUFFICIENT_FUNDSPayer USDC balance too low.The buyer (payer) funds the transfer, not you.
NONCE_ALREADY_USEDEIP-3009 nonce already settled.The auth was already spent — a replay, not a new sale.
SETTLEMENT_FAILEDBroadcast failed.If a txHash is present, verify on-chain and do not retry (trap 2).

The agent never needs this page at runtime. The payment_required your checkout returns is self-describing — it carries description (what to pay and how) and next (the literal call to make). Docs are for you, the merchant; the wire is for the agent.

Go-live checklist

A Sepolia settle confirmed end to end

A real testnet USDC transfer landed at your receiving address, with a verifiable txHash.

Domain handling reads the wire

Name/version come from the live 402's extra, falling back to the per-chain table — never a single hardcoded name.

Ambiguous outcomes verify, never retry

A txHash on a "failure" is treated as authoritative; no code path blind-retries a broadcast.

Live credentials present and required

CDP keys are set, and a live settle refuses to run on the mock rail.

One real mainnet purchase verified

A small live order confirmed on BaseScan before you opened the doors.

Then verify the receipt it produced and you're live.