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

# Webhooks

> Receive real-time events from Invent on your own HTTPS endpoint.

A webhook endpoint is a URL of yours that Invent calls when something in your workspace
needs your systems to react. Instead of polling the API, your server is told.

The catalog is deliberately narrow: **events you have to act on** — a contact who can no
longer be reached, a send that did not land. Routine create/read/update traffic belongs to
the API, not to a push feed. Use these to suppress a bad address in your own CRM, keep an
opt-out in sync, or alert whoever owns a failed campaign.

## Two secrets, two directions

Webhooks involve two different credentials. They are not interchangeable.

| Credential                              | Direction    | Used for                                                               |
| :-------------------------------------- | :----------- | :--------------------------------------------------------------------- |
| **API key** (`Authorization: Bearer …`) | you → Invent | Creating, listing, updating and deleting endpoints via the API         |
| **Signing secret** (`whsec_…`)          | Invent → you | Verifying that a request hitting your endpoint really came from Invent |

Each endpoint gets its own signing secret. Rotating one endpoint's secret never affects any
other endpoint, and a leaked signing secret grants no access to your workspace.

## Create an endpoint

Manage endpoints from **Settings → Webhooks**, or over the API with your
[API key](/workspace-management/api-keys):

```bash theme={"system"}
curl -X POST "https://api.useinvent.com/orgs/c/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production",
    "url": "https://example.com/webhooks",
    "events": ["contact.unsubscribed", "broadcast.bounced"]
  }'
```

```json Response theme={"system"}
{
  "id": "whk_...",
  "name": "Production",
  "url": "https://example.com/webhooks",
  "events": ["contact.unsubscribed", "broadcast.bounced"],
  "enabled": true,
  "secret": "whsec_a1b2c3...",
  "created_at": "2026-07-28T10:00:00.000Z"
}
```

<Warning>
  The full `secret` is returned **only** on create and on rotate. Every later read returns it
  masked. Store it in your secret manager right away — if you lose it, rotate to get a new one.
</Warning>

Endpoint URLs must be **HTTPS**, must not embed credentials (`https://user:pass@…`), and must
not resolve to a private or loopback address.

## The payload

Every delivery is a `POST` with a JSON body in one envelope shape: metadata plus a
`type` and its `data`.

```json theme={"system"}
{
  "id": "evt_29fj20fj2",
  "type": "broadcast.bounced",
  "org_id": "org_8sk20dk1",
  "created_at": "2026-07-28T10:00:00.000Z",
  "data": {
    "broadcast_id": "bc_...",
    "contact_id": "ctc_...",
    "email": "someone@example.com",
    "message_id": "0100018f...",
    "reason": "smtp; 550 5.1.1 user unknown"
  }
}
```

`type` tells you what happened; `data` is that event's payload. Switch on `type` and you can
add new subscriptions later without changing how you parse the envelope.

| Field        | Meaning                                                         |
| :----------- | :-------------------------------------------------------------- |
| `id`         | Unique per event, **stable across retries** — deduplicate on it |
| `type`       | The event id (see the catalog below)                            |
| `org_id`     | The workspace the event belongs to                              |
| `created_at` | When the event happened, not when it was delivered              |
| `data`       | Event-specific payload                                          |

## Events

| Event                  | Fires when                                                                                           | Act on it by                                                            |
| :--------------------- | :--------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------- |
| `chat.assigned`        | A conversation changes hands — the AI hands off, an agent takes it, or it is unassigned              | Paging the new owner, opening a ticket                                  |
| `contact.blocked`      | A contact is blocked                                                                                 | Stopping outreach to them on your side                                  |
| `contact.unsubscribed` | A contact opts out — manually, via an unsubscribe link, or automatically after a bounce or complaint | Syncing the opt-out to your CRM. `unsubscribe_reason` carries the cause |
| `broadcast.failed`     | A broadcast fails                                                                                    | Alerting whoever owns the campaign                                      |
| `broadcast.bounced`    | A broadcast email hard-bounced                                                                       | Marking the address invalid                                             |
| `broadcast.complained` | A recipient marked the email as spam                                                                 | Suppressing them everywhere                                             |
| `broadcast.suppressed` | A send was blocked because the address is on the suppression list                                    | Cleaning the address out of your source list                            |
| `ping`                 | Sent by the **Test** action only — never by a subscription                                           | —                                                                       |

<Note>
  `chat.assigned` covers **real customer conversations only** — internal workspace chats and the
  assistant playground never produce it.

  The payload is the full chat object. Assignment lives under `state`: `state.assigned_user` is
  who it landed on (**absent** means it was unassigned), alongside `state.assigned_at`,
  `state.assistant`, `state.integration_id` and `state.ai_enabled`.
</Note>

<Note>
  `broadcast.bounced`, `broadcast.complained` and `broadcast.suppressed` are **per recipient**,
  not per broadcast — one delivery per affected address, carrying `email`, `message_id`, the
  provider `reason`, and `contact_id` when the address still maps to a contact.
</Note>

The live list is always available at `GET /orgs/c/webhooks/events`.

## Verifying a request

**Always verify the signature before trusting a payload.** Anyone can POST JSON at your URL;
the signature is what proves Invent sent it.

Every delivery carries these headers:

