Webhooks
Because capture is local, polling has no bounded answer — a recording may arrive in seconds or hours, or never. Webhooks are how you find out.
Register endpoints at capturly.app/account/webhooks .
Events
| Event | Fires when |
|---|---|
capture.completed | A recording finished uploading and is in the library |
capture.buffered | A live recording is durable but not yet saved by the user |
capture.failed | A recording could not be delivered |
capture_request.submission_received | Someone submitted a take |
capture_request.closed | A request hit its deadline or was closed |
capture.buffered and capture.completed are split because every /v1 read
filters on ready recordings. Announcing a buffered take as completed would
point you at an id that 404s.
The payload
{
"id": "del_...",
"type": "capture.completed",
"data": {
"event": "capture.completed",
"createdAt": "2026-09-08T12:00:00.000Z",
"recording": { "id": "rec_...", "files": [] }
}
}id is the delivery id and your dedupe key. Delivery is at-least-once, and a
redelivery reuses the same id rather than minting a new one, so an event you
have already handled is recognisable.
One field on capture_request.submission_received is worth handling rather
than logging: replaced. A respondent who re-records before the deadline
replaces their earlier answer, and the previous take is deleted — recording
and all. If you mirror takes as they arrive, replaced: true means the copy
you already pulled for that person is superseded and its recording id no
longer resolves.
Verifying the signature
Every request carries a Capturly-Signature header:
Capturly-Signature: t=1757160000,v1=<hex hmac>The signed value is {timestamp}.{raw request body}, HMAC-SHA256 with your
endpoint secret, hex-encoded. Verify against the raw body, before any JSON
parsing — re-serializing changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1 ?? '', 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}The timestamp check is what stops a captured request being replayed later. Five minutes is the tolerance we sign against.
Retries
A delivery retries with growing delay for about a day: 30s, then doubling to
an hourly ceiling, 31 attempts in total. Return any 2xx to accept; anything
else is a failure.
After 20 consecutive failures an endpoint is auto-disabled and your organization’s admins are emailed. Re-enabling it resets the counter and re-queues the deliveries dropped while it was off — a deliberate disable stays a discard.
Respond quickly and do the work afterwards. A slow receiver is retried, not waited on: each attempt has a 10-second budget.
The delivery log
Every delivery is kept for 30 days and visible per endpoint at /account/webhooks , with the event, how many attempts it took, the last response code and a body excerpt.
States distinguish what you can act on:
| State | Meaning |
|---|---|
delivered | A 2xx came back |
queued / retrying | Accepted; not yet delivered |
failed | Out of attempts. Your endpoint was reached and kept refusing. |
| Not sent — endpoint off | Dropped because the endpoint was disabled |
| Not sent — plan lapsed | Dropped because the plan stopped including webhooks |
The last two are not your receiver’s fault, which is why they are not reported as failures. A failed delivery can be sent again from the log; it keeps its delivery id, so a receiver that dedupes will recognise it.
Registration rules
Endpoints must be public https URLs. Private, loopback and link-local
addresses are refused, and the check runs again on every edit rather than only
at creation.