Skip to content

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.

EventSent whendata contains
recording.readyA recording finishes uploading and processing, and can be watchedrecording
comment.createdSomeone comments on a recordingrecording, comment
issue.createdAn issue is created in a tracker from a recordingrecording, issue
recording_link.completedSomeone finishes sending a recording through a recording linkrecording
pingAn admin sends a test from the endpoint’s page in settingsrecording, 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.

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.

FieldNotes
idThe event’s id. The same for every endpoint, every retry and every redelivery
versionThe envelope’s version, "1"
typeThe event name
created_atWhen the event happened, ISO 8601 with milliseconds
workspaceid and name
dataWhat the event is about

data.recording is the recording object with four more fields:

FieldNotes
environmentThe browser environment captured with the recording, or null. Same keys as the capture format’s Environment
sourcerecording_link for a recording sent through a link, otherwise member
recording_linknull, or the link’s id, title and external_ref
app_metadataWhat 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.

HeaderValue
Content-Typeapplication/json
User-AgentFaylee-Webhooks/1.0
Faylee-Webhook-IdThe event id, the same as id in the body. Use it to drop repeats
Faylee-Delivery-IdThe delivery’s id. A redelivery is a new delivery with a new id
Faylee-AttemptThe attempt number, starting at 1
Faylee-TimestampUnix seconds when this attempt was signed
Faylee-Signaturev1=<hex>, an HMAC-SHA256 signature. Two, comma separated, while a secret rotation overlaps

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.

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)
end
end

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

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().

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.

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

AttemptAfter the previous attempt
21 minute
35 minutes
430 minutes
52 hours
66 hours
724 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.

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.

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.

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.

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.

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

FieldTypeNotes
idinteger
namestringUp to 100 characters
urlstringhttp:// or https://, up to 2,000 characters
subscribed_eventsstring[]Any of recording.ready, comment.created, issue.created, recording_link.completed
activeboolean
disabled_reasonstring or nullmanual, failing or gone
disabled_explanationstring or nullA sentence saying why it is off, while it is off
previous_secret_expires_atstring or nullWhile a rotation overlaps, when the old secret stops signing
latest_deliveryobject or nullid, event_type, state, response_code, error_code and created_at of the newest delivery
created_atstringISO 8601

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

MethodPathNotes
GET/webhook_endpoints{ "webhook_endpoints": [...] }, sorted by name
POST/webhook_endpointsCreates an endpoint, active. Returns 201 and the endpoint
GET/webhook_endpoints/:idThe endpoint
PATCH/webhook_endpoints/:idChanges name, url, subscribed_events or active. Returns the endpoint
DELETE/webhook_endpoints/:idDeletes the endpoint and its delivery log. Returns 204
POST/webhook_endpoints/:id/secretReturns { "signing_secret": "whsec_..." }

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

Terminal window
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:

Terminal window
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:

Terminal window
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.