Concepts
A2A Protocol
Agent-to-Agent JSON-RPC adapter with x402 payment metadata over Solana USDC-SPL
Solvela exposes an A2A (Agent-to-Agent) endpoint so autonomous agents can discover the gateway, get a price quote, and pay — all over JSON-RPC 2.0 instead of raw HTTP 402 handshakes. The A2A layer is a protocol adapter, not new payment logic: it translates message/send calls into the same x402 verification and chat pipeline used by POST /v1/chat/completions.
Two routes:
| Route | Purpose |
|---|---|
GET /.well-known/agent-card.json | AgentCard discovery — advertises AP2 + x402 extensions |
POST /a2a | JSON-RPC 2.0 endpoint (methods below) |
All A2A v0.3 mandatory methods are served on POST /a2a:
| Method | Purpose |
|---|---|
message/send | Get a payment quote (no taskId) or submit a payment (taskId present) |
tasks/get | Retrieve a task's current state — including the stored quote, the paid output, or payment evidence (see Retrieving tasks) |
tasks/cancel | Cancel an unpaid task (see Canceling tasks) |
tasks/pushNotificationConfig/set / get / list / delete | Always -32003 PushNotificationNotSupportedError — the card declares pushNotifications: false |
message/stream and tasks/resubscribe are not routed (-32601); the card truthfully advertises streaming: false (streaming is optional in v0.3).
A2A is text-only today. Messages carrying image data parts (contentType starting with image/) are rejected with an invalid-params error before any paid work happens.
Note
The A2A task store lives in Redis. If Redis is not configured, new message/send requests fail with an internal error (-32603) rather than issuing a task ID a client could pay against but never redeem. Task records expire 10 minutes after their last state change — the TTL refreshes when a task completes, so a finished task stays retrievable via tasks/get for roughly 10 minutes after completion. After expiry, any method referencing the task returns -32001 (task not found).
AgentCard discovery
Agents discover Solvela by fetching GET /.well-known/agent-card.json. The card advertises the AP2 extension (merchant role) and the x402 extension (Solana USDC-SPL settlement). The legacy /.well-known/agent.json path remains available as an alias for backward compatibility. A representative trimmed response (version tracks the gateway release and changes over time; url reflects the deployment):
{
"name": "Solvela",
"description": "Solana-native AI agent payment gateway — pay for LLM API calls with USDC-SPL via x402",
"url": "https://api.solvela.ai/a2a",
"version": "<gateway release version>",
"provider": { "organization": "Solvela", "url": "https://solvela.ai" },
"protocolVersion": "0.3.0",
"preferredTransport": "JSONRPC",
"capabilities": {
"streaming": false,
"pushNotifications": false,
"extensions": [
{
"uri": "https://github.com/google-agentic-commerce/ap2/tree/v0.1",
"description": "AP2 merchant for AI agent LLM payments",
"required": true,
"params": { "roles": ["merchant"] }
},
{
"uri": "https://github.com/google-a2a/a2a-x402/v0.1",
"description": "x402 on-chain settlement via Solana USDC-SPL",
"required": true,
"params": {
"network": "solana",
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"schemes": ["exact", "escrow"]
}
}
]
},
"skills": [
{
"id": "chat-completion",
"name": "Chat Completion",
"description": "Proxy AI chat completions to 25 paid models across 5 providers (OpenAI, Anthropic, Google, xAI, DeepSeek), settled per request in USDC-SPL via x402",
"tags": ["llm", "chat", "completions", "ai", "x402"],
"inputModes": ["text/plain"],
"outputModes": ["text/plain"]
}
]
}Two details matter for payment construction:
params.assetis the configured USDC mint — the one the verifier actually enforces — never a compile-time constant. Build your payment against the mint the card advertises.params.schemescontains"exact"always, and"escrow"only when the gateway has escrow configured.
If the client sends an x-a2a-extensions header, the gateway echoes the x402 extension URI back in the same header on every /a2a response.
The message/send flow
The payment flow is two message/send calls against the same task.
First call — no taskId — get a payment quote
The agent sends its prompt as a text part. An optional metadata.model hint accepts the same values as the REST route — routing profiles (auto, eco, premium), aliases (sonnet, gpt5), or direct model IDs. It defaults to auto.
{
"jsonrpc": "2.0",
"method": "message/send",
"id": 1,
"params": {
"message": {
"role": "user",
"parts": [{ "kind": "text", "text": "What is x402?" }],
"metadata": { "model": "auto" }
}
}
}The gateway resolves the model, estimates cost (quoting against the model's full completion-token ceiling — the same cap later applied to the provider call, so the quote and the settlement match by construction), stores a task record, and returns a Task in input-required state. The x402.payment.required metadata value is the same PaymentRequired object the REST route returns in a 402 body:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"id": "a2a_3f2c9a1b...",
"status": {
"state": "input-required",
"message": {
"role": "agent",
"parts": [
{ "kind": "text", "text": "Payment required to process this request." }
],
"metadata": {
"x402.payment.status": "payment-required",
"x402.payment.required": {
"x402_version": 2,
"resource": { "url": "/v1/chat/completions", "method": "POST" },
"accepts": [
{
"scheme": "exact",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"amount": "315",
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"pay_to": "<gateway-wallet>",
"max_timeout_seconds": 300
}
],
"cost_breakdown": {
"provider_cost": "0.000300",
"platform_fee": "0.000015",
"total": "0.000315",
"currency": "USDC",
"fee_percent": 5
},
"error": "Payment required"
}
}
}
}
}
}When escrow is configured, accepts also contains a "escrow" entry with an escrow_program_id field, exactly as on the REST path.
Agent signs a Solana transaction
For the exact scheme, the agent builds and signs a USDC-SPL transfer of amount (atomic units) to pay_to. For escrow, it builds a deposit transaction — see Escrow System.
Second call — with taskId — submit the payment
The agent resends message/send with the taskId from step 1 and two metadata keys: x402.payment.status set to payment-submitted, and x402.payment.payload carrying the same PaymentPayload JSON the REST path sends in the payment-signature header (here as plain JSON, not base64):
{
"jsonrpc": "2.0",
"method": "message/send",
"id": 2,
"params": {
"taskId": "a2a_3f2c9a1b...",
"message": {
"role": "user",
"parts": [{ "kind": "text", "text": "Payment attached." }],
"metadata": {
"x402.payment.status": "payment-submitted",
"x402.payment.payload": {
"x402_version": 2,
"resource": { "url": "/v1/chat/completions", "method": "POST" },
"accepted": {
"scheme": "exact",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"amount": "315",
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"pay_to": "<gateway-wallet>",
"max_timeout_seconds": 300
},
"payload": {
"transaction": "<base64-encoded-signed-tx>"
}
}
}
}
}
}The gateway runs the prompt guard as a money-free pre-check FIRST (blocked content rejects before any lock or settlement — content is screened before funds move), then runs replay protection, validates the submitted accepted against the stored offer, verifies and settles the payment on-chain, and proxies to the LLM provider with the same max_tokens cap that priced the quote. The response is a completed Task with the chat output as both the status message and an artifact, plus a settlement receipt:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"id": "a2a_3f2c9a1b...",
"status": {
"state": "completed",
"message": {
"role": "agent",
"parts": [
{ "kind": "text", "text": "x402 is an HTTP-native payment protocol..." }
],
"metadata": {
"x402.payment.status": "payment-completed",
"x402.payment.receipts": { "tx_signature": "<solana-tx-signature>" }
}
}
},
"artifacts": [
{
"parts": [
{ "kind": "text", "text": "x402 is an HTTP-native payment protocol..." }
]
}
]
}
}Payment metadata keys
All x402 state rides in message metadata under namespaced keys:
| Key | Direction | Value |
|---|---|---|
x402.payment.status | both | One of payment-required, payment-submitted, payment-completed, payment-failed |
x402.payment.required | gateway → agent | The PaymentRequired object (same shape as the REST 402 body) |
x402.payment.payload | agent → gateway | The PaymentPayload object (same shape as the REST payment-signature header, unencoded) |
x402.payment.receipts | gateway → agent | { "tx_signature": "...", "receipt": "/v1/receipts/<uuid>" } settlement receipt (the receipt path is present when a durable receipt was written) |
Task state machine
Task states serialize in kebab-case: input-required, working, completed, failed, canceled. Valid transitions as implemented:
| From | To |
|---|---|
input-required | working, canceled |
working | input-required, completed, failed |
completed | — (terminal) |
failed | — (terminal) |
canceled | — (terminal) |
working is the persisted settlement-in-progress marker: it is written under the per-task settlement lock before any funds move, and reverts to input-required if the settlement fails before funds move (so a corrected payment can be retried). Every paid path passes through working before reaching a terminal state; a working task accepts neither another payment nor a cancel. Task records (state, original message, the quoted PaymentRequired snapshot, the model and max_tokens cap, and — on terminal states — the delivered output and receipt references) live in Redis with a 10-minute TTL from the last state change; paying after expiry yields a task-not-found error.
Retrieving tasks with tasks/get
tasks/get takes { "id": "<taskId>" } (the optional historyLength is accepted and ignored — the gateway keeps no message history) and returns the Task projected by state:
| State | Response carries |
|---|---|
input-required | The stored x402.payment.required quote — a client that lost the quote can still construct and submit payment |
working | Status only (a settlement is in flight) |
completed | The delivered output as an artifact, plus x402.payment.receipts — a client that lost the paid response (e.g. a dropped connection) recovers its paid output here |
failed | x402.payment.receipts payment evidence (the payment was collected before the provider failure; no artifact exists) |
canceled | Status only |
Retrieval works for roughly 10 minutes after the task's last state change (the Redis TTL refreshes at completion). The task ID is a bearer capability — anyone holding it can read the task within that window — so tasks/get is rate-limited per client IP, like GET /v1/receipts/{id}.
Canceling tasks with tasks/cancel
tasks/cancel takes { "id": "<taskId>" } and cancels a task only while it is input-required (the natural "agent declines the quote" path — no funds have moved). It serializes against the payment leg through the same per-task settlement lock, re-checks the state under the lock, and returns the updated Task with status.state: "canceled". Any other state — a settlement in flight (working), a lock held by an in-progress payment, or a terminal state — returns -32002 TaskNotCancelableError. Canceled is terminal: a later payment against a canceled task is rejected before any settlement. Unpaid tasks also simply expire at the 10-minute TTL, so cancellation is a courtesy, not a requirement.
Error codes
JSON-RPC errors are returned with HTTP 200 and an error object. Task-adjacent codes follow A2A v0.3 §8.2 (-32001…-32003); Solvela-specific codes live in the implementation range (-32007…):
| Code | Meaning |
|---|---|
-32600 | Invalid request — well-formed JSON whose jsonrpc field is not "2.0" |
-32601 | Method not found (see the method table above; includes the unrouted message/stream / tasks/resubscribe) |
-32602 | Invalid params (missing text part, image parts, malformed payment metadata) |
-32603 | Internal error (e.g. task store unavailable — no Redis; retry) |
-32001 | Task not found or expired (TaskNotFoundError) |
-32002 | Task not cancelable (TaskNotCancelableError — settlement in flight or terminal state) |
-32003 | Push notifications not supported (PushNotificationNotSupportedError) |
-32007 | Payment failed (replay detected, offer mismatch, verification or settlement failure) |
-32008 | Provider error |
-32009 | Model not found |
Transport edge cases: a malformed request body — invalid JSON, or a missing id field — is rejected by the HTTP layer with a 4xx status and no JSON-RPC error envelope. JSON-RPC batch requests are not supported. tasks/get may additionally answer HTTP 429 (with Retry-After) when the per-IP rate limit is exceeded.
Same x402 verification as the REST path
A2A adds no payment logic of its own. On the payment-submitted path the gateway reuses the REST pipeline piece by piece:
- Replay protection — the transaction is checked against the same Redis replay set as REST payments. Without Redis, durable-nonce transactions are denied outright (their 24-hour replay window exceeds what the in-memory fallback can cover); regular transactions fall back to in-memory replay protection.
- Offer validation — the submitted
acceptedmust match an offer from the original quote on(scheme, network, asset, pay_to), and the submitted amount (compared as integers) must be at least the quoted amount. This prevents a client from having the verifier check the on-chain transfer against a self-supplied lower amount. - Settlement — verification and settlement go through the same facilitator as the REST path. Deterministic on-chain rejections are reported as not-retryable (with the numeric program error code); transient submission or timeout failures are reported as retryable.
- Prompt guard — the same injection/jailbreak/PII checks as the chat route run as a money-free pre-check BEFORE the settlement lock and on-chain settlement, so blocked content rejects with no funds moved (but never on the unpaid quote path, so anonymous callers cannot force the scans for free).
- Output cap — the provider is called with the same
max_tokenscap that priced the quote (the model's completion-token ceiling, stored on the task at quote time), so actual output cannot exceed what the agent paid for.
One A2A-specific restriction: wallets provisioned with per-tenant budgeting (require_tenant) are rejected on the A2A path with a payment-failed error before any settlement — those wallets must use POST /v1/chat/completions, where per-tenant budget enforcement runs. This applies to channel vouchers too: the wallet is checked from the channel's ledger row, not the payload.
Channel vouchers on A2A
The spend-down channel works on the A2A paid leg too: repeat callers can settle tasks with signed vouchers instead of a per-task on-chain transaction. Discovery is documentation-only — the task's accepts[] still lists only exact (plus escrow when configured), exactly like the REST 402; channel-aware clients opt in by reading the quote and submitting a voucher payload. A gateway with channels disabled rejects voucher submissions with channel scheme is not accepted on this endpoint, indistinguishable from one without the feature.
The hybrid flow. Opening and closing a channel are REST + Solana-signer operations (POST /v1/channel/open with a signed USDC funding transfer; POST /v1/channel/close), not A2A methods — an A2A-only agent cannot open one. With an open channel:
- Send
message/sendwithout payment as usual →input-requiredTask. Read the fee-inclusive quote from the firstaccepts[]entry'samount. - Sign a voucher for
previous cumulative + quoteand submit it asx402.payment.payloadwithaccepted.scheme: "channel",accepted.amountechoing the quote exactly, andnetwork/asset/pay_toas advertised. - The gateway verifies the voucher off-chain, serves the request, then advances the channel ledger — the task completes with
x402.payment.status: "payment-completed"and a receipt reference. No Solana transaction happens.
The digest contract (versioned: solvela-a2a-channel-digest-v1). A2A has no raw request body to hash, so the voucher's request_digest binds to the task instead:
request_digest = base64( SHA-256( "solvela-a2a-channel-digest-v1:" + taskId ) ) // UTF-8 bytesThe task id is an unguessable capability minted at quote time, and the served request derives entirely from the task's stored message — binding the voucher to the task binds it to the request content. The version prefix keeps this preimage permanently distinct from the REST channel digests (SHA-256 of the raw request bytes), so a voucher signed for one surface can never be replayed on the other. The prefix is a permanent wire contract; any change would be a new version, never an edit.
Voucher expiry. Expiry is checked once, at acceptance (requiring a ~50-slot buffer), and deliberately never re-checked at settlement — a voucher verified in time is legitimately consumed even when the serve outlasts its signed expiry (the A2A serve bound is 540 s ≈ 1,350 slots). Sign expiry_slot ≥ current slot + ~1,500 slots (current slot from GET /v1/escrow/config) so acceptance never races the buffer.
Settlement semantics. Channel settlement on A2A is deliver-then-record: the channel is drawn only after the provider delivers. A provider failure or serve timeout takes no draw — the task reverts to input-required and the same voucher may be resubmitted (its delta still equals the quote). This deliberately differs from exact/escrow, which settle on-chain before the provider call. Draws are strictly serial per channel (the same per-channel lock as the REST surfaces), and channel submissions never touch the tx replay set — replay defense is the digest binding plus the strictly-increasing cumulative.
Channel-specific error surface:
| Arm | Code | error.data |
|---|---|---|
| Channel not enabled / not configured on this gateway | -32007 — message channel scheme is not accepted on this endpoint | — |
| Scheme/payload pairing mismatch; amount echo or network/asset/pay_to mismatch; malformed voucher; unknown or closed channel; bad signature; tenant-restricted wallet | -32007, static message | — |
| Post-authentication voucher rejections (wrong delta, stale cumulative, expiry, over-draw) | -32007 | { "last_cumulative": "<atomic string>" } — the channel's authoritative cumulative (the same resync field the REST surfaces return); recompute the next voucher as last_cumulative + quote |
| Draw lock busy; current-slot unavailable; channel load error | -32603 (retryable) | — |
| Provider failure / serve timeout — no draw was taken; retry the same voucher | -32008 | — |
Exhausted channel (over-draw). When a voucher's cumulative would exceed the deposit, the resync figure cannot help — your last_cumulative was already right. The task reverts to input-required and its stored quote still lists exact, so the recovery is to pay that same task per-request; replenishment means opening a new channel (there is no top-up).
Receipts have no chain anchor. A channel settlement produces a durable receipt (x402.payment.receipts.receipt) but no tx_signature — nothing touched Solana. The audit artifact is the channel's append-only voucher row (channel_id, cumulative), which the receipt reflects. In the rare case the gateway delivers but cannot record the draw (an internal race with a concurrent close), the task completes without x402.payment.status or x402.payment.receipts — on the in-band response and on every later tasks/get poll — and nothing was drawn: the absence of a receipt always means the absence of a debit.
See x402 Protocol for the underlying payment flow and Escrow System for the escrow scheme.