---
source: https://a2a.exposed/docs/pairing-events
---

# Pairing events

When `EVENT_URL` and `EVENT_SECRET` are set, the agent POSTs a JSON event after a pairing request is created and after it is approved or denied. On the dashboard host that URL is `https://<dashboard-host>/api/pairing-events`. The navbar count is the open-request total from the latest accepted event.

A pairing code lasts 60 minutes. `expiresAt` is that deadline.

## What the agent sends

The JSON body uses these fields, in this order. `decision` is present when `type` is `pairing_decided` and the value is `approved` or `denied`.

| Field | Meaning |
|---|---|
| `type` | `pairing_requested` or `pairing_decided` |
| `agent` | Public host from the agent URL, or the tenant id |
| `requestId` | `device_hash`, the hash of the device code |
| `code` | The code a person reads, such as `WDJB-4827` |
| `name` | `client_name` from the device request. An empty string when it is absent |
| `alias` | `client_id` from the device request. An empty string when it is absent |
| `ip` | Client IP. A decided event whose row leaves the IP blank sends an empty string |
| `country` | Cloudflare `cf.country`. An empty string when it is absent |
| `createdAt` | When the request was created, ISO 8601 |
| `expiresAt` | When the code expires, ISO 8601 |
| `pending` | Count of pending requests still inside the 60-minute window, read when the event is sent |
| `decision` | `approved` or `denied`. Only on `pairing_decided` |

A request that just opened:

```json
{
  "type": "pairing_requested",
  "agent": "northwind.a2a.exposed",
  "requestId": "device-hash",
  "code": "WDJB-4827",
  "name": "Support",
  "alias": "support",
  "ip": "203.0.113.10",
  "country": "DE",
  "createdAt": "2026-10-16T12:00:00.000Z",
  "expiresAt": "2026-10-16T13:00:00.000Z",
  "pending": 1
}
```

A decided event is the same object with `"type": "pairing_decided"` and `"decision": "approved"` or `"decision": "denied"` after `pending`.

## Signature

`x-a2a-timestamp` is unix seconds. `x-a2a-signature` is `sha256=` plus the HMAC-SHA256 hex of the timestamp, a dot, and the raw JSON body. The key is `EVENT_SECRET`.

```js
import { createHmac } from "node:crypto";

const event = {
  type: "pairing_requested",
  agent: "northwind.a2a.exposed",
  requestId: "device-hash",
  code: "WDJB-4827",
  name: "Support",
  alias: "support",
  ip: "203.0.113.10",
  country: "DE",
  createdAt: "2026-10-16T12:00:00.000Z",
  expiresAt: "2026-10-16T13:00:00.000Z",
  pending: 1,
};
const secret = process.env.EVENT_SECRET;
const body = JSON.stringify(event);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`;
```

The POST sends `content-type: application/json`, `x-a2a-timestamp`, and `x-a2a-signature`. The receiver replies HTTP 204.

The dashboard stores the count when the timestamp is within 5 minutes and the signature matches a known agent. An unsigned, bad-signature, stale, replayed, or unknown-agent event leaves the count unchanged.

An accepted event updates the open dashboard. The account's tabs receive the new total on a WebSocket at `/app/pending-live`, and the navbar badge, the tab title, the agent cards, and a visible pairing list follow it. Events reach open tabs instantly. Focusing the window, or returning to the tab, reads `GET /app/pending-count` and updates the badge. That read uses the signed-in session. It waits for an agent that does not send events, then returns the stored total. An agent that already sends events skips that wait.

Approval itself is on [approving a request](/docs/approving).
