Your Agent Card Needs a Public URL: Testing A2A Push Notifications on Localhost
Your A2A agent card needs a public URL. Learn to test push notifications on localhost with a stable tunnel and SSRF-safe webhook registration.

Quick answer
Test A2A Push Notifications on Localhost (Agent Card URL): quick comparison answer
Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.
Which tunnel tool is best for public webhook testing?
Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.
When should I choose a private network tool instead?
Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.
The Agent2Agent (A2A) protocol grew up quickly. Google announced it in April 2025, the Linux Foundation took it over in June 2025, and the stable v1.0 specification shipped on March 12, 2026. In August 2026 it became a hosted project of the Agentic AI Foundation (AAIF). Two of its building blocks matter for local development. Push notifications are plain webhooks: the agent server sends an HTTP POST to a URL the client registered. The agent card is a JSON document served from a well-known path.
Both hit the same wall on a laptop. Remote peers can’t reach localhost, and a carefully built agent server may refuse to POST to it. This guide covers why, how to test A2A push notifications from your machine through a public tunnel, and how to register webhooks without opening an SSRF hole.
It is about agent-to-agent callbacks and safe webhook registration. It deliberately skips OAuth discovery and authorization-server flows.
Where A2A stands in November 2026
- v1.0 shipped on March 12, 2026. The project’s own blog and the Turing Post guide both date it to that day, and Turing Post notes a v1.0.1 patch in May. Version 1.0 was the first stable, production-ready specification. Its protobuf definition,
a2a.proto, is the normative source, and the JSON, gRPC and REST forms are generated from it. - Governance moved in two steps. Google donated A2A to the Linux Foundation in June 2025. On August 17, 2026 it was announced as a hosted project of the AAIF, the Linux Foundation body that launched in December 2025 around MCP, goose and AGENTS.md. The A2A project’s own announcement is dated August 27, 2026 and describes A2A as a Growth Stage project of the AAIF. Press coverage of the move ran between August 17 and 20. No control changed hands. Google had already donated the spec, and each AAIF project keeps its own maintainers and release schedule.
- The ecosystem is broad. The Linux Foundation reported more than 150 supporting organizations at the project’s one-year mark in April 2026. Official SDKs exist for Python, Go, JavaScript, Java, .NET and Rust. The JavaScript SDK (
@a2a-js/sdk) reached 1.0.0 general availability on July 22, 2026, with opt-in v0.3 compatibility for staged migrations. - v1.0 defines three standard protocol bindings: JSON-RPC 2.0 over HTTP(S), gRPC, and HTTP+JSON/REST.
The practical upshot is that the spec is stable enough to invest in tooling and security work. But many deployed agents still serve pre-1.0 card shapes, so check each peer’s version before you test against it.
Two public URLs, not one
The title says “your agent card needs a public URL.” That’s true, but only half of a local A2A test needs it, and mixing up the two halves causes most of the confusion.
1. The agent card URL (the server’s address). A2A servers publish their card at https://{server_domain}/.well-known/agent-card.json. The well-known path follows RFC 8615. Older pre-1.0 servers commonly used /.well-known/agent.json, and tools such as a2aops fall back to that legacy path when they check a card.
The card lists the endpoints clients should call in supportedInterfaces. This is an ordered array in which the first entry is the preferred interface. Each entry carries a url, a protocolBinding (JSONRPC, GRPC or HTTP+JSON) and a protocolVersion, with an optional tenant. The spec says an interface URL must be an absolute HTTPS URL in production.
If your server runs locally and a remote client needs to reach it, such as a hosted orchestrator, a teammate’s agent or a cloud demo, the card and its interface URLs must be publicly resolvable.
2. The webhook URL (the client’s address). Push notifications flow the other way. The client registers a webhook and the server POSTs to it. If you’re developing the client locally and testing against a remote agent, your webhook needs the public address.
If you run two local agents that talk to each other from different networks, you need both: two stable public hostnames.
How A2A push notifications work
Push notifications exist for very long-running tasks (minutes, hours or days) and for clients that can’t hold a streaming connection open, such as mobile apps or serverless functions. The agent advertises support with capabilities.pushNotifications: true in its card. If the flag is false or absent, the push-config operations must return PushNotificationNotSupportedError.
- Register a config. The client sends a
TaskPushNotificationConfigeither inline in theSendMessagerequest (underconfiguration, with the task ID left empty) or separately through the create operation on an existing task. The v1.0 spec defines create, get, list and delete operations. In JSON-RPC they areCreateTaskPushNotificationConfig,GetTaskPushNotificationConfig,ListTaskPushNotificationConfigsandDeleteTaskPushNotificationConfig. In REST they live under/tasks/{id}/pushNotificationConfigs. - What the config carries. It holds a webhook
url, an optionaltokenfor client-side validation, and optionalauthenticationdetails (schemeandcredentials) that tell the server how to authenticate to your webhook. It also carries an optionalidand thetaskId. - The server POSTs on state changes. The body is a
StreamResponse, the same shape used in streaming. It contains exactly one oftask,message,statusUpdateorartifactUpdate. - Your receiver checks it. The spec’s worked example shows the server sending an
Authorizationheader built from yourauthenticationsettings, plusX-A2A-Notification-Tokencarrying thetokenyou supplied.
The spec doesn’t agree with itself on every detail, so build the receiver defensively:
- The normative text names no token header. The worked example uses
X-A2A-Notification-Token. Other implementations differ: the Rusta2a-protocol-servercrate sends bothX-A2A-Notification-Tokenand an unprefixedA2A-Notification-Token, so accept either. - Examples in the spec show the webhook’s
Content-Typeasapplication/a2a+jsonin one place andapplication/jsonin another. Parse the JSON body without insisting on one media type.
A registration looks roughly like this. It is simplified, so confirm the exact field layout against a2a.proto for your SDK version:
{
"jsonrpc": "2.0",
"id": "1",
"method": "CreateTaskPushNotificationConfig",
"params": {
"taskId": "task-123",
"url": "https://a2a-client-dev.example.com/webhooks/a2a",
"token": "per-task-secret-value",
"authentication": { "scheme": "Bearer", "credentials": "webhook-credential" }
}
}
Send the A2A-Version: 1.0 header with every request. The spec requires clients to send it so an agent that upgrades later doesn’t silently change behavior.
A minimal v1.0-style card fragment shows where the public address lives. A complete card also requires defaultInputModes, defaultOutputModes and skills:
{
"name": "Flight Booking Agent",
"description": "Handles flight reservations.",
"version": "1.0.0",
"supportedInterfaces": [
{
"url": "https://a2a-agent-dev.example.com/a2a",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"capabilities": { "streaming": true, "pushNotifications": true }
}
What the spec asks of senders and receivers
- Servers must attempt delivery at least once and may retry with exponential backoff. They should use a webhook timeout of roughly 10 to 30 seconds and may stop after repeated failures.
- Receivers must answer with a 2xx status to acknowledge delivery, should process notifications idempotently because duplicates can occur, and must check that the task ID matches a task they expect.
- A2A’s async guide says that after verifying a notification’s authenticity, a client typically calls
GetTaskwith the notification’s task ID to fetch the full, current state. Treat the payload as a trigger and re-fetch the task before acting on anything important.
Why localhost breaks A2A push notifications
Three separate failures hide behind “it works on my machine.”
The card advertises an address nobody else can reach. Many frameworks build the card from the bind address, so the card says http://localhost:8000/a2a. A remote client fetches the card (if it can even reach it), then follows supportedInterfaces[0].url to its own loopback interface. The fix is to set the server’s public base URL explicitly so the card reflects the tunnel hostname.
The agent can’t reach your webhook. If your webhook is http://localhost:9000/hook, the remote agent resolves that to itself, and nothing arrives.
A well-built server may refuse to try. The spec says servers should not blindly POST to any client-supplied URL, because that opens SSRF and DDoS-amplification attacks. Real implementations respond to this in different ways:
- The Rust
a2a-protocol-servercrate (0.15.0) validates webhook URLs at config creation and again at delivery. It rejects loopback, private and link-local targets, as well aslocalhost,.localand.internalnames. Itsallow_private_urls()escape hatch is documented as testing-only and as disabling SSRF protection. - A pull request against the official Python SDK (#1164) describes the problem plainly. The sender used
push_info.urlexactly as supplied, with no checks. The PR adds dispatch-time validation of the scheme, DNS resolution and public-only destinations, with anallow_private_push_urlsopt-out for operators whose webhooks live on private networks. An overlapping PR (#1169) covers the same ground, and I could not confirm whether either has merged. Check the SDK release you’re using.
So whether localhost works as a webhook depends on your server’s SDK and version. Where it works, it works because the guard is missing. Disabling the guard so 127.0.0.1 passes tests a code path you’ll never run in production. A public tunnel gives your webhook a real, globally routable HTTPS address that passes the same validation a production endpoint would.
The security backdrop: webhook SSRF
The webhook is attacker-influenced input by definition. Whoever registers a push config chooses where your server sends HTTP requests. Tyk’s A2A security guide (June 2026) calls the webhook URL its own attack surface, with SSRF if outbound destinations aren’t allow-listed and spoofing if the receiver doesn’t authenticate notifications.
The A2A documentation lists mitigations for servers: allowlisting trusted domains, ownership verification such as challenge-response, and network controls such as egress firewalls. For receivers it says the endpoint must verify that notifications really come from the legitimate server, using signatures, tokens or the registered token. It also says notifications should carry a timestamp so stale ones can be rejected, and recommends unique IDs for critical events to catch replays. The docs also describe an optional JWT-plus-JWKS flow for asymmetric verification.
The Apache Camel advisory shows how a validator can go wrong:
- CVE-2026-71569 (Apache Camel, published October 29, 2026). The
camel-a2acomponent’sWebhookUrlValidatorclassified hosts in two places that didn’t agree. A host written as an IP literal was matched against string prefixes that included the unique-local rangefc00::/7. A host given as a name was resolved and checked with JDK predicates (isLoopbackAddress,isSiteLocalAddressand so on), and those don’t coverfc00::/7. A name resolving intofc00::/7therefore passed a guard that refused the identical literal.- The dispatcher also sent any configured
Authorizationheader to that address. The attacker supplies the scheme and credentials, so this is not a leak of the victim’s secrets. - It affects 4.21.0 up to but not including 4.22.2. Fixes ship in 4.22.2 and 4.23.0, and the 4.21.x line is end-of-life with no fix. The component was introduced in 4.21.0 and is marked Preview.
- It is rated Medium (CVSS 3.1 score 4.8). The request is blind, since the response isn’t relayed to the caller. Exploitation also needs the name to resolve into
fc00::/7, an HTTPS service listening there, and a successful TLS handshake for that name. - The string pre-check was also wrong in the other direction. It matched the raw host string without first confirming it was an IP literal, so legitimate names starting with
fcorfd, such asfcm.googleapis.com, were refused. - The fix classifies addresses from their raw bytes in one shared classifier. It covers
fc00::/7, the shared address space100.64.0.0/10, and IPv4-compatible, NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses whose embedded IPv4 isn’t globally routable. The vendor’s workaround for those who can’t upgrade is to put authentication in front of the push-config path and deny egress tofc00::/7.The lesson is that every code path must share one classifier.
- The dispatcher also sent any configured
- The official Python SDK. PR #1164 states two residual risks. DNS rebinding between validation and the POST stays possible, because the lookup and the connection resolve the name separately. A reviewer also noted that redirects aren’t re-validated, so the HTTP client must keep
follow_redirects=False, which is the httpx default.
An SSRF-safe webhook registration checklist
If you build or operate an A2A server, validate in layers:
- HTTPS only. Reject other schemes and URLs carrying embedded credentials. Some SDK guards also allow plain
http. Tighten that for production. - Restrict ports. Allow 443 unless you have a documented reason.
- Resolve, then classify every address. One bad answer fails the whole URL. Resolving also catches decimal, hex and octal host spellings such as
2130706433, which a string match would miss. Unwrap IPv4-mapped IPv6, NAT64 and 6to4 addresses before classifying them, and make sure unique-localfc00::/7and100.64.0.0/10are covered. - Validate at registration and at delivery. Configs can arrive by several paths (inline in a send request or through the dedicated operation), and DNS can change between the two moments.
- Close the rebinding window. Where your HTTP client allows it, connect to the address you validated while keeping the original hostname for TLS and the
Hostheader. The Rust crate takes a different route forhttps://targets. It doesn’t pin the IP, and reasons that a rebind to a private address fails the TLS handshake because the attacker can’t present a valid certificate for your hostname. Pick one approach on purpose. - Never follow redirects on push delivery.
- Verify ownership with a challenge before sending real events.
- Don’t attach credentials to unvalidated destinations. Add an egress proxy or network policy as defense in depth.
- Reject header injection. Refuse CR or LF characters in the
tokenandauthenticationvalues before putting them in headers.
A Python starting point for steps 1 to 3 follows. I ran it against Python 3.13. It rejects loopback, private, link-local, CGNAT and unique-local targets, integer-encoded hosts, localhost, IPv4-mapped and IPv4-compatible IPv6, NAT64 and 6to4 addresses wrapping private IPv4, bad ports and embedded credentials. It accepts public hosts. Python’s is_global rules have changed between releases, so retest on your own version:
import ipaddress
import socket
from urllib.parse import urlsplit
ALLOWED_PORTS = {443}
NAT64 = ipaddress.ip_network("64:ff9b::/96")
def unwrap(ip):
"""Return the IPv4 hidden inside an IPv6 address, if there is one."""
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped: # ::ffff:a.b.c.d
return ip.ipv4_mapped
if ip in NAT64: # 64:ff9b::/96
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
if ip.sixtofour: # 2002::/16
return ip.sixtofour
if int(ip) >> 32 == 0 and int(ip) > 1: # deprecated ::a.b.c.d
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
return ip
def check_webhook_url(url: str) -> list[str]:
parts = urlsplit(url)
if parts.scheme != "https" or not parts.hostname:
raise ValueError("webhook must be an https URL with a host")
if parts.username or parts.password:
raise ValueError("credentials in the URL are not allowed")
try:
port = parts.port or 443
except ValueError:
raise ValueError("invalid port")
if port not in ALLOWED_PORTS:
raise ValueError("port not allowed")
public_ips = []
for *_, sockaddr in socket.getaddrinfo(parts.hostname, port, type=socket.SOCK_STREAM):
ip = ipaddress.ip_address(sockaddr[0].split("%")[0])
# is_global rejects private, loopback, link-local, unique-local and CGNAT.
if not unwrap(ip).is_global:
raise ValueError(f"{ip} is not a public address")
public_ips.append(str(ip))
return public_ips # connect to one of these, not to a fresh DNS lookup
On the receiving side, compare the token in constant time, accept either header spelling, answer fast and fetch state separately. I tested this FastAPI handler. It returns 401 for a missing or wrong token and 200 for either header name:
import hmac
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
EXPECTED = os.environ["A2A_WEBHOOK_TOKEN"]
@app.post("/webhooks/a2a")
async def a2a_webhook(
request: Request,
x_a2a_notification_token: str = Header(default=""),
a2a_notification_token: str = Header(default=""),
):
token = x_a2a_notification_token or a2a_notification_token
if not hmac.compare_digest(token.encode(), EXPECTED.encode()):
raise HTTPException(status_code=401)
event = await request.json() # accepts application/json or application/a2a+json
# enqueue event; re-fetch the task with GetTask before acting on it
return {"received": True}
If your server also sends an Authorization header from authentication, verify that too. The token alone proves only that the sender knew a per-task secret.
Testing A2A push notifications on localhost with a tunnel
A tunnel gives your local port a public HTTPS hostname, so you can run the real flow. Any tunneling tool works. The properties that matter are HTTPS, a stable hostname, and support for plain, non-interactive POST requests.
Step 1: Reserve a stable hostname. Random per-session URLs break A2A testing. Clients cache cards, and the spec tells servers to send Cache-Control and ETag headers and clients to honor them. Registered push configs hold your webhook URL. When the hostname changes, they point at nothing.
A throwaway tunnel is fine for a first smoke test. cloudflared tunnel --url http://localhost:8000 creates a random trycloudflare.com URL with no account. Cloudflare documents several limits for these Quick Tunnels:
- There is no uptime guarantee.
- Each tunnel supports up to 200 in-flight requests, and extra requests return a 429.
- The hostname changes every time.
- Server-Sent Events aren’t supported. A2A’s streaming operations (
SendStreamingMessage,SubscribeToTask) use SSE, so a Quick Tunnel can carry push webhooks and plain request/response calls but not streaming tests. - Quick Tunnels don’t work if a
config.yamlexists in~/.cloudflared. - Quick Tunnels now have an optional
--allowed-mailflag that gates the URL behind an email one-time PIN. Cloudflare says it needs an interactive browser session and doesn’t support non-interactive clients. An agent server POSTing to your webhook can’t complete that, so leave it off for A2A callbacks.
For anything iterative, use a named tunnel or another provider’s reserved subdomain.
Step 2: Make the card say the public address. Configure your agent’s public base URL from an environment variable and rebuild the card from it. Then check it from outside your network:
curl -s https://a2a-agent-dev.example.com/.well-known/agent-card.json | jq '.supportedInterfaces'
You should see the tunnel hostname, not localhost. If a peer is on a pre-1.0 card shape, a2aops compat <base-url> prints a v0.x to v1.0 migration checklist. It’s a small third-party beta tool, so treat its output as a hint.
Step 3: Tunnel the webhook receiver. Run the receiver above on its own port with its own public hostname, or a path-routed one.
Step 4: Register and trigger. Create the push config for a long-running task, then start the task. Watch the tunnel’s request inspector (most tools offer one) for the exact headers and body the server sends.
Step 5: Run the negative tests. These matter as much as the happy path:
- Register
https://127.0.0.1/hookandhttps://localhost/hook. A hardened server should reject both. - Register a hostname you control that resolves to a private address, and a decimal-encoded loopback host such as
https://2130706433/hook. Both should be rejected. - Send a request with a wrong or missing token. Your receiver should return 401.
- Replay an old captured notification. Your receiver should reject it as stale or duplicate.
- Return a 5xx from the receiver once and confirm the server retries. Then return a 401 and see whether it stops. Retry behavior varies, and the Rust crate retries only 408, 429 and 5xx responses.
Common pitfalls
- Card and tunnel drift. You restart the tunnel, the hostname changes, and the card (and every stored push config) still advertises the old one.
- Pre-1.0 cards. A peer may serve
/.well-known/agent.jsonor an older schema. v1.0 introduced breaking changes, including a unifiedPartstructure, renamed methods and new card interface fields. - Public tunnel, public agent. A tunnel exposes your dev server to the internet. Don’t put secrets in the card, don’t put production data behind a dev agent, and shut the tunnel down when you’re done.
- Slow webhook handlers. Acknowledge quickly and process asynchronously. The Rust crate defaults to a 10-second request timeout, and the spec recommends 10 to 30 seconds. A slow handler invites retries and duplicate deliveries.
- Shared tokens. Use a unique, single-purpose token per push config.
FAQ
Does an A2A agent card have to be at a public URL?
For discovery by remote clients, yes. It’s served at /.well-known/agent-card.json on the agent’s domain, and the interface URLs inside must be reachable by whoever will call you. Cards can also be fetched from a registry or configured directly.
Why does my A2A server reject my localhost webhook?
It’s almost certainly SSRF protection. Hardened servers block private, loopback and link-local destinations by default. Use a public HTTPS tunnel instead of disabling the guard.
What header carries the A2A push notification token?
The spec’s worked example uses X-A2A-Notification-Token, populated from the token you supplied at registration. The normative text doesn’t name a header, and some implementations also send A2A-Notification-Token, so accept both.
Can I test A2A streaming through a Cloudflare Quick Tunnel?
No. Cloudflare says Quick Tunnels don’t support Server-Sent Events, which A2A streaming uses. Push webhooks work fine over one.
Is A2A stable enough to build on?
v1.0 is the current stable specification, governed under the Linux Foundation and hosted by the Agentic AI Foundation since August 2026. SDKs and integrations are still maturing, so test version support, optional capabilities and authentication end to end with each peer.
Key takeaways
- A local A2A test can need two public addresses: the card’s interface URL for the server, and the webhook URL for the client.
- Whether a localhost webhook works depends on your server’s SDK. Hardened servers refuse private targets on purpose, and a tunnel tests the realistic path where disabling the guard doesn’t.
- Webhook registration is an SSRF surface. Resolve and classify every address with one shared classifier, validate at registration and at delivery, decide how you’ll close the rebinding window, and refuse redirects.
- Use a stable tunnel hostname, authenticate every notification, treat the payload as a trigger to call
GetTask, and run the negative tests.
Sources
- A2A Protocol Specification v1.0.0
- Streaming and Asynchronous Operations (A2A docs)
- A New Chapter for A2A: Joining the Agentic AI Foundation (A2A blog, Aug 27, 2026)
- A2A Protocol Surpasses 150 Organizations (Linux Foundation, Apr 9, 2026)
- A2A Joins AAIF: The 2026 Agent Protocol Consolidation (NerdLevelTech)
- Agent2Agent (A2A) Protocol: v1.0 Guide for 2026 (Turing Post)
- A2A security: The developer’s complete guide (Tyk)
- CVE-2026-71569, Apache Camel A2A webhook SSRF and the Apache Camel advisory
- a2a-python PR #1164, validate push-notification URLs before dispatch
- a2a-protocol-server push sender (docs.rs)
- a2a-js v1.0.0 release
- a2aops on PyPI
- Cloudflare Quick Tunnels documentation
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.