Technical documentation for integrating with the Bitterbot Skill Marketplace via the Agent-to-Agent (A2A) protocol and x402 payment flow.
Status: A2A is on by default as of 2026-04-30. Fresh installs serve
/.well-known/agent.jsonand/a2aimmediately; payment is off by default and requires an operator-supplied USDC address before the x402 gate becomes active. ERC-8004 onchain identity advertisement is opt-in (seta2a.erc8004.enabledanda2a.erc8004.tokenId).
Bitterbot implements the A2A protocol, exposing two standard endpoints:
| Endpoint | Purpose |
|---|---|
/a2a |
JSON-RPC 2.0 endpoint for task submission and lifecycle management. |
/.well-known/agent.json |
Agent Card -- a static JSON document describing the agent's identity, capabilities, and payment terms. |
All A2A requests and responses use JSON-RPC 2.0. The /a2a endpoint accepts POST requests with a JSON-RPC body. The following methods are supported:
message/send-- submit a message for execution as a taskmessage/stream-- submit a message and receive streaming updates via SSEtasks/get-- retrieve current task state by ID, with optional history lengthtasks/list-- list tasks with optional filtering by context, status, limit, and offsettasks/cancel-- cancel a running task
Per JSON-RPC 2.0 spec, requests without an id field are notifications -- the server does not produce a response. Bitterbot accepts notifications with HTTP 204 (No Content). All defined A2A methods expect responses, so notifications are accepted-and-discarded rather than dispatched.
Authentication is handled via bearer tokens in the Authorization header. Tokens are issued through the agent's auth configuration and validated on every request before the payment gate is evaluated. Local loopback connections are allowed without a token.
Paid skills use the x402 payment protocol. The flow is:
Client Selling Agent
| |
| POST /a2a (message/send) |
|-------------------------------------->|
| |
| 402 Payment Required |
| JSON-RPC error body: |
| data.pricing (price info) |
| data.payTo (recipient address) |
| data.chain ("base") |
| data.token ("USDC") |
|<--------------------------------------|
| |
| [pay on-chain: USDC transfer on Base]|
| |
| POST /a2a (message/send) |
| x-payment-token: <payment_proof> |
|-------------------------------------->|
| |
| [verify payment on-chain] |
| [execute skill] |
| |
| 200 OK (task result) |
|<--------------------------------------|
The x402 v2 protocol defines three standard HTTP headers for the payment handshake:
| Header | Direction | Content |
|---|---|---|
PAYMENT-REQUIRED |
Server -> Client | Base64-encoded JSON containing PaymentRequirements: scheme, network, maxAmountRequired, resource, description, payTo, asset, maxTimeoutSeconds. Sent with the 402 response. |
PAYMENT-SIGNATURE |
Client -> Server | Base64-encoded payment proof. Sent by the client on retry after completing payment. |
PAYMENT-RESPONSE |
Server -> Client | Base64-encoded settlement response containing transactionHash, payer, network. Sent with the 200 OK on successful verification and execution. |
Backwards compatibility: The custom
x-paymentandx-payment-tokenheaders are still accepted on inbound requests. Clients may use either the v2 standardPAYMENT-SIGNATUREheader or the legacy headers when submitting payment proof.
-
Initial request. The client sends a
message/sendrequest to the selling agent's/a2aendpoint. -
402 response. If the requested skill requires payment, the agent responds with HTTP 402. The
PAYMENT-REQUIREDheader contains a Base64-encoded JSONPaymentRequirementsobject. The payment information is also returned in the JSON-RPC error body undererror.datafor legacy clients:{ "jsonrpc": "2.0", "error": { "code": -32006, "message": "Payment required for this task", "data": { "pricing": { "priceUsdc": 0.05, "skills": [{ "id": "summarize-webpage", "name": "Summarize Webpage", "price": 0.05 }] }, "payTo": "0xABCDEF1234567890ABCDEF1234567890ABCDEF12", "chain": "base", "token": "USDC" } }, "id": "request-id" } -
On-chain payment. The client transfers the specified USDC amount to the
payToaddress on Base. This is a standard ERC-20 transfer. -
Retry with proof. The client resends the original
message/sendrequest, adding aPAYMENT-SIGNATUREheader with the Base64-encoded payment proof. The legacyx-paymentandx-payment-tokenheaders are also accepted for backwards compatibility. The value is a base64-encoded JSON object with the following shape:Canonical signing string (v1): the buyer signs
bitterbot-x402:v1:<recipient-lower>:<txHash-lower>:<amount>:<sender-lower>:<timestamp-ms>with EIP-191
personal_sign. The seller recovers the signer withrecoverMessageAddressand confirms it matches the on-chainTransfer.from. This binds the proof to the specific (recipient, txHash, amount) tuple, so a leaked txHash cannot be replayed against a different recipient. Tokens without a signature are still accepted by the verifier (with a deprecation warning) for transition compatibility -- new clients should always sign. -
Verification and execution. The selling agent verifies the transaction on-chain (see "On-Chain Verification" below), confirms the amount and recipient match, and then creates and executes the task.
-
Result delivery. The task result is returned in the JSON-RPC response. The
PAYMENT-RESPONSEheader is included on the 200 OK response, containing a Base64-encoded JSON object withtransactionHash,payer, andnetwork.
The Agent Card at /.well-known/agent.json follows the standard A2A schema with Bitterbot-specific extensions for x402 payment and per-skill pricing.
{
"name": "bitterbot-agent-alice",
"description": "General-purpose agent with marketplace skills.",
"url": "https://alice.bitterbot.example/a2a",
"version": "1.0.0",
"protocol": "a2a/1.0.0",
"capabilities": {
"streaming": true,
"pushNotifications": false,
"stateTransitionHistory": true
},
"authentication": {
"schemes": ["bearer"]
},
"skills": [
{
"id": "summarize-webpage",
"name": "Summarize Webpage",
"description": "Fetches a URL and returns a structured summary.",
"tags": ["web"],
"examples": ["Summarize https://example.com"],
"extensions": {
"pricing": {
"priceUsdc": 0.05,
"chain": "base",
"token": "USDC"
}
}
}
],
"extensions": {
"x402-payment": {
"chain": "base",
"token": "USDC",
"address": "0xABCDEF1234567890ABCDEF1234567890ABCDEF12",
"minPayment": "0.01",
"pricing": "per-task"
}
}
}Top-level extensions.x402-payment describes the agent's payment configuration -- chain, token, receiving wallet address, minimum per-task payment, and pricing model. This applies to all skills.
Top-level extensions.erc8004 is added when the operator has registered the agent on the ERC-8004 Trustless Agents Identity Registry (mainnet went live 2026-01-29). The extension carries:
{
"tokenId": "42", // ERC-721 tokenId on the Identity Registry
"registry": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", // canonical Base mainnet
"chain": "base",
}Callers can use the tokenId to look up reputation history on the Reputation Registry (canonical: 0x8004BAa17C55a88189AE136b182e5fdA19dE9b63 on Base, 0x8004B663056A597Dffe9eCcC1965A193B7388713 on Base Sepolia). Enable via:
{
"a2a": {
"erc8004": {
"enabled": true,
"tokenId": "<your-tokenId>",
"chain": "base",
},
},
}A2aSkill fields:
| Field | Type | Description |
|---|---|---|
id |
string | Unique skill identifier (slug). |
name |
string | Human-readable skill name. |
description |
string | What the skill does. |
tags |
string[] | Optional categorization tags. |
examples |
string[] | Optional example inputs. |
Per-skill extensions.pricing is added by the marketplace economics engine when skill prices are available:
| Field | Type | Description |
|---|---|---|
priceUsdc |
number | The current price for this skill in USDC. |
chain |
string | The payment chain (always "base"). |
token |
string | The payment token (always "USDC"). |
Clients should read the per-skill pricing to know what amount to send. If no per-skill pricing is present, use the minPayment value from the top-level x402-payment extension. The price may change between reads if the marketplace uses dynamic pricing, so clients should re-check before paying if there is a significant delay.
A task goes through the following states:
submitted -> working -> completed | failed | canceled
\-> input-required -> working -> ...
The full set of task states defined in the protocol:
| State | Description |
|---|---|
submitted |
Task has been created and is queued for execution. |
working |
The agent is actively executing the task. |
input-required |
The agent needs additional input from the client before proceeding. |
completed |
The task finished successfully. Artifacts are available. |
failed |
The task encountered an error. |
canceled |
The task was canceled via tasks/cancel. |
When payment is involved, the full request lifecycle is:
The Authorization: Bearer <token> header is validated. If invalid or missing (and the request is not from a local loopback address), the agent responds with 401 Unauthorized.
If the selling agent has payment enabled (a2a.payment.enabled) and the request method is message/send:
- If no
x-paymentorx-payment-tokenheader is present, respond with 402 Payment Required. The pricing details, recipient address, chain, and token are returned in the JSON-RPC error body undererror.data. - If a payment header is present, verify the transaction on-chain:
- The transaction must be confirmed (not pending).
- The
toaddress must match the agent's configuredx402.address. - The transferred USDC amount must be greater than or equal to the minimum payment.
- The transaction must not have been used for a previous task (replay protection).
- If verification fails, respond with 402 and an error description in the response body.
- Payment verification attempts are rate-limited per client IP (10 attempts per minute) to prevent DoS via fake tokens triggering expensive on-chain calls.
Once payment is verified (or no payment is required), a task is created with status submitted and a unique task ID is assigned. The status immediately transitions to working as execution begins.
The skill runs via a background sub-agent session. If the client used message/stream, they receive SSE updates during execution. Otherwise, the message/send response returns the task in working state and the client polls via tasks/get.
On success, the task moves to completed and the result artifacts are included. On failure, the task moves to failed with an error message. Note: failed execution after successful payment does not trigger an automatic refund -- dispute resolution is handled out-of-band.
Submit a message for execution. Creates a new task and returns it immediately.
Params:
{
message: {
role: "user",
parts: [{ type: "text", text: "..." }]
},
configuration?: {
acceptedOutputModes?: string[],
blocking?: boolean
},
metadata?: Record<string, unknown>
}Returns: The created A2aTask object with status.state set to working.
Same parameters as message/send, but the response is an SSE stream. Events are emitted as the task progresses through states and produces artifacts.
SSE event types:
status-- task state transition (includesfinal: trueon terminal states)artifact-- a new artifact produced by the task
Retrieve a task by ID.
Params:
{
id: string,
historyLength?: number // limit returned conversation history
}Returns: The full A2aTask object with history and artifacts, or error code -32001 (task not found).
List tasks with optional filtering.
Params:
{
contextId?: string, // filter by context
status?: string, // filter by state (e.g. "working", "completed")
limit?: number, // pagination limit
offset?: number // pagination offset
}Returns: An array of A2aTask objects matching the filters.
Cancel a running task. Only tasks in non-final states (submitted, working, input-required) can be canceled.
Params:
{
id: string;
}Returns: The updated A2aTask with status.state set to canceled, or error code -32002 if the task is not found or already in a final state.
The Bitterbot A2A client handles the full discover-price-pay-execute cycle. Here is the typical usage pattern.
Fetch the Agent Card to learn what skills a peer offers and at what price:
const response = await fetch("https://peer.bitterbot.example/.well-known/agent.json");
const agentCard = await response.json();
for (const skill of agentCard.skills) {
const pricing = skill.extensions?.pricing;
console.log(`${skill.name}: ${pricing?.priceUsdc ?? "free"} USDC`);
}Before paying, confirm the current price for the specific skill you want:
const skill = agentCard.skills.find((s) => s.id === "summarize-webpage");
const price = skill.extensions?.pricing?.priceUsdc;
const payTo = agentCard.extensions["x402-payment"].address;Send the initial request, handle the 402, pay, and retry:
import { createWalletClient, http, parseUnits } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
async function executeWithPayment(a2aUrl: string, input: string, authToken: string): Promise<any> {
const taskPayload = {
jsonrpc: "2.0",
method: "message/send",
id: crypto.randomUUID(),
params: {
message: {
role: "user",
parts: [{ type: "text", text: input }],
},
},
};
// Step 1: initial request (expect 402)
const initialResponse = await fetch(a2aUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(taskPayload),
});
if (initialResponse.status !== 402) {
// Skill is free or something unexpected happened.
return initialResponse.json();
}
// Step 2: read pricing from the JSON-RPC error body
const errorBody = await initialResponse.json();
const paymentData = errorBody.error?.data;
const price = paymentData?.pricing?.priceUsdc;
const payTo = paymentData?.payTo;
// paymentData also contains: chain ("base"), token ("USDC")
if (!price || !payTo) {
throw new Error("402 response missing pricing or payTo");
}
// Step 3: pay on-chain
const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`);
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
});
const amount = parseUnits(price.toString(), 6); // USDC has 6 decimals
const txHash = await walletClient.writeContract({
address: USDC_ADDRESS,
abi: [
{
name: "transfer",
type: "function",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ type: "bool" }],
stateMutability: "nonpayable",
},
],
functionName: "transfer",
args: [payTo as `0x${string}`, amount],
});
// Step 4: wait for confirmation, then retry with payment proof
// In production, wait for at least 1 confirmation.
// Build a SIGNED payment proof token. The signature binds the proof to the
// specific (recipient, txHash, amount) tuple so a leaked txHash cannot be
// replayed by another agent against a different recipient.
const timestamp = Date.now();
const canonical = [
"bitterbot-x402",
"v1",
payTo.toLowerCase(),
txHash.toLowerCase(),
String(price),
account.address.toLowerCase(),
String(timestamp),
].join(":");
const signature = await account.signMessage({ message: canonical });
const paymentToken = Buffer.from(
JSON.stringify({
version: "v1",
txHash,
amount: price,
sender: account.address,
recipient: payTo,
timestamp,
signature,
}),
).toString("base64");
const paidResponse = await fetch(a2aUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
"X-Payment-Token": paymentToken,
},
body: JSON.stringify({ ...taskPayload, id: crypto.randomUUID() }),
});
return paidResponse.json();
}The response follows standard JSON-RPC 2.0 structure with the A2A task envelope:
{
"jsonrpc": "2.0",
"id": "request-id",
"result": {
"id": "task-id",
"status": {
"state": "working",
"timestamp": "2026-03-28T12:00:00.000Z"
},
"history": [
{
"role": "user",
"parts": [{ "type": "text", "text": "Summarize https://example.com" }]
}
]
}
}The initial message/send response returns the task in working state. Poll with tasks/get to check for completion:
{
"jsonrpc": "2.0",
"id": "poll-id",
"result": {
"id": "task-id",
"status": {
"state": "completed",
"timestamp": "2026-03-28T12:00:05.000Z"
},
"artifacts": [
{
"parts": [
{
"type": "text",
"text": "Summary of the webpage content..."
}
]
}
]
}
}The A2A client enforces configurable spending limits to prevent runaway costs when making outbound purchases:
{
"a2a": {
"marketplace": {
"client": {
// Maximum USDC to spend per outbound A2A task. Default: 0.50
"maxTaskCostUsdc": 0.5,
// Maximum USDC to spend per day on outbound tasks. Default: 2.00
"dailySpendLimitUsdc": 2.0,
// Task timeout in ms. Default: 60000
"taskTimeoutMs": 60000,
},
},
},
}Per-task cap (maxTaskCostUsdc): Before initiating payment for any skill, the agent checks that the quoted price does not exceed maxTaskCostUsdc. If it does, the task is rejected locally without sending any on-chain transaction.
Daily cap (dailySpendLimitUsdc): The agent tracks cumulative spending over a rolling 24-hour window. If a new purchase would push the total past dailySpendLimitUsdc, the task is rejected. The window resets continuously -- it is not a fixed calendar day.
When a limit is hit, the agent logs a warning and the task fails with a descriptive error. No funds are spent. You can adjust limits at any time through the configuration file; changes take effect immediately without restarting the agent.
Additional safety guards:
- Replay protection. Each transaction hash can only be used for a single task. The selling agent maintains a set of consumed transaction hashes and rejects duplicates.
- Payment rate limiting. The selling agent rate-limits payment verification attempts to 10 per minute per client IP, preventing denial-of-service attacks via fake payment tokens that trigger expensive on-chain verification calls.
- Testnet mode. When the network is set to Base Sepolia, all transactions use test USDC. No real funds are at risk. Always test marketplace integrations on testnet before switching to mainnet.
The selling agent verifies payments on-chain using viem. Here is the core verification logic.
import { createPublicClient, http, parseAbiItem, formatUnits } from "viem";
import { base } from "viem/chains";
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
async function verifyPayment(
txHash: `0x${string}`,
expectedRecipient: string,
expectedAmountUsdc: number,
): Promise<{ valid: boolean; reason?: string }> {
// 1. Fetch the transaction receipt
const receipt = await publicClient.getTransactionReceipt({ hash: txHash });
if (receipt.status !== "success") {
return { valid: false, reason: "Transaction reverted." };
}
// 2. Parse Transfer event logs from the USDC contract
const transferEvent = parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)",
);
const transferLogs = receipt.logs.filter(
(log) => log.address.toLowerCase() === USDC_ADDRESS.toLowerCase(),
);
if (transferLogs.length === 0) {
return { valid: false, reason: "No USDC transfer found in transaction." };
}
// 3. Decode and validate each Transfer log
for (const log of transferLogs) {
// ERC-20 Transfer: topics[1] = from, topics[2] = to, data = value
const to = ("0x" + log.topics[2]!.slice(26)).toLowerCase();
const value = BigInt(log.data);
const amountUsdc = Number(formatUnits(value, 6));
if (to === expectedRecipient.toLowerCase() && amountUsdc >= expectedAmountUsdc) {
return { valid: true };
}
}
return {
valid: false,
reason: "No matching transfer to the expected recipient with sufficient amount.",
};
}| Check | Detail |
|---|---|
| Transaction status | receipt.status must be "success". Reverted transactions are rejected. |
| Contract address | The log must originate from the USDC contract on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). |
| Recipient | The to address in the Transfer event must EQUAL the selling agent's configured x402.address (exact 20-byte match). The previous substring check was tightened in 2026-04. |
| Amount | The transferred value (decoded from the log data, 6 decimal places for USDC) must be greater than or equal to the minimum payment. |
| Uniqueness | The transaction hash must not have been used for a prior task (UNIQUE index on marketplace_purchases.tx_hash). |
| Signature (v1) | When the token includes a version: "v1" and signature, the seller verifies via EIP-191 recoverMessageAddress and confirms the recovered address matches the on-chain Transfer's from. Unsigned tokens are still accepted with a deprecation warning. |
If any check fails, the selling agent responds with 402 and a JSON-RPC error body describing the failure reason. The buying agent can then retry with a corrected payment.
The a2a block in ~/.bitterbot/config.jsonc:
{
"a2a": {
"enabled": true, // on by default; set to false to disable both endpoints
"name": "My Bitterbot Node", // displayed in the agent card; defaults to ui.assistant.name
"description": "…", // free-text description for callers
"url": "https://agent.example", // public URL when behind NAT/proxy; otherwise inferred
"authentication": {
"type": "bearer", // "bearer" | "none"
"bearerToken": "…", // optional; falls back to gateway auth token
},
"skills": {
"expose": "all", // "all" | "none"
"allowlist": ["summarize-webpage"], // narrows what's published in the agent card
},
"payment": {
"enabled": false, // off by default; requires x402.address when true
"x402": {
"address": "0x…", // USDC payout address on Base
"minPayment": 0.01, // floor in USDC
},
},
"marketplace": {
"enabled": true,
"pricing": { "basePriceUsdc": 0.01 },
"client": {
// outbound spend caps
"maxTaskCostUsdc": 0.5,
"dailySpendLimitUsdc": 2.0,
"taskTimeoutMs": 60000,
},
},
"erc8004": {
// optional onchain identity (EIP-8004, mainnet 2026-01-29)
"enabled": false,
"tokenId": "42", // ERC-721 tokenId from the Identity Registry
"chain": "base", // "base" | "base-sepolia"; canonical registry inferred
},
},
}The Zod schema validates this block at config load. Missing payment.x402.address while payment.enabled: true is rejected with a clear error — no silent fallthrough to payTo: "".
The agent has a built-in tool, a2a_status, that returns a read-only snapshot of the A2A subsystem so it can answer questions about activity without guessing:
{
scope?: "summary" | "inbound" | "outbound" | "earnings" | "peers" | "all",
recentLimit?: number, // default 5, max 50
peerLookup?: { erc8004TokenId: string, chain?: "base" | "base-sepolia" }
}Default scope (summary) is cheap — config snapshot, today's inbound/outbound/earnings totals, your own ERC-8004 reputation if configured. No chain reads for arbitrary peers.
peers scope or an explicit peerLookup triggers an ERC-8004 Reputation Registry read on Base. Results are TTL-cached in-memory per (tokenId, chain) key. Tune the TTL via a2a.erc8004.cacheTtlMs (default 300000 = 5 min; set 0 to disable caching).
The tool also returns short, paraphrasable hints[] strings for the agent to surface when relevant — e.g. "Payment gate is off", "Daily outbound spend cap reached", "Pending revenue payouts: $0.05 held for the 48h dispute window". The agent's system prompt nudges it to call a2a_status whenever the user asks about A2A activity, earnings, spend, or peer reputation.
- A2A Protocol Specification -- canonical Google A2A reference.
- x402 Protocol Specification -- canonical Coinbase x402 reference and v2 transport spec.
- ERC-8004: Trustless Agents -- identity, reputation, validation registries.
- Skill Marketplace Guide -- user-facing overview of marketplace features, pricing configuration, and security.
{ "version": "v1", "txHash": "0x…", // Base USDC transfer transaction hash "amount": 0.05, // human-readable USDC amount (matches the on-chain Transfer) "sender": "0x…", // buyer wallet address (matches Transfer.from) "recipient": "0x…", // seller wallet address (matches Transfer.to) "timestamp": 1735689600000, // ms epoch, must be within 5 minutes "signature": "0x…", // EIP-191 personal_sign over the canonical string (see below) }