> ## Documentation Index
> Fetch the complete documentation index at: https://docs.punchrescue.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive Punch Rescue events in real time — emergency and device lifecycle transitions delivered as HTTP callbacks with at-least-once delivery, retries, dedup, and stable per-entity ordering.

## Overview

Webhooks push every Punch Rescue event to an HTTP endpoint you control, in real time. The moment an emergency is declared, re-categorized, or resolved — or a device goes offline, comes back, loses power, or fails over — Rescue fans the event out to the [base-station devices](/docs/public-api-introduction) in the org **and** to every registered webhook subscriber whose scope covers that org.

Every delivery uses the same stable JSON envelope — the `PublicWebhookEnvelope` — regardless of event type, so you write one parser and switch on `eventType`.

<Note>
  Webhook **subscriptions are registered by a Punch Rescue Admin portal**, not through the Public API — there's no public CRUD for subscriptions in v1. Tell your Punch Rescue contact the URL to call, which events you want, and the partner / enterprise / org scope, and they'll provision it. Everything on this page describes what you'll *receive* once a subscription exists.
</Note>

## How a subscription is scoped

A single subscription can cover one org or thousands. Your Rescue admin configures these knobs at registration:

| Knob                 | What it does                                                                                                                                                                               |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Subject**          | `partner`, `enterprise`, or `org` — the subscription receives events for **every org under that subject**. (A reserved `global` subject covering all orgs exists for Rescue-internal use.) |
| **`allowedOrgIds`**  | Optional whitelist that narrows a partner/enterprise subscription to a subset of its orgs. Forbidden on `org`-subject subscriptions (redundant).                                           |
| **`eventTypes`**     | The exact set of event types this endpoint receives. An event is delivered only if its type is in this list.                                                                               |
| **`categoryFilter`** | Optional list of `categoryId`s. When set, emergency events are delivered only when the emergency's category is in the list. (Does not affect `device.*` events.)                           |
| **`apiVersion`**     | The payload version this subscription is pinned to (see [Versioning](#versioning)).                                                                                                        |
| **Custom headers**   | Arbitrary request headers Rescue attaches to every delivery — used to authenticate the callback (see [Securing your endpoint](#securing-your-endpoint)).                                   |

Because the subject can span many orgs, **always route on the envelope's `orgId`** — it's the org the event actually happened in, not the subscription's subject.

## The delivery envelope

Every webhook is an HTTP `POST` with this top-level JSON body. Only `data` varies by event type.

```json theme={null}
{
  "eventId": "01HXYZABCDEFGHJKMNPQRSTVWX",
  "eventType": "emergency.declared",
  "apiVersion": "2026-05-01",
  "sequence": 1,
  "createdAt": "2026-05-12T14:32:11Z",
  "orgId": "8b0c1234-0000-0000-0000-000000000000",
  "subscriptionId": "01HXSUB0000000000000000000",
  "deliveryId": "01HXDEL0000000000000000000",
  "data": { "...": "event-type-specific; see below" }
}
```

| Field            | Type    | Notes                                                                                                    |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `eventId`        | string  | Stable id for the underlying event, **shared across all subscriptions** that receive it. Your dedup key. |
| `eventType`      | string  | One of the [event types](#event-types) below. Switch on this.                                            |
| `apiVersion`     | string  | The payload version this delivery conforms to — the version pinned on your subscription.                 |
| `sequence`       | integer | Orders events for the same entity. See [Ordering & idempotency](#ordering-amp-idempotency).              |
| `createdAt`      | string  | ISO-8601 (UTC) timestamp the delivery was built.                                                         |
| `orgId`          | string  | The org the event happened in. **Route on this.**                                                        |
| `subscriptionId` | string  | The subscription this delivery was sent to.                                                              |
| `deliveryId`     | string  | Unique per delivery **attempt** — do *not* use it for dedup (retries get a new one).                     |
| `data`           | object  | Event-type-specific payload.                                                                             |

## Event types

A subscription receives only the event types it was registered for. Today's catalog:

| `eventType`                  | `data` shape                 | When it fires                                                                                                                 |
| ---------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `emergency.declared`         | [emergency](#emergency-data) | A new emergency is created in a covered org.                                                                                  |
| `emergency.categorized`      | [emergency](#emergency-data) | An emergency's category changes (initial categorization or re-categorization).                                                |
| `emergency.resolved`         | [emergency](#emergency-data) | An emergency is resolved.                                                                                                     |
| `device.offline`             | [device](#device-data)       | A device (base station, wearable, repeater, or card) crosses its offline threshold. Debounced — re-fires only after recovery. |
| `device.online`              | [device](#device-data)       | A previously-offline device starts reporting again. Carries `offlineDurationMinutes`.                                         |
| `device.power_lost`          | [device](#device-data)       | A repeater reports it has lost mains power and is on battery. One-shot per episode; no paired recovery event.                 |
| `device.connection_switched` | [device](#device-data)       | A base station fails over from Ethernet to cellular. One-shot per episode; no paired recovery event.                          |
| `webhook.ping`               | `{ "message": "ping" }`      | Synthetic test event — fired only on demand (see [Testing your endpoint](#testing-your-endpoint)). Never fires automatically. |

<Note>
  The catalog grows additively. **New event types can appear within your `apiVersion`** — ignore any `eventType` you don't recognize rather than erroring.
</Note>

### Emergency `data`

Payload for the `emergency.*` events.

```json theme={null}
{
  "emergencyId": "01HXEMG0000000000000000000",
  "externalId": "incident-7421",
  "status": "declared",
  "category": { "id": "cat-1", "name": "Medical", "color": "red", "severity": 2 },
  "source": "verkada-camera",
  "description": "Detected at Gate 3",
  "occurredAt": "2026-05-12T14:32:10Z",
  "location": { "latitude": 41.88, "longitude": -87.63, "floorId": "floor-2" },
  "declaredBy": { "type": "api_key", "id": "rk_live_1a2b3c4d", "name": "Verkada bridge" },
  "resolvedBy": null
}
```

| Field         | Notes                                                                                                              |
| ------------- | ------------------------------------------------------------------------------------------------------------------ |
| `emergencyId` | Rescue's id for the incident.                                                                                      |
| `externalId`  | Your correlation id, if you set one on declare. May be `null`.                                                     |
| `status`      | `declared`, `categorized`, or `resolved` — tracks `eventType`.                                                     |
| `category`    | `{ id, name, color, severity }`. `color` and `severity` may be `null`.                                             |
| `source`      | The reporting source (e.g. `verkada-camera`), if known.                                                            |
| `description` | Free-text description, if any.                                                                                     |
| `occurredAt`  | ISO-8601 time the incident occurred.                                                                               |
| `location`    | `{ latitude, longitude, floorId }` — any field may be `null`; whole object is `null` when unknown.                 |
| `declaredBy`  | Actor `{ type, id, name }`. `type` is `user`, `api_key`, or `system`. API-key actors carry the `rk_live_…` key id. |
| `resolvedBy`  | Actor who resolved it — present only on `emergency.resolved`, `null` otherwise.                                    |

### Device `data`

Payload for the `device.*` events.

```json theme={null}
{
  "deviceId": "base-001",
  "deviceType": "base-station",
  "name": "Gym Base Station",
  "status": "online",
  "lastSeen": "2026-05-12T13:20:00Z",
  "detectedAt": "2026-05-12T14:32:11Z",
  "offlineDurationMinutes": 72
}
```

| Field                    | Notes                                                                                                                                                       |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deviceId`               | The device's id.                                                                                                                                            |
| `deviceType`             | `base-station`, `wearable`, `repeater`, or `card`.                                                                                                          |
| `name`                   | Human-friendly device name, if set.                                                                                                                         |
| `status`                 | `offline` / `online` on the offline/online pair. **`null`** on `device.power_lost` and `device.connection_switched` — there the `eventType`*is* the signal. |
| `lastSeen`               | ISO-8601 time the device was last heard from.                                                                                                               |
| `detectedAt`             | ISO-8601 time Rescue detected the transition.                                                                                                               |
| `offlineDurationMinutes` | Whole minutes the device was down. Set on `device.online` only.                                                                                             |

## Delivery semantics

* **Transport.** HTTP `POST` to your subscription URL with the JSON envelope as the body and `Content-Type: application/json`.
* **Headers.** Every request carries `Content-Type: application/json` and `User-Agent: Rescue-Webhook/1.0`, plus any custom headers configured on your subscription. (Custom headers can't override those two reserved names.)
* **Timeout.** Rescue waits up to **10 seconds** for your response. Respond fast — do the real work asynchronously after acking.
* **Success.** Any `2xx` marks the delivery succeeded.
* **At-least-once.** Fan-out is asynchronous and retried, so you may receive the same event more than once. Make your handler idempotent (see below).

### Retries & auto-disable

| Your response                           | What Rescue does                                                                           |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `2xx`                                   | Delivery recorded as **succeeded**.                                                        |
| `4xx` (except `429`)                    | Recorded as **failed**, **not retried** — treated as a permanent rejection of the payload. |
| `5xx`, `429`, network error, or timeout | **Retried** with backoff; exhausted attempts land in a dead-letter queue.                  |

<Warning>
  After **10 consecutive failed deliveries**, Rescue **auto-disables** the subscription (`status` flips to `disabled_failure`) and stops sending. A successful delivery resets the counter. Re-enabling a disabled subscription is a Rescue-admin action — reach out to your contact once your endpoint is healthy again.
</Warning>

## Ordering & idempotency

* **Dedup on `eventId`.** The same `eventId` is sent to every subscription for a given event and is your dedup key. Do **not** dedup on `deliveryId` — that's unique per attempt and changes on retry.
* **Order on `(entity, sequence)`.** Deliveries can arrive out of order. Use `sequence` to order events for the same entity:
  | Event                                              | `sequence` | Order by                  |
  | -------------------------------------------------- | ---------- | ------------------------- |
  | `emergency.declared`                               | `1`        | `(emergencyId, sequence)` |
  | `emergency.categorized`                            | `2`        | `(emergencyId, sequence)` |
  | `emergency.resolved`                               | `3`        | `(emergencyId, sequence)` |
  | `device.offline`                                   | `1`        | `(deviceId, sequence)`    |
  | `device.online`                                    | `2`        | `(deviceId, sequence)`    |
  | `device.power_lost` / `device.connection_switched` | `1`        | one-shot; no paired event |
  | `webhook.ping`                                     | `0`        | —                         |

A robust consumer keys on `eventId`, drops duplicates, and never lets a lower `sequence` overwrite committed state for the same entity.

## Securing your endpoint

<Warning>
  v1 does **not** sign deliveries with an HMAC signature. Authenticate callbacks using the **custom headers** on your subscription. HMAC request signing is on the roadmap as an opt-in enhancement in a future `apiVersion`.
</Warning>

* **Shared-secret header.** Ask your Rescue admin to attach a secret header to your subscription (e.g. `X-Webhook-Token: <random>`) and reject any request that doesn't carry the expected value. Header **values are write-only** — after registration only the header *names* are visible, so a leaked subscription record can't reveal the secret.
* **HTTPS only.** Serve the endpoint over TLS so the secret header and payload aren't sent in the clear.
* **Validate what you receive.** Confirm `orgId` is one you expect and `eventType` is one you handle before acting on a delivery.
* **Don't trust the network alone.** Combine the secret header with allow-listing Rescue's egress where your infrastructure supports it.

## Testing your endpoint

Rescue can fire a synthetic **`webhook.ping`** at your subscription so you can confirm reachability, auth headers, and parsing before any real event flows. Ask your Rescue admin to trigger a ping (for partner/enterprise subscriptions they pick which `orgId` the synthetic envelope carries). You'll receive a standard envelope:

```json theme={null}
{
  "eventId": "01HXPING000000000000000000",
  "eventType": "webhook.ping",
  "apiVersion": "2026-05-01",
  "sequence": 0,
  "createdAt": "2026-05-12T14:32:11Z",
  "orgId": "8b0c1234-0000-0000-0000-000000000000",
  "subscriptionId": "01HXSUB0000000000000000000",
  "deliveryId": "01HXDEL0000000000000000000",
  "data": { "message": "ping" }
}
```

Return any `2xx` and you're wired up.

## Versioning

`apiVersion` is **date-versioned** (`YYYY-MM-DD`) and **pinned at subscription creation**. The current version is **`2026-05-01`**.

* **Additive changes ship within a version** — new fields and new event types can appear without a version bump. Build tolerantly: ignore unknown fields and unknown `eventType`s.
* **Breaking changes ship a new `apiVersion`.** Old versions keep flowing to subscriptions pinned to them until you migrate, so an upgrade is never forced on you mid-integration.

## Best practices

<AccordionGroup>
  <Accordion title="Ack fast, process later" icon="bolt">
    Return `2xx` within the 10-second window, then do the heavy lifting (DB writes, downstream calls) asynchronously. Slow handlers get treated as timeouts and retried.
  </Accordion>

  <Accordion title="Make handlers idempotent" icon="copy">
    Delivery is at-least-once. Dedup on `eventId` and design writes so re-processing the same event is a no-op.
  </Accordion>

  <Accordion title="Order with sequence, not arrival time" icon="arrow-down-1-9">
    Never assume `declared` arrives before `resolved`. Order on `(emergencyId, sequence)` / `(deviceId, sequence)` and ignore stale, lower-sequence updates.
  </Accordion>

  <Accordion title="Tolerate growth" icon="seedling">
    New event types and new `data` fields can appear within your `apiVersion`. Switch on known `eventType`s and ignore the rest instead of erroring.
  </Accordion>

  <Accordion title="Keep your endpoint healthy" icon="heart-pulse">
    Ten consecutive failures auto-disable the subscription. Monitor your `2xx` rate and return `5xx` (not `4xx`) for transient problems so Rescue retries instead of giving up.
  </Accordion>
</AccordionGroup>

<Tip>
  Debugging a missed or malformed delivery? Reach out to your Rescue contact with the `eventId`, `deliveryId`, and `subscriptionId` — that triple lets us trace exactly what we sent and when.
</Tip>
