Skip to content

x402 and the Payments SDK ​

x402 lets an API charge per request over plain HTTP. The server answers 402 Payment Required with payment requirements, the client pays and retries, and the server serves the resource. With Curvy, every payment lands in a shielded note that you own. The paying agent's address is public, but your revenue is private.

Curvy supports two x402 schemes on the same one-time payTo:

SchemeConstantHow the agent paysNeeds
exactEXACT_SCHEMESigns an EIP-3009 authorization; a facilitator submits the transfer and pays gasAn x402 v2 facilitator; Curvy's is the default
curvy-transferTRANSFER_SCHEMESends a plain ERC-20 transfer of amount to payTo from its own wallet, paying gas, and presents the transaction hashNothing besides the chain

Both schemes pay the portal rail fees. exact is what standard x402 clients speak; curvy-transfer needs no facilitator at all.

Everything you need is in @0xcurvy/payments-sdk. There is no separate x402 package, and the merchant side needs no @x402/* packages.

Roles ​

RoleResponsibilitySoftware
Paying agent (client)Answers the 402: an EIP-3009 authorization for exact, or an ERC-20 transfer for curvy-transferAny standard x402 client (@x402/fetch) for exact, or createX402Payer from @0xcurvy/payments-sdk/x402 for both
Resource server (you)Issues challenges, derives your notes, checks that payTo holds the amount, registers the portal with the broadcaster, confirms payments on chaincreateX402Merchant from @0xcurvy/payments-sdk/x402/merchant
Portal broadcaster (Curvy)Screens every funded portal, deploys it and shields it into your note. The same service that shields human checkoutOperated by Curvy
x402 facilitator (Curvy by default)Verifies and settles exact payments: submits the payer's EIP-3009 authorization and pays gasCurvy's facilitator at https://api.curvy.box/portal/x402, served by the portal broadcaster; any other x402 v2 facilitator can take its place

Neither the facilitator nor the broadcaster holds your receiving keys, and neither can redirect a payment. The SDK derives every note on your server, and a payment counts as confirmed only when a note on chain pays your request: the stored owner, the token and the amount after fees, not merely your payment reference.

You need your public receiving keys (S, V, BabyJubjub) on the server, as the one CURVY_PAYMENTS_PUBLIC_KEY value from the web app's Payments setup (the same value as human checkout). You do not need a request signing key or /.well-known/curvy-payments.json, because those are for human checkout only.

Install ​

bash
pnpm add @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
bash
npm install @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
bash
yarn add @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
ImportUsed for
@0xcurvy/payments-sdk/x402/merchantcreateX402Merchant: the whole merchant side (Node only, needs @0xcurvy/rs-core-wasm)
@0xcurvy/payments-sdk/x402Scheme constants, wire types, createBroadcasterClient, createFacilitatorClient, createX402Payer, EIP-712 types, parsers, header codec
@0xcurvy/payments-sdk/economicsFee reads and quotes
@0xcurvy/payments-sdk/merchantcreatePaymentRequest and verifyPayment (only if you build the flow yourself)
@0xcurvy/payments-sdk/chainpredictPortalAddress (internal and unstable) and findNoteInReceipt (a discovery hint, not proof), only if you build the flow yourself

Network values ​

The merchant needs:

ValueUsed forDefault
RPC URL (or a viem PublicClient)Chain reads, portal balance checks and payment confirmationnone, yours
TokensThe tokens you charge in, preferred first: symbols Curvy takes on the chain ("USDC", "USDT") or token addresses, all with the same decimals. The 402 offers each. Curvy's tokens have their EIP-712 domains built in; any other token's is read on chainevery token Curvy takes there: USDC and USDT on Arbitrum One, USDC on Ethereum Sepolia
Public receiving keysreceivingKeys: the one CURVY_PAYMENTS_PUBLIC_KEY value packing S, V and BabyJubjub (or recipient, the same keys as three strings); the SDK derives a fresh note per challenge from themnone, yours
Portal broadcaster URLcreateX402Merchant registers funded portals with it for shielding and reads its minimum per payment from GET /portal/networks/:chainId; on a chain the SDK does not know, it also reads the Curvy contract addresses therehttps://api.curvy.box
Facilitator URLVerifies and settles exact. Any x402 v2 facilitator that serves your chain works; false offers curvy-transfer onlyhttps://api.curvy.box/portal/x402 (the broadcaster in use, plus /portal/x402)

Curvy's production stack runs on Arbitrum One (eip155:42161), and on Ethereum Sepolia (eip155:11155111) for test money. The SDK has the contract addresses for both built in; they are listed, with the tokens and the minimum per payment, in Networks.

Contract addresses

On Arbitrum One and Ethereum Sepolia, createX402Merchant uses the aggregator, portalFactory and vault addresses built into the SDK, not the broadcaster's. On any other chain it discovers them from the broadcaster; in production, pass them in addresses so confirmation does not depend on what a service advertises. Addresses you pass in addresses always come first.

How a payment flows ​

Each request gets a fresh one-time entry portal address as payTo. The agent pays that address, either through a facilitator (exact) or directly (curvy-transfer). Once the portal holds the amount, you serve the resource. The portal broadcaster then deploys the portal, which shields the funds into your note, and the SDK confirms on chain that the note pays your request.

agent ──GET /api/thing──────────────▶ you: charge() → 402 + PAYMENT-REQUIRED (fresh note, payTo, one row per scheme)
agent pays payTo:
  exact ──────── signs EIP-3009 ─────▶ you: charge() ──/verify, /settle──▶ facilitator (transfer to payTo)
  curvy-transfer ─ ERC-20 transfer ──▶ chain; retries with { txHash }
                                       you: charge() checks balanceOf(payTo) ≥ amount → serve (+ PAYMENT-RESPONSE)
                                       SDK, in the background:
                                         ──POST /portal/payments──▶ broadcaster (screen, deploy portal, shield)
                                         ──GET /portal/status────▶ broadcaster, until "shielded"
                                         verifyPayment(the 402's request) → "confirmed"

1. Create the merchant once ​

ts
import { createX402Merchant } from "@0xcurvy/payments-sdk/x402/merchant";

const x402 = await createX402Merchant({
  rpcUrl: process.env.RPC_URL!,                      // Arbitrum One (or Ethereum Sepolia for test money); or publicClient
  // tokens: ["USDC"],                              // default: USDC and USDT on Arbitrum One
  // otherNetworks: [8453],                         // also take USDC on Base; Curvy bridges it to Arbitrum One
  receivingKeys: process.env.CURVY_PAYMENTS_PUBLIC_KEY!, // from the web app's Payments setup
  confirmations: 12,
  onEvent: ({ type, payment }) => console.log(type, payment.payTo, payment.status),
  // Defaults you can override:
  // addresses: { aggregator, portalFactory, vault }, // built in on Curvy's networks; pass them on any other chain
  // broadcaster: "https://api.curvy.box",            // Curvy's portal broadcaster (URL or BroadcasterClient)
  // facilitator: "https://api.curvy.box/portal/x402", // Curvy's facilitator; any x402 v2 URL, or false for curvy-transfer only
});

At startup this reads the chain id from the RPC and turns tokens into addresses. It takes the Curvy contract addresses from addresses, then from the SDK's built-in networks, and only on any other chain from the broadcaster's GET /portal/networks/:chainId. It also asks the broadcaster for its minimum per payment (unless enforceBroadcasterMinimum is false), reads each token's vault id (and EIP-712 domain, unless built in), and, unless facilitator is false, fails fast unless the facilitator's /supported offers x402 v2 exact on that chain, and on each of otherNetworks.

facilitator defaults to the one served by the broadcaster in use (<broadcaster>/portal/x402), so a custom broadcaster URL brings its own facilitator along. schemes defaults to ["exact", "curvy-transfer"], or ["curvy-transfer"] when facilitator is false. Set it to restrict the offer; exact throws without a facilitator.

Other options: recovery (see the warning below), tokenDomains ({ [address]: { name, version } } for a token the SDK doesn't know that has no version()), challengeTtlSeconds (default 300), shieldDeadlineSeconds (default 86 400), settleTimeoutMs (default 60 000) and transferConfirmations (default 1) for curvy-transfer, merchantOrigin (your public origin; without it the SDK uses the request URL's origin, or the Host header when the framework passes a path, so set it behind a proxy), store (see below), autoShield (default true), confirmPollMs and confirmTimeoutMs.

No recovery address by default

Every payTo is derived with recovery = NO_RECOVERY_ADDRESS (0x…dEaD) unless you set your own. The portal contract needs a non-zero recovery and only lets that address reclaim funds, so with the default, funds that reach a portal the broadcaster never shields are lost permanently: a payer who fails compliance screening, an underpayment, the wrong token, a payment after the broadcaster's shieldDeadlineSeconds (default one day), a second payment to a used portal, or a transfer to a challenge the merchant has already forgotten (the in-memory store drops expired challenges once it is full). Nobody, not Curvy, not you, not the payer, can move them. Say so in your terms. To be able to reclaim such funds yourself, set recovery to an address whose key you hold and call Portal.recover() from it.

Taking payments on other networks ​

Most x402 agents pay in USDC on Base. A merchant on Arbitrum One can take those payments too, with otherNetworks:

ts
const x402 = await createX402Merchant({
  rpcUrl: process.env.RPC_URL!, // Arbitrum One
  receivingKeys: process.env.CURVY_PAYMENTS_PUBLIC_KEY!,
  otherNetworks: [8453, 10],    // Base and Optimism
  confirmations: 12,
});
  • What's offered: exact in USDC on each listed network, to the same payTo. Curvy bridges the payment to USDC on Arbitrum One, never swaps it, and shields it into your note as usual. Only USDC has signed transfers (EIP-3009) on those networks: Ethereum (1), Base (8453), Optimism (10), Polygon (137) and Linea (59144). The list is X402_BRIDGED_TOKENS from @0xcurvy/payments-sdk/x402, and other chain ids throw.
  • You absorb the bridge cost, at most 3% of the price. A network is offered for a price only while the broadcaster quotes its bridge under that, so small prices may get Arbitrum One only. Quotes are cached for a minute per network and price.
  • The facilitator's settlement is the go-ahead there. The merchant reads no chain but its own, so on another network it serves the resource once the facilitator reports the transfer settled, as any x402 merchant does. The shield on Arbitrum One is still verified on chain before the payment counts as confirmed, and verifyPayment accepts it up to 3% short for what bridging cost.
  • The payment record says where it was paid: payment.paidOn (for example eip155:8453) and payment.token, the token on Arbitrum One it counts in.
  • The facilitator needs gas on those networks. Curvy's settles on every network the broadcaster serves.

2. Charge each request ​

charge() is framework-agnostic. Pass anything with method, url and headers (a Fetch Request, or Express's req), and the price of this call in token base units:

ts
import { toResponse } from "@0xcurvy/payments-sdk/x402/merchant";

app.get("/api/thing", async (c) => {
  const result = await x402.charge(c.req.raw, { price: 10_000n, description: "One thing" });
  if (result.status === "payment-required") return toResponse(result.response);
  return c.json({ thing: "…" }, 200, result.headers);
});
ts
app.get("/api/thing", async (req, res) => {
  const result = await x402.charge(
    { method: req.method, url: req.originalUrl, headers: req.headers },
    { price: 10_000n, description: "One thing" },
  );
  if (result.status === "payment-required") {
    res.status(402).set(result.response.headers).json(result.response.body);
    return;
  }
  res.set(result.headers).json({ thing: "…" });
});

A request without a valid PAYMENT-SIGNATURE returns payment-required: send result.response (status, headers and JSON body) to the client. Its accepts carries one row per offered scheme, all with the same fresh payTo. If the client presented a payment that was rejected, result.error says why, and the 402 offers a new or re-offered challenge so a standard client simply retries.

A request with a valid payment returns paid only once the one-time portal actually holds the amount on chain. For exact, the facilitator must have verified and settled the transfer and balanceOf(payTo) must cover the price; a settle reply alone is not enough. For curvy-transfer, charge() waits up to settleTimeoutMs for the presented transaction and transferConfirmations blocks, then checks the portal balance. Serve the resource and add result.headers (PAYMENT-RESPONSE) to your response. result.payment is the stored record (payTo, amount, payer, settleTxHash).

charge() enforces, without contacting anyone: the echoed requirements must equal one of the rows you issued, the challenge must be unused and unexpired, and your price must match the challenge. Each challenge is settled exactly once, even under concurrent retries with the same header, and a price below the on-chain fee floor throws (see Fees).

If the facilitator cannot be reached during verification, or its settlement reply is lost, charge() still answers with a 402 that re-offers the same challenge: the payer retries the identical header, and x402 facilitators deduplicate settlements by payer and nonce. A payment whose settle reply was lost stays settling; shield() then checks the portal's balance before registering it. Only a definitive refusal by the facilitator marks a payment failed. The payer sees short reasons; the full facilitator error lands in payment.error and the error event.

Because an ERC-20 transfer cannot be taken back, a curvy-transfer that lands after the challenge's challengeTtlSeconds is still honoured: the balance is the gate. A transfer that has not been mined yet gets a 402 that re-offers the same portal, and createX402Payer keeps retrying the same header instead of paying twice.

Dynamic prices

Compute price from the request (path, query, plan) the same way on the first call and on the paid retry. If it differs, the retry gets a new 402 with "the price of this resource changed".

3. Shield and confirm ​

After every settlement the SDK, by default, registers the funded portal with the portal broadcaster (POST /portal/payments), follows GET /portal/status until the portal is shielded, then polls verifyPayment from @0xcurvy/payments-sdk/merchant, the same check as human checkout, with the request behind the 402 until the note is confirmed. The note must pay that request's owner, token and amount after fees; carrying your payment reference is not enough. Follow progress through onEvent or x402.getPayment(payTo):

payment.statusMeaning
pending402 issued, waiting for the payer
settlingexact verified and settlement requested; stays here if the facilitator's reply was lost (retry the same header, or let shield() check the portal)
settledFunds are at payTo. charge() returned paid
shieldedThe broadcaster deployed the portal; shieldTxHash is its hint
confirmedverifyPayment returned paid: your note, in the right token and amount after fees, with confirmations blocks; noteId and netAmount are authoritative
failedThe facilitator definitively refused an exact settlement; no funds moved
expiredNobody paid in time (a late curvy-transfer still revives it)

payment.portalState is the broadcaster's last reported state (compliance_checking, shielding, shielded, or a terminal compliance_failed / expired / failed). The background loop polls it every confirmPollMs for up to shieldDeadlineSeconds, then confirms for confirmTimeoutMs. A terminal state is reported as an error event and payment.error (ShieldRefusedError), and the loop stops: those funds will not be shielded (see the warning above).

If a registration or confirmation attempt fails for another reason, the record keeps its status and payment.error says why. Registering is idempotent, so the loop simply keeps polling; a missing shield hash is fine, because confirmation without it scans the aggregator from the block of the transfer into the portal for a note that pays the request. A note that is yours but too small or in another token is reported in payment.error (payment underpaid or payment wrong token) and as an error event, and the payment is not confirmed. Transient RPC errors are retried; only a reverted or foreign transaction stops the loop. Call x402.shield(payTo) or x402.confirm(payTo) to retry by hand, or set autoShield: false to drive these yourself, for example from a queue.

Store

The default store is an in-memory Map: pending challenges and unshielded settlements are lost on restart, and beyond maxEntries (10 000) it drops unpaid challenges, never payments with funds in flight. Pass store: { get, put, list, claim? } backed by your database. Within one process the merchant serialises work per payTo; if several instances share the store, implement claim(payTo, from, to) as an atomic compare-and-set so that only one of them settles a challenge.

Fees and pricing ​

x402.quote(price, token?) returns the fee breakdown for a price on the portal rail (in your first token unless you name another), and x402.minimumPrice() the smallest gross amount you may charge in every one of your tokens: the highest on-chain fee floor among them, or the broadcaster's USD minimum per portal (USD 0.50) converted to token units, whichever is higher. The broadcaster reports that minimum in GET /portal/networks/:chainId (x402.minimumPortalUsd), and charge() throws below it unless enforceBroadcasterMinimum: false, because a refused portal's funds are lost with the default recovery. See Fees and minimum amounts.

Paying from an agent ​

Any x402 v2 client that supports exact with EIP-3009 pays the exact row, for example @x402/fetch with @x402/evm. The SDK also ships a small payer for agents and tests that speaks both schemes:

ts
import { createX402Payer } from "@0xcurvy/payments-sdk/x402";
import { erc20Abi } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const payer = createX402Payer({
  signer: privateKeyToAccount(AGENT_KEY), // pays exact (optional)
  send: ({ token, to, amount }) =>        // pays curvy-transfer (optional)
    walletClient.writeContract({ address: token, abi: erc20Abi, functionName: "transfer", args: [to, amount] }),
  maxAmount: 50_000n,
});
const response = await payer.fetch("https://api.example.com/api/thing"); // pays one 402, returns the paid response

createX402Payer needs signer or send (or both) and maxAmount (required, since a 402 names its own price; optionally also network / asset). With a signer it signs one TransferWithAuthorization for the first exact option on an EVM network within maxAmount; with send it pays a curvy-transfer option by sending the tokens itself, paying gas, and presents { txHash }. When both are offered and both are configured, exact is preferred. It retries with PAYMENT-SIGNATURE, and while the merchant re-offers the same portal (a transfer not mined yet) it keeps retrying the same header for retryForMs (default 90 s) every retryEveryMs (default 2 s) rather than paying again. Every non-402 response passes through. The request is replayed, so a streaming body cannot be paid for. An exact authorization is valid for maxTimeoutSeconds from the payer's clock (now, default Date.now()); the token checks it against block.timestamp, so if your process clock is skewed from the chain, pass the latest block's timestamp as now. Lower-level pieces are exported too: selectExactRequirements, createExactPayment, selectTransferRequirements, createTransferPayment, encodePaymentSignature, paymentRequiredFrom(response) and paymentResponseFrom(response).

Building the flow yourself ​

createX402Merchant composes public SDK functions. If you need a different shape, the pieces are: getCurvyNetwork(chainId) (/chain) for Curvy's contract addresses, createPaymentRequest (/merchant) to derive a note, predictPortalAddress (/chain) with the chosen recovery to get payTo, createFacilitatorClient (/x402) for /supported, /verify and /settle, createBroadcasterClient (/x402) for /portal/networks/:chainId, /portal/payments and /portal/status, the parsers and requirementsEqual for the PAYMENT-SIGNATURE payload, and verifyPayment (/merchant) to confirm. predictPortalAddress is internal and unstable (its inputs change when Curvy migrates portal factories), and findNoteInReceipt (/chain) matches only the payment reference, so it is a discovery hint, not proof. The broadcaster recomputes getEntryPortalAddress(ownerHash, recovery) from the registration and only shields that address once it holds expectedAmount; never reuse a payTo.

Example ​

On This Day is a small open-source resource server built on createX402Merchant: one paid lookup per request, exact and curvy-transfer on the same payTo, and the lifecycle events on a status page. It is deployed at https://on-this-day-x402.fly.dev. Clone it as a starting point, or pay it with any x402 client to see the 402 flow end to end.