# CRM change webhooks

URL: /docs/webhooks

Get a signed HTTPS request whenever a contact, company, task or lead changes in a store's CRM, with ids and field names only.

CRM webhooks tell your systems about changes in a store's CRM as they happen: a new contact, a stage change, a completed task, a new lead. Each event is posted as JSON to your HTTPS endpoint and signed with that endpoint's own secret. Payloads carry ids, stage keys and the names of the fields that changed, never personal details. To read the values, your receiver calls the merchant CRM API.

## Set up an endpoint [#set-up-an-endpoint]

In the store admin, go to **Settings → CRM → Webhooks** and add an endpoint with its URL, the events it wants and a description.

- The URL must be `https://` and must resolve to a public address. Private, loopback and reserved addresses are refused, as is a name that does not resolve.
- A store can have up to 20 endpoints.
- Each endpoint has its own signing secret, starting `whsec_`. It is shown **once**, when the endpoint is created. Copy it into your receiver's configuration straight away.
- **New secret** makes a new one, shown once. The old secret stops working at once, so update your receiver at the same time.
- **Send a test** sends one signed `webhook.test` event now.
- **Switch off** pauses an endpoint: changes send nothing to it until you switch it back on.

The signing secret is per endpoint. It is never the store's API secret.

## Events [#events]

| Event | When | `data` |
|---|---|---|
| `contact.created` | A contact got its CRM record (a form, a message, an order, the admin, the API…). | `object`, `id`, `changed`, `actor` |
| `contact.updated` | A contact's CRM fields changed: name, email, phone, title, stage, owner, company, tags or custom fields. | `object`, `id`, `changed`, `actor` |
| `contact.merged` | Two contacts were merged. | `object`, `id` (the contact that stays), `merged_id`, `merge_id` |
| `contact.erased` | A contact was erased. | `object`, `id`, and nothing else |
| `company.created` | A company was created. | `object`, `id`, `changed`, `actor` |
| `company.updated` | A company changed. | `object`, `id`, `changed`, `actor` |
| `stage.changed` | A contact's or company's lifecycle stage changed. Sent as well as the `*.created` or `*.updated` event. | `object`, `id`, `from`, `to`, `actor` |
| `task.created` | A task was created, including the next task of a recurring one. | `object` (`task`), `id`, `records`, `actor` |
| `task.completed` | A task was completed. | `object` (`task`), `id`, `records`, `next_task_id`, `actor` |
| `lead.created` | A new lead opened. | `object` (`lead`), `id`, `contact_id`, `source`, `owner_id` |
| `webhook.test` | **Send a test** was pressed. | `object` (`endpoint`), `id`, `message` |

- `actor.type` says who made the change: `staff`, `system`, `ai`, `import`, `flow`, `api` or `merge`.
- `changed` holds field names: `first_name`, `last_name`, `title`, `email`, `phone`, `lifecycle_stage`, `owner`, `company`, `tags` and `custom.{key}` for contacts; `name`, `domains`, `address_*`, `industry`, `size` and `is_sole_trader` for companies.
- `from` and `to` are stage keys, such as `lead` and `customer`.
- `records` lists what a task is about, such as `contact:12`.

## The payload [#the-payload]

Every event has the same envelope:

```json title="POST to your endpoint"
{
  "id": "01JB3K8Q2V7N4M6P9R1T3W5Y7Z",
  "type": "contact.updated",
  "created_at": "2026-10-24T09:30:00+00:00",
  "store": "hearth-and-oak",
  "api_version": "2026-10-01",
  "data": {
    "object": "contact",
    "id": 12,
    "changed": ["email", "lifecycle_stage"],
    "actor": { "type": "api" }
  }
}
```

| Field | Meaning |
|---|---|
| `id` | The event id. The same on every retry of this event. |
| `type` | The event, as in the table above. |
| `created_at` | When the event happened, ISO 8601 in UTC. |
| `store` | The store's slug. |
| `api_version` | The payload version, `2026-10-01`. |
| `data` | The event's details. |

**Never sent:** names, email addresses, phone numbers, postal addresses, custom field values, tag values, note or task text, or message content. A sensitive custom field's name can appear in `changed`, but its value never does.

## Headers [#headers]

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `ElaraOne-Webhooks/1.0` |
| `X-ElaraOne-Event` | The event, such as `contact.updated`. |
| `X-ElaraOne-Delivery` | The event id (the body's `id`). The same on every retry: use it to ignore repeats. |
| `X-ElaraOne-Signature` | `t=<unix time>,v1=<hex HMAC-SHA256>`. See [Verify signatures](/docs/webhooks/verify-signatures). |
| `X-ElaraOne-Environment` | `staging`, only on events from a staging store. |

A staging store signs with its own endpoint secrets. If one receiver serves both, use `X-ElaraOne-Environment` to keep test events out of live data.

## Handle an event [#handle-an-event]

1. Read the raw body and [verify the signature](/docs/webhooks/verify-signatures). Refuse anything that fails.
2. Skip the event if you have already processed its `X-ElaraOne-Delivery`.
3. Answer `2xx` straight away. Each attempt times out after 10 seconds, and redirects are not followed.
4. Do the work afterwards, on a queue: fetch the record through the merchant CRM API and update your system.

```ts title="Fetch the contact after contact.updated"
const res = await fetch(`https://api.elaraone.app/api/v1/merchant/crm/contacts/${event.data.id}`, {
  headers: {
    Accept: 'application/json',
    'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
    'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
    Authorization: `Bearer ${process.env.ELARAONE_MERCHANT_KEY}`, // a key with crm:read
  },
})
```

The API checks the key's access, so your copy is only as complete as the key allows. A contact that has been merged into another answers `404`; follow `contact.merged` to the contact that stays. On `contact.erased`, delete everything you hold about that contact id.

Events for one record usually arrive in order, but a retry can overtake a later event. Use `created_at`, or simply read the record's current state through the API, rather than trusting arrival order.

<Cards>
  <Card title="Verify signatures" href="/docs/webhooks/verify-signatures" description="Check the HMAC signature in Node, Next.js, PHP and Laravel." />
  <Card title="Retries and delivery" href="/docs/webhooks/retries" description="At-least-once delivery, backoff, auto-disable and the delivery log." />
</Cards>
