Agents
How an agent with its own wallet pays a ZEAM :: Bridge seller, passes a gate, reads each error and takes a refund.
Overview
A ZEAM :: Bridge seller serves its tools on its own site:
- MCP at
<base>/mcp.tools/listis free. A paid call carries the x402 PaymentPayload as a JSON object in_meta["x402/payment"], or an MPP credential (section 7). - HTTP at
POST <base>/v1/<tool>, with the x402PAYMENT-SIGNATUREheader (base64 of the PaymentPayload JSON), or an MPP credential (section 7).<base>/openapi.jsonlists the tools; itsinfo.descriptionstates the mode: paywall, gate or both. A seller may list only its paid tools there, and itsinfo.descriptionthen says where the free ones are. Each paid operation carriesx-payment-info:price({"mode":"fixed","currency":"USD","amount":"0.02"}, or"dynamic"withminandmaxwhere the seller states them),protocols, andofferswith the MPPevmcharge where the tool's 402 carries a Payment challenge now. The 402 is what counts. A seller may serve the same document at/openapi.jsonon its origin's root, each path written in full.
An agent pays a seller with a pass (section 6), with ZEAM :: Pass and its own key (section 1), or with any x402 or MPP client.
Request bodies below are written short: {channelId, issued, signature} is a JSON object with those keys and your
values. No sign-up, no API key. To pay: hold USDC on Base and sign. An agent needs no ETH to pay on the USDC row without Permit2, or for a refund the seller sends through the ZEAM relay.
To pay and to get money back, the agent needs ETH on Base only where it sends a transaction itself, in five cases: the Permit2 row's one-time approval, selfSend (from any wallet), initiateWithdraw and finalizeWithdraw (from the channel's payer), and, with an MPP session, an open or top-up it sends itself for a credential of type hash (the USDC approval for the session escrow, then its open or topUp) and requestClose and withdraw on the session escrow without the relay (each from the channel's payer, section 7). To pass a gate: sign with your key; nothing is paid and the key needs no funds.
1 Pay with your own wallet
Hold USDC on Base. No approval.
ZEAM :: Pass pays from a key in your environment. It needs Node.js 20 or later. Set PASS_MCP_URL to the seller; without it the client
calls its default, ZEAM :: Prism (https://mcp.zeamprism.com/mcp).
export PASS_PRIVATE_KEY=0x…
PASS_MCP_URL=https://seller.example/agents/mcp npx -y @zeampass/client --tools
PASS_MCP_URL=https://seller.example/agents/mcp npx -y @zeampass/client --call add '{"a":1,"b":2}'
Without --call or --tools the client is an MCP stdio server; over HTTP its paid_fetch tool pays a 402.
It pays by MPP session, over MCP and over HTTP, only where PASS_SESSION_ESCROW names the escrow (section 7, Sessions).
- Use the eip3009 row. For USDC, the
accepts[]row whoseextrahasnameandversionand noassetTransferMethodis EIP-3009: signed offline, no approval. The"assetTransferMethod": "permit2"row needs a one-time on-chain Permit2 approval, sent by the agent and paid in its ETH. - Deposit = price × multiplier. The first call on a channel deposits; later calls spend the deposit. Multiplier:
5 for the stock x402 SDK, set with
new BatchSettlementEvmScheme(signer, { depositPolicy: { depositMultiplier: N } });PASS_DEPOSIT_MULTIPLIERfor ZEAM :: Pass (default 40, minimum 3). The deposit must reach the floor in the 402'sdepositline; below it the answer isfunding_requires_open_feewithneededMicroUSD. The floor tracks gas: when gas rises between the 402 and your deposit, the same answer gives the figure needed now. The floor is for every deposit, a later top-up included. Raise the multiplier until price × multiplier ≥ floor. The floor is for the call you make, and each call's own 402 states it. - The channel id. Every paid answer carries it:
extra.channelState.channelIdinPAYMENT-RESPONSE. Line and refund requests name the channel by it. For a channel that never had a paid answer, compute it from the channel's seven fields withcomputeChannelIdfrom@x402/evm/batch-settlement/client, or read it from theChannelCreatedevent of your deposit. - Two USDC rows.
acceptslists the USDC row without Permit2 first and again last, identical: some x402 indexers keep only the last row per scheme and network. Sign either. A 402 that corrects a payment is the one exception: the channel's state (extra.channelState,extra.voucherState) is on the first row only, as the x402 SDK's own server sends it, and at the top of the body aschannelState. Resync from those. - A failed call is not charged. On a line a call uses the time it runs, whether or not it succeeds. Arguments are checked first; a failed tool releases the hold. An unpaid call
with no arguments (no body, an empty body or
{}) gets the 402 with the price over HTTP; over MCP a seller may check the arguments first and answerinvalid_arguments. A call the tool completes is charged, even when the connection drops before the answer arrives. Settlement comes back in thePAYMENT-RESPONSEheader (MCP:_meta["x402/payment-response"]). Do not read itsamount: it is filled only on the call that deposits and is empty on every other. What a call cost isextra.chargedAmount; what a first call deposited isextra.depositAmount.
A connector link from a pass: add it as a custom MCP connector. It pays the pass's seller; its free pass_balance
tool states the balance. Section 6 funds one.
2 Read a seller's price
Call a tool without paying:
- HTTP:
402. ThePAYMENT-REQUIREDheader is base64 JSON of the terms; the body carries the same, but for one member: the header leaves out the x402 discovery extension (extensions.bazaar) when it is over 4,096 bytes or would take the header past 12,288 characters, and the body keeps it. An MPP challenge, when offered, is inWWW-Authenticate: Payment(section 7). - MCP:
isError: true;structuredContentis the terms.
accepts[].amount is the price per call in micro-USDC: "20000" is $0.02. pricing states it in dollars
("$0.02 per call. A failed call is not charged."). deposit states the minimum deposit in the words of a first call; a later top-up meets the same floor
("First call on a channel deposits at least $0.03. Later calls spend it. The unspent balance is refundable.").
refund states the refund rule (section 5). A gate's amount is "0".
Each tool has its own price. prices in the 402 tags the tools the Bridge engine serves (a seller's own free tools outside it may be missing), or the tool called alone when the 402 carries priceList, which names the seller's mcp and openapi addresses, where every tag is; tools/list tags each tool in
_meta["zeam-pass/price"]; openapi.json in each operation's x-price:
| Tag | Meaning |
|---|---|
{"usd":"0.02","per":"call"} |
$0.02 per call |
{"usd":"0.0001","per":"unit","upTo":"0.05"} |
usage: sign $0.05; charged $0.0001 × the units the tool reports, at most $0.05 |
{"usd":"0","per":"call","free":true,"perHour":60} |
free: no payment, no admission; 60 calls an hour per address, then 429 free_limit |
{"usd":"0","per":"call","free":true} |
free, with no hourly limit |
{"per":"call","varies":true} |
depends on the arguments; the call's 402 states it |
{"usd":"0.000001","per":"ms","ms":1} |
buy_time: line time at $0.000001 per millisecond. Buy any number of milliseconds, and buying again adds time. "ms":10 means the price is per 10 ms and purchases are multiples of 10 ms. A tag with maxMs is a seller that limits one purchase (see Line time) |
{"per":"time","usd":"0.000001","ms":1,"callUSD":"0.01","callMs":10000} |
a time tool: on a line it burns line time at usd per ms milliseconds; without one, $0.01 a call for up to 10,000 ms |
Usage: sign accepts[].amount, the reserve. PAYMENT-RESPONSE extra states chargedAmount (used) and
reservedAmount on every settled call, equal when the whole reserve was charged; the rest stays in your channel. Sign the next voucher on extra.channelState.chargedCumulativeAmount
(stock x402 clients do).
Line time
A seller that sells time lists buy_time and line, tags each time tool
{"per":"time","usd":"0.000001","ms":1,"callUSD":"0.01","callMs":10000}, and states the rule in the
402's time line.
- Buy. Call
buy_time{"ms": N}and pay for N milliseconds at the tagged rate (its tag isper: "ms"). The time goes to the paying channel with the payment: time that cannot be recorded is not charged (500tool_failed, as for any tool), and a payment that does not settle credits no time. The answer statesboughtMsandmsRemaining. With nomsthe seller's default amount is bought; thebuy_timeschema states it. A seller that limits one purchase tagsmaxMsand refuses more with 400invalid_arguments; most set none. - Open a line.
POST <base>/line{"op":"open","channelId":"0x…"}answers{nonce, sign}. Signsign(EIP-191) with the channel's payer key (orpayerAuthorizer), thenPOST{"op":"prove","channelId":"0x…","nonce":"…","signature":"0x…"}: the answer carriescredential. A nonce is one attempt, valid 300 s. MCP: the freelinetool, same arguments. - Call. Send the credential as the
x-lineheader (MCP:_meta["zeam-pass/line"], orx-lineon the MCP request). No payment per call. The answer carriesx-pass-ms-remainingandx-pass-ms-elapsed(MCP:_meta["zeam-pass/meter"]), a failed call's answer too. With calls at once, a JSON-RPC batch included, each answer carries its own reading, which can differ from what the line holds once they all finish;POST <base>/line {"op":"status"}with the credential is exact once no call is running. Read while a call or a subscription runs, it shows the time burned up to that read, and the call goes on burning until it ends. The burn starts once the line has recorded the call, so a mid-call reading can come out higher than the call's share of the final figure by the time those records took: up to a few tens of milliseconds (about 25 ms seen), more on a slow disk or a seller on more than one process, as every WordPress seller is. Billing is unaffected: concurrent calls burn one clock and the final status is exact. To stop a call over MCP, sendnotifications/cancelledwith itsrequestIdand the line credential the call carried (params._meta["zeam-pass/line"]or thex-lineheader), or on theMcp-Session-Idthe call was sent on (the answer toinitializecarries one): the line is billed to that moment, nothing after; a cancel with neither is answered 202 and stops nothing. Hanging up does the same on a Node seller and on a Python seller served over ASGI. A WordPress seller, and a Python seller served over WSGI, cannot see a hang-up: there a call you hang up on burns until it returns, so cancel it first. A call cut for time, or one whose line ran out while it ran, whether or not the tool failed, answersout_of_timewithmsRemainingin the body and carries no meter reading. A seller's long-running tool may instead answer 200 withcode: "out_of_time", what it had gathered,needsMsand the meter reading: readcode, not only the status. - Burn. Time burns while a call runs on the line; calls at the same time burn one clock. No more than
maxCalls(1..1000, default 1000) may be live on a line at once; the next gets 429too_many_callswithmsRemaining. A seller may also burn a fixed idle time after each call; the line's status shows what was burned. A call stops when the time runs out: 402out_of_time, its time spent. - Meter.
{"op":"off"}: no new calls on the line; a running call burns to its end; a call sent meanwhile gets 402meter_offwithmsRemaining, the time the line still holds.{"op":"on"},{"op":"status"},{"op":"close"}, each with the credential as"credential"in the body or as thex-lineheader. Over HTTP the line isPOST <base>/line, orPOST <base>/v1/linewith the same body; both answer a failed step with its status and{op, code, why},opbeingline_failed./v1/lineis the line tool's route, so it checks the body against the tool's schema first: anopthat is not in the schema answers400 invalid_argumentsthere. While a refund is settling on the channel,{"op":"on"}and a call on the line each answer 402refund_settlingwithretry_after_seconds; nothing is charged. Retry after it. While a refund asked for on the channel is being worked out,{"op":"on"}answers 402refund_pendingwithretry_after_secondsand changes nothing (section 5). - Leave. A refund (section 5) returns the unburned time with the balance (
timeReturnedMs) and closes the line.
Without a line a time tool is a paid call at callUSD, for up to callMs; a call past it: 402 out_of_time, not
charged.
3 Pass a gate
A gate admits listed keys. Each call is signed with your key as a zero-value payment: nothing is paid, the key needs no funds or ETH, nothing goes on chain.
Make a key once. Keep the private key; give the seller its address.
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
const key = generatePrivateKey()
privateKeyToAccount(key).address
from zeam_bridge import key_address, new_key
key = new_key()
key_address(key)
Sign every call.
- Call without a proof. The 402 has one
exactrequirement:amount: "0",payTothe seller's wallet,realm: "ZEAM Pass", andhow. - Sign it with any x402 client that signs
exact(an EIP-3009TransferWithAuthorizationof value 0, never sent to the chain). Send it asPAYMENT-SIGNATURE(MCP:_meta["x402/payment"]). - Use a new nonce per call. A signature is accepted once (again:
replayed) for as long as itsvalidBeforeallows; x402 clients andgate_proofsign it valid 300 seconds. Every signature a key makes here (a proof, a voucher, an authorization, a grant, a line proof) is 65 bytes withv27 or 28 and a lows, as wallets and signing libraries produce it; a voucher or an authorization in another encoding is refused.
Node, with the x402 client:
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import { x402Client, wrapFetchWithPayment } from '@x402/fetch'
import { toClientEvmSigner } from '@x402/evm'
import { ExactEvmScheme } from '@x402/evm/exact/client'
const signer = toClientEvmSigner(privateKeyToAccount(process.env.AGENT_KEY), createPublicClient({ chain: base, transport: http() }))
const f = wrapFetchWithPayment(fetch, new x402Client().register('eip155:8453', new ExactEvmScheme(signer)))
const r = await f('https://seller.example/agents/v1/search', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"q":"solar"}' })
Python, with zeambridge (gate_proof signs the zero-value row of the terms):
import json, os, urllib.error, urllib.request
from zeam_bridge import gate_proof
def call(url, body, grant=None):
def post(headers):
req = urllib.request.Request(url, json.dumps(body).encode(), {"content-type": "application/json", **headers}, method="POST")
return urllib.request.urlopen(req)
try:
return post({})
except urllib.error.HTTPError as e:
if e.code != 402:
raise
proof = gate_proof(os.environ["AGENT_KEY"], e.headers["PAYMENT-REQUIRED"])
return post({"payment-signature": proof, **({"x-grant": grant} if grant else {})})
Getting admitted. A key not on the list gets 403 refused with a how. The seller's contact (an http(s)://
page or a mailto: address), when set, is in that answer, in every 402 and in openapi.json info.contact. A
WordPress seller's default is its site address. Send the seller your key's address, or ask for a grant.
Grants. A listed key can admit your key with a grant: your key's address, one tool or all (*), an end time.
Send x-grant: <base64url of JSON {delegate, scope, until, signature}> on every call, and sign the call with your own
key as above. The grant works only for the named key. It ends at until, or when the seller removes the signing key.
signature is the admitting key's EIP-191 signature of:
ZEAM Pass grant
delegate: <your key's address, lowercase>
scope: <tool, lowercase, or *>
until: <ISO time>
The admitting side signs with signGrant (Node, @zeambridge/bridge) or sign_grant (Python, zeambridge):
Install. x-grant is an HTTP header, on POST <base>/mcp too, where it covers
every tool call in the request; never in _meta. An answer through a grant carries
X-Pass-Granted-By: <admitting key's address, lowercase> (MCP: _meta["zeam-pass/granted-by"]). With
@x402/fetch, add 'x-grant': grant to the request headers.
Both mode. The key must be admitted (directly or by x-grant) and the call paid from it. The unpaid 402 has an
admission field and batch-settlement rows in accepts; a gate has an exact row. With @x402/fetch, register
BatchSettlementEvmScheme beside ExactEvmScheme; its signer reads your channel on Base and sends no transaction.
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import { x402Client, wrapFetchWithPayment } from '@x402/fetch'
import { toClientEvmSigner } from '@x402/evm'
import { ExactEvmScheme } from '@x402/evm/exact/client'
import { BatchSettlementEvmScheme } from '@x402/evm/batch-settlement/client'
const signer = toClientEvmSigner(privateKeyToAccount(process.env.AGENT_KEY), createPublicClient({ chain: base, transport: http() }))
const grant = process.env.AGENT_GRANT
const client = new x402Client()
.register('eip155:8453', new BatchSettlementEvmScheme(signer))
.register('eip155:8453', new ExactEvmScheme(signer))
const pay = wrapFetchWithPayment(fetch, client)
const r = await pay('https://seller.example/agents/v1/search', { method: 'POST', headers: { 'content-type': 'application/json', ...(grant ? { 'x-grant': grant } : {}) }, body: '{"q":"solar"}' })
r.headers.get('x-pass-granted-by')
ZEAM :: Pass sends PASS_GRANT as x-grant on every request to the seller's own origin, over MCP and HTTP. Use ZEAM :: Pass
4.0.0 or later: earlier versions, formerly @zeam-labs/x402-mcp-bridge, read X402_… settings.
4 Errors
A seller's HTTP answers to a call carry error and message, and some carry code, reason, how and contact. Its refund and line
routes answer a refused request differently, with op, code and why; a body too large for the seller to read is refused before
either route, with error and message. (The Pass API at
api.zeampass.com, section 6, is not a seller: a refusal there carries either a sentence in error alone, or a code in error with the
sentence in message. Read message when it is there, else error. A gas quote from its refund route (needsSignature: true) is a step, not a
refusal: it has neither, and its sentence is in how.) An MCP isError
result carries the same fields in structuredContent, merged with the x402 terms when the answer has them (an
argument the tool's schema rejects may come back from the MCP layer itself as text, with no structuredContent and
no code). A payment
that does not verify: error is the x402 code (invalid_batch_settlement_evm_…), the same as in the
PAYMENT-REQUIRED terms, and code is payment_invalid; resync the channel from accepts. A deposit sent while the channel already covers the call answers the same way with reason: "channel_funded": pay with a voucher. The refund route answers
op: "refund_failed" with code and why, op: "refund_quote" when there is something to sign, op: "refunded"
when it was sent, or op: "refund_signed" for selfSend (section 5).
| code | status | meaning | next step |
|---|---|---|---|
invalid_arguments |
400 | the arguments do not match the tool's inputSchema, or the body is not JSON (ZEAM :: Prism answers invalid_json for a body that is not JSON); nothing held. Over HTTP an unpaid call with no arguments gets the 402 instead; over MCP a seller may answer this |
fix the arguments from tools/list |
unknown_tool |
404 | no tool by that name; the answer lists the tools or says where the catalog is (over MCP, some sellers answer JSON-RPC -32602 instead) | use a name from tools/list |
an x402 code ending channel_busy, with code: "payment_invalid" |
402 | another call on this channel is being paid for right now; its accepts carry no channel state. A call that arrives while another call or a deposit holds the channel gets this answer whatever amount it signed. Two calls that arrive in the same instant, before either holds it, are different: the later one may get a cumulative amount mismatch whose state does not yet count the other |
send the same call again in a moment; if it answers a cumulative amount mismatch, re-sign from that answer's accepts. Sending one paid call at a time per channel avoids both |
tool_failed |
500 | the tool failed after the hold (for buy_time: the time could not be recorded), its result is not valid JSON, or a usage tool reported no units; hold released, nothing charged per call (on a line the call has used the time it ran, and the answer carries the meter reading, over HTTP with msRemaining and elapsedMs in the body too). Over HTTP the body carries every field the failure carries over MCP in structuredContent; a tool may name its own error and, for a refusal that is yours, a status of 400 to 599 (never 402), with Retry-After when it gives retry_after_seconds |
retry later, or with other arguments; for a refusal that is yours, do what its message says |
not_found |
404 | (WordPress) no published post with that id; nothing charged | find an id with search_posts |
internal_error |
500 | the seller's server failed | retry later |
price_invalid |
500 | the seller cannot price this call; nothing held | retry later, or with other arguments |
line_unknown |
403 | (a call on a line, and the line route) the x-line credential names no open line (closed, or refunded) |
open a line |
no_time |
404 | (line route) this seller sells no line time | pay per call |
meter_off |
402 | the line's meter is off; msRemaining is the time the line still holds |
POST <base>/line {"op":"on","credential":"<yours>"} |
out_of_time |
402 | the line has no time left, or the call ran past its time; that time is spent. Without a line: past callMs, not charged |
on a line: buy_time, then call again. Without one: buy time (needsMs says how much when the tool knows), open a line and call on it |
too_many_calls |
429 | (a call on a line) more calls are live on the line than maxCalls (1..1000, default 1000); msRemaining is the time the line still holds |
wait for a live call to finish, then retry |
client_gone |
499 | the caller hung up, or canceled the call over MCP (notifications/cancelled), and the call was stopped. On a line the time the call ran is billed and nothing after (msRemaining and elapsedMs say so, with the meter reading, when the call had started); a call priced per call is not charged. Over MCP the canceled call's own answer is an isError result with these fields |
nothing; call again when you want the result |
no_challenge, bad_signature |
400 | (line route) the nonce is used, expired or unknown, or the signature does not recover | {"op":"open","channelId":"0x…"} again and sign sign |
free_limit |
429 | over the free tool's calls an hour per address (callers the seller sees no address for share one count); retry_after_seconds |
retry after retry_after_seconds |
body_too_large |
413 | (a Python seller) the body is over 1 MB. The Pass API at api.zeampass.com answers the same with 413, error body_too_large and the sentence in message |
send a smaller body |
payload_too_large |
413 | (ZEAM :: Prism) the body is too large | send a smaller body |
not_ready |
503 | (WordPress, Python) the seller's setup is incomplete | retry later |
payment_unavailable |
503 | the payment check or settlement failed; nothing charged | retry |
(none, with accepts) |
402 | payment required; for a gate, a signed proof of key | pay one row of accepts (section 1), or sign the zero-value row (section 3) |
bad_payment_header |
400 | the payment does not decode, or its voucher.maxClaimableAmount is not an unsigned integer |
send base64 of the PaymentPayload JSON (MCP: the object), the amount as a base-10 or 0x string or a whole JSON number up to 9007199254740991 |
bad_payment_type |
400 | the payment is neither a voucher nor a deposit | pay with a voucher or a deposit |
no_matching_requirement |
402 | the payment matches no row of this call's accepts. This is checked before the channel's balance and before the deposit floor, so a deposit signed on a dearer row gets this answer whatever its size, and not channel_funded or funding_requires_open_fee. A row is its price, asset, payTo and terms and names no tool, so a payment signed from another tool's row is refused only when that row differs |
sign a row of this call's 402 |
an x402 code, with code: "payment_invalid" |
402 | the payment did not verify (signature, channel, balance or cumulative amount); error names the kind and reason, when it differs, the cause: a voucher over the channel's balance answers error …cumulative_amount_mismatch with reason …cumulative_exceeds_balance when it is for the cumulative amount this call expects (the channel's chargedCumulativeAmount plus the price). A voucher for any other amount, however far over the balance, answers error and reason …cumulative_amount_mismatch: that is checked first |
re-sign from the returned accepts, which carry the channel's current state; when reason says the balance is exceeded, deposit more first |
price_changed |
402 | a deposit made on a row that is for less than this call's price by 2 micro-USD or more, such as a cheaper tool's row. Any other payment for less, a deposit on a row exactly 1 micro-USD under among them, answers no_matching_requirement or payment_invalid |
pay the accepts quote |
funding_requires_open_fee |
402 | the deposit is signed on a row of this call's accepts and is under the floor, or gas rose past the floor the 402 stated before the deposit was sent; nothing charged |
deposit at least neededMicroUSD |
channel_leaving |
402; 409 on the line route | the channel is withdrawing | open a new channel |
refund_settling |
402 | a refund is settling on this channel; nothing charged. A paid call (voucher or deposit), a line's {"op":"on"} and a call on a line each get it; retry_after_seconds is when to ask again |
retry after retry_after_seconds |
refund_pending |
402 | (line route) {"op":"on"} while a refund asked for on the channel is being worked out: its proof verified and the chain shows a balance, so the line's meter is off until the refund is answered; nothing changed; retry_after_seconds |
retry after retry_after_seconds. Once the refund is refused, {"op":"on"} rides the line again; once it is sent, the line is closed |
refund_outstanding |
402 | the channel has an unsent signed refund | send it (the transaction in the answer), or open a new channel |
settle_failed |
402 | the payment did not settle; not delivered, nothing charged | call again with a new payment |
no_payment |
402 | (gate) no PAYMENT-SIGNATURE, or not base64 JSON of an x402 payment |
sign the zero-amount requirement with your key |
bad_payment |
402 | (gate) no authorization in the payment | send an exact EIP-3009 payment |
invalid_payment |
402 | (gate) the signature, amount, recipient or time window is wrong; message names it |
re-sign as given: value 0, its payTo, a future validBefore |
replayed |
402 | (gate) this authorization was already used | sign again with a new nonce |
bad_grant |
402 | (gate and both) x-grant is malformed or expired, its signer is not admitted, or it names another delegate than the key that signed the call; message names it; nothing held |
get a new grant from an admitted key, naming the key you sign with |
refused |
403 | your key is not admitted, or your grant's scope is another tool; how says how to get in; contact, when set, says where |
ask the seller to admit your key's address, or ask an admitted key for a grant; do not retry as is |
gate_credit_exhausted |
503 | this gate has no checks left this month and its seller has not bought more | nothing you can do; tell the seller (contact in its 402) |
invalid_json, invalid_request |
400 | (refund route) the body is not a JSON object, or channelId is missing or malformed |
send {channelId, issued, signature} |
invalid_json |
400 | (a tool call at ZEAM :: Prism) the body is not JSON | send a JSON object of the tool's arguments |
bad_request |
400 | (line route) the body is not JSON, op is not one of open, prove, on, off, status, close, channelId is missing or malformed on open or prove, a prove came with no nonce or no signature, or no credential came with on, off, status or close |
send one of those, as in section 2; why says which part is missing |
not_a_paywall |
404 | (refund route) this seller takes no payments | stop: there is no channel here to refund |
unknown_channel |
404 | (refund route) no channel with that id here; a channel with no calls is found by its channelConfig |
check the id, or send channelConfig with the proof |
unknown_channel |
404 | (line route) no channel with that id here | pay a call on the channel first, buy_time for one, then open the line |
channel_config_mismatch |
400 | (refund route) the channelConfig is not this seller's channel (checked whenever sent); mismatch: channelId (hashes to another id; why names both), receiver or receiverAuthorizer (another seller), token (not accepted here), fields (not the 7 channel fields), or balance (the channel holds nothing on chain) |
send the config as deposited, to the seller it names |
proof_invalid |
400 | (refund route) the signed proof does not verify or is stale; sign shows the text |
sign that text within 5 minutes |
not_the_payer |
403 | (refund and line routes) the proof is not signed by the channel's payer | sign with the payer's key |
line_unavailable |
503 | (line route) the line could not be read, or its channel could not be held for an {"op":"on"}, right now; nothing changed |
send the same request again |
request_open |
409 | (refund route) a paid call is open on the channel, or a call is running on the channel's line | retry after retry_after_seconds |
refund_settling |
409 | (refund route) a refund is already settling on the channel: it is being sent now; nothing more is sent. A request that would be refused is never told this: it gets its own answer | retry after retry_after_seconds |
nothing_to_return |
409 | (refund route) the balance is 0, or refund gas ≥ balance; returnedMicroUSD 0 |
with a balance of 0 there is nothing to do. Otherwise keep spending the balance, or send selfSend for a signed refund of all of it at your own gas. If the channel has a line and held a balance, the request switched the line's meter off: POST <base>/line {"op":"on","credential":"<yours>"} to ride it again |
chain_unreadable |
409 | (refund route) the chain read failed | retry after retry_after_seconds |
gas_unpriced |
503 | (refund route) refund gas cannot be priced, or the relay is unreachable | retry after retry_after_seconds, or send "selfSend": true |
refund_unavailable |
503 | (refund route) the seller's refund handler failed, it could not hold the channel for the refund, or more refund requests on the channel are already being worked out than the seller takes at once (four); nothing sent; retry_after_seconds |
retry after retry_after_seconds |
refund_error |
409 | (refund route) the refund could not be built, sent or recorded; nothing sent. Always retry_after_seconds; leftMicroUSD where the balance was read, and the gas figures only where the relay did not send |
retry after retry_after_seconds |
gas_payment_needed |
409 | (refund route, op: "refund_quote") channel fees (feeMicroUSD) < the gas of its deposit, its refund and any calls not yet settled, and of every further deposit and every earlier refund ZEAM paid for on that channel, plus 15% (coverMicroUSD); gasMicroUSD is the quoted refund gas, L1 fee included (l1FeeWei), 15% margin, priced by the relay (quotedBy: "relay") or the seller (quotedBy: "engine"). With requoted: true and sendCostMicroUSD: your payment is under the send cost (the cost rose, or you signed less than the quote); nothing sent; a new quote |
sign sign (EIP-712) and POST again with a new issued and signature, plus gasPayment: {authorization, signature}, within 5 minutes. The request switched the line's meter off, if the channel has a line: POST <base>/line {"op":"on","credential":"<yours>"} to ride it again |
MCP transport errors are JSON-RPC: -32700 the body is not JSON, -32600 not a JSON-RPC request, -32601 no such
method, -32602 bad params, -32603 the server failed.
5 Refunds
POST <base>/refund (the 402's refund line names the URL) with {channelId, issued, signature}. signature is
the payer's (or payerAuthorizer's) EIP-191 signature of:
ZEAM Pass refund
channel: <channel id, lowercase>
issued: <ISO time, within 5 minutes>
A channel with no calls: add channelConfig, the 7 channel fields as deposited: payer, payerAuthorizer, receiver, receiverAuthorizer, token, withdrawDelay, salt. The seller checks that it hashes to
channelId, names this seller as receiver, and holds a balance on chain. A config that fails a check, for any
channel, is 400 channel_config_mismatch with mismatch; nothing is sent.
A channel with an open line: a refund request whose proof verifies switches the line's meter off, so the time left
holds still while the amount is worked out. Until the request is answered, {"op":"on"} answers 402 refund_pending
and changes nothing, a call on the line answers meter_off, and a paid call on the channel waits for the answer for
up to a few seconds. The meter stays off whatever the answer is, a gas quote included: once it is answered,
POST <base>/line {"op":"on","credential":"<yours>"} rides the line again. A refund that is sent closes the line.
A request refused before the amount is worked out leaves the meter as it was: nothing_to_return on a
channel that holds nothing.
The rule: ZEAM pays the gas when the channel's fees (its spend times the fee address's share of the seller's split:
9.99% for most sellers, who keep 90.01%; 100% for one whose split pays the fee address everything, like ZEAM :: Prism; the
402's refund names it) ≥ coverMicroUSD: the gas of its deposit, its refund and any calls not yet settled, and of every further deposit into the channel and every earlier refund of it that ZEAM paid for, plus 15%, at the current gas and ETH price. The figure in the
402's refund line and coverMicroUSD already have the 15% in them; do not add it again. feeMicroUSD
rounds down, coverMicroUSD rounds up; free when feeMicroUSD >= coverMicroUSD. The seller, the relay and the 402
apply the same comparison. An unsettled call rides the refund as a claim and adds its gas to both.
- Free:
op: "refunded",returnedMicroUSD= the balance,gasMicroUSD: 0,transaction. - Not free: 409
op: "refund_quote",code: "gas_payment_needed", withfeeMicroUSD,coverMicroUSD,gasMicroUSD(the quote),gasUnits(refund + riding claim + your payment),gasUnitsWithMargin,l1FeeWei,gasPriceWei,ethUSD,marginPercent,quotedBy(relay, orenginewhen the relay gave no quote),returnedMicroUSD,payTo(the relay's gas wallet, its/healthsender),authorizationandsign(EIP-712 typed data of an EIP-3009TransferWithAuthorizationofgasMicroUSDUSDC from your payer topayTo). Signsignwith the payer (viem:account.signTypedData(answer.sign)) and POST again with a newissuedandsignature, plusgasPayment: {authorization, signature}. One transaction sends the refund, then the gas payment; you need no USDC or ETH beforehand. Answer:op: "refunded",returnedMicroUSD(out of the escrow),gasMicroUSD(your payment); net =returnedMicroUSD - gasMicroUSD. - Refund gas ≥ balance: 409
nothing_to_return;leftMicroUSDstays in the channel,returnedMicroUSD0. "selfSend": true: 200op: "refund_signed",microUSD(the full balance),transaction({chainId, to, data, value}, a signed refund you send from any wallet with ETH on Base for gas),why, andtimeReturnedMswhen line time was returned. Asked again before it is sent: the sametransaction.- No answer from the seller:
initiateWithdrawfrom the payer's wallet, for at most thebalanceminustotalClaimedthe escrow'schannels(channelId)returns (more reverts), thenfinalizeWithdrawafter the channel'swithdrawDelay(it pays the payer), each with ETH on Base for gas.
While a refund is settling on a channel, a paid call, a line's {"op":"on"} and a call on the line each get 402 refund_settling with retry_after_seconds; nothing is charged. Retry after it.
The quote. The seller hands its signed refund to the relay. The relay prices it: the gas the refund uses (eth_simulateV1 from its
sender; eth_estimateGas where the RPC lacks it), + the gas a payment adds (median of its paid refunds), + Base's L1 data fee, at the current gas and
ETH price, + 15%. No quote from the relay: the
seller prices from the same measurements (quotedBy: "engine"), with the ETH price and payTo it read from the
relay's /health in the last 60 seconds. Without those it prices nothing: 503 gas_unpriced with
retry_after_seconds. At send time the relay estimates the transaction and
its L1 fee at current prices, and sends when your payment covers that cost. If your payment is under that cost, because it rose or because you signed less than the quote,
nothing is sent: 409 refund_quote with requoted: true, sendCostMicroUSD, and a new authorization and sign
for that cost + 15%. Sign and POST again. The signature is valid 5 minutes.
Check the price. The relay answers anyone, free, with no key.
GET https://api.zeampass.com/relay/quoteprices by shape.?claim=1: an unsettled call's claim rides the refund.?payment=0: a refund alone (ZEAM pays the gas). Default: refund + your gas payment, no claim.POST https://api.zeampass.com/relay/quotewith{"call": {"to": "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003", "data": "<the refund>"}, "payment": true}(tois the x402 batch-settlement escrow on Base) prices one refund:refundWithSignature, or the escrowmulticallof claim then refund (the calldata of aselfSendanswer).
{"op": "refund_gas_quote", "shape": {"claim": false, "payment": true}, "gasUnits": 155600, "gasFrom": "calibrated",
"gasPriceWei": "6525513", "l1FeeWei": "32085809811", "ethUSD": 2701.52, "costMicroUSD": 2830, "marginPercent": 15,
"quoteMicroUSD": 3255, "payTo": "<the relay's gas wallet: sender in /relay/health>",
"measured": {"paidRefunds": 0, "paidRefundsWithClaim": 1, "calibrated": "2026-09-29"}}
costMicroUSD: an estimate of the send cost now, from gasUnits; the cost applied when the refund is sent
a few seconds later, from a fresh gas estimate, can be higher. quoteMicroUSD: cost + margin, the figure a seller quotes. gasFrom: simulated (the posted refund run
against the chain), estimated (the posted refund, gas estimate), measured (median of the relay's paid refunds of
that shape) or calibrated (starting units: a paid shape before the relay's first paid refund of it, and a free refund's shape always, since only paid
refunds are measured). Unreadable body: 400. Chain unreadable: 503 with
retry_after_seconds.
Ordering. The payment is a TransferWithAuthorization, not a ReceiveWithAuthorization:
receiveWithAuthorization requires the caller to be the payee, and the caller USDC sees is Multicall3. The relay
enforces the order: the payment is sent after the refund, in the same transaction, and both land or neither lands. It
never sends the payment alone or inside an escrow call. The seller hands the relay the
refund and payment as one Multicall3 bundle, refund first, neither leg allowed to fail. The relay sends the bundle as
is, or nested in a larger Multicall3 with other buyers' calls, where each bundle can fail alone: another buyer's
failure does not undo yours, and a failing bundle undoes its own refund and payment. Anyone holding your signed
payment can submit it alone to USDC: it pays only the relay's gas wallet, only the quoted amount, within 5 minutes,
and your refund is then quoted again. Sign it only to post it, over HTTPS.
6 Fund a pass
A pass is an allowance at one ZEAM :: Bridge seller, for another agent or a person's chatbot: a channel with your wallet as payer, a
pass key as spender, the seller as receiver. It pays that seller up to what you load, or returns the balance to your
wallet. The page https://zeampass.com/ runs these steps in a browser; a seller links to it with
?seller=<its MCP URL>. The buyer API is https://api.zeampass.com/pass (every path below is under it); connector
links are https://api.zeampass.com/c/<token>/mcp.
POST https://api.zeampass.com/pass/quote {"seller": "<the seller's MCP URL or HTTP base>"}answersseller: the terms the pass is locked to (receiver, its split;receiverAuthorizer;token;withdrawDelay;refund;admission, for a seller with an access list;contact, when set) and its prices:pricing(a sentence: the per-call range, tools priced by use, line time),priceMicroandpriceTool(its cheapest per-call tool, a time tool counted at itscallUSD; neverbuy_time),prices(each tool's tag:{tool, usd, per}withpercall;{tool, usd, per, upTo}withperunit;{tool, per, ...}withpertime;{tool, free}withfreetrue; or{tool, varies}withvariestrue) andtime({tool, usd, ms}:usdpermsmilliseconds, plusmaxMsif the seller limits one purchase, when it sells line time). It creates nothing.Derive the pass key from your wallet. Sign this text with the wallet (EIP-191, personal_sign), with the seller's receiver address from the quote, checksummed, in place of
<receiver>:ZEAM Pass merchant: <receiver> Signing this derives the spending keys for your passes at this merchant. It moves no money and approves no transaction.seed = keccak256(signature), over the signature's 65 bytes, not its hex text. Passnhas keykeccak256(seed ‖ "key:n")and channel saltkeccak256(seed ‖ "salt:n"), where"key:0"is those ASCII bytes. The token is the key's 32 bytes, base64url without padding.POST /issue {"seller": "<the seller's MCP URL>", "token": "<your pass token>"}answerstoken,passAddress,connectorUrlandpage.409 pass_in_use: passnis funded; usen + 1. This is checked before any grant, so walknfirst and ask for a grant for the pass you land on. Withouttokenthe service makes a random key and returns it once, astokenin the same answer; it stores no key. For such a pass thesaltbelow is any 32 bytes of hex you choose and keep. A seller withadmissionmust admit the pass address: add"grant", anx-grantvalue with delegate =passAddress, signed by an admitted key (section 3). No grant:400 grant_required("this seller admits only listed keys. Get a grant for this pass first.") withpassAddress; no pass is made. Withouttokenthat refusal carries nopassAddress: send your own key astokenso a grant can name the pass. A grant for another key:400 grant_wrong_delegatewithpassAddressanddelegate(neither when you sent notoken: the answer says to send one). Not anx-grantvalue:400 grant_invalidwithpassAddress. No refusal on this route carriespassAddresswhen you sent notoken. The service then asks the seller whether it admits the grant (a call carrying the grant, for the pass, that pays nothing): not admitted,400 grant_not_admittedwith the seller's reason inmessage; the seller could not be asked,502 grant_unchecked; no pass is made. All carry the seller'scontactwhen set./linkanswers the same.POST /c/<token>/prepare {"payer": "<your wallet>", "amount": "1.00", "salt": "<the pass's salt>"}answerstypedData: an EIP-3009ReceiveWithAuthorizationofamountfrom your wallet. Sign it (no gas), thenPOST /c/<token>/depositwith the prepare answer plussignature. The service sends the deposit and pays the gas, only into a channel to this pass's seller, in its asset.503 deposit_not_sentwithretry_after_seconds: nothing moved; send the same request again.502 deposit_unconfirmedwithtransaction: it was sent; do not send it again, read/status.502 deposit_revertedwithtransaction: the chain turned it down, nothing moved; prepare again.502 deposit_failed: not sent, nothing moved.400 receiver_not_zeam_splitwithreceiver: the service pays deposit gas only into a split that pays ZEAM its fee; not sent.429withRetry-AfterandperHour: too many deposits from one address, or to one pass, this hour.501: this service has no gas key and sends no deposit. Every other refusal says what was wrong in a sentence, inerror; nothing was sent.POST /c/<token>/link {"payer": "<your wallet>", "salt": "<the pass's salt>"}checks the channel on chain. TheconnectorUrlthen pays that seller.GET /c/<token>/status:loaded(all deposits;fundedis the same figure),spent,returned(back to your wallet, net of gas),gas_paid(quoted refund gas you paid in USDC) andremaining, with eachtransactioninreturns(usdnet to your wallet,gas_usdpaid out of it).loaded = spent + returned + gas_paid + remaining. The connector's freepass_balancetool (loaded_usd,spent_usd,returned_usd,gas_paid_usd,remaining_usd) and the pass page show the same five.POST /c/<token>/refundreturns the balance: it calls the seller's refund route with the channel config, so a pass with no calls is refunded too.- Seller quotes gas:
409 needsSignature. Signsignwith your wallet and POST/c/<token>/refundagain with{gasPayment: {authorization, signature}}. A quote changes nothing; the pass keeps paying until a refund is sent or its withdrawal is handed to you. A requote (requoted: true): nothing sent, no withdrawal; sign the new one. - Seller says retry (any answer with
retry_after_seconds, such asrefund_error):409with the seller'scode(also aserror), amessage,retry_after_secondsand aRetry-Afterheader; nothing starts, and a gas payment sent with the request is not used. A pass has noselfSend. - Seller refuses (no
retry_after_seconds): the service does not send the withdrawal or pay its gas. It answersfunderWithdraw: truewithamountMicroUSD,goesTo(the wallet that funded the pass, the channel'spayer),readyInSeconds(thewithdrawDelay) and two calldata objects for you to broadcast from that wallet:withdraw(aninitiateWithdraw) now, thenfinalize(afinalizeWithdraw) afterreadyInSeconds. Each needs ETH on Base for gas; the escrow pays the funding wallet. The service cannot send them: the escrow takes only thepayeror the pass key as sender, and the pass holds no gas. A withdrawal already pending:already: true,funderFinalize: true, with thefinalizecalldata,readyAtanddue. A gas payment posted with the refusal is not used:409 refund_not_sent. No balance to withdraw:409 nothing_left; the channel could not be read:502 withdrawal_unavailablewithretry_after_seconds.POST /c/<token>/finalizereturns thefinalizecalldata to broadcast once it is due; it does not send it. - A pass sent home (refund sent, or its withdrawal handed to you as above, before you broadcast it) pays nothing
and takes no top-up:
prepare,depositandlinkanswer409 pass_sent_home, and the service drops the pass key from memory. Fund a new pass.
- Seller quotes gas:
The link is the key: whoever holds it can spend the pass at its seller, nothing else. The service stores no key and no secret that derives one.
7 Pay with an MPP client
A 402 from a ZEAM :: Bridge seller for a tool priced per call also carries a WWW-Authenticate: Payment challenge of intent charge when the fee address's share of that one call (9.99% for most sellers; 100% for one whose split pays the fee address everything) covers the gas of one USDC transfer plus 15%.
ZEAM :: Prism offers no charge: it takes x402 and an MPP session (Sessions, below).
A price under that carries no charge challenge, whatever the split. A seller that offers sessions carries a challenge of intent session at any price per call (Sessions, below). The 402 says which challenges a call carries.
A time tool called without a line is a tool priced per call here, at its callUSD (section 2, Line time).
The charge: method evm, intent charge, USDC on Base (chain 8453), credential type authorization.
- Sign an EIP-3009
transferWithAuthorizationofrequest.amounttorequest.recipient:validAfter0,validBeforeno later than the challenge'sexpires,noncethe keccak256 of the JSON array[id, realm]as UTF-8, written compactly:["<id>","<realm>"], no space after the comma (Python:json.dumps([id, realm], separators=(",", ":"), ensure_ascii=False)). - Resend with
Authorization: Payment <credential>orPayment-Authorization: Payment <credential>. The credential is the base64url, without padding, of the JSON{"challenge": {…}, "payload": {…}}.challengeis the challenge's parameters as the header gave them,requestandopaquestill the base64url strings they came as.payloadis{"type": "authorization", "from": "0x…", "to": "0x…", "value": "…", "validAfter": "0", "validBefore": "…", "nonce": "0x…", "signature": "0x…"}: what you signed, the three numbers as base-10 strings,nonceandsignatureas0xhex. Over MCP:params._meta["org.paymentauth/credential"], the same object as JSON, the challenge echoed as_metagave it:requesta JSON object,opaquethe base64url string. - The answer carries
Payment-Receipt(MCP:result._meta["org.paymentauth/receipt"]). Itsreferenceis the transaction.
- By a charge, one authorization pays one call. No channel, no deposit, nothing to refund.
- A failed call sends nothing. The authorization is not used and expires at its
validBefore. - The payer's wallet needs no ETH. The ZEAM relay sends the transfer.
- No Payment challenge on a 402. ZEAM's fee on that call does not cover the gas of one transfer plus 15% and the seller offers no session, or the tool is priced by use, or the call buys line time.
- Over MCP the unpaid answer is a tool result, unless the client declared payment (next). The challenges are in
result._meta["org.paymentauth/payment-required"]. - A client that declares payment over MCP is answered as the MPP MCP transport draft writes it. Declare
capabilities.experimental.payment, an object such as{"methods": {"evm": {"intents": ["charge"]}}}, atinitialize, and send theMcp-Session-Idheader that answer carries on every later request; on MCP revision2026-07-28(the Node engine), which has no handshake, declare it in each request's_meta["io.modelcontextprotocol/clientCapabilities"]. Theinitializeanswer names incapabilities.experimental.payment.methods.evm.intentsthe intents the seller's 402s carry (charge,session), and has no payment capability where they carry neither. An unpaid call is then JSON-RPC error-32042with the same challenges inerror.data.challenges; a refused credential is error-32043with the problem inerror.data.problem, its reason and detail inerror.data.failure, and a fresh challenge; a malformed credential is error-32602. The problem inerror.data.problemcarries every member the HTTP problem does (requiredDeposit,requiredCumulative,spentand the rest), lessacceptsandsession. A payment refused for its grant (bad_grant, section 3) is error-32043too, witherror.data.failure.reasonbad_grantand the refusal'serror,messageandhowinerror.data. A credential is read from the_metaat the root of the message too. Where no Payment challenge is offered, the answer is the tool result every client gets. - A refused credential: 402 with a problem
typeendingmalformed-credential,invalid-challengeorverification-failed;detailsays which check. It carries a fresh challenge of each intent the tool's 402 offers, and none when the 402 offers none. A tool priced by use, a purchase of line time and a gate do not read an MPP credential: the answer is the 402 an unpaid call gets, with no problem. - The nonce may instead be the keccak256 of
idfollowed byrealm, as the EVM charge draft writes it. methodDetails.permit2Addressis the canonical Permit2 contract,0x000000000022D473030F116dDEE9F6B43aC78BA3, which the EVM charge draft requires in every request. It is not an offer:credentialTypesis["authorization"]and apermit2credential is refused asmalformed-credential.
Sessions
A ZEAM :: Bridge seller on the Node engine that has sessions turned on, and ZEAM :: Prism, offer the MPP session intent
(draft-evm-session-00, method evm, USDC on Base) for every tool priced per call: the 402 carries a second
Payment challenge with intent="session", after the charge one when there is one. A Python seller and a
WordPress seller offer no session today, and no seller offers one for a tool priced by use or for line time. A call
costs the same by session, by charge and by x402.
A session is a payment channel: one deposit into an escrow contract, then one signed voucher per call for the running total, and what is not spent comes back when the channel closes. The deposit is what a session commits; a call that fails is not charged.
The session contracts are not audited. The escrow is EvmPaymentChannel at
0x5e09aD70b644414cD1cD7Df1d9a10fF7899D92B8 on Base and its payee factory is
0xE18fCE1266056becA4a3B0edfc15F8634EA6901B, both deployed on 2026-10-13 and immutable. Nobody outside their
authors has reviewed them. It is a separate contract from the x402 batch-settlement escrow (section 5), and a
deposit sits in it until the channel closes.
What the 402 carries. The challenge's request: amount (the price of one call in base units), unitType
"request", suggestedDeposit (the least deposit the relay sends for you), currency, recipient (the seller's
payee) and methodDetails: {"chainId": 8453, "escrowContract": "0x…", "feePayer": true, "credentialTypes": ["authorization", "hash"]}. Beside the challenge, in the 402 body and the PAYMENT-REQUIRED header (MCP: the unpaid
result and _meta["org.paymentauth/payment-required"]), is a member
session: {"receiver", "signer", "split": {"recipients", "allocations", "totalAllocation", "distributionIncentive"}, "salt"}.
Check the payee before you deposit. recipient is not the seller's wallet. It is the seller's SessionPayee, a
contract in front of the seller's split: it signs for the split, which cannot sign, and forwards all it receives to
it. Trust none of the session member; check it on Base:
escrowContractis the escrow above.- The 0xSplits pull split factory (
0x6B9118074aB15142d7524E8c4ea8f62A3Bdb98f1) answersisDeployed(split, 0x0000000000000000000000000000000000000000, salt)withreceiver: the split you were shown is the one at that address, andreceiveris thepayToof the 402's x402 rows. - The payee factory above answers
addressOf(signer, receiver)with the challenge'srecipient.
The credential. As for the charge: Authorization: Payment <credential>, the base64url, without padding, of
{"challenge": {…}, "payload": {…}} with the challenge echoed as the header gave it; over MCP the same object in
params._meta["org.paymentauth/credential"]. A credential of type hash adds "source": "did:pkh:eip155:8453:<payer>". The draft is published at https://paymentauth.org/draft-evm-session-00 and each
payload below is its own (section 10.2). In every one, amounts are
base-10 strings in USDC base units, addresses are 0x hex, channelId, salt, nonce and hash are 0x and 64
hex digits, and a signature is 65 bytes of 0x hex (r, s, then v 27 or 28, with a low s), or one the
signer's isValidSignature accepts when the signer is a contract.
The channel. A channel is fixed by the payer, payee (the challenge's recipient), token (its currency),
the escrow and chain (its methodDetails), and two values the payer picks. salt is 32 random bytes, new for each
channel. It is not the salt in the 402's session member: that one belongs to the seller's split and serves only
to check the payee (above). authorizedSigner is the address that signs the vouchers, or the zero address, which
means the payer; leave it out of the payload for the payer. The channel id is
keccak256(abi.encode(payer, payee, token, salt, authorizedSigner, escrow, chainId)), the types
(address, address, address, bytes32, address, address, uint256); the escrow's
computeChannelId(payer, payee, token, salt, authorizedSigner) answers the same.
What is signed. A voucher is EIP-712 Voucher(bytes32 channelId,uint128 cumulativeAmount) in the escrow's
domain: name EVM Payment Channel, version 1, chain 8453, verifying contract the escrow. A deposit the relay sends
is an EIP-3009 ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce) in USDC's domain: name USD Coin, version 2, chain 8453, verifying contract the
currency. In it from is the payer, to the escrow, validAfter a time already past (0 will do) and
validBefore one to come. Its nonce is derived from the channel, as below, never random: the escrow refuses any
other.
Open. {"action": "open", "type": "authorization", "channelId": "0x…", "authorization": {"from": "0x…", "to": "0x…", "value": "…", "validAfter": "0", "validBefore": "…", "nonce": "0x…"}, "signature": "0x…", "cumulativeAmount": "…", "voucherSignature": "0x…", "salt": "0x…"}, with "authorizedSigner": "0x…" when another address signs the
vouchers. authorization moves the deposit, value, at least suggestedDeposit, and its nonce is
keccak256(abi.encode(from, payee, token, salt, authorizedSigner)), the types (address, address, address, bytes32, address), with the zero address when there is no authorizedSigner. signature signs authorization.
cumulativeAmount is the price of this call (amount) and voucherSignature the voucher for it: an open carries
the first voucher and pays the call it rides on. The ZEAM relay sends the deposit: your wallet needs USDC and no ETH.
A deposit under suggestedDeposit is refused insufficient-balance with requiredDeposit. Or open the channel
yourself, at your own gas: approve the escrow for the deposit, send its open(payee, token, deposit, salt, authorizedSigner), and send {"action": "open", "type": "hash", "channelId": "0x…", "hash": "0x…", "cumulativeAmount": "…", "signature": "0x…", "salt": "0x…"} (and authorizedSigner), hash that transaction and
signature the voucher, with source naming the payer.
A voucher per call. {"action": "voucher", "channelId": "0x…", "cumulativeAmount": "…", "signature": "0x…"},
signed by the payer or the channel's authorizedSigner. Sign what the receipts show spent plus the price of this
call: a call is paid only by a voucher for exactly that, so one for less or more, or a credential sent again, pays no call. A voucher over the
deposit is refused amount-exceeds-deposit. One voucher for the whole deposit is accepted and pays no call by
itself: the answer is insufficient-balance with requiredCumulative, the amount to sign. One call runs on a
channel at a time. The answer carries Payment-Receipt: reference is the channel id, acceptedCumulative your
highest voucher, spent what the calls have cost. A voucher may carry the draft's deposit, which opens or tops
up the channel in the same credential: {"action": "open", "type": "authorization", "authorization": {…}, "signature": "0x…", "salt": "0x…"} (and authorizedSigner) for an open, with the voucher's own cumulativeAmount
and signature as its first voucher, or {"action": "topUp", "type": "authorization", "authorization": {…}, "signature": "0x…", "salt": "0x…"} for a top-up of authorization.value.
Top up. {"action": "topUp", "type": "authorization", "channelId": "0x…", "salt": "0x…", "authorization": {…}, "signature": "0x…", "additionalDeposit": "…"}. additionalDeposit is authorization.value, salt is 32
random bytes new for this top-up, and the authorization's nonce is
keccak256(abi.encode(channelId, additionalDeposit, from, salt)), the types (bytes32, uint128, address, bytes32). Or, at your own gas from the channel's payer, approve the escrow for the top-up, send its topUp(channelId, additionalDeposit) and {"action": "topUp", "type": "hash", "channelId": "0x…", "hash": "0x…", "additionalDeposit": "…"}, with source. A top-up the relay sends has a least amount; under it the answer is insufficient-balance with requiredDeposit. A top-up runs no tool.
Without a call. A topUp, a close, any credential on HEAD, and any credential with MCP
_meta["zeam-bridge/payment-only"]: true run no tool: 204 with Payment-Receipt over HTTP, a result with empty
content and the receipt in _meta["org.paymentauth/receipt"] and in structuredContent as {receipt} over MCP. The
tool's listed outputSchema admits that shape, so an MCP SDK client that checks results against it reads the answer.
Leaving. A payer can always leave. There are three ways, and each returns the deposit less what the seller has been paid:
- The seller's close.
{"action": "close", "channelId": "0x…", "cumulativeAmount": "…", "signature": "0x…"}, a voucher for what was spent (the receipt'sspent): the seller is paid what was spent and the rest of the deposit returns to the payer in the same transaction. The relay sends it free when the channel's fees (its spend times the fee address's share of the seller's split) cover the gas of its open, of every top-up and close request the relay sent for it, and of its close. When they do not, the close is answeredverification-failedwithcode: "gas_payment_needed"and a refund's gas quote: signsignand send the close again with"gasPayment": {"authorization": {…}, "signature": "0x…"}in its payload,authorizationthe answer's as it came, and one transaction closes the channel and takes the gas in USDC from what returns, with no ETH (code: "nothing_to_return"when the gas is not less than what returns). - Without the seller, through the relay, with no ETH. Sign
RequestCloseAuthorization(bytes32 channelId,uint256 nonce,uint256 deadline)in the escrow's domain (a voucher's: nameEVM Payment Channel, version1, chain 8453, the escrow) and POST the escrow callrequestCloseWithAuthorization(channelId, nonce, deadline, payerSignature)tohttps://api.zeampass.com/relay/relayas{"calls": [{"to": "<escrow>", "data": "0x…"}], "split": <the 402's session member>}.nonceis any number the payer has not used on this channel (32 random bytes; the escrow'sisPayerNonceUsed(payer, channelId, nonce)says),deadlinethe Unix time after which the escrow refuses the call (a few minutes ahead), andsplitthe wholesessionmember,{receiver, signer, split, salt}, as the 402 gave it. The answer'sresultsholds one entry per call:hashwhen it was sent,held: truewithsendAfteranduntilwhen the relay keeps it, orerrorandcode. After the escrow's grace period, 15 minutes from the request's block (closeRequestedAt+CLOSE_GRACE_PERIOD, both read on the escrow), do the same withWithdrawAuthorizationandwithdrawWithAuthorization: everything the seller has not settled returns to the payer. The relay sends the two free when the fees on what the channel has settled cover the gas of its open, of every top-up the relay sent and of its exit; when they do not it answerscode: "gas_payment_needed". ThenPOST https://api.zeampass.com/relay/quotewith{"call": {"to": "<escrow>", "data": "<the request>"}, "split": <the session member>}: it answersop: "refund_gas_quote",shape: {"session": "exit", "payment": true},quoteMicroUSD(the gas of the request and the withdrawal, 15% included, in USDC base units),payTo(the relay's gas wallet) andcovered, with the other figures of a refund's quote (section 5). Sign the withdrawal and a USDCTransferWithAuthorizationofquoteMicroUSDfrom the payer topayTo: itsvalidAfterthe chain's time now plus the grace period plus 30 s, so nobody can take it during the grace period, and itsvalidBeforeand the withdrawal'sdeadlineat least 60 s after that. POST the request together with a Multicall3 (0xcA11bde05977b3631167028862bE2a173976CA11)aggregate3of the withdrawal and the gas payment, neither allowed to fail, as twocalls: the relay sends the request, holds the rest and sends it after the grace period, with no ETH. These two calls are ZEAM's additions to the draft's escrow. - Without the seller or the relay, at your own gas. The payer sends the draft's
requestClose(channelId)to the escrow, andwithdraw(channelId)after the same 15 minutes. This needs ETH on Base and nothing from anyone.
A channel with a close requested takes no more vouchers (channel-finalized). A top-up the payer funds cancels the
payer's close request. A buyer a seller has taken off its access list can still close: a close is never asked of the
list.
Refusals. RFC 9457 problems, Content-Type: application/problem+json, with accepts and fresh challenges:
the session draft's invalid-signature, signer-mismatch, amount-exceeds-deposit, delta-too-small,
challenge-not-found and insufficient-balance (402), channel-not-found and channel-finalized (410) and
transaction-reverted (409); and malformed-credential, invalid-challenge and verification-failed (402;
verification-failed: the relay declined, a call is in flight, or a transaction has no receipt yet).
A verification-failed with code: "sent_not_shown" means your open, top-up or close was sent and the seller's node
does not show it yet: send the same credential again after retry_after_seconds; it is taken once.
Over MCP every step is a tools/call on the paid tool, with the credential in
params._meta["org.paymentauth/credential"] and the receipt in result._meta["org.paymentauth/receipt"]. A client
that declared payment gets a refusal as error -32043 with error.data.failure.reason session/<type> and a fresh
challenge, and a malformed credential as -32602. To stop a call, send notifications/cancelled with its
requestId on the Mcp-Session-Id the call was sent on: the call is released and not charged.