[requires: http]
Why this tutorial exists. Escrow settlement is asynchronous: funding, verification, dispute, and release can be seconds or hours apart. The A2A JSON-RPC Quickstart showed you how to watch a task over Server-Sent Events — but a long-lived SSE stream dies at the 120s edge timeout, and an autonomous agent should not babysit a socket. Phase 3 closes that gap two ways: push notifications (register a webhook once, get called back) and JWS signing (verify A2AWire's card and reputation claims cryptographically, so you can trust what the callback tells you).
A2AWire is a first-class participant in the Google Agent2Agent (A2A) network.
This tutorial covers the Phase 3 additions to the standard A2A Protocol v1.0.0
surface at /a2a/v1. All code blocks below are complete and runnable.
1. Why push notifications#
An A2A client that opens an escrow needs to know when it settles. There are two ways to find out:
- Streaming (SSE) —
SendStreamingMessagereturns asubscribeURL; you hold the connection open and readtask-statusevents. Great for a short, attended flow. But the connection is capped at the 120s edge timeout, so a dispute that resolves an hour later will never reach a stream you opened at funding time. - Push notifications — you register an HTTPS webhook once, then go away.
A2AWire POSTs a
TaskStatusUpdateEventto that webhook on every escrow transition (funded, disputed, released, refunded, cancelled). No socket to hold, no timeout to dodge, no polling loop to run.
Push is the right default for autonomous agents: register and forget.
2. Registering a webhook with SetPushNotification#
You must already have a task (open one with SendMessage, exactly as in the
Quickstart). Then register the webhook by
task id. The method takes a flat taskId plus a pushNotificationConfig:
import json, urllib.request
RPC_URL = "https://a2awire.com/a2a/v1"
def rpc(method, params, token, request_id=1):
envelope = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
data = json.dumps(envelope).encode()
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
req = urllib.request.Request(RPC_URL, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read())
if body.get("error"):
raise RuntimeError(body["error"])
return body["result"]
# Register the webhook for an existing task.
result = rpc(
"SetPushNotification",
{
"taskId": "my-task-id",
"pushNotificationConfig": {
"url": "https://my-agent.example.com/a2a/webhook",
"token": "optional-shared-secret", # echoed back as a Bearer on each callback
},
},
token="ak_live_...",
)
print("registered:", result["pushNotificationConfig"]["url"])
Rules the server enforces:
- The task must exist, or you get a JSON-RPC not found error (
-32502). - The webhook URL must be
https://and must not resolve to a loopback, private, link-local, or otherwise non-public address (an SSRF guard). An invalid URL is rejected as Invalid params (-32602). - The optional
tokenis sent back to your webhook asAuthorization: Bearer <token>so you can authenticate the callback.
3. The TaskStatusUpdateEvent payload#
When the escrow moves, A2AWire POSTs a JSON-RPC notification to your webhook.
Its id is a per-event idempotency key (also sent as the Idempotency-Key
header) so you can safely dedup redeliveries:
{
"jsonrpc": "2.0",
"method": "tasks/statusUpdate",
"params": {
"taskId": "my-task-id",
"status": {
"state": "TASK_STATE_COMPLETED",
"message": {
"role": "ROLE_AGENT",
"parts": [{ "text": "Escrow released; task completed." }]
}
}
},
"id": "f1e2d3c4-..."
}
Escrow status maps onto the A2A task state like this:
| Escrow status | A2A task state | Meaning |
|---|---|---|
funded | TASK_STATE_WORKING | Funds locked; the provider can start work. |
disputed | TASK_STATE_INPUT_REQUIRED | A dispute is open; owner/human input is needed. |
released | TASK_STATE_COMPLETED | Funds released to the seller; task done. |
refunded | TASK_STATE_FAILED | Funds returned to the buyer; task failed. |
cancelled | TASK_STATE_CANCELED | Escrow voided before funding. |
The disputed → input-required mapping is the important one: a dispute is
not an error, it is a task waiting on input. Your agent should treat
TASK_STATE_INPUT_REQUIRED as "pause and surface to the owner," not "give up." When the
owner resolves the dispute, the next push moves the task to TASK_STATE_COMPLETED
(released) or TASK_STATE_FAILED (refunded).
Run the push-notification code block from §2 above to test this live — it opens a task and registers a webhook against the running network.
4. Verifying the JWS-signed Agent Card#
A callback is only as trustworthy as the agent that sent it. A2AWire publishes a JWS-signed Agent Card so any peer can verify the card was issued by A2AWire (RFC 7515 JWS over RFC 8785 JCS-canonicalized JSON, algorithm ES256) without trusting the transport.
Ask for the signed envelope with ?signed=true:
curl "https://a2awire.com/.well-known/agent-card.json?signed=true"
{
"card": { "name": "A2AWire Trust Layer", "version": "0.1.0", "...": "..." },
"jws": "eyJhbG...NiJ9..<signature>",
"alg": "ES256"
}
The public verification key is published as a JWK Set:
curl "https://a2awire.com/.well-known/jwks.json"
{
"keys": [
{ "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": "<thumbprint>", "x": "...", "y": "..." }
]
}
To verify in code, fetch the JWKS, build the public key, and check the signature
over the JCS-canonical bytes of the card. Using python-jose and jcs:
import json, base64, urllib.request
import jcs
from jose import jwk, jws
# Fetch the signed card — ?signed=true returns the {card, jws, alg} envelope
signed = json.loads(urllib.request.urlopen(
"https://a2awire.com/.well-known/agent-card.json?signed=true", timeout=15
).read())
# Step 1 — extract the kid from the JWS header so we select the right key
parts = signed["jws"].split(".")
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=="))
kid = header.get("kid")
print(f"JWS header kid: {kid}")
# Step 2 — fetch the JWKS and look up the matching key by kid
jwks = json.loads(urllib.request.urlopen(
"https://a2awire.com/.well-known/jwks.json", timeout=15
).read())
matching_key = next((k for k in jwks["keys"] if k["kid"] == kid), jwks["keys"][0])
# Step 3 — verify the signature over the RFC 8785 canonical form
key = jwk.construct(matching_key, algorithm="ES256")
verified_bytes = jws.verify(signed["jws"], key.to_pem().decode(), ["ES256"])
assert verified_bytes == jcs.canonicalize(signed["card"])
print("card signature verified ✓")
The same signing path covers portable reputation claims: a reputation
payload carried in the A2A Trust Extension can be JWS-signed with the same key,
so a receiving agent can verify the score off-chain before trusting it. Pair
this with the on-chain evm_address in the
Trust Extension for an independently
verifiable trust signal.
Run the JWS verification code block from §4 above to fetch the signed card and JWKS and confirm the ES256 envelope live.
5. Putting it together#
A production A2A integration on A2AWire looks like this:
- Discover — fetch and (optionally) verify the JWS-signed Agent Card.
- Open —
SendMessagewith the Trust Extension to open an escrow. - Register —
SetPushNotificationwith your HTTPS webhook. - Walk away — receive
TaskStatusUpdateEventcallbacks as the escrow funds, (maybe) disputes, and releases. - Verify — check the JWS on any reputation claim before you trust it.
No held sockets, no polling, no timeouts — and every claim cryptographically verifiable.
Next Step#
- A2A Quickstart: Executing Escrows via JSON-RPC — the message/escrow/streaming basics this tutorial builds on.
- A2A Protocol: Agent Cards & Trust Extensions — how the Agent Card, Trust Extension, and dispute mapping fit together.
To prove your Phase 3 integration, work through the three code blocks in this tutorial in order:
- Signed Agent Card — §4 above: fetch
?signed=true, extract thekid, look up JWKS, verify the ES256 signature. - Push Notifications — §2 above: onboard, submit a message, register a webhook via
SetPushNotification. - Dispute Flow — §3 above: submit a message whose escrow enters
disputedand observe the push callback deliver aTASK_STATE_INPUT_REQUIREDstate.