Skip to content

A2A transport (seller)

x402 is transport-agnostic: the same payment envelope rides over plain HTTP or over the Agent2Agent (A2A) protocol’s JSON-RPC. Over HTTP a 402 is a status code and a header; over A2A a payment is a Task that moves input-required → completed, with the x402 envelope carried in the Task/Message metadata under five namespaced x402.payment.* keys instead of base64 headers.

PipRail gives you the A2A seller side as a thin adapter over the exact same PaymentGate you already use for HTTP — no second verifier, no backend, no chain-specific code. It’s a codec above the PaymentDriver boundary, so every chain family works over A2A for free, exactly as it works over HTTP.

import { createPaymentGate, createA2APaymentHandler } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
const pay = createA2APaymentHandler({
gate, // ← the SAME gate your HTTP path uses (one shared replay set — see "Co-resident with HTTP")
fulfill: async ({ receipt }) => [
// `receipt` is the X402Receipt: `receipt.amount` is BASE units (e.g. "10000"), `receipt.asset`,
// `receipt.transaction`, … — format with the token's decimals if you want a human amount.
{ name: 'result', parts: [{ kind: 'text', text: `Settled (tx ${receipt.transaction}) — here's your result.` }] },
],
})
const task = await pay.handleMessage(message, taskId) // → a transport-metadata A2A Task

handleMessage(message, taskId?) takes an inbound A2A message and returns a transport-metadata A2A Task; the payment state rides in task.status.message.metadata under the x402.payment.* keys, keyed off a coarse A2A Task state. You wire that into your A2A server’s executor — see Mount it in @a2a-js/sdk for a full, runnable server.

A2A servers are event-driven: an AgentExecutor.execute(requestContext, eventBus) publishes events. PipRail’s handleMessage returns a complete Task, so the bridge is two lines — bus.publish(task); bus.finished().

Terminal window
npm i @piprail/sdk @a2a-js/sdk express
import express from 'express'
import { randomUUID } from 'node:crypto'
import type { AgentCard, Task } from '@a2a-js/sdk'
import {
type AgentExecutor, type RequestContext, type ExecutionEventBus,
DefaultRequestHandler, InMemoryTaskStore,
} from '@a2a-js/sdk/server'
import { A2AExpressApp } from '@a2a-js/sdk/server/express'
import { createPaymentGate, createA2APaymentHandler } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
const pay = createA2APaymentHandler({
gate,
fulfill: async ({ taskId }) => [
{ name: 'result', parts: [{ kind: 'text', text: `Forecast for ${taskId}: clear skies.` }] },
],
})
// Bridge PipRail's complete-Task handler to the SDK's event-driven executor.
class X402Executor implements AgentExecutor {
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
const task = await pay.handleMessage(ctx.userMessage, ctx.taskId)
// Stamp the SDK-required, host-owned identifiers the runtime correlates events by (messageId /
// artifactId / contextId). PipRail's A2A types are transport metadata only (zero @a2a dep, by
// design), so the adapter supplies these.
const msg = task.status.message
if (msg) { msg.messageId ??= randomUUID(); msg.parts ??= [] }
for (const a of task.artifacts ?? []) a.artifactId ??= randomUUID()
task.contextId = ctx.contextId
bus.publish(task as unknown as Task)
bus.finished()
}
async cancelTask(): Promise<void> {} // PipRail holds no long-running work to cancel
}
const agentCard: AgentCard = {
name: 'My x402 Merchant',
description: 'A paid skill, payable over A2A with x402.',
url: 'https://my-agent.example.com/',
version: '1.0.0',
protocolVersion: '0.3.0',
// Advertise the x402 extension so clients opt in (with the X-A2A-Extensions header):
capabilities: { extensions: [pay.agentCardExtension({ required: false })] },
defaultInputModes: ['text/plain'],
defaultOutputModes: ['application/json'],
skills: [{ id: 'forecast', name: 'Forecast', description: 'Pay-gated forecast.', tags: ['paid'] }],
}
const handler = new DefaultRequestHandler(agentCard, new InMemoryTaskStore(), new X402Executor())
const app = express()
new A2AExpressApp(handler).setupRoutes(app) // serves /.well-known/agent-card.json + message/send
app.listen(3000)

That’s the whole seller: the AgentCard (with the x402 extension declaration) is served at /.well-known/agent-card.json, and every message/send flows through handleMessage. pay.agentCardExtension({ required: false }) returns the A2AExtensionDeclaration{ uri, description }, plus required: true when you set it. On a hand-built card, make sure capabilities.extensions is initialised to an array before you push onto it.

A2A carries the buyer’s payment as a raw JSON object under x402.payment.payload — the same object the HTTP path base64-encodes into the payment-signature header. Until the A2A A2APayer buyer ships, the simplest way to produce one is to pay an HTTP 402 with a PipRailClient and decode the payment-signature it sends:

