Skip to content
tnsaiDocs
GitHub

x402 Payments

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 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.

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

Version is inherited from the BOM, so pin the BOM, not the module. The module resolves from the TnsAI repository like every other tnsai-* artifact.

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

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. Identities of the paying and the paid agent
LocalIdentityProvider identities = new LocalIdentityProvider();
AgentPrincipal payer = identities.issue(AgentDescriptor.builder()
        .agentClass("com.example.Payer").systemPrompt("payer").model("openai:gpt-4o-mini").build());
AgentPrincipal payee = identities.issue(AgentDescriptor.builder()
        .agentClass("com.example.Payee").systemPrompt("payee").model("openai:gpt-4o-mini").build());

// 4. 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:

WalletUse forKey material
InMemoryWallet.forTesting(address, network)Unit tests only. Refuses to sign with a real curve, deterministic bytesNone (synthetic)
Web3jWallet.fromPrivateKey(hex, network)Local dogfood, smoke tests against testnetRaw private key passed in code
EnvKeystoreWallet.fromEnv(prefix, network)Single-tenant production, single-user deployments, CI testnet runsRaw private key in <PREFIX>_PRIVATE_KEY env var

EnvKeystoreWallet

The recommended starter wallet. With prefix AGENT_X402:

export AGENT_X402_PRIVATE_KEY=0x...
Wallet wallet = EnvKeystoreWallet.fromEnv("AGENT_X402", Network.BASE_SEPOLIA);

Whitespace is trimmed, the prefix lookup is case-insensitive, and 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, and 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.

To use a keystore file, decrypt it in your own bootstrap and pass the raw key to Web3jWallet.fromPrivateKey.

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 in the same broker → AlreadySettled. A retried settle(quote) in the same process does not charge again. The broker remembers settled keys in memory and returns Settlement.AlreadySettled with the original transactionId.
  • Same key after a restart → Rejected. The in-memory record is gone, so the broker sends the payment again. The facilitator refuses the already consumed nonce and the broker returns Settlement.Rejected. There is no second charge, but the original transactionId is not returned.
  • Same service description → same key by default. A regenerated Quote for the same logical operation lands on the same key and nonce, so the cases above apply to it.

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

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 into the broker config to cap how much an agent can spend within a validity window:

import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
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());

// sink must be a durable LiabilitySink implementation -- the broker sums
// prior settlements from it to enforce the cap (see below), and an
// in-memory implementation such as RecordingLiabilitySink would silently
// reset the cap on every process restart, defeating the point of a
// persistent ceiling.
LiabilitySink sink = yourDurableLiabilitySink();

X402Config cfg = X402Config.builder()
        .wallet(wallet)
        .network(Network.BASE_SEPOLIA)
        .facilitator(URI.create("https://facilitator.x402.org"))
        .liabilitySink(sink)
        .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

liabilitySink is optional on X402Config, but for a spend ceiling to mean anything across restarts it must be wired to a durable implementation here, because an in-memory one silently resets the running total, per above. With no LiabilitySink wired at all, the broker falls back to "block on first settle". The alternative, assuming 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 durable sink.

Liability records

When a LiabilitySink is configured, every terminal Settlement produces one AgentLiabilityRecord:

OutcomeLiabilityClass
SettledMEDIUM
RejectedHIGH
ExpiredLOW
AlreadySettled(no new record, because 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:

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:

NetworkFamilyChain idUse for
BASE_MAINNETEVM8453Real USDC. Real money. Configure after testnet validation.
BASE_SEPOLIAEVM84532Faucet USDC. Free testnet. The default for development.
SOLANA_MAINNETSVM0 (not an EVM chain)Not supported for settlement.

Adding a network here is a deliberate choice. It 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:

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:

  • Issue a new scope with a higher ceiling (typically requires elevated permission in your agent). The ceiling does not reset: spend is summed from the scope's issuedAt, and once the scope expires every settlement is rejected.
  • 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 (a replay after a restart; see Idempotency).

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? for the path forward.