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, with 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, where `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, so 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.verifyObjectn/a
3a. Valid (kind: 'paid')n/athe served artifacts + a success receiptcompleted · payment-completed
3b. Rejected (kind: 'invalid')n/aa fresh challenge + a failure receipt + error code, retryableinput-required · payment-failed
3c. No usable proof (kind: 'challenge')n/aa fresh challengeinput-required · payment-required
3d. Settlement error (SettlementError thrown)n/aa terminal failurefailed · payment-failed
3e. Settle OK but fulfill threwn/athe success receipt + an error-annotation artifactcompleted · payment-completed

Read the pairings carefully, because 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 is 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 is 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 means the money moved. The success receipt is in x402.payment.receipts[] and the served work is in the Task’s artifacts[].
  • failed + payment-failed is a settlement-side error: the relayer/facilitator never moved funds (only on the exact rail). Same status as a rejection, but terminal (state: failed), because 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, because 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. It 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()).