Developers · agent SDK
Epp402Agent, method by method.
One class drives the buyer side of the protocol. Every table here is read from the exported types in @epp402/client; nothing is promised that the code does not do.
Construct an agent
A signer, a network, and optionally a policy and a registry.
import { privateKeyToAccount } from "viem/accounts";
import { Epp402Agent } from "@epp402/client";
const agent = new Epp402Agent({
signer: privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`),
network: "elysium-testnet",
registryUrl: "https://epp402-core.fly.dev", // optional: publishes sessions, quotes, intents and receipts
});
const { document } = await agent.discover("https://inference.example.com");
const asset = document.assets[0]!.address;
const session = await agent.openSession({ asset, spendingLimit: "25000000" }); // 25 EPPUSD
const quote = await agent.requestQuote(document, "inference.standard", { text: "Summarise Q3." }, { session });
const paid = await agent.pay(quote, { session, body: { text: "Summarise Q3." } });
console.log(paid.settlement.txHash, paid.receipt?.receiptId, paid.verification?.ok);
await agent.completeSession(session);signerAgentSigner · required
An address and signTypedData: a viem LocalAccount works as is. Signs intents and session attestations, and the x402 authorizations.network"local" | "elysium-testnet" | NetworkDefinition · required
The chain the agent pays on. A provider that does not settle there is refused at discover.policySpendingPolicy · optional
Limits enforced before anything is signed. Without one only x402's own spend controls apply.agentstring · optional
A label recorded on sessions, so the explorer can show which program paid.sessionStoreSessionStore · optional
Where sessions live. Defaults to memory; a Postgres store is in @epp402/session.registryUrlstring · optional
Publishes signed session snapshots on every change, and the quote, intent and verified receipt after each payment.fetchFetchLike · optional
A fetch to use for every request, for tests and proxies.log(event, data) => void · optional
Structured events: discover, quote, session.open, intent.signed, paid, registry.publish_failed, …now() => number · optional
A clock in Unix seconds, for tests.
The lifecycle
Methods in the order a purchase uses them.
discover(providerUrl: string) => Promise<Discovered>
Fetches and validates /.well-known/epp402.json. Discovered has document, discoveryUrl and signatureValid (true, false, or null when unsigned).searchServices(filter?: { category?, q?, limit? }) => Promise<unknown[]>
Queries the registry's /v1/services. Needs registryUrl.openSession({ asset, spendingLimit, ttlSeconds?, metadata?, network? }) => Promise<Session>
A budget in minor units of asset. ttlSeconds defaults to 3600.requestQuote(document, serviceId, params?, { quantity?, session? }?) => Promise<Quote>
POSTs the provider's quotes endpoint with your address and session, verifies the provider's signature, attaches the quote to the session.attachQuote(session, quote) => Promise<Session>
Attach a quote obtained elsewhere.pay(quote, options?: PayOptions) => Promise<PaymentResult>
The payment, described below. Throws PaymentFailed.completeSession(session, acceptPartial = false) => Promise<Session>
Moves the session to completed. With acceptPartial, a partially settled session completes too.intent(intentId) => PaymentIntent | undefined
An intent this agent created, with its current status.verifyReceipt(receipt, options?) => Promise<ReceiptVerification>
The same verification pay runs, for a receipt you obtained elsewhere.
What pay does
One call, nine steps, each one visible in the result.
- 1
Checks the quote
Unexpired, signed by the provider named in the discovery document, priced in the agent's network and asset. - 2
Applies the policy
Per-transaction, per-session and per-day limits, allowed providers and assets, the approval threshold. A refusal throws PaymentFailed with policy_violation or approval_required before anything is signed. - 3
Builds the intent
A Payment Intent for the quote, recorded on the session as authorized. - 4
Sends the request
With EPP-QUOTE, EPP-SESSION and EPP-INTENT (the intent id). The provider answers 402 with PAYMENT-REQUIRED built from the quote. - 5
Signs the authorization
The x402 client refuses requirements that differ from the quote, then signs the EIP-3009 TransferWithAuthorization. - 6
Binds and signs the intent
The intent takes the authorization's nonce and window and is signed by the agent key. - 7
Retries with the payment
PAYMENT-SIGNATURE carries the x402 payload; EPP-INTENT now carries the signed intent, which the provider verifies before fulfilling. - 8
Reads the settlement and receipt
PAYMENT-RESPONSE gives the transaction; EPP-RECEIPT gives the provider's signed receipt, verified against the quote, the payer, the amount, the hash and, with options.verify, the chain. - 9
Records and publishes
The session records the settlement and receipt; with registryUrl the quote, intent and verified receipt are posted to the registry.
sessionSession | string · optional
The session the payment belongs to.bodyunknown · optional
Request body for the paid call; objects are JSON-encoded with content-type application/json.headersRecord<string, string> · optional
Extra request headers.methodstring · optional
Override the service's method.urlstring · optional
Override the service's endpoint URL.approvedboolean · optional
A human approved an amount above the policy's approvalThreshold.verify{ publicClient?, settlementContract? } · optional
Also check the receipt against the chain: the transaction, the transfer, and the relay's commerce reference.
responseResponse
The paid response, body unread.quoteQuote
The quote that was paid.intentPaymentIntent
Signed, bound to the authorization, status settled.settlementSettlement
Built from PAYMENT-RESPONSE: transaction hash, payer, amount, status confirmed.x402SettleResponse
The facilitator's response as decoded from the header.receiptReceipt | undefined
The provider's signed receipt, when it sent one.verificationReceiptVerification | undefined
ok, signatureValid, schemaValid, chain, problems.sessionSession | undefined
The session after recording the settlement and receipt.
When it fails
pay throws PaymentFailed, an EppError with the protocol's code, the HTTP response when there was one, and the facilitator's x402 response when settlement was attempted.
import { PaymentFailed } from "@epp402/client";
try {
await agent.pay(quote, { session, body });
} catch (error) {
if (error instanceof PaymentFailed) {
// error.code: payment_invalid | settlement_failed | policy_violation | approval_required | …
console.error(error.code, error.message, error.x402?.errorReason);
} else throw error;
}quote_expiredbefore signing
The quote's expiresAt has passed.quote_invalid_signaturebefore signing
The quote does not recover to the provider's address.quote_mismatchat the 402
The provider's PAYMENT-REQUIRED differs from the quote it named.policy_violationbefore signing
A limit, provider or asset rule refused the payment.approval_requiredbefore signing
Above approvalThreshold and approved was not passed.session_limit_exceededbefore signing
spent + authorized + amount would exceed the session's limit.payment_invalidat the request
The provider refused the payment (400) or the request failed.settlement_failedat the request
The provider answered 402 again: the facilitator could not settle. x402.errorReason says why.
Spending policy
A buyer-side document the agent checks before it signs. It never leaves the agent.
import type { SpendingPolicy } from "@epp402/protocol";
// Enforced in the agent before anything is signed; never leaves the buyer.
const policy: SpendingPolicy = {
version: "1.0.0",
name: "research-agent",
network: "eip155:99801",
asset: "0x…EPPUSD",
maxPerTransaction: "2000000", // 2 EPPUSD
maxPerSession: "25000000", // 25 EPPUSD
maxPerDay: "100000000", // 100 EPPUSD
authorizationTtlSeconds: 300,
approvalThreshold: "10000000", // above 10 EPPUSD, pay(quote, { approved: true }) is required
};
const agent = new Epp402Agent({ signer, network: "elysium-testnet", policy });version"1.0.0" · required
namestring · optional
A label.networkeip155:<chainId> · required
Quotes on other networks are refused.assetaddress · required
The payment asset the policy covers.maxPerTransactionamount · optional
Ceiling for one quote.maxPerSessionamount · optional
Ceiling for a session's total.maxPerDayamount · optional
Rolling 24-hour ceiling, kept in the agent's ledger.allowedProviders(providerId | address)[] · optional
When set, only these providers may be paid.allowedAssets{ network, address }[] · optional
Additional assets the agent may pay in.authorizationTtlSecondsinteger ≥ 30 · optional
How long a signed authorization stays valid.approvalThresholdamount · optional
Above this, pay needs approved: true.
Amounts are decimal strings in the asset's minor units (EPPUSD has 6 decimals, so 1 EPPUSD is 1000000).
Verifying a receipt
The same function the explorer's verifier and the CLI use.
import { createPublicClient, http } from "viem";
import { verifyReceipt } from "@epp402/receipts";
const publicClient = createPublicClient({ transport: http("https://testnet-rpc.elysium.kinetiq.xyz") });
const result = await verifyReceipt(receipt, {
publicClient,
expectedProvider: document.provider.address,
expectedRecipient: quote.payTo,
settlementContract: "0x…EPP402Settlement", // from contracts/deployments/99801.json
});
// result.ok, result.signatureValid, result.chain?.transferMatches, result.problemsWithout a publicClient only the schema and the signature are checked. With one, the transaction receipt is read, the asset's Transfer event must match the payer, the recipient and the amount, and when a settlement contract is given its Settled event must carry the commerce reference of the receipt's quote, intent and session. Try it on the receipt verifier.