# Push notifications (/docs/a2a/push)



```ts
import { A2aPushVerificationError, verifyA2aPush } from "@nylorun/agents/a2a";

export async function POST(request: Request) {
  try {
    const update = await verifyA2aPush({
      secret: process.env.A2A_PUSH_SECRET!, // whsec_…, the Runtime's push secret
      headers: request.headers,
      body: await request.text(),
    });
    await apply(update); // idempotent: deliveries can repeat
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof A2aPushVerificationError) return new Response(null, { status: 401 });
    throw error;
  }
}
```

An Agent does not stream: its card says `streaming: false`. Instead, the
Runtime posts each status and artifact update of a task to a URL the caller
gives, signed with [Standard Webhooks](https://www.standardwebhooks.com).
Deliveries are at least once, retried for up to 24 hours. The
[change feed](/docs/a2a/client#the-change-feed) covers anything a receiver
missed.

## Turn push on [#turn-push-on]

The operator gives the Runtime a push secret. Without one, the card says
`pushNotifications: false`, and a push config is refused with
`PushNotificationNotSupportedError`.

| Variable                       | Value                                                                                                                                                                         |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NYLORUN_A2A_PUSH_SECRET`      | A Standard Webhooks secret: `whsec_` and the base64 of at least 24 bytes. It signs every push; receivers verify with it.                                                      |
| `NYLORUN_A2A_PUSH_ALLOW_HOSTS` | Comma-separated `host:port` entries, or `host:*` for any port, that a push may reach although they are private, loopback, or link-local. See [Allowed hosts](#allowed-hosts). |

`npx nylorun start` passes both from its shell to the Runtime:

```sh
NYLORUN_A2A_PUSH_SECRET="whsec_$(openssl rand -base64 32)" npx nylorun start
```

Give each receiver the same secret, and keep it on the receiver's server.

## Give a push config [#give-a-push-config]

Send the config with the message, in `configuration`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "SendMessage",
  "params": {
    "message": {
      "role": "ROLE_USER",
      "messageId": "m-1",
      "contextId": "ticket-4821",
      "parts": [{ "text": "Refund order demo-123." }]
    },
    "configuration": {
      "returnImmediately": true,
      "taskPushNotificationConfig": {
        "url": "https://app.example.com/a2a/push",
        "token": "c2b1…"
      }
    }
  }
}
```

| Field            | Required | Meaning                                                                                                        |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `url`            | Yes      | Where the Runtime posts each update: an `http` or `https` URL the Runtime may reach.                           |
| `token`          | No       | Sent back on every delivery as `X-A2A-Notification-Token`, so the receiver can match a delivery to the config. |
| `authentication` | No       | `{ scheme, credentials }`, sent on every delivery as `Authorization: <scheme> <credentials>`.                  |
| `id`             | No       | The config's id within its task. The Runtime makes one when it is absent.                                      |

A URL the Runtime will not call is refused when the config is given, with
`InvalidParamsError`. A task keeps at most 10 configs. The four config
methods, `CreateTaskPushNotificationConfig`, `GetTaskPushNotificationConfig`,
`ListTaskPushNotificationConfigs`, and `DeleteTaskPushNotificationConfig`, add,
read, and remove them on a task that exists. A finished task's configs are
kept for 7 days. Each method is a JSON-RPC request to
[`POST /v1/a2a/agents/{agentId}`](/reference/runtime/sessions/a2a/call-an-agent-over-a2a).
With the SDK, pass `push` to `a2a.send()`, or use `a2a.call()` for the config
methods; see
[A2A client](/docs/a2a/client#send-a-task).

A config carries the data of the extensions its request activated with
`A2A-Extensions`, or of all five when the request named none.

## Deliveries [#deliveries]

Each delivery is an HTTP `POST` to the config's `url`. The body is one A2A
`StreamResponse` object:

| Body                      | When                                                                                                                                               |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ "statusUpdate": … }`   | The task's state changed, the agent wrote step text, or it carries new extension data, such as a plan from [`progress/v1`](/docs/a2a/progress/v1). |
| `{ "artifactUpdate": … }` | The turn wrote a file, under [`files/v1`](/docs/a2a/files/v1), or ended with its `output`.                                                         |

| Header                     | Value                                                                                                                   |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`             | `application/json`                                                                                                      |
| `X-A2A-Notification-Token` | The config's `token`, when it has one.                                                                                  |
| `Authorization`            | The config's `authentication`, when it has one.                                                                         |
| `webhook-id`               | The delivery's id, the same on every attempt.                                                                           |
| `webhook-timestamp`        | When this attempt was signed, in Unix seconds.                                                                          |
| `webhook-signature`        | `v1,` and the base64 of HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the push secret's bytes. |

Every `statusUpdate` carries `status.timestamp`, the time of the event it
reports. Deliveries can repeat and can arrive out of order: drop a
`webhook-id` you have applied, order a task's updates by `status.timestamp`,
and let an update older than the one you hold change nothing.

## Verify a delivery [#verify-a-delivery]

`verifyA2aPush()` checks the signature and the timestamp, and resolves to the
parsed body. It is async. Pass the body exactly as received, as a string or
bytes, before any JSON parsing.

| Option             | Meaning                                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------- |
| `secret`           | The push secret, `whsec_…`.                                                                 |
| `headers`          | The request's headers: a `Headers`, `node:http`'s `IncomingHttpHeaders`, or a plain record. |
| `body`             | The raw body, a string or a `Uint8Array`.                                                   |
| `toleranceSeconds` | How far `webhook-timestamp` may be from now, either way. Default 300.                       |
| `token`            | The config's `token`, when you gave one: `X-A2A-Notification-Token` must match it.          |

A delivery that fails a check throws `A2aPushVerificationError`, whose
`reason` is `missing_headers`, `invalid_secret`, `invalid_timestamp`,
`timestamp_out_of_range`, `invalid_signature`, `invalid_token`, or
`invalid_body`. Answer it with `401` and drop it.

With Node's own crypto:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPush(secret: string, headers: Headers, body: string): unknown {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new Error("unsigned delivery");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("stale delivery");

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("bad signature");
  return JSON.parse(body);
}
```

`webhook-signature` can hold several space-separated `v1,…` entries; one that
matches verifies the delivery. Compare in constant time, as above.

## Retries [#retries]

A delivery ends when the receiver answers `2xx`, or `410` to say it is gone.
Any other answer, a timeout of 10 seconds, or a failed connection is sent
again, with the same `webhook-id` and body. The wait starts at 5 seconds and
doubles after each failure, up to 10 minutes, and the Runtime drops the
delivery once it is 24 hours old. A delivery whose URL has become refused is
dropped at once.

Answer quickly and do the work after. A receiver that was down for longer than
24 hours, or that refused deliveries, catches up from the
[change feed](/docs/a2a/client#the-change-feed) and `GetTask`.

## Allowed hosts [#allowed-hosts]

The Runtime sends pushes through its address checks, as it calls HTTP tools,
with one difference: a push never goes to a private, loopback, or link-local
address unless that `host:port` is in `NYLORUN_A2A_PUSH_ALLOW_HOSTS`, whatever
`NYLORUN_ENDPOINT_PRIVATE` says. Plain `http` follows
`NYLORUN_ENDPOINT_HTTP`; see [Private addresses](/docs/deploy/vm#private-addresses).

```sh
NYLORUN_A2A_PUSH_ALLOW_HOSTS=172.17.0.1:18787,receiver.internal:*
```

The same list governs the files the Runtime fetches from a message's `url`
parts.
