Webhooks
Early access. Capvo is rolling out gradually. API and webhook access is enabled for early-access accounts — join the waitlist.
Webhooks let your systems react to call events the moment they happen. No polling. The Capvo backend POSTs a JSON payload to your endpoint, signs it with HMAC-SHA256, and retries on failure.
Setup
You can register a webhook endpoint in two ways:
From the desktop app. Open Settings → Webhooks → Add endpoint. Paste your URL, choose the events you care about, and save. The signing secret is shown once; copy it before navigating away.
From the API. See POST /v1/webhooks. The response
returns the same secret, again shown only once.
Endpoint URLs must be https:// and publicly reachable — URLs that resolve to
private or loopback addresses are rejected at registration and re-checked at
delivery time. Each endpoint can subscribe to one or more events.
Events
| Event | Fires when |
|---|---|
meeting.started | A recording has started. |
meeting.transcribed | A call has finished and its transcript is ready. |
meeting.completed | Alias for meeting.transcribed. |
summary.ready | An AI summary has finished generating. |
meeting.transcribed and meeting.completed are aliases for the same moment.
An endpoint subscribed to either (or both) receives a single delivery, labelled
with the event name it subscribed to.
Payload format
Every webhook delivery has the same envelope:
{
"event": "meeting.transcribed",
"created_at": "2026-04-27T14:32:55Z",
"data": {
"id": "fd1c2b0e-08a2-4ee0-ab26-9f3a7a14b2c1",
"title": "Q2 Revenue Review",
"platform": "meet",
"duration_seconds": 1842,
"started_at": "2026-04-27T14:02:11Z",
"ended_at": "2026-04-27T14:32:53Z",
"transcript": [
{ "sequence": 1, "speaker": "Albert", "content": "Let's start with the numbers.", "start_time": 4.2, "end_time": null },
{ "sequence": 2, "speaker": "Maya", "content": "Revenue is up 18% quarter over quarter.", "start_time": 9.1, "end_time": null }
],
"summary": null
}
}data.id is the meeting UUID and is stable across retries, so use it as your
idempotency key: if you process the same id twice for the same event, drop the
second.
The summary.ready event has the same envelope with data.summary populated
(context, decisions, action items, participants — see the
API reference for the full shape).
interface WebhookPayload<T> {
event: 'meeting.started' | 'meeting.transcribed' | 'meeting.completed' | 'summary.ready'
created_at: string
data: T
}Alongside the signature headers below, every delivery carries an X-Capvo-Event
header with the event name, so you can route before parsing the body.
HMAC verification
Capvo signs every delivery with HMAC-SHA256 using the endpoint's secret. The
signature is sent in the X-Capvo-Signature header in the form:
X-Capvo-Signature: t=1714224728,v1=8a3f5c…
X-Capvo-Timestamp: 1714224728tis the Unix timestamp at which the signature was generated (also sent separately inX-Capvo-Timestamp).v1is the hex-encoded HMAC-SHA256 oft + "." + raw_bodykeyed with your secret.
Reject requests where the timestamp is more than 5 minutes old, or where the signature does not match.
Node.js
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post(
'/capvo',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('x-capvo-signature') ?? ''
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
) as { t?: string; v1?: string }
if (!parts.t || !parts.v1) return res.status(400).end()
const tooOld = Math.abs(Date.now() / 1000 - Number(parts.t)) > 5 * 60
if (tooOld) return res.status(400).end()
const expected = crypto
.createHmac('sha256', process.env.CAPVO_WEBHOOK_SECRET!)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex')
const ok = crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex')
)
if (!ok) return res.status(400).end()
const payload = JSON.parse(req.body.toString('utf8'))
// …handle payload
res.status(200).end()
}
)Python
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CAPVO_WEBHOOK_SECRET"].encode()
@app.post("/capvo")
def capvo():
header = request.headers.get("X-Capvo-Signature", "")
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
abort(400)
if abs(time.time() - int(t)) > 5 * 60:
abort(400)
body = request.get_data() # raw bytes, do NOT use request.json here
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(400)
payload = request.get_json(force=True)
# …handle payload
return "", 200Always verify against the raw request body, not a parsed-and-reserialized version. Whitespace and key ordering matter for the signature.
Retry policy
A delivery is considered successful if your endpoint returns any 2xx status code
within 10 seconds. Anything else (4xx, 5xx, timeout, TLS error, DNS failure) counts
as a failed attempt.
If the initial delivery fails, Capvo retries up to 3 more times with increasing backoff:
| Attempt | Delay after previous attempt |
|---|---|
| 1 — initial delivery | — |
| 2 — first retry | 30 seconds |
| 3 — second retry | 5 minutes |
| 4 — third retry | 30 minutes |
If all 4 attempts fail, the event is considered permanently failed and the endpoint is marked inactive: no further deliveries are sent to it, and the account owner is notified by email. Re-enable the endpoint from Settings → Webhooks once you've fixed the issue. Events that occurred while the endpoint was inactive are not replayed automatically — fetch them via the API if you need to backfill.
Every attempt (status code, attempt number, payload) is recorded and queryable via
GET /v1/webhooks/:id/deliveries, so you can debug
failures without waiting for the next event.