---
title: x402 and the Payments SDK
description: Accept Curvy payments from AI agents over HTTP 402 with @0xcurvy/payments-sdk. Covers createX402Merchant, the exact and curvy-transfer schemes, the portal broadcaster and paying from an agent.
---

# x402 and the Payments SDK

[x402](https://www.x402.org/) 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`:

| Scheme | Constant | How the agent pays | Needs |
| --- | --- | --- | --- |
| `exact` | `EXACT_SCHEME` | Signs an EIP-3009 authorization; a facilitator submits the transfer and pays gas | An x402 v2 facilitator; Curvy's is the default |
| `curvy-transfer` | `TRANSFER_SCHEME` | Sends a plain ERC-20 transfer of `amount` to `payTo` from its own wallet, paying gas, and presents the transaction hash | Nothing besides the chain |

Both schemes pay the [portal rail](./fees) 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

| Role | Responsibility | Software |
| --- | --- | --- |
| Paying agent (client) | Answers the 402: an EIP-3009 authorization for `exact`, or an ERC-20 transfer for `curvy-transfer` | Any 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 chain | `createX402Merchant` 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 checkout | Operated by Curvy |
| x402 facilitator (Curvy by default) | Verifies and settles `exact` payments: submits the payer's EIP-3009 authorization and pays gas | Curvy'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](./human-checkout) only.

## Install

::: code-group

```bash [pnpm]
pnpm add @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
```

```bash [npm]
npm install @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
```

```bash [yarn]
yarn add @0xcurvy/payments-sdk@0.2.0-rc.3 @0xcurvy/rs-core-wasm@0.1.0-rc.4 viem
```

:::

| Import | Used for |
| --- | --- |
| `@0xcurvy/payments-sdk/x402/merchant` | `createX402Merchant`: the whole merchant side (Node only, needs `@0xcurvy/rs-core-wasm`) |
| `@0xcurvy/payments-sdk/x402` | Scheme constants, wire types, `createBroadcasterClient`, `createFacilitatorClient`, `createX402Payer`, EIP-712 types, parsers, header codec |
| `@0xcurvy/payments-sdk/economics` | Fee reads and quotes |
| `@0xcurvy/payments-sdk/merchant` | `createPaymentRequest` and `verifyPayment` (only if you build the flow yourself) |
| `@0xcurvy/payments-sdk/chain` | `predictPortalAddress` (internal and unstable) and `findNoteInReceipt` (a discovery hint, not proof), only if you build the flow yourself |

## Network values

The merchant needs:

| Value | Used for | Default |
| --- | --- | --- |
| RPC URL (or a viem `PublicClient`) | Chain reads, portal balance checks and payment confirmation | none, yours |
| Tokens | The 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 chain | every token Curvy takes there: USDC and USDT on Arbitrum One, USDC on Ethereum Sepolia |
| Public receiving keys | `receivingKeys`: 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 them | none, yours |
| Portal broadcaster URL | `createX402Merchant` 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 there | `https://api.curvy.box` |
| Facilitator URL | Verifies and settles `exact`. Any x402 v2 facilitator that serves your chain works; `false` offers `curvy-transfer` only | `https://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](./getting-started#production-values).

::: tip 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`.

::: danger 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:

::: code-group

```ts [Hono]
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 [Express]
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](./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.

::: tip 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.status` | Meaning |
| --- | --- |
| `pending` | 402 issued, waiting for the payer |
| `settling` | `exact` verified and settlement requested; stays here if the facilitator's reply was lost (retry the same header, or let `shield()` check the portal) |
| `settled` | Funds are at `payTo`. `charge()` returned `paid` |
| `shielded` | The broadcaster deployed the portal; `shieldTxHash` is its hint |
| `confirmed` | `verifyPayment` returned `paid`: **your** note, in the right token and amount after fees, with `confirmations` blocks; `noteId` and `netAmount` are authoritative |
| `failed` | The facilitator definitively refused an `exact` settlement; no funds moved |
| `expired` | Nobody 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.

::: warning 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](./fees).

### 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](https://github.com/lazartravica/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.

## Related

- [Fees and minimum amounts](./fees)
- [Confirming payments](./confirming-payments): what `verifyPayment` checks
- [API surface](./api): all Payments SDK entry points