import { PipRailClient, decodeBase64Json, HEADER_SIGNATURE, A2A_PAYLOAD_KEY } from '@piprail/sdk'
// The buyer pays a normal HTTP 402 once — its PipRailClient retries the request with the proof in the
// `payment-signature` header:
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.KEY } })
await client.fetch('https://merchant.example/api/resource')
// Wherever that request lands (your HTTP gate, or a capturing fetch — `req` is the inbound request),
// the base64 `payment-signature` header it carried decodes to the EXACT object A2A wants:
const buyerPayload = decodeBase64Json(req.headers[HEADER_SIGNATURE])
// Hand it to a Merchant Agent over A2A:
const submission = { kind: 'message', role: 'user', taskId, metadata: { [A2A_PAYLOAD_KEY]: buyerPayload } }

The committed x402-parity-sandbox/suites/04-a2a-google.mjs is the working instance: it pays a real Base-mainnet payment and routes the decoded payload through the A2A handler (pay.handleMessage), proving an end-to-end settle. (It runs over plain node:http, not a JSON-RPC A2A runtime — it exercises the handler + the on-chain settle, not the @a2a-js/sdk wiring.)

handleMessage mirrors gate.verify()’s VerifyPaymentResult exactly, then dresses the verdict in an A2A Task. Each step pairs a Task state with an x402.payment.status — and PipRail emits only the spec-correct merchant statuses (payment-required / payment-completed / payment-failed):

StepInboundWhat PipRail returnsTask state · x402.payment.status
1. Service requestmessage/send with no x402.payment.payloada fresh challenge in x402.payment.requiredinput-required · payment-required
2. Buyer submits a proofmessage/send carrying x402.payment.payloaddispatched through gate.verifyObject
3a. Valid (kind: 'paid')the served artifacts + a success receiptcompleted · payment-completed
3b. Rejected (kind: 'invalid')a fresh challenge + a failure receipt + error code — retryableinput-required · payment-failed
3c. No usable proof (kind: 'challenge')a fresh challengeinput-required · payment-required
3d. Settlement error (SettlementError thrown)a terminal failurefailed · payment-failed
3e. Settle OK but fulfill threwthe success receipt + an error-annotation artifactcompleted · payment-completed

Read the pairings carefully — they’re the part most adapters get wrong. The key idea: the x402.payment.status says what happened to the payment, while the A2A Task state says whether you can retry. A failed payment is payment-failed either way — input-required when it’s retryable, failed when it’s terminal.

  • input-required + payment-required — a genuine challenge: the first 402 (no payload yet) or a present-but-empty payload (3c). No payment has failed, so the status is payment-required with no error code and no failure receipt.
  • input-required + payment-failed — a submitted proof that failed verification (expired / wrong amount / replayed; 3b). Per the spec a failed payment is payment-failed, but it’s still retryable: the Task stays input-required, a fresh challenge is re-issued in x402.payment.required, and the reason rides in the appended failure receipt + x402.payment.error code — so the buyer can fix the issue and retry on the same taskId.
  • completed + payment-completed — the money moved. The success receipt is in x402.payment.receipts[] and the served work is in the Task’s artifacts[].
  • failed + payment-failed — a settlement-side error: the relayer/facilitator never moved funds (only on the exact rail). Same status as a rejection, but terminal (state: failed) — re-submitting the same authorization won’t help until the merchant fixes their relayer.

Every failure carries a conformant receipt in the append-only x402.payment.receipts array: { success: false, transaction: "", errorReason }, plus network when it can be attributed (the buyer’s submitted rail for a settlement failure, the challenge’s single rail for a rejection; best-effort, omitted only when genuinely ambiguous). The transaction: "" is deliberate — the x402 v2 SettlementResponse marks transaction Required (“empty string if settlement failed”), so it’s an explicit empty string, never a missing key. (A success receipt is a superset of x402’s SettleResponse — it also carries scheme / asset / payTo / verifiedAt; a strict SettleResponse consumer simply ignores the extras.)

gate.verifyObject(payload) — the raw-JSON verify seam

Section titled “gate.verifyObject(payload) — the raw-JSON verify seam”

The HTTP path verifies a base64 payment-signature header; A2A carries the payment as a raw JSON object in metadata['x402.payment.payload']. gate.verifyObject(rawObject) is the object-level twin of gate.verify — it runs the identical dispatch (upto → exact → onchain-proof), reads sig.payload.nonce off the object exactly as HTTP does, and re-derives every trusted field (payTo / amount / asset / network) from your own config. It just skips the base64 decode. Its mint-side twin is gate.challenge(url) — the same call handleMessage uses to build the x402.payment.required block. For manual adapters, the low-level codecs toA2APaymentRequired, toA2APaymentReceipts, fromA2APaymentRequired, and fromA2APaymentPayload are exported too.

const result = await gate.verifyObject(message.metadata['x402.payment.payload'])
// result.kind: 'paid' | 'invalid' | 'challenge' — the SAME VerifyPaymentResult as gate.verify()

Two properties matter for A2A, and createA2APaymentHandler relies on both:

  • Shared replay set. Pass the same gate instance to createA2APaymentHandler, and HTTP and A2A share one used-proof set. A proof settled over HTTP can’t be replayed over A2A, or vice-versa — the nonce is claimed once, in one set, regardless of transport.
  • Never throws on malformed input. A garbage, empty, or null payload object doesn’t crash — it simply yields kind: 'challenge', a fresh 402. (The only throw is a SettlementError on the exact rail, which the handler catches and maps to a failed Task; see the lifecycle table.)

