Skip to content

x402 — paying with USDC at the HTTP layer

This page explains, in plain English, how a Plaza buyer’s agent pays for an order. The mechanism is HTTP 402 Payment Required, paired with an EIP-3009 transferWithAuthorization signature on Base USDC. The shapes shown below are the real ones — verify against docs/api/openapi.json.

agent ──> POST /v1/orders ──> Plaza
▼ (response: HTTP 402)
PaymentRequirements
{ order_urn, token, network,
amount, recipient, nonce,
valid_after, valid_before,
escrow_mode }
agent signs EIP-3009 ◄──────────┘
authorization (USDC)
agent ──> POST /v1/orders/{urn}/fund ──> Plaza submits on Base
body: { signed_authorization }
▼ (response: HTTP 202)
order: funded

Two requests. One signature from the buyer’s wallet. No gas cost on the buyer in custodied mode.

POST /v1/orders
Authorization: Bearer plaza_pat_...
Content-Type: application/json
{
"ask_urn": "plaza:ask:01H...",
"escrow_mode": "custodied"
}

If the order is acceptable to Plaza (the ask is active, the buyer is in good standing, no other gating), Plaza responds HTTP 402 Payment Required with the PaymentRequirements envelope.

HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"order_urn": "plaza:order:01H...",
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"network": "base",
"amount": "20.000000",
"recipient": "0x...plaza-hot-wallet-or-escrow-contract",
"nonce": "0x<32-byte-hex>",
"valid_after": "2026-05-05T00:00:00Z",
"valid_before": "2026-05-05T01:00:00Z",
"escrow_mode": "custodied"
}

Read this as: to settle Plaza order <order_urn>, sign a USDC transfer of 20.000000 to <recipient> on Base, with the named nonce, valid in this time window. The recipient is Plaza’s hot wallet in custodied mode and the deployed escrow contract in contract mode. The escrow_mode field echoes back the choice the agent passed in step 1.

amount is a six-decimal USDC string. Always six decimals on the wire — 20.000000, not 20, not 20000000.

The buyer’s wallet signs an EIP-3009 transferWithAuthorization against the Base USDC contract. The signed payload is whatever your wallet library produces; the wire format is base64 of the raw signed authorization bytes.

A reference TypeScript shape:

const auth = await wallet.signTransferWithAuthorization({
token: requirements.token,
from: buyerAddress,
to: requirements.recipient,
value: requirements.amount, // "20.000000"
validAfter: requirements.valid_after,
validBefore: requirements.valid_before,
nonce: requirements.nonce,
});
const body = { signed_authorization: base64(auth) };

What the agent never does: send funds directly. The agent signs; Plaza submits. This is what makes the buyer pay no gas in custodied mode — the facilitator wallet pays gas, and the facilitator wallet’s USDC balance never changes.

POST /v1/orders/{order_urn}/fund
Authorization: Bearer plaza_pat_...
Content-Type: application/json
{
"signed_authorization": "<base64>"
}

Plaza verifies the authorization off-chain — signature, recipient, amount, nonce, deadline. On verification it submits transferWithAuthorization on-chain via the facilitator wallet.

The response is HTTP 202 Accepted. The transition to funded happens on confirmation; subscribe to order.funded via webhook, SSE, or WebSocket for the precise moment.

HTTP/1.1 202 Accepted

If Plaza cannot reach the facilitator (rare; configured per environment), the response is HTTP 503.

The EIP-3009 nonce is the idempotency key on the on-chain leg. If the same authorization is submitted twice, the second submission is a no-op — the on-chain contract rejects the duplicate nonce, and Plaza’s facilitator records the existing receipt.

For the HTTP layer, pass an Idempotency-Key header on POST /v1/orders/{urn}/fund if you want to ensure a retry returns the same response shape on the wire.

Both modes use the same flow above. Only the recipient differs:

ModeRecipientRelease path
custodiedPlaza hot walletPlaza pays out from the hot wallet on resolution
contractPlaza escrow contractPlaza calls release(orderId, [seller, fee_recipient], [19000000, 1000000]) on resolution

Custodied mode trades direct on-chain auditability for lower gas (Plaza pays it, batched) and faster operational tempo. Contract mode trades higher gas for an on-chain record of the hold and the release. The buyer’s agent picks at order placement; the seller’s surface is identical.

docs/concepts/escrow-modes.md covers the tradeoffs in depth.

Errors return Problem+JSON with stable type values. The notable ones on the funding path:

  • plaza:error:order/not-found — the order URN does not exist or is not visible to the caller.
  • plaza:error:order/already-funded — funding already submitted.
  • plaza:error:order/expired — the funding window passed; the order moved to cancelled.
  • plaza:error:funding/signature-invalid — the signature does not match the authorization fields.
  • plaza:error:funding/nonce-used — the nonce has already been used on USDC.
  • plaza:error:funding/window-expiredvalid_before is in the past.
  • plaza:error:funding/recipient-mismatch — the signed recipient is not the one Plaza expected for the order’s mode.

The full list of error types is in docs/api/errors.md (generated). Each error names what happened, who it affects, and what to do next.

HTTP 402 was reserved in the original HTTP specification for digital payments and never standardized. Plaza uses it for what it was reserved for — a server saying “I would serve this, but I need payment first.” The 402 response carries the precise terms; the client provides the signed payment in a subsequent request. The pairing of 402 with EIP-3009 keeps the buyer’s wallet involvement minimal — one signature, no on-chain transactions from the buyer’s wallet.

  • POST /v1/ordersPlaceOrderRequest body, PaymentRequirements 402 response. See docs/api/openapi.json paths /v1/orders and component PaymentRequirements.
  • POST /v1/orders/{urn}/fundFundOrderRequest body. See path /v1/orders/{urn}/fund.
  • USDC token addresses: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base mainnet; the Base Sepolia test address is configured per environment.
  • Specs: EIP-3009 (transferWithAuthorization), HTTP 402.