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.
Events
Section titled “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 | 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
Section titled “The request”Each delivery is a POST with a JSON body:
{ "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 with four more fields:
| Field | Notes |
|---|---|
environment | The browser environment captured with the recording, or null. Same keys as the capture format’s 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(), 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
Section titled “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
Section titled “Verify signatures”Every request is signed with the endpoint’s signing secret. To check one:
- Read the raw request body before any JSON parsing.
- Reject the request if
Faylee-Timestampis more than five minutes from your clock. This stops a captured request from being replayed later. - Compute the HMAC-SHA256 of
<timestamp>.<raw body>with the signing secret, as lowercase hex. Use the whole secret, including itswhsec_prefix. - Split
Faylee-Signatureon commas, stripv1=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.
import crypto from "node:crypto"import express from "express"
// 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)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) endendIn a Rails controller, pass request.headers and request.raw_post.
Python
Section titled “Python”import hashlibimport hmacimport 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().
Responding
Section titled “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
Section titled “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
429or503with aRetry-Afterheader waits that long instead, in seconds or as an HTTP date, up to 24 hours. - A
410 Goneis not retried. It turns the endpoint off.
Automatic disabling
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Manage endpoints over the API”These endpoints are for workspace admins. Anyone else gets 403.
The endpoint object
Section titled “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
Section titled “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.
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"] } }'{ "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:
curl -X POST https://app.faylee.app/api/v1/webhook_endpoints/9/secret \ -H "Authorization: Bearer $FAYLEE_TOKEN"{ "signing_secret": "whsec_7Hq2..." }Turn an endpoint off or on:
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.