You normally never call verifyObject yourself — handleMessage does, on the payload it reads out of the inbound message. It’s documented here because it’s the seam that makes A2A and HTTP one gate.

Co-resident with HTTP — one gate, one replay set

Section titled “Co-resident with HTTP — one gate, one replay set”

If you accept payments over both HTTP and A2A, build one gate and pass it to both adapters. That’s the whole trick — a proof settled on either transport is then un-replayable on the other:

import { requirePayment, createA2APaymentHandler, createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…' })
app.use('/api', requirePayment({ gate })) // HTTP
const pay = createA2APaymentHandler({ gate }) // A2A — SAME gate, SAME replay set

The payment state rides in five namespaced metadata keys, exported as constants so you never hard-code a typo. A standard A2A reader ignores them, and they coexist with any other metadata.

ConstantString valueCarries
A2A_STATUS_KEYx402.payment.statusthe A2APaymentStatus — PipRail (merchant) emits payment-required / payment-completed / payment-failed; payment-submitted / payment-rejected are client→merchant statuses it reads, never emits
A2A_REQUIRED_KEYx402.payment.requiredthe raw X402Challenge (NOT base64) on a challenge
A2A_PAYLOAD_KEYx402.payment.payloadthe raw payment payload object the buyer submits (fed to verifyObject)
A2A_RECEIPTS_KEYx402.payment.receiptsthe append-only (X402Receipt | SettleOutcome)[] history
A2A_ERROR_KEYx402.payment.errorthe spec error enum (e.g. EXPIRED_PAYMENT) on a rejection/failure

Extension activation rides in an HTTP header on the A2A request:

ConstantValuePurpose
A2A_EXTENSIONS_HEADERX-A2A-Extensionsthe header a client sends to opt into the x402 extension
A2A_X402_EXTENSION_URI_V01https://github.com/google-a2a/a2a-x402/v0.1the default extension URI (the seller’s agentCardExtension() advertises this)
A2A_X402_EXTENSION_URI_V02https://github.com/google-agentic-commerce/a2a-x402/blob/main/spec/v0.2the newer v0.2 URI (AP2 Embedded-Flow targets; carriage not yet built)
import {
A2A_STATUS_KEY, A2A_REQUIRED_KEY, A2A_PAYLOAD_KEY, A2A_RECEIPTS_KEY, A2A_ERROR_KEY,
A2A_EXTENSIONS_HEADER,
} from '@piprail/sdk'
const rawPayload = message.metadata?.[A2A_PAYLOAD_KEY] // read by constant, not a string literal

PipRail maps its lowercase verify codes to the spec’s screaming-snake error enum for x402.payment.error (payment_expired → EXPIRED_PAYMENT, tx_already_used → DUPLICATE_NONCE, amount_too_low → INVALID_AMOUNT, …) via the exported toA2AErrorCode(code). The raw PipRail code is still preserved in the re-challenge’s extensions.piprail, so a buyer agent branches identically across transports.

createA2APaymentHandler({
gate, // the shared PaymentGate (RECOMMENDED — one replay set across transports)
fulfill: async (ctx) => [], // ({ taskId, receipt, message }) → A2AArtifact[] to attach on success
taskStore, // optional bounded store correlating a follow-up to its in-flight Task
taskTtlMs, // TTL for the default in-memory task store (default = the replay window)
// …or, instead of `gate`, any inline RequirePaymentOptions to build a fresh gate (see the trap above).
})
OptionTypePurpose
gatePaymentGateThe primary form. Pass the SAME gate your HTTP path uses → one shared replay set.
fulfill(ctx: { taskId, receipt, message }) => A2AArtifact[] | Promise<A2AArtifact[]>Produce the served artifacts for a settled task. Omit for a metadata-only completion.
taskStoreA2ATaskStoreA bounded, pluggable TRANSPORT-lifecycle store — correlates a follow-up message/send to its in-flight Task and accumulates receipts[]. Default = an in-memory TTL Map. NOT on the verification path.
taskTtlMsnumberTTL (ms) for the default task store. Default = maxTimeoutSeconds * 1000 (the replay window).

The taskStore holds only the per-task receipt history (A2ATaskRecord = { receipts? }) — no nonce, no security state. That history is bounded to the most recent MAX_TASK_RECEIPTS (64) entries: a real task holds one or two, but a hostile caller pinning one taskId and streaming throw-producing payloads could otherwise grow it without limit, so only the tail is kept.

The returned handler is { handleMessage, agentCardExtension, gate }. agentCardExtension({ required?, version? }) produces the A2AExtensionDeclaration to push into your AgentCard — set required: true to reject callers that didn’t activate the extension, and version: 'v0.2' to advertise the newer URI (the URI only; AP2 mandate carriage is not yet built). The gate is re-exposed as an escape hatch for advanced flows (gate.describe(), gate.landingPage()).