| Header                | Value                                         |
| :-------------------- | :-------------------------------------------- |
| `x-webhook-id`        | The event id — same value as `id` in the body |
| `x-webhook-event`     | The event type                                |
| `x-webhook-timestamp` | Unix seconds when the request was signed      |
| `x-webhook-attempt`   | Attempt number, starting at `1`               |
| `x-webhook-signature` | `t=<timestamp>,v1=<hex hmac>`                 |

The signature is an **HMAC-SHA256**, keyed with your endpoint's signing secret, over the
**raw request body** joined to the id and timestamp:

```
signed_content = "{x-webhook-id}.{x-webhook-timestamp}.{raw_body}"
v1             = hex(hmac_sha256(signing_secret, signed_content))
```

Two rules matter for a correct implementation:

1. Sign the **raw body bytes**, exactly as received. Parsing the JSON and re-serializing it
   changes the bytes and the signature will not match.
2. Compare in **constant time** (`crypto.timingSafeEqual`, `hmac.compare_digest`), and reject
   a timestamp older than a few minutes so a captured request cannot be replayed later.

<CodeGroup>
  ```ts Node.js (Express) theme={"system"}
  import express from 'express';
  import { createHmac, timingSafeEqual } from 'node:crypto';

  const SECRET = process.env.INVENT_WEBHOOK_SECRET!;
  const TOLERANCE_S = 5 * 60;

  const app = express();

  // raw body — do NOT use express.json() before verifying
  app.post(
    '/hooks/invent',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const id = req.header('x-webhook-id') ?? '';
      const timestamp = req.header('x-webhook-timestamp') ?? '';
      const header = req.header('x-webhook-signature') ?? '';

      const signature = header
        .split(',')
        .find((part) => part.startsWith('v1='))
        ?.slice(3);

      if (!signature) return res.sendStatus(400);

      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_S) {
        return res.sendStatus(400);
      }

      const expected = createHmac('sha256', SECRET)
        .update(`${id}.${timestamp}.${req.body.toString('utf8')}`)
        .digest('hex');

      const a = Buffer.from(expected);
      const b = Buffer.from(signature);

      if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(401);
      }

      const event = JSON.parse(req.body.toString('utf8'));

      switch (event.type) {
        case 'broadcast.bounced': {
          // handle it
          break;
        }
        default: {
          break;
        }
      }

      // acknowledge immediately, process asynchronously
      res.sendStatus(200);
    },
  );
  ```

  ```python Python (Flask) theme={"system"}
  import hashlib
  import hmac
  import time

  from flask import Flask, request

  SECRET = os.environ["INVENT_WEBHOOK_SECRET"]
  TOLERANCE_S = 5 * 60

  app = Flask(__name__)


  @app.post("/hooks/invent")
  def receive():
      raw = request.get_data()  # raw bytes, not request.json
      event_id = request.headers.get("x-webhook-id", "")
      timestamp = request.headers.get("x-webhook-timestamp", "")
      header = request.headers.get("x-webhook-signature", "")

      signature = next(
          (p[3:] for p in header.split(",") if p.startswith("v1=")), None
      )

      if not signature:
          return "", 400

      if abs(time.time() - int(timestamp or 0)) > TOLERANCE_S:
          return "", 400

      expected = hmac.new(
          SECRET.encode(),
          f"{event_id}.{timestamp}.".encode() + raw,
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(expected, signature):
          return "", 401

      event = request.get_json()

      if event["type"] == "broadcast.bounced":
          pass  # handle it

      return "", 200
  ```
</CodeGroup>

## Responding

Return any `2xx` as soon as you have durably accepted the event — do the real work
afterwards, out of the request. Requests time out after **10 seconds**.

Anything that is not a `2xx`, plus timeouts and connection errors, counts as a failure and
is retried.

## Retries

Failed deliveries are retried with a widening backoff: **5s → 5m → 30m → 2h → 5h → 10h**
(7 attempts total). After the last attempt the delivery is marked `FAILED`.

Delivery is **at-least-once**: the same event can arrive twice if your `2xx` was lost on the
way back. Deduplicate on `id` (or the `x-webhook-id` header) and make your handler idempotent.

Ordering is **not guaranteed** — a retry can land after a newer event. Order by `created_at`
if sequence matters.

An endpoint that fails 30 deliveries in a row is **disabled automatically**, and the reason is
shown on the endpoint. Re-enabling it (PATCH `enabled: true`) clears the failure streak.

<Note>
  Deliveries also pause while a workspace has no balance, and resume once it is topped up.
</Note>

## Inspecting and replaying

Every attempt is recorded for **7 days**: status, response code, response body and duration.

```bash theme={"system"}
# recent deliveries for an endpoint
curl "https://api.useinvent.com/orgs/c/webhooks/whk_.../deliveries" \
  -H "Authorization: Bearer YOUR_API_KEY"

# send a signed ping to check the endpoint is reachable
curl -X POST "https://api.useinvent.com/orgs/c/webhooks/whk_.../test" \
  -H "Authorization: Bearer YOUR_API_KEY"

# re-send a past event (succeeded or failed)
curl -X POST \
  "https://api.useinvent.com/orgs/c/webhooks/whk_.../deliveries/whd_.../replay" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Replay is how you backfill after an outage on your side, or reprocess events once your
handler is fixed.

## Rotating a secret

```bash theme={"system"}
curl -X POST "https://api.useinvent.com/orgs/c/webhooks/whk_.../rotate" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The response contains the new secret in full. The old secret stops signing immediately, so
deploy the new one first, or accept a short window of rejected deliveries — they will be
retried.
