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

# Receive webhook events

> Get a signed POST when a discovery job finishes or a company, contact or signal changes, instead of polling.

Enrik can tell your system when something happened, so it does not have to poll. Each event is a
small signed `POST` to an HTTPS endpoint you control. It carries identifiers and an outcome, never
record data: your system reads the details through the public API with its own key, so the key's
scopes still decide what it sees.

Webhook events are in **beta**. The event types are `companyDiscoveryJob.terminal` and the record
changes below: `company.*`, `contact.*` and `companySignal.created`.

This page is about webhook **events**. An export delivered to the same destination is a separate
system: it carries `X-CRM-Signature` instead, and a failed one is retried up to 3 attempts within about
30 seconds with the same `t`. See [Security & data handling](https://docs.enrik.ai/security-and-data-handling.md).

> [!NOTE]
>
> Webhook events are being rolled out and are not sent yet. A workspace owner or admin subscribes a
> receiver to event types in the Enrik app (Settings → Webhooks), or with an API key that holds
> `webhooks:write`; see [Manage subscriptions with the API](#manage-subscriptions-with-the-api).

## The event

```json theme={null}
{
  "id": "0199a1b2-0000-7000-8000-00000000a538",
  "type": "companyDiscoveryJob.terminal",
  "version": 1,
  "occurredAt": "2026-10-07T08:00:09.000Z",
  "workspaceId": "0199a1b2-0000-7000-8000-00000000b001",
  "data": { "jobId": "0199a1b2-0000-7000-8000-00000000d001", "status": "completed" }
}
```

* `id` identifies the event. It is the same on every attempt and every redelivery: **deduplicate by
  it**.
* `type` and `version` name the payload. A breaking change is a new version that a subscription opts
  in to; within a version, fields and values may be added, so ignore what you do not know.
* `occurredAt` is when the job reached its outcome.
* `data.status` is the job's own outcome: `completed`, `completed_with_errors`, `failed` or
  `canceled`.

To read the job, call `GET /v1/company-discovery-jobs/{jobId}` with a key that holds
`discovery:read`. The read shows the job as it is now, which may be later than the event (for
example `archived`), or be refused if your key cannot see it.

## Record changes

A subscription can also name the record changes. Each is sent once per change, whoever made it: a
member in the app, an API key, a CSV import, an automation such as enrichment, or the in-app
assistant.

| Type | Sent when | `data` | Read with |
| - | - | - | - |
| `company.created`, `.deleted`, `.restored` | a company is created, trashed, restored | `companyId`, `changeSource`, `apiKeyId` | `GET /v1/companies/{companyId}` |
| `company.updated` | a company's fields change | `companyId`, `changedFields`, `changeSource`, `apiKeyId` | `GET /v1/companies/{companyId}` |
| `contact.created`, `.deleted`, `.restored` | a contact is created, trashed, restored | `contactId`, `changeSource`, `apiKeyId` | `GET /v1/contacts/{contactId}` |
| `contact.updated` | a contact's fields change | `contactId`, `changedFields`, `changeSource`, `apiKeyId` | `GET /v1/contacts/{contactId}` |
| `companySignal.created` | a signal is applied to a company | `signalId`, `companyId` | `GET /v1/company-signals/{signalId}` |

```json theme={null}
{
  "id": "0199a1b2-0000-7000-8000-00000000e102",
  "type": "company.updated",
  "version": 1,
  "occurredAt": "2026-10-09T08:00:00.000Z",
  "workspaceId": "0199a1b2-0000-7000-8000-00000000b001",
  "data": {
    "companyId": "0199a1b2-0000-7000-8000-00000000c001",
    "changedFields": ["industry", "ownerUserId"],
    "changeSource": "api",
    "apiKeyId": "0199a1b2-0000-7000-8000-00000000a0a1"
  }
}
```

* `changedFields` names the fields that changed, never their values: the public field names, and
  `customFields.<key>` for a custom field. An update that changes nothing sends nothing.
* `changeSource` is who made the change: `app`, `api`, `import`, `automation`, `assistant` or
  `system`; values may be added. When it is `api`, `apiKeyId` names the key, so your integration can
  ignore the echo of its own writes and avoid a sync loop.
* Every read needs `crm:read`. A deleted record reads as not found; an `updated` that arrives after a
  `deleted` reads that way too, which is the current state.

## The request

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Enrik-Webhooks/1` |
| `X-Enrik-Event-Id` | The event `id` |
| `X-Enrik-Event-Type` | The event `type` |
| `X-Enrik-Delivery-Id` | This subscription's delivery of the event; stable |
| `X-Enrik-Delivery-Attempt` | The attempt number; it only ever grows |
| `X-Enrik-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`; see below |

The destination's own static headers and bearer token are sent too. They cannot replace any header
above.

## Verify the signature

The signature is HMAC-SHA256 under the destination's signing secret, over the timestamp, a dot and
the raw body exactly as received:

```
v1 = hex(HMAC_SHA256(signing_secret, t + "." + raw_body))
```

1. Split `X-Enrik-Signature` on `,` and read `t` and `v1`.
2. Compute `v1` over the raw request body, before any JSON parsing.
3. Compare in constant time, and reject a `t` more than 300 seconds from your clock.

`t` is fresh on every attempt, so a retry hours later still verifies; the body bytes never change.
Exports to the same destination keep their own `X-CRM-Signature` header, with the same scheme but
the same `t` on every retry.

## Delivery

* **Answer 2xx within 10 seconds.** Anything else is a failed attempt.
* **At least once.** A crash after your endpoint answered can lead to the same event again.
* **Unordered.** Events can arrive in any order; the API read is the source of truth.
* **Retries.** `408`, `429`, `5xx`, timeouts and network errors are retried after about 1 minute,
  5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours (±10 %), up to 8 attempts. A longer
  `Retry-After` is honoured up to the next step. Every other `4xx`, a redirect, or an address the
  egress refuses ends the delivery at once.
* **Bounded.** A delivery is attempted for at most 72 hours. It ends earlier when the subscription is
  disabled or archived, its destination is archived or deleted, or the workspace is deleted, each
  checked before every attempt. A request already sent cannot be recalled.
* **Auto-disable.** After 10 consecutive failed deliveries the subscription is disabled. An owner or
  admin re-enables it in the app; deliveries canceled meanwhile are not sent again on their own.
* **Redelivery.** An owner or admin can resend one delivery while its event is less than 27 days old.
  It keeps the same event id and body, with a fresh retry budget. Events are kept for 30 days.

## Manage subscriptions with the API

Everything Settings → Webhooks does, a key can do too, in beta. Two scopes cover it:

* `webhooks:read` lists subscriptions, the event types they can name and their delivery log.
* `webhooks:write` creates, edits, tests, pauses, resumes and archives subscriptions, rotates their
  signing secret, and redelivers an event. Only a workspace owner or admin can grant it, because only
  they manage subscriptions in the app.

Subscribe a receiver URL to event types. Send an `Idempotency-Key`: the answer carries the signing
secret **once**, and a retry with the same key and body answers the same response, secret included,
for 24 hours.

```bash theme={null}
curl -X POST https://api.enrik.ai/v1/webhook-subscriptions \
  -H "Authorization: Bearer $ENRIK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/enrik","description":"Order sync","eventTypes":["companyDiscoveryJob.terminal"]}'
```

Store `signingSecret` from the answer and verify every request with it, as above. The other
operations:

| Operation | What it does |
| - | - |
| `webhookEventTypes.list` | The event types a subscription can name, and the scope to read what each names |
| `webhookSubscriptions.list` | A page of the subscriptions, newest first; never a secret |
| `webhookSubscriptions.get` | One subscription: its `url`, event types, status and failures |
| `webhookSubscriptions.update` | Change its event types, `url` or `description`; the secret stays |
| `webhookSubscriptions.test` | Send one signed `webhook.ping` now; answers the status and the duration |
| `webhookSubscriptions.rotateSecret` | A new signing secret, answered once; takes an `Idempotency-Key` |
| `webhookSubscriptions.disable` | Pause it: no new deliveries, pending ones canceled |
| `webhookSubscriptions.enable` | Resume it and reset its failure count |
| `webhookSubscriptions.archive` | End it for good, with the destination its `url` create made |
| `webhookEventDeliveries.list` | A page of one subscription's deliveries, newest first |
| `webhookEventDeliveries.get` | One delivery with its attempts: status, error and duration |
| `webhookEventDeliveries.redeliver` | Send the same event again; takes an `Idempotency-Key` |

The lists are pages: pass `pageInfo.endCursor` as `cursor` for the next one. A subscription
disabled after 10 failed deliveries is resumed with `webhookSubscriptions.enable`.

A subscription made with a `url` can move to a new one with `webhookSubscriptions.update`. One that
sends to an export destination with a bearer token keeps its URL there: a new `url` is
`422 validation_error`, because the stored token would follow it to the new receiver. Change that URL
together with its token in the app, under Exports → Destinations.

## Test a subscription

`POST /v1/webhook-subscriptions/{id}/test` (or **Send test** in Settings → Webhooks) sends one
`webhook.ping` to the receiver right away, paused or not, and answers what happened:

```json theme={null}
{ "responseStatus": 200, "durationMs": 182, "errorCode": null }
```

The ping has the headers above and is signed like any event, so it also checks your signature code:

```json theme={null}
{
  "id": "0199a1b2-0000-7000-8000-00000000f538",
  "type": "webhook.ping",
  "version": 1,
  "occurredAt": "2026-10-09T08:00:00.000Z",
  "workspaceId": "0199a1b2-0000-7000-8000-00000000b001",
  "data": { "subscriptionId": "0199a1b2-0000-7000-8000-000000000538" }
}
```

It is one attempt with no retry, and nothing is stored: it never appears in the delivery log, and a
subscription never receives `webhook.ping` otherwise. Acknowledge it with any 2xx.

## Rotate the signing secret

`POST /v1/webhook-subscriptions/{id}/rotate-secret` (or **Rotate secret** in the app) replaces the
signing secret and answers the new one **once**; the old one stops verifying at once, so update your
receiver straight away. Send an `Idempotency-Key`: a retry with the same key answers the same secret
for 24 hours, until the secret is rotated again.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.