# Events and webhooks

> Receive updates when a user connects, a browser comes online or goes offline, or site access changes. Set up a webhook and try a signed test event.

Keep your app up to date when a user connects or disconnects, one of their browsers is attached,
comes online or goes offline, or their site access changes.
Apigami can send these updates to your server as webhooks. You can also read them with
[`GET /events`](#reading-the-journal-get-events).

## Registering an endpoint

In the [dashboard](https://app.apigami.com), open your app, then **Webhooks** → **New endpoint**.

1. Enter your server's public HTTPS URL.
2. Choose the events to receive. Leave the selection empty to receive all types.
3. Copy the signing secret into your server's configuration. It's shown only once.

Verify each request's signature, save the event for processing, and return a `2xx` response
within 10 seconds. Apigami retries failed deliveries.

## Event types

| Type | Who receives it | What it means | `data` |
|---|---|---|---|
| `extension.online` | Every app connected to the browser's user | One of your user's browsers came online | `flavor` string, `presence_changed_at` timestamp, `last_seen_at` timestamp |
| `extension.offline` | Every app connected to the browser's user | One of your user's browsers went offline | `flavor` string, `presence_changed_at` timestamp, `last_seen_at` timestamp, `reason` string |
| `extension.attached` | Every app connected to the browser's user | Your user attached a browser to their account: your app can reach it | `label` string, `flavor` string, `presence` string |
| `extension.detached` | Every app connected to the browser's user | Your user detached a browser from their account: your app no longer reaches it |  |
| `app_user.created` | The app it names | A user connected their account to your app | `connected_at` timestamp, `grants` array |
| `app_user.updated` | The app it names | The approvals of this user changed: a site was approved, refused or withdrawn | `connected_at` timestamp, `grants` array, `change` object |
| `app_user.removed` | The app it names | The user disconnected their account from your app |  |
| `stream.opened` | The app it names | The browser opened a live stream you subscribed to | `stream_id` uuid, `domain` string, `seq` integer, `status` integer, `headers` object |
| `stream.event` | The app it names | The site pushed an event on a live stream | `stream_id` uuid, `domain` string, `seq` integer, `event` object |
| `stream.closed` | The app it names | A live stream closed: the site ended it, the browser lost it, or the subscription was removed | `stream_id` uuid, `domain` string, `seq` integer, `reason` string, `status` integer, `error` string, `retry_in_ms` integer |
| `app.health.failed` | The app's own team | A published version of your service stopped answering its health check | `app_id` uuid, `version_id` uuid, `production` boolean, `checked_at` timestamp, `error` string |
| `app.health.recovered` | The app's own team | A published version of your service answers its health check again | `app_id` uuid, `version_id` uuid, `production` boolean, `checked_at` timestamp |

Each event includes an `id`, a `type`, a `created_at` timestamp, your `app_id`, the `user_ref`
of the user it concerns and the `install_ref` of the browser, the same references
[`POST /connect/token`](/docs/connect/#3-exchange-the-code) returns. `install_ref` is `null` on an
event about the user rather than one browser, such as `app_user.updated`.

<details>
<summary>Users, site decisions and browser presence</summary>

`app_user.created` and `app_user.updated` carry the user's complete `grants` list, including
refusals. Each entry has a `domain`, `granted` and `decided_at`. A missing domain hasn't been
requested; `granted: false` means access is denied. Approvals belong to the user, so one list
covers all their browsers.

On `app_user.updated`, `change` describes the decision. A revocation has
`decision: "denied"` and `was_granted: true`; a first refusal has `was_granted: false`.

A user who disconnects from your app produces `app_user.removed`; their decisions are kept. If
they connect again, `app_user.created` includes those decisions.

`extension.attached` tells you a user added a browser your app can now reach, with the name they
gave it and its `presence`; `extension.detached` that one left their account. A user detaches a
browser from their account page or, from Apigami 0.15.0, from Apigami's settings in that browser:
your streams for that user on it close with `detached`, and the user's approvals stay, for their
other browsers. The other accounts attached to that browser keep it. `extension.online`
and `extension.offline` go to every app connected to the browser's user.

Brief connection interruptions don't produce presence events. A browser that's gone offline is
announced within about three minutes. For an immediate check, use
[`GET /v1/users/{user_ref}/installs`](/docs/connect/#a-users-browsers).

`presence_changed_at` records the published status change; `last_seen_at` is a throttled heartbeat.
Neither replaces `created_at` for ordering events.

</details>

## The request

```http
POST /apigami/webhooks HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Apigami-Webhooks/1
Apigami-Event-Id: 6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f
Apigami-Event-Type: app_user.updated
Apigami-Delivery-Attempt: 1
Apigami-Signature: t=1789035151,v1=5f8c…
```

```json
{
  "id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "type": "app_user.updated",
  "created_at": "2026-09-10T10:12:31.128Z",
  "app_id": "2b7f6a41-0c9d-4a3e-9f21-8d5c4b3a2109",
  "user_ref": "usr_3f9c1a0e7b2d4c58a61e9f0b2d7c4e13",
  "install_ref": null,
  "data": {
    "connected_at": "2026-09-01T08:00:00.000Z",
    "grants": [
      { "domain": "acme.com", "granted": false, "decided_at": "2026-09-10T10:12:31.128Z" },
      { "domain": "example.com", "granted": true, "decided_at": "2026-09-02T09:30:00.000Z" }
    ],
    "change": { "domain": "acme.com", "decision": "denied", "was_granted": true }
  }
}
```

The dashboard shows the delivery status and the first kilobyte of your response body.
A short error message helps you diagnose failed attempts.

## Verifying the signature

Check the `Apigami-Signature` header using the raw request body and your signing secret.
Do this before parsing JSON. The example below checks the signature and rejects timestamps more
than five minutes from the current time.

<details>
<summary>Signature verification in JavaScript</summary>

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

// rawBody: the request body exactly as it arrived, as a string. secret: the whsec_… value.
export function verifySignature(rawBody, header, secret) {
  const fields = (header ?? '').split(',').map((field) => field.trim().split('='))
  const t = fields.find(([key]) => key === 't')?.[1]
  const signatures = fields.filter(([key]) => key === 'v1').map(([, value]) => value)
  if (!t || !/^\d+$/.test(t) || !Number.isSafeInteger(Number(t)) || signatures.length === 0) return false

  // Fresh: the attempt is signed with its own time, so a replay stops verifying five minutes later.
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  return signatures.some((signature) => {
    if (!/^[a-f0-9]{64}$/i.test(signature ?? '')) return false
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}
```

Read the raw body before a JSON parser changes it. For example, with a text-body middleware:

```js
app.post('/apigami/webhooks', express.text({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req.body, req.get('Apigami-Signature'), process.env.APIGAMI_WEBHOOK_SECRET)) {
    return res.sendStatus(400)
  }
  const event = JSON.parse(req.body)
  await queue.add(event) // Persist to your job queue before acknowledging.
  res.sendStatus(200)
})
```

Use a durable job queue for `queue.add` and handle failures through your server's error handler.
Register the raw-body route before any global JSON parser.

The signature is `HMAC-SHA256` over `` `${t}.${rawBody}` ``, using the full `whsec_…` secret
as UTF-8. Every retry has a fresh timestamp. If multiple `v1` signatures are present,
accept any valid match.

</details>

## Trying it out

Choose **Send a test** in the dashboard. It sends a signed `webhook.test` event and shows
your endpoint's response status and time. Use it to check the signature verification.

Return `200` for test events. They have an empty `data` object and null `user_ref` and `install_ref`;
they aren't retried or included in `GET /events`.

## Delivery and retries

Process each event once using its `id`. Delivery order can change during retries:
when updating a user's or a browser's state, keep the event with the newest `created_at`.

<details>
<summary>Retry schedule and disabled endpoints</summary>

A failed delivery gets seven retries after the first attempt: after 1 minute, 5 minutes,
30 minutes, 2 hours, 8 hours, 24 hours and 48 hours from the previous attempt.
After eight failed attempts, that delivery stops. Events are retried independently.

Redirects, non-`2xx` responses and timeouts count as failures. Only public HTTPS endpoints are
accepted; the address is checked before each attempt and redirects aren't followed.
Each matching endpoint gets its own delivery. Event filters accept types and namespaces
such as `app_user.*`.

After seven days of continuous failures, the endpoint is disabled and pending deliveries fail.
Fix and re-enable it in the dashboard, or update its URL. It resumes with new events;
use `GET /events` to recover what you missed.

Keep processed event IDs for at least four days to cover the retry window. If you also poll,
deduplicate those events using the same IDs. `created_at` determines which state is newest,
including for presence events; `last_seen_at` and `presence_changed_at` don't.

</details>

## Reading the journal: `GET /events`

Events are available for 30 days. Read them with your app's API key to recover missed deliveries,
or use polling if you don't have a public webhook URL.

```bash
curl -G https://api.eu.apigami.com/events \
  -H "Authorization: Bearer $APIGAMI_API_KEY" \
  --data-urlencode "since=2026-09-10T00:00:00Z" \
  --data-urlencode "types=app_user.*,extension.offline" \
  --data-urlencode "limit=100"
```

<details>
<summary>Pagination and polling example</summary>

| Parameter | |
|---|---|
| `since` | An ISO timestamp; events strictly after it. Default: 30 days ago |
| `cursor` | The `next` of a previous answer. It encodes a position, not a row, so a cursor kept across the 30-day purge still works. `cursor` wins over `since` |
| `types` | Comma-separated types or `<namespace>.*`. An unknown type matches nothing |
| `limit` | Default 100, clamped to 1000 |

The answer is `{ "events": [...], "next": "<cursor>" | null }`, oldest first, ordered by
`(created_at, id)`. Only your own events come back: the key names the app, and there is no parameter
to name another one. API keys only — a dashboard session or a personal access token answers
`401 bad client credentials`, as on `POST /call`.

To poll instead of receiving, keep the last `next` and ask again:

```js
let cursor = await store.get('apigami:cursor') // null the first time

for (;;) {
  const query = cursor ? { cursor } : { since: '2026-09-10T00:00:00Z', limit: '100' }
  const url = `https://api.eu.apigami.com/events?${new URLSearchParams(query)}`
  const { events, next } = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.APIGAMI_API_KEY}` },
  }).then((r) => r.json())

  for (const event of events) await handle(event) // idempotent, by event.id

  // Store the cursor only once the page is handled: a crash then costs a repeat, never a gap.
  if (next) await store.set('apigami:cursor', (cursor = next))
  if (events.length === 0) await sleep(30_000)
}
```

Full parameters and shapes: [`GET /events`](/docs/api/operations/listevents/).

</details>