Events and webhooks
MarkdownKeep 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.
Registering an endpoint
Section titled “Registering an endpoint”In the dashboard, open your app, then Webhooks → New endpoint.
- Enter your server’s public HTTPS URL.
- Choose the events to receive. Leave the selection empty to receive all types.
- 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
Section titled “Event types”| Type | What it means |
|---|---|
extension.online | One of your user's browsers came online |
extension.offline | One of your user's browsers went offline |
extension.attached | Your user attached a browser to their account: your app can reach it |
extension.detached | Your user detached a browser from their account: your app no longer reaches it |
app_user.created | A user connected their account to your app |
app_user.updated | The approvals of this user changed: a site was approved, refused or withdrawn |
app_user.removed | The user disconnected their account from your app |
stream.opened | The browser opened a live stream you subscribed to |
stream.event | The site pushed an event on a live stream |
stream.closed | A live stream closed: the site ended it, the browser lost it, or the subscription was removed |
app.health.failed | A published version of your service stopped answering its health check |
app.health.recovered | A published version of your service answers its health check again |
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 returns. install_ref is null on an
event about the user rather than one browser, such as app_user.updated.
Users, site decisions and browser presence
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.
presence_changed_at records the published status change; last_seen_at is a throttled heartbeat.
Neither replaces created_at for ordering events.
The request
Section titled “The request”POST /apigami/webhooks HTTP/1.1Host: hooks.example.comContent-Type: application/jsonUser-Agent: Apigami-Webhooks/1Apigami-Event-Id: 6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5fApigami-Event-Type: app_user.updatedApigami-Delivery-Attempt: 1Apigami-Signature: t=1789035151,v1=5f8c…{ "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
Section titled “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.
Signature verification in JavaScript
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:
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.
Trying it out
Section titled “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
Section titled “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.
Retry schedule and disabled endpoints
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.
Reading the journal: GET /events
Section titled “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.
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"Pagination and polling example
| 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:
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.