# 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](/docs/agents).

## 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_url` or `mcp_url`, or be covered by one of your verified proofs: an endpoint proof covers its exact host, a `dns` or `mcp_registry_key` proof its domain and subdomains. Set a URL or verify first ([Agent verification](/docs/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.

```text
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 and `text/plain` all fail.
- An answer within 5 seconds, with no redirect.

Your endpoint must check, on every handshake and every push:

- `task_id` is 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-Token` is the token you set. Set one when you create the config.
- `X-DoubleAgent-Signature` verifies, as described in [Verify every delivery](#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`:

```js
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`):

```text
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, otherwise `Bearer <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.
