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

# Webhooks

> One endpoint, every merchant you own, signed and replayable.

Your product needs to know when a customer writes in, when the assistant
answers, and when somebody needs a person. You register **one** endpoint and it
receives events for every merchant you own — one URL, one secret, rather than a
pair per merchant.

## Registering

```http theme={null}
POST /api/v1/partner/webhooks/endpoints/
```

```json theme={null}
{ "url": "https://crm.example.com/hooks/impel", "enabled_events": ["*"] }
```

```json theme={null}
{ "id": "…", "url": "…", "status": "active", "api_version": "2026-06-01",
  "enabled_events": ["*"], "secret": "whsec_…", "secret_hint": "9f2a" }
```

<Warning>
  **The secret is returned exactly once.** Store it before you close the response.
  There is no endpoint that will show it to you again — only `secret_hint`, the
  last four characters, so you can tell two keys apart.
</Warning>

|                                                               |                                           |
| ------------------------------------------------------------- | ----------------------------------------- |
| `GET /api/v1/partner/webhooks/endpoints/`                     | list                                      |
| `PATCH /api/v1/partner/webhooks/endpoints/{id}/`              | `enabled_events`, `status`, `description` |
| `DELETE /api/v1/partner/webhooks/endpoints/{id}/`             | remove                                    |
| `POST /api/v1/partner/webhooks/endpoints/{id}/rotate-secret/` | new key                                   |
| `GET /api/v1/partner/webhooks/deliveries/`                    | recent deliveries, with `tenant_ref`      |

Rotation is not an outage. The old key stays valid for **24 hours** and we sign
with both, so you add the new one, confirm it works, then drop the old one.

<Note>
  What your endpoint may receive is bounded by the scopes on the key that created
  it. A key without `messages:read` registers fine and gets no `message.*` events.
  Create the endpoint with the key you actually intend to run on.
</Note>

## The envelope

```json theme={null}
{
  "id": "evt_9RmTb2xK…",
  "type": "message.received",
  "api_version": "2026-06-01",
  "created_at": "2026-09-07T10:12:00Z",
  "tenant": "3f7c…",
  "tenant_ref": "merchant_8812",
  "partner": "acme-reseller",
  "data": { "object": { … } },
  "correlation": { "request_id": "…", "trace_id": "…", "job_id": "" }
}
```

<Info>
  **`tenant_ref` is the field you want.** It is *your* identifier for the
  merchant — the same string you provisioned them with and use in every URL.
  `tenant` is our internal id and you have never seen it.
</Info>

The envelope shape is fixed **per endpoint**, not per deploy, so your parser
will not change under you. Partner endpoints default to `2026-06-01`.

## Verifying

```
X-Impel-Signature: t=1757239920,v1=6f3a…
X-Impel-Event: message.received
X-Impel-Event-Id: evt_9RmTb2xK…
X-Impel-Delivery-Id: …
X-Impel-Attempt: 1
X-Impel-Api-Version: 2026-06-01
```

Sign `"{t}."` followed by the **exact raw body bytes** with HMAC-SHA256 and your
secret, compare in constant time, and reject anything older than 300 seconds.

<Warning>
  Verify against the bytes you received, not a re-serialised copy. Parsing and
  re-encoding JSON changes whitespace and key order, and the signature will never
  match again.
</Warning>

During a rotation window there may be several `v1=` values. Accept the delivery
if **any** of them matches.

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, time

  def verify(secret: str, body: bytes, header: str, max_age: int = 300) -> bool:
      parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
      ts = parts.get('t', '')
      if not ts.isdigit() or abs(time.time() - int(ts)) > max_age:
          return False
      expected = hmac.new(secret.encode(), f'{ts}.'.encode() + body,
                          hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(expected, v.split('=', 1)[1])
                 for v in header.split(',') if v.startswith('v1='))
  ```

  ```javascript Node theme={null}
  import crypto from 'node:crypto'

  export function verify(secret, body, header, maxAge = 300) {
    const parts = Object.fromEntries(
      header.split(',').map(p => p.split('=', 2)))
    const ts = Number(parts.t)
    if (!ts || Math.abs(Date.now() / 1000 - ts) > maxAge) return false
    const expected = crypto.createHmac('sha256', secret)
      .update(`${ts}.`).update(body).digest('hex')
    return header.split(',').filter(p => p.startsWith('v1='))
      .some(p => crypto.timingSafeEqual(
        Buffer.from(expected), Buffer.from(p.slice(3))))
  }
  ```
</CodeGroup>

## Events

| Event                                            | Fires when                                                                               |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `message.received`                               | a customer's message is accepted — **including media**, before transcription finishes    |
| `message.sent`                                   | an outbound message is accepted by Meta — AI, the merchant's staff, or your own API call |
| `conversation.created`                           | a thread is opened                                                                       |
| `conversation.completed`                         | a thread reaches a terminal state                                                        |
| `handoff.requested`                              | the assistant stopped and a person is needed. Time-sensitive                             |
| `lead.hot` <sup>preview</sup>                    | a message showed buying intent                                                           |
| `lead.verification_requested` <sup>preview</sup> | a lead asked to be verified                                                              |
| `agent.run.started` `.completed` `.failed`       | one assistant run                                                                        |
| `usage.threshold_reached`                        | a metered balance crossed a threshold                                                    |

`GET /api/v2/webhooks/catalogue/` returns the live list, including the resource
each event carries and the scope it needs. Your code probably wants that rather
than this table.

<Note>
  `message.received` fires for media **before** the voice note is transcribed, so
  `channel.media.transcription` may be empty on arrival. Render the message
  immediately rather than waiting — a customer's voice note appearing thirty
  seconds late looks like a broken inbox.
</Note>

## Delivery behaviour

<AccordionGroup>
  <Accordion title="Retries">
    Three attempts: immediately, then 60s, 5min and 30min. 5xx responses, timeouts
    and connection failures are retried. **4xx is not** — a 4xx says our payload is
    wrong, and sending the same thing again will not make it right.

    Our timeout is **5 seconds**. Acknowledge with a 2xx as soon as you have the
    body and do the work afterwards; a slow handler becomes a retried handler.
  </Accordion>

  <Accordion title="Ordering is not guaranteed">
    Deliveries are independent, so `message.sent` can reach you before the
    `message.received` it answers — and a shared endpoint interleaves every
    merchant's traffic.

    **Dedupe on `id` (`evt_…`)**, which is stable across retries, and order by
    `created_at` within a conversation. Never order by arrival.
  </Accordion>

  <Accordion title="Bursts are delayed, not dropped">
    One endpoint carrying every merchant is a different traffic shape from a single
    tenant's. When you exceed the rate we hold deliveries back and retry with a
    growing delay rather than failing them — our throttle is not your failure and
    must not cost you an event.

    Per-merchant fairness still applies underneath, so one busy merchant cannot
    starve the rest of your book.
  </Accordion>

  <Accordion title="Your endpoint is private to you">
    Merchants cannot see, list or replay deliveries to your endpoint. It is your
    infrastructure and they never configured it.
  </Accordion>
</AccordionGroup>

## Requirements

Endpoints must be `https` and publicly resolvable. Private, loopback and
link-local addresses are refused when the endpoint is created, not silently at
delivery time.

<Check>
  **Before you go live:** secret stored, signature verified against raw bytes,
  timestamp checked, deduping on `evt_` id, not relying on arrival order, and
  answering 2xx within five seconds.
</Check>
