---
title: x402 Payments
description: Coinbase-led HTTP 402 + EIP-3009 micropayments over Base. Sub-cent USDC settlement with a signed authorisation header — no API keys, no KYC, no subscription. The first concrete PaymentBroker adapter that ships with TnsAI.
---

# x402 Payments

[x402](https://github.com/coinbase/x402) is a Coinbase-led standard for HTTP-native micropayments. A server signals "this resource costs money" with a real HTTP 402; the client signs a one-time USDC transfer authorisation (EIP-3009), replays the request with the signature in a header, and the server validates and serves the response. Settlement happens on Base (EVM L2) at fractions of a cent per call.

The `tnsai-payments` module implements this end-to-end behind the [`PaymentBroker`](../reference/spi.md) SPI. Your agent code calls `broker.quote(...)` then `broker.settle(...)` — the EIP-712 typed-data hashing, secp256k1 signing, header construction, and idempotency tracking happen below the line.

## Module dependency

`tnsai-payments` is **opt-in** — depend on it only when you actually settle payments. The crypto stack (`org.web3j:crypto` — the slice of web3j without JSON-RPC or contract bindings, ~1.5 MB transitive) lives in this module so the rest of the framework stays light.

```xml
<dependency>
    <groupId>dev.tnsai</groupId>
    <artifactId>tnsai-payments</artifactId>
</dependency>
```

Version is inherited from the [BOM](../start/installation.md) — pin the BOM, not the module.

## Quickstart

The minimum agent flow has three pieces: build a wallet, configure a broker, call `quote` → `settle`. Below is the smallest working snippet against Base Sepolia (testnet):

```java
import com.tnsai.payment.*;
import com.tnsai.payment.x402.*;
import com.tnsai.identity.*;

import java.math.BigDecimal;
import java.net.URI;
import java.util.Map;
import java.util.Optional;

// 1. Wallet — see "Wallet options" below for production guidance
Wallet wallet = EnvKeystoreWallet.fromEnv("AGENT_X402", Network.BASE_SEPOLIA);

// 2. Broker configured with the wallet + network
PaymentBroker broker = X402PaymentBroker.create(
        X402Config.builder()
                .wallet(wallet)
                .network(Network.BASE_SEPOLIA)
                .facilitator(URI.create("https://facilitator.x402.org"))
                .build());

// 3. Describe what the payer is paying for. The resource URL goes in
//    service.metadata under the special key METADATA_RESOURCE_URL — the
//    broker probes it for the 402 response.
Service embed = new Service(
        "embed.text",
        "request",
        BigDecimal.ONE,
        Optional.of(new BigDecimal("0.001")),  // expected price, drift-tolerant
        Map.of(X402PaymentBroker.METADATA_RESOURCE_URL,
               "https://api.example.com/embed"));

Quote quote = broker.quote(payer, payee, embed);
Settlement result = broker.settle(quote);

switch (result) {
    case Settlement.Settled s        -> System.out.println("paid: " + s.transactionId());
    case Settlement.AlreadySettled a -> System.out.println("dedup: " + a.transactionId());
    case Settlement.Rejected r       -> System.err.println("rejected: " + r.reason());
    case Settlement.Expired e        -> System.err.println("quote expired");
}
```

That's the whole agent-side surface. The broker handles the HTTP 402 probe, the EIP-712 signature, and the second request with the `X-PAYMENT` header internally.

## How the dance works

For reference — the broker walks this protocol on every quote/settle pair:

```
   Agent                Broker                Resource Server
     │                     │                         │
     │ quote(payer, payee, service)                  │
     ├────────────────────►│                         │
     │                     │  GET /embed             │
     │                     ├────────────────────────►│
     │                     │  HTTP 402 + { accepts } │
     │                     │◄────────────────────────┤
     │                     │  pick scheme,           │
     │                     │  validate slippage,     │
     │                     │  mint Quote             │
     │   Quote             │                         │
     │◄────────────────────┤                         │
     │                     │                         │
     │ settle(quote)       │                         │
     ├────────────────────►│                         │
     │                     │  build EIP-3009         │
     │                     │  transferWithAuth,      │
     │                     │  sign EIP-712 (wallet)  │
     │                     │  GET /embed             │
     │                     │  X-PAYMENT: <base64>    │
     │                     ├────────────────────────►│
     │                     │  HTTP 200 + body        │
     │                     │  X-PAYMENT-RESPONSE     │
     │                     │◄────────────────────────┤
     │   Settlement.Settled│  emit liability record  │
     │◄────────────────────┤                         │
```

## Wallet options

Three `Wallet` implementations ship today, each with a different security posture:

| Wallet | Use for | Key material |
|---|---|---|
| `InMemoryWallet.forTesting(address, network)` | Unit tests only — refuses to sign with a real curve, deterministic bytes | None — synthetic |
| `Web3jWallet.fromPrivateKey(hex, network)` | Local dogfood, smoke tests against testnet | Raw private key passed in code |
| `EnvKeystoreWallet.fromEnv(prefix, network)` | Single-tenant production, single-user deployments, CI testnet runs | Raw private key in `<PREFIX>_PRIVATE_KEY` env var |

### `EnvKeystoreWallet`

The recommended starter wallet. With prefix `AGENT_X402`:

```bash
export AGENT_X402_PRIVATE_KEY=0x...
```

```java
Wallet wallet = EnvKeystoreWallet.fromEnv("AGENT_X402", Network.BASE_SEPOLIA);
```

Whitespace is trimmed; the prefix lookup is case-insensitive; missing / blank / malformed inputs all throw with operator-actionable messages.

**The env var is a real attack surface** — it's visible to anyone with process-listing access and lands in the env table of any child process. Treat it like a password: inject from a secret manager at process start; never check it into source.

### What about encrypted JSON keystore files?

The standard Ethereum JSON keystore (the file format Geth, MetaMask export, and `WalletUtils.generateNewWalletFile` produce) is scrypt-encrypted and passphrase-protected. Decrypting it needs `org.web3j:core`, which drags in JSON-RPC + contract-binding code that the payments module otherwise doesn't need.

The deferred decision is whether to inline scrypt + AES (keep the slim profile) or pull the larger artifact when there's demonstrated demand. If you need keystore-file support today, you can either:

- Decrypt the keystore in your own bootstrap and pass the raw key to `Web3jWallet.fromPrivateKey`, or
- Open an issue describing your use case — we'll prioritise the call based on actual demand.

### Production: zero in-JVM key material

For deployments where keeping the private key out of JVM memory is a hard requirement (regulated environments, multi-tenant fleets), `DelegatedWallet` will proxy signing to an external service — KMS, HSM, a signing daemon, or a hardware wallet. The `Wallet` SPI surface stays identical; only the construction path changes. Tracked as a follow-up issue.

## Idempotency

Every `Quote` carries an `idempotencyKey` derived deterministically from the service description + resource URL. The broker maps that key to the EIP-3009 `nonce` via Keccak-256:

```
nonce = keccak256(idempotencyKey)
```

Two consequences:

- **Same key → same nonce → facilitator dedups.** A retried `settle(quote)` against a flaky network won't double-charge; the facilitator returns the prior transaction, and the broker surfaces it as `Settlement.AlreadySettled` with the original `transactionId`.
- **Same service description → same key by default.** If your agent regenerates a `Quote` for the exact same logical operation (e.g. retrying after a process crash), it will land on the same idempotency key and settle exactly once.

If you need to force a different key — for example, two genuinely distinct invocations that happen to have identical service descriptions — supply your own:

```java
Service embed = new Service(
        "embed.text", "request", BigDecimal.ONE, Optional.empty(),
        Map.of(
            X402PaymentBroker.METADATA_RESOURCE_URL, resourceUrl,
            "idempotencyKey", "explicit-key-" + UUID.randomUUID()));
```

## Mandate enforcement

Wire an [`AuthorityScope`](../security/accountability.md) into the broker config to cap how much an agent can spend within a validity window:

```java
import com.tnsai.accountability.AuthorityScope;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;

AuthorityScope dailyCap = new AuthorityScope(
        Set.of(),                          // allowedActions: empty = any type
        Set.of(),                          // targetSystems:  empty = any target
        Optional.of(new BigDecimal("0.50")),  // $0.50 daily cap
        Duration.ofDays(1),
        java.time.Instant.now());

X402Config cfg = X402Config.builder()
        .wallet(wallet)
        .network(Network.BASE_SEPOLIA)
        .facilitator(URI.create("https://facilitator.x402.org"))
        .liabilitySink(new FilesystemLiabilitySink(Path.of("/var/log/x402-audit.jsonl")))
        .authorityScope(dailyCap)
        .build();
```

Before each `settle`, the broker:

1. Reads `authorityScope.spendCeilingUSD`.
2. Sums prior successful `x402.settle` records from the configured `LiabilitySink` (filtered by payer + scope window).
3. Rejects with `Settlement.Rejected("mandate ceiling exceeded: ...")` if the next settlement would push cumulative spend past the cap.

### Conservative default

A configured ceiling **with no `LiabilitySink` wired** falls back to "block on first settle". The alternative — assume zero prior spend — would silently bypass the ceiling after every process restart, which is the wrong default for a financial control. If you want a ceiling, wire a sink.

## Liability records

When a `LiabilitySink` is configured, every terminal `Settlement` produces one [`AgentLiabilityRecord`](../security/accountability.md):

| Outcome | `LiabilityClass` |
|---|---|
| `Settled` | `MEDIUM` |
| `Rejected` | `HIGH` |
| `Expired` | `LOW` |
| `AlreadySettled` | _(no new record — the original settlement already produced one)_ |

The record's `action.parameters` carries `quoteId`, `priceUSD`, `idempotencyKey`, `network`, `payeeId`, `serviceName`, `outcomeType` — so audit queries don't have to join through the broker. `correlationId` is the `quoteId`, for end-to-end traceability with OpenTelemetry spans and `LLMCallLog` entries.

A sink that throws is logged at WARN and swallowed: a sink outage must not mask the settlement outcome itself.

## Slippage protection

If you pass `Service.expectedPriceUSD`, the broker enforces a slippage budget (default 50 basis points, configurable via `X402Config.maxSlippageBps`). When the server's quoted price drifts past tolerance, `quote()` throws `QuoteRejectedException` instead of accepting a runaway charge. Tighten the tolerance for budget-sensitive agents:

```java
X402Config.builder()
        // ...
        .maxSlippageBps(new BigDecimal("10"))  // 10 bps (0.1%)
        .build();
```

## Networks

The Network enum maps each shipped settlement chain to its canonical x402 slug + EVM chain id:

| Network | Family | Chain id | Use for |
|---|---|---|---|
| `BASE_MAINNET` | EVM | 8453 | Real USDC. Real money. Configure after testnet validation. |
| `BASE_SEPOLIA` | EVM | 84532 | Faucet USDC. Free testnet. The default for development. |
| `SOLANA_MAINNET` | SVM | — | Stub today. Ed25519 signing scheme; not yet wired. |

Adding a network here is a deliberate choice — implies a tested chain RPC, a working facilitator, and a settlement-asset address (USDC etc.) the adapter trusts.

## Troubleshooting

### `QuoteRejectedException: service.metadata['x402.resource'] is required for x402 quoting`

You called `broker.quote(...)` with a `Service` that didn't have `x402.resource` in its metadata. The broker needs the resource URL to probe for the 402 response. Set it explicitly:

```java
Service s = new Service(name, unit, qty, expected,
        Map.of(X402PaymentBroker.METADATA_RESOURCE_URL, "https://api.example.com/..."));
```

### `QuoteRejectedException: expected HTTP 402 from ..., got 200`

The server returned a successful response without asking for payment — probably because the resource is free for the agent identity that called it (allowlisted, signed-in, anonymous tier, etc.). x402 only kicks in when the server returns a real 402. If you're hitting an x402-enabled endpoint and seeing 200, you may have already paid (check for an existing session cookie / API key in the request) or the endpoint is not actually paid.

### `QuoteRejectedException: price ... outside slippage tolerance`

The server's quoted price drifted past `maxSlippageBps` away from your `Service.expectedPriceUSD`. Either widen the tolerance, update your expected price, or treat this as a signal that the server pricing changed and refuse to pay.

### `Settlement.Rejected: mandate ceiling exceeded`

The configured `AuthorityScope.spendCeilingUSD` would be exceeded by this settlement. Either:

- Wait — the ceiling resets after `AuthorityScope.validFor` elapses.
- Issue a new scope with a higher ceiling (typically requires elevated permission in your agent).
- Investigate cumulative spend in the liability sink — `AuditQuery.builder().agentId(payerId).build()` returns every recorded settlement.

### `Settlement.Rejected: facilitator rejected payment: ...`

The facilitator validated the signed authorisation and refused it. Common causes: payer wallet has insufficient USDC balance, the EIP-3009 `validBefore` window already closed, or the nonce was already consumed by an earlier settlement (idempotency replay should show as `AlreadySettled`, not `Rejected` — if you see `Rejected` here, the broker's idempotency cache may have been cleared).

### Build error: `cannot find symbol: class WalletUtils`

You added `tnsai-payments` to your dependencies and tried to use `WalletUtils.loadCredentials`. That class lives in `org.web3j:core`, which `tnsai-payments` deliberately does not pull in. See [Wallet options → What about encrypted JSON keystore files?](#what-about-encrypted-json-keystore-files) for the path forward.

## Related

- [Payments index](index.md) — broader payments overview.
- [Security: Accountability](../security/accountability.md) — `AgentLiabilityRecord`, `LiabilitySink`, `AuthorityScope`.
- [Security: Cost Governance](../security/cost-governance.md) — broader LLM/agent spend budgeting.
- [Reference: SPI](../reference/spi.md) — `PaymentBroker` interface.
- TNS-449 — the implementation issue.
- [x402 protocol spec](https://github.com/coinbase/x402) — the underlying standard.
