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

# Webhooks

> Webhooks push events to your server in real time, so you don't have to poll. When something happens in the CRM — a record is created, an SMS arrives — we…

Webhooks push events to your server in real time, so you don't have to poll. When
something happens in the CRM — a record is created, an SMS arrives — we send an
HTTP POST to a URL you register.

This page is the catalogue of events you can subscribe to. Registering a
subscription, signature verification and delivery are covered in the pages linked
at the bottom (added as those endpoints ship).

You can always fetch this catalogue live:

```bash theme={null}
curl -H "x-api-key: $KEY" \
  https://<host>/functions/v1/public-api/v1/webhook-events
```

***

## What an event looks like

Every event has a **name** and a **payload**. Names are dot-namespaced and
**provider-free** — `message.sms.received`, never anything naming the underlying
SMS vendor. That's deliberate: which providers we use is our operational detail,
not part of your integration.

The payload carries only the fields listed below. Nothing else is ever included —
if an internal system starts recording a new field, it does **not** silently
begin flowing to your endpoint.

***

## The catalogue

### Records

| Event            | Fires when                                                |
| ---------------- | --------------------------------------------------------- |
| `record.created` | A record is created in any object (built-in or custom).   |
| `record.updated` | A record is updated. `changed_fields` lists what changed. |
| `record.deleted` | A record is deleted.                                      |

Payload: `object`, `record_id`, `changed_fields` (update only), `occurred_at`.

### Messaging

| Event                      | Fires when                                                       |
| -------------------------- | ---------------------------------------------------------------- |
| `message.sms.received`     | An inbound SMS arrives.                                          |
| `message.email.received`   | An inbound email arrives.                                        |
| `message.email.engagement` | A sent email is opened or clicked (`engagement: open \| click`). |
| `fax.received`             | An inbound fax arrives (`pages`).                                |
| `call.completed`           | A voice call completes (`outcome`, `duration_seconds`).          |

Messaging payloads carry `record_id` when the message is linked to a record, plus
event-specific fields and a timestamp. Sender/recipient are normalised and never
carry a provider identifier.

### Workflow

| Event                     | Fires when                                    |
| ------------------------- | --------------------------------------------- |
| `form.submitted`          | A form is submitted (`form`).                 |
| `sla.breached`            | An SLA is breached (`sla`).                   |
| `appointment.scheduled`   | An appointment is scheduled (`scheduled_at`). |
| `appointment.rescheduled` | An appointment is moved (`scheduled_at`).     |
| `appointment.cancelled`   | An appointment is cancelled.                  |

***

## Notes for building against this

* **Treat the catalogue as the contract.** Fetch `GET /v1/webhook-events` and map
  from `name`; do not hard-code payload field lists you scraped from a sample.
* **New events may be added.** Handle an unrecognised event name gracefully
  (ignore it) rather than erroring — we may publish new events over time.
* **Payloads only grow within their whitelist.** A field will not appear that
  isn't documented here, but treat missing optional fields as normal.

***

## Verifying signatures

Every delivery carries a `Flow9-Signature` header. Verify it before trusting the
payload — it proves the request came from Flow9 and hasn't been replayed.

```
Flow9-Signature: t=1784800000,v1=5f3c…(64 hex chars)
```

`v1` is `HMAC-SHA256(secret, t + "." + rawBody)`, hex-encoded, where `secret` is
the value shown once when you created the subscription and `rawBody` is the exact
bytes of the request body. The timestamp `t` is inside the signed material, so an
old signature can't be replayed with a new timestamp. Reject anything where `t`
is more than **5 minutes** from your clock.

**Verify against the raw body, before JSON-parsing it.** Re-serialising parsed
JSON can change bytes and break the check.

### Node

```js theme={null}
const crypto = require("crypto");

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  // constant-time compare
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "", "hex").length ? Buffer.from(parts.v1) : Buffer.alloc(0);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Python

```python theme={null}
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

Use your platform's constant-time comparison (`crypto.timingSafeEqual`,
`hmac.compare_digest`) — a plain `==` leaks, through timing, how much of the
signature matched, which is enough to forge one over many requests.

## Filtering deliveries

A subscription can carry a `filter` so you only receive events that match — for
example, only leads from a particular source, or only high-value deals. Filtering
happens before delivery: a non-matching event produces no request to your
endpoint at all.

The filter uses the same grammar as [record filtering](/api-reference/records#filtering):

```json theme={null}
{ "url": "https://hooks.example.com/flow9",
  "events": ["record.created"],
  "filter": { "and": [
    { "field": "object", "op": "eq", "value": "leads" },
    { "field": "source", "op": "in", "value": ["facebook", "google"] }
  ]}}
```

* Filter fields must be fields the subscribed events actually carry (see each
  event's payload above, plus `object` and `record_id`). A filter naming an
  unknown field is rejected with `422` **when you create the subscription**, not
  silently ignored later.
* Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`, `is_null`,
  combined with `and` / `or` nested up to 3 deep.
* Filters are evaluated as data, never executed — values containing punctuation
  or code are compared literally.

## Delivery log, replay and test events

```
GET  /v1/webhooks/{id}/deliveries                  the delivery log
POST /v1/webhooks/{id}/test                         send a sample event
POST /v1/webhooks/deliveries/{deliveryId}/replay    resend a past delivery
```

* **Delivery log** shows each attempt: status, attempts, last response code, and a
  truncated response snippet — enough to debug a failing consumer.
* **Test event** enqueues a synthetic delivery with a sample payload for one of the
  subscription's events, so you can wire up and verify signature handling before
  real traffic. It is signed exactly like a real delivery.
* **Replay** enqueues a fresh copy of a past delivery (new id, signed anew). Replay
  and test both require `settings:configure`; replaying another tenant's delivery
  is a 404.

## Related

* [Working with records](/api-reference/records) — the objects that emit `record.*`
* [Authentication and scopes](/api-reference/authentication)
