---
title: "Webhooks"
description: "Receive signed POST requests when recordings are ready, comments and issues are created, or a recording link is used, and verify them in Node, Ruby or Python."
canonical: https://faylee.app/docs/api/webhooks
---

# Webhooks

A webhook endpoint is a URL of yours that Faylee sends a `POST` to when something happens in your workspace. A workspace can have several endpoints, each subscribed to the events it needs. Admins manage them at **Settings → Webhooks**, or [over the API](#manage-endpoints-over-the-api).

## Events

| Event | Sent when | `data` contains |
| --- | --- | --- |
| `recording.ready` | A recording finishes uploading and processing, and can be watched | `recording` |
| `comment.created` | Someone comments on a recording | `recording`, `comment` |
| `issue.created` | An issue is created in a tracker from a recording | `recording`, `issue` |
| `recording_link.completed` | Someone finishes sending a recording through a [recording link](https://faylee.app/docs/api/recording-links.md) | `recording` |
| `ping` | An admin sends a test from the endpoint's page in settings | `recording`, with made-up values |

You cannot subscribe to `ping`. It goes to one endpoint when an admin asks for it, even when that endpoint is turned off.

A recording sent through a recording link produces both `recording_link.completed`, when the upload completes, and `recording.ready`, when it can be watched.

## The request

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "0b6f2c1e-6a4d-4f0e-9a51-3f1f9a7d2c88",
  "version": "1",
  "type": "comment.created",
  "created_at": "2026-09-30T14:03:11.000Z",
  "workspace": { "id": 1, "name": "Acme" },
  "data": {
    "recording": {
      "id": 4812,
      "share_id": "V1StGXR8_Z",
      "kind": "video",
      "title": "Checkout fails with a 500",
      "status": "ready",
      "share_url": "https://app.faylee.app/s/V1StGXR8_Z",
      "author": "Ann Park",
      "environment": { "browser": { "name": "Chrome", "version": "131.0" }, "os": { "name": "macOS", "version": "15.1" } },
      "source": "member",
      "recording_link": null,
      "app_metadata": { "userId": "u_1", "plan": "pro" }
    },
    "comment": { "id": 311, "body": "Happens on Safari too", "author": "Dana Ruiz", "at_ms": 12400 }
  }
}
```

The `recording` above is shortened.

| Field | Notes |
| --- | --- |
| `id` | The event's id. The same for every endpoint, every retry and every redelivery |
| `version` | The envelope's version, `"1"` |
| `type` | The event name |
| `created_at` | When the event happened, ISO 8601 with milliseconds |
| `workspace` | `id` and `name` |
| `data` | What the event is about |

`data.recording` is the [recording object](https://faylee.app/docs/api/overview#the-recording-object) with four more fields:

| Field | Notes |
| --- | --- |
| `environment` | The browser environment captured with the recording, or `null`. Same keys as the capture format's [Environment](https://faylee.app/docs/api/capture-format#environment) |
| `source` | `recording_link` for a recording sent through a link, otherwise `member` |
| `recording_link` | `null`, or the link's `id`, `title` and `external_ref` |
| `app_metadata` | What the recorded page attached with [`window.faylee.metadata()`](https://faylee.app/docs/capture/app-metadata.md), merged with later values winning. `{}` when there is none |

`data.comment` has `id`, `body`, `author` (a display name) and `at_ms`, the time in the recording the comment points at, or `null`.

`data.issue` has `id`, `provider` (such as `linear` or `github`), `key` and `url`.

The body is rendered once, when the event happens, and stored. Retries and redeliveries send exactly the same bytes.

## Headers

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `Faylee-Webhooks/1.0` |
| `Faylee-Webhook-Id` | The event id, the same as `id` in the body. Use it to drop repeats |
| `Faylee-Delivery-Id` | The delivery's id. A redelivery is a new delivery with a new id |
| `Faylee-Attempt` | The attempt number, starting at `1` |
| `Faylee-Timestamp` | Unix seconds when this attempt was signed |
| `Faylee-Signature` | `v1=<hex>`, an HMAC-SHA256 signature. Two, comma separated, while a secret rotation overlaps |

## Verify signatures

Every request is signed with the endpoint's signing secret. To check one:

1. Read the raw request body before any JSON parsing.
2. Reject the request if `Faylee-Timestamp` is more than five minutes from your clock. This stops a captured request from being replayed later.
3. Compute the HMAC-SHA256 of `<timestamp>.<raw body>` with the signing secret, as lowercase hex. Use the whole secret, including its `whsec_` prefix.
4. Split `Faylee-Signature` on commas, strip `v1=` from each part, and compare each with your result in constant time. Accept the request if any matches.

During a rotation there is one signature per secret, so a receiver holding either the old or the new secret accepts the request.

### Node

```js

// body is the raw request body as a Buffer, before any JSON parsing.
function isFayleeRequest(headers, body, secret) {
  const timestamp = Number(headers["faylee-timestamp"])
  if (!(Math.abs(Date.now() / 1000 - timestamp) <= 300)) {
    return false
  }

  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex")
  )
  return String(headers["faylee-signature"] ?? "").split(",").some((part) => {
    const signature = Buffer.from(part.trim().replace(/^v1=/, ""))
    return signature.length === expected.length && crypto.timingSafeEqual(signature, expected)
  })
}

