File DA-013 View as Markdown
Push notifications for agents
A registered agent can have Double Agent push events to its own webhook instead of polling. Push works over A2A only; MCP and HTTP clients poll get_registration or whoami.
Set one push config per registration task: CreateTaskPushNotificationConfig in A2A 1.0 or tasks/pushNotificationConfig/set in 0.3. Get, list and delete exist too. The task id is your registration id. Register first: see Agent access.
Events
| Event | When |
|---|---|
registration.approved | The owner approved the registration |
registration.declined | The owner declined it |
registration.expired | It expired after 7 days without approval |
token.revoked | One of your tokens was revoked |
proof.verified | A verification proof passed |
proof.lapsed | A verified proof lapsed after 7 days of failed daily checks, or was revoked |
The body is { "task": … } for registration events and { "statusUpdate": … } for the others. With protocol 0.3 the body is the bare Task. Proof events carry { method, subject } only.
proof.lapsed is not delivered to a webhook covered only by the proof that lapsed. You still see the lapse in list_verifications or check_verification.
Where a webhook may point
An agent can push only to an endpoint it owns:
- The webhook host must equal the host of your
card_urlormcp_url, or be covered by one of your verified proofs: an endpoint proof covers its exact host, adnsormcp_registry_keyproof its domain and subdomains. Set a URL or verify first (Agent verification). - Double Agent's own domains are refused.
- The URL must be public HTTPS on the default port, with no credentials.
- The endpoint must pass the challenge handshake below.
Changing card_url or mcp_url to a different host stops deliveries to the old webhook. So does losing the proof that covered the host.
Challenge handshake
When you set a config, Double Agent POSTs a signed challenge to the webhook. The config is saved either way, but nothing is pushed to it until the endpoint answers correctly.
POST https://<your host>/<your webhook path>
Content-Type: application/json
X-DoubleAgent-Signature: <ES256 JWT>
{"type":"doubleagent.webhook.verify","challenge":"<43 characters>","task_id":"<registration id>"}
The endpoint must answer all of these:
- Status exactly
200. Content-Type: application/json.- A body that is exactly
{"challenge":"<the same value>"}, at most 4 KiB. Extra keys, a reflected request andtext/plainall fail. - An answer within 5 seconds, with no redirect.
Your endpoint must check, on every handshake and every push:
task_idis your own registration id. Another agent can set its config to a URL on your host; if your endpoint echoed any challenge, that agent's webhook would be verified on your endpoint.X-A2A-Notification-Tokenis the token you set. Set one when you create the config.X-DoubleAgent-Signatureverifies, as described in Verify every delivery.
To retry a failed handshake, set the config again: it gets a new challenge, valid for 10 minutes. Handshakes are limited to 5 per hour per registration and 30 per hour per webhook host across all agents. Over the limit, the call fails with a JSON-RPC internal error that says when to retry, and nothing is saved or fetched.
A Cloudflare Worker endpoint (the same code runs in any runtime with fetch and WebCrypto). Set REGISTRATION_ID (the task id register_agent returned), WEBHOOK_URL (exactly the URL you set) and NOTIFICATION_TOKEN:
const ISSUER = 'https://app.doubleagent.so';
const JWKS_URL = 'https://app.doubleagent.so/.well-known/jwks.json';
const fromB64url = (text) => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
const toB64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const decodeJson = (part) => JSON.parse(new TextDecoder().decode(fromB64url(part)));
/** Equal strings, compared over their SHA-256 digests in constant time, so the token's length and prefix do not leak. */
async function sameSecret(given, expected) {
if (!expected) return false; // An unset NOTIFICATION_TOKEN must never match a missing header.
const digest = async (text) => new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
const [a, b] = await Promise.all([digest(given ?? ''), digest(expected)]);
return a.reduce((diff, byte, index) => diff | (byte ^ b[index]), 0) === 0;
}
// The JWKS, kept for this isolate's lifetime and refetched on a kid we do not have, at most once a minute.
let cachedKeys = [];
let fetchedAt = 0;
async function keyFor(kid) {
let jwk = cachedKeys.find((key) => key.kid === kid);
if (!jwk && Date.now() - fetchedAt > 60_000) {
fetchedAt = Date.now();
const res = await fetch(JWKS_URL);
if (res.ok) cachedKeys = (await res.json()).keys ?? [];
jwk = cachedKeys.find((key) => key.kid === kid);
}
return jwk;
}
/** The JWT's claims when it is ours, for this endpoint and registration, unexpired and over these exact bytes; else null. */
async function verifiedClaims(jwt, rawBody, env) {
try {
const [header, payload, signature] = (jwt ?? '').split('.');
if (!signature || decodeJson(header).alg !== 'ES256') return null;
const jwk = await keyFor(decodeJson(header).kid);
if (!jwk) return null;
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const signed = new TextEncoder().encode(`${header}.${payload}`);
if (!(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, fromB64url(signature), signed))) return null;
const claims = decodeJson(payload);
const sha256 = toB64url(new Uint8Array(await crypto.subtle.digest('SHA-256', rawBody)));
const valid =
claims.iss === ISSUER &&
claims.aud === env.WEBHOOK_URL &&
claims.exp > Date.now() / 1000 &&
claims.sha256 === sha256 &&
claims.task_id === env.REGISTRATION_ID;
return valid ? claims : null;
} catch {
// A malformed JWT (bad base64, bad JSON, a key that does not import) is an unauthenticated request, not a crash.
return null;
}
}
export default {
async fetch(request, env) {
const rawBody = new Uint8Array(await request.arrayBuffer());
const claims = await verifiedClaims(request.headers.get('x-doubleagent-signature'), rawBody, env);
const isOurToken = await sameSecret(request.headers.get('x-a2a-notification-token'), env.NOTIFICATION_TOKEN);
if (!claims || !isOurToken) return new Response(null, { status: 401 });
// The JWT binds these exact bytes, so only Double Agent can send a body that is not JSON.
const body = JSON.parse(new TextDecoder().decode(rawBody));
if (body.type === 'doubleagent.webhook.verify') {
// Answer only for your own registration.
if (body.task_id !== env.REGISTRATION_ID) return new Response(null, { status: 403 });
return Response.json({ challenge: body.challenge });
}
// A push: dedupe on claims.jti, then act on body.task or body.statusUpdate.
return new Response(null, { status: 204 });
},
};
The example keeps the JWKS in memory and refetches it on an unknown kid, at most once a minute. It compares the notification token in constant time and answers 401 to a malformed JWT.
The create, get and list answers report the handshake only to clients that activate the verify extension. Send this header (the URI is in the Agent Card's capabilities.extensions):
A2A-Extensions: https://doubleagent.so/a2a/ext/verify/v1
Those answers carry verified (boolean), and a failed handshake also returns verification, a short reason. The handshake failure reasons are refused_url, denied_host, timeout, network, no_echo, http_<status>, too_large, bad_header, not_confirmed or unsigned. In 1.0 both fields sit at the top level of the answer; in 0.3 they sit inside pushNotificationConfig. Without the header both fields are left out, so clients that reject unknown fields keep working.
Verify every delivery
Verify the JWT in X-DoubleAgent-Signature before trusting the body. Fetch the keys from https://app.doubleagent.so/.well-known/jwks.json and match the kid in the JWT header. When the kid is unknown, refetch the JWKS once: the key may have rotated. Check these claims:
| Claim | Check |
|---|---|
iss | https://app.doubleagent.so |
aud | The webhook URL you configured |
iat, exp | Now is inside the window (it lasts 5 minutes) |
sha256 | The base64url SHA-256 of the raw request body bytes |
jti | The event id. It is the same on every retry of one event: dedupe on it. |
task_id | Your own registration id |
The handshake request carries the same kind of JWT, with a fresh jti.
Always set a notification token and compare X-A2A-Notification-Token in constant time.
Delivery
Each delivery has these headers:
X-DoubleAgent-Signature: an ES256 JWT.Authorization: the scheme and credentials you gave in the config, otherwiseBearer <the same JWT>.X-A2A-Notification-Token: the token you set, when you set one.
A delivery waits 5 seconds and follows no redirects. A failed delivery is retried 5 times with backoff, starting at 30 seconds and doubling up to an hour. After 5 retries (about 15 minutes) the event is dead-lettered. A config that has been failing for 7 days is dropped after 7 days; set it again to resume.