---
key: api-webhooks
title: Receive webhooks when records change
audience: developer
section: api/webhooks
routes: ["/settings/org/integrations/webhooks/endpoints"]
since: 0.0.1
updated: 0.0.1
status: published
summary: Subscribe an endpoint to Nova events, verify the signature on every delivery, and replay what you missed.
keywords: [webhooks, events, signature, hmac, delivery, replay]
related: [api-overview, api-conventions, api-record-identity, api-errors]
---

Webhooks tell your system when something changed so you do not have to poll.
An admin opens **Administration**, then **Webhooks** under
**Integrations & API** (`/settings/org/integrations/webhooks/endpoints`),
presses **Create endpoint**, enters your HTTPS address, chooses the events,
and Nova shows the signing secret once. A new endpoint starts switched off:
your receiver has to pass a verification check first, then the endpoint is
switched on.

## Verify the endpoint and switch it on

**Verify endpoint** (or `POST /v1/integrations/webhooks/{endpoint_uid}/verify`)
sends one request to your address with `X-Nova-Webhook-Mode: verify` and a
one-time token in `X-Nova-Webhook-Token`; the JSON body carries the same
`token`. Answer `2xx` and echo the token back, either in an
`X-Nova-Webhook-Token` response header or in a JSON body:

```json
{ "token": "<the token Nova sent>" }
```

Once verified, switch deliveries on. Through the API you can do both in one
call, `POST …/verify` with `{"enable_on_success": true}`, or switch on a
verified endpoint with `PATCH /v1/integrations/webhooks/{endpoint_uid}` and
`{"enabled": true}`. Switching on an endpoint that is not verified answers
`400`.

## What a delivery looks like

Each delivery is a `POST` with `Content-Type: application/json` and these
headers:

| Header | Value |
|---|---|
| `X-Nova-Event` | The event name, for example `crm.account.updated` |
| `X-Nova-Canonical-Event` | The current name of the event, when you subscribed under an older alias |
| `X-Nova-Delivery` | Unique per delivery; use it to ignore duplicates |
| `X-Nova-Timestamp` | Unix seconds when the delivery was signed |
| `X-Nova-Signature` | `t=<timestamp>,v1=<hex HMAC-SHA256>` |
| `X-Nova-Signature-Previous` | Same format, signed with the old secret, while a rotation is in progress |
| `X-Nova-Occurred-At` | When the change happened (ISO 8601) |
| `User-Agent` | `Nova-Webhooks/1.0` |

The body is an envelope around the event's data:

```json
{
  "spec_version": "2026-03-30",
  "schema_version": "2026-03-30",
  "organization_uid": "org_3f9a1c2b7d4e5f60",
  "event_name": "crm.account.updated",
  "canonical_event_name": "crm.account.updated",
  "event_id": "5f2c8e1a9b3d4c7e8f6a2b1c0d9e8f7a",
  "delivery_id": "0b6f5a4e-2c3d-4e5f-8a9b-1c2d3e4f5a6b",
  "timestamp": "2026-09-26T14:03:12.481Z",
  "occurred_at": "2026-09-26T14:03:11.902Z",
  "subject": { "type": "account", "id": "acct_a1b2c3d4e5f6a1b2" },
  "resource": { "type": "account", "id": "acct_a1b2c3d4e5f6a1b2" },
  "data": { "uid": "acct_a1b2c3d4e5f6a1b2", "display_name": "Harper Residence" }
}
```

The envelope also carries `scope_tier`, `classification`, `actor`,
`correlation_id`, `causation_id`, `ordering_key` and `legacy_aliases`.
Records are named by their opaque id (see [[api-record-identity]]). Fetch the
record through the API when you need more than `data` carries. A payload too
large to send whole arrives as the envelope with a marker instead of `data`;
fetch the record in that case.

## Verify the signature

1. Read `t` and `v1` from `X-Nova-Signature`.
2. Compute HMAC-SHA256 with your secret over `t + "." + raw_body` (the exact
   bytes you received), hex encoded.
3. Compare it to `v1` in constant time. During a rotation, also accept a match
   on `X-Nova-Signature-Previous`.
4. Reject a `t` more than five minutes old, then answer `2xx` quickly and do
   the work afterwards.

```python
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = parts["t"].encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts["v1"])
```

## Events

The **Event catalog** tab on the webhooks page lists every event with a sample
payload. The same list is at `GET /v1/integrations/webhooks/catalog`.

## Retries, dead letters and replay

A delivery that does not get a `2xx` is retried with growing, jittered waits,
up to the endpoint's attempt limit (10 at most). After that it is marked
dead-lettered. An endpoint that keeps failing is paused for a cool-down and
shows as unhealthy under **Endpoint health**.

From **Delivery history** an admin can **Retry** one delivery or
**Retry filtered**, and **Replay** a time range after an outage. Make your
handler idempotent on `X-Nova-Delivery`, because a retry or a replay can send
the same change twice.

## If something looks different

The webhooks page cannot open an endpoint's edit panel today (it shows
"Invalid webhook link."), so switching an endpoint on, renaming it or changing
its events has to go through the API calls above until that is fixed.

## Rotating the secret

Press **Rotate secret** on the endpoint, or call
`POST /v1/integrations/webhooks/{endpoint_uid}/rotate-secret`. For a grace period Nova signs with
both secrets and sends the old signature in `X-Nova-Signature-Previous`, so
you can switch without dropping deliveries.
