Skip to Content
APIWebhooks

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

EventFires when
capture.completedA recording finished uploading and is in the library
capture.bufferedA live recording is durable but not yet saved by the user
capture.failedA recording could not be delivered
capture_request.submission_receivedSomeone submitted a take
capture_request.closedA 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:

StateMeaning
deliveredA 2xx came back
queued / retryingAccepted; not yet delivered
failedOut of attempts. Your endpoint was reached and kept refusing.
Not sent — endpoint offDropped because the endpoint was disabled
Not sent — plan lapsedDropped 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.

Last updated on