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:

RoutePurpose
GET /.well-known/agent-card.jsonAgentCard discovery — advertises AP2 + x402 extensions
POST /a2aJSON-RPC 2.0 endpoint (methods below)

All A2A v0.3 mandatory methods are served on POST /a2a:

MethodPurpose
message/sendGet a payment quote (no taskId) or submit a payment (taskId present)
tasks/getRetrieve a task's current state — including the stored quote, the paid output, or payment evidence (see Retrieving tasks)
tasks/cancelCancel an unpaid task (see Canceling tasks)
tasks/pushNotificationConfig/set / get / list / deleteAlways -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.asset is 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.schemes contains "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:

KeyDirectionValue
x402.payment.statusbothOne of payment-required, payment-submitted, payment-completed, payment-failed
x402.payment.requiredgateway → agentThe PaymentRequired object (same shape as the REST 402 body)
x402.payment.payloadagent → gatewayThe PaymentPayload object (same shape as the REST payment-signature header, unencoded)
x402.payment.receiptsgateway → 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:

FromTo
input-requiredworking, canceled
workinginput-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:

StateResponse carries
input-requiredThe stored x402.payment.required quote — a client that lost the quote can still construct and submit payment
workingStatus only (a settlement is in flight)
completedThe 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
failedx402.payment.receipts payment evidence (the payment was collected before the provider failure; no artifact exists)
canceledStatus 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…):

CodeMeaning
-32600Invalid request — well-formed JSON whose jsonrpc field is not "2.0"
-32601Method not found (see the method table above; includes the unrouted message/stream / tasks/resubscribe)
-32602Invalid params (missing text part, image parts, malformed payment metadata)
-32603Internal error (e.g. task store unavailable — no Redis; retry)
-32001Task not found or expired (TaskNotFoundError)
-32002Task not cancelable (TaskNotCancelableError — settlement in flight or terminal state)
-32003Push notifications not supported (PushNotificationNotSupportedError)
-32007Payment failed (replay detected, offer mismatch, verification or settlement failure)
-32008Provider error
-32009Model 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 accepted must 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_tokens cap 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:

  1. Send message/send without payment as usual → input-required Task. Read the fee-inclusive quote from the first accepts[] entry's amount.
  2. Sign a voucher for previous cumulative + quote and submit it as x402.payment.payload with accepted.scheme: "channel", accepted.amount echoing the quote exactly, and network/asset/pay_to as advertised.
  3. 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 bytes

The 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:

ArmCodeerror.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.