const app = express()

app.post("/webhooks/faylee", express.raw({ type: "application/json" }), (req, res) => {
  if (!isFayleeRequest(req.headers, req.body, process.env.FAYLEE_WEBHOOK_SECRET)) {
    return res.sendStatus(401)
  }
  const event = JSON.parse(req.body)
  // Handle event.type here, or queue it and return quickly.
  res.sendStatus(200)
})

app.listen(3000)
```

### Ruby

```ruby
require "openssl"
require "rack/utils"

# body is the raw request body, before any JSON parsing.
def ember_request?(headers, body, secret)
  timestamp = headers["Faylee-Timestamp"].to_i
  # Refuse anything older than five minutes, so a captured request cannot be replayed.
  return false if (Time.now.to_i - timestamp).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
  headers["Faylee-Signature"].to_s.split(",").any? do |signature|
    Rack::Utils.secure_compare(signature.strip.delete_prefix("v1="), expected)
  end
end
```

In a Rails controller, pass `request.headers` and `request.raw_post`.

### Python

```python
import hashlib
import hmac
import time

def is_faylee_request(headers, body: bytes, secret: str) -> bool:
    """body is the raw request body, before any JSON parsing."""
    try:
        timestamp = int(headers.get("Faylee-Timestamp", ""))
    except ValueError:
        return False
    # Refuse anything older than five minutes, so a captured request cannot be replayed.
    if abs(time.time() - timestamp) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return any(
        hmac.compare_digest(part.strip().removeprefix("v1="), expected)
        for part in headers.get("Faylee-Signature", "").split(",")
    )
```

In Flask, pass `request.headers` and `request.get_data()`.

:::caution
Verify against the raw body. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
:::

## Responding

Answer with any `2xx` status within 7 seconds. Do slow work after you respond, for example by queueing the event.

Faylee treats anything else as a failure:

- Any status other than `2xx`, including redirects. Faylee does not follow redirects.
- No answer within 7 seconds to connect, or 7 seconds to respond.
- A response body larger than 100 KB.
- A URL that resolves to a private or reserved address. Faylee refuses to send to these.
- DNS or TLS failures.

The delivery log in settings names each failure: `http_<status>`, `private_uri`, `dns_lookup_failed`, `connection_timeout`, `destination_unreachable`, `failed_tls`, `response_too_large`, or `interrupted` when an attempt was cut off on Faylee's side.

Deliveries are at least once. Use `Faylee-Webhook-Id` to ignore an event you have already handled.

## Retries

A failed delivery is retried on this schedule, each delay give or take 20%:

| Attempt | After the previous attempt |
| --- | --- |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |

After the seventh attempt fails, the delivery is given up. `4xx` answers are retried like any other failure, with two exceptions:

- A `429` or `503` with a `Retry-After` header waits that long instead, in seconds or as an HTTP date, up to 24 hours.
- A `410 Gone` is not retried. It turns the endpoint off.

## Automatic disabling

An endpoint is turned off when:

- It fails 10 deliveries in a row, and the first of those failures was more than an hour ago. The workspace's admins get an email. A single success resets the count.
- It answers `410 Gone`. Admins get an email.

Turning an endpoint off, automatically or by hand, cancels its waiting retries, so turning it back on never releases a backlog at once. The endpoint object's `disabled_reason` says why it is off: `manual`, `failing` or `gone`.

Turn it back on at **Settings → Webhooks** or with `PATCH /webhook_endpoints/:id` and `"active": true`. Events from while it was off are not sent on their own. Redeliver the ones you need.

## Redelivery

Each endpoint's page in **Settings → Webhooks** shows a log of its deliveries for the last 30 days, with the request headers, the response code and the start of the response body. Open a delivery and choose **Redeliver** to send it again.

A redelivery sends the same body with the same `Faylee-Webhook-Id`. It gets a new `Faylee-Delivery-Id`, starts again at `Faylee-Attempt: 1`, and is signed with a fresh timestamp and the endpoint's current secrets.

## Signing secrets

Faylee generates each endpoint's signing secret: `whsec_` followed by 40 characters. It is stored encrypted and never appears in a list or in the endpoint object. Admins see it on the endpoint's page, or fetch it with `POST /webhook_endpoints/:id/secret`.

### Rotation

Rotate a secret from the endpoint's page in settings. The new secret signs at once. The old one keeps signing alongside it for 24 hours, so `Faylee-Signature` carries two values, the new secret's first. Update your receiver within that window. The endpoint object's `previous_secret_expires_at` says when the old secret stops signing.

## Manage endpoints over the API

These endpoints are for workspace admins. Anyone else gets `403`.

### The endpoint object

| Field | Type | Notes |
| --- | --- | --- |
| `id` | integer | |
| `name` | string | Up to 100 characters |
| `url` | string | `http://` or `https://`, up to 2,000 characters |
| `subscribed_events` | string[] | Any of `recording.ready`, `comment.created`, `issue.created`, `recording_link.completed` |
| `active` | boolean | |
| `disabled_reason` | string or null | `manual`, `failing` or `gone` |
| `disabled_explanation` | string or null | A sentence saying why it is off, while it is off |
| `previous_secret_expires_at` | string or null | While a rotation overlaps, when the old secret stops signing |
| `latest_delivery` | object or null | `id`, `event_type`, `state`, `response_code`, `error_code` and `created_at` of the newest delivery |
| `created_at` | string | ISO 8601 |

A delivery's `state` is `pending`, `delivering`, `succeeded`, `failed` (waiting to retry) or `dead` (given up).

### Requests

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/webhook_endpoints` | `{ "webhook_endpoints": [...] }`, sorted by name |
| `POST` | `/webhook_endpoints` | Creates an endpoint, active. Returns `201` and the endpoint |
| `GET` | `/webhook_endpoints/:id` | The endpoint |
| `PATCH` | `/webhook_endpoints/:id` | Changes `name`, `url`, `subscribed_events` or `active`. Returns the endpoint |
| `DELETE` | `/webhook_endpoints/:id` | Deletes the endpoint and its delivery log. Returns `204` |
| `POST` | `/webhook_endpoints/:id/secret` | Returns `{ "signing_secret": "whsec_..." }` |

Send fields inside a `webhook_endpoint` object. Unknown event names are dropped, and at least one event is required.

```bash
curl -X POST https://app.faylee.app/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $FAYLEE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_endpoint": {
      "name": "Support bot",
      "url": "https://bot.example.com/webhooks/faylee",
      "subscribed_events": ["recording_link.completed", "recording.ready"]
    }
  }'
```

```json
{
  "id": 9,
  "name": "Support bot",
  "url": "https://bot.example.com/webhooks/faylee",
  "subscribed_events": ["recording.ready", "recording_link.completed"],
  "active": true,
  "disabled_reason": null,
  "created_at": "2026-10-01T09:20:05.000Z",
  "disabled_explanation": null,
  "previous_secret_expires_at": null,
  "latest_delivery": null
}
```

Then fetch the secret for your receiver:

```bash
curl -X POST https://app.faylee.app/api/v1/webhook_endpoints/9/secret \
  -H "Authorization: Bearer $FAYLEE_TOKEN"
```

```json
{ "signing_secret": "whsec_7Hq2..." }
```

Turn an endpoint off or on:

```bash
curl -X PATCH https://app.faylee.app/api/v1/webhook_endpoints/9 \
  -H "Authorization: Bearer $FAYLEE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "webhook_endpoint": { "active": false } }'
```

An invalid URL or name gets `422`, for example `URL must start with http:// or https://`. Creating, changing and deleting endpoints is written to the audit log.

Sending a test, rotating the secret and redelivering are done from the endpoint's page in settings. They are not available over the API.

:::note
Endpoints moved from Faylee's older Webhook integration also receive `X-Faylee-Signature` (`t=<timestamp>,v1=<hex>`, current secret only), `X-Faylee-Event` and `X-Faylee-Delivery`, and keep the older body, with `event`, `sent_at` and the `data` fields at the top level. New endpoints never use this format.
:::
