Webhooks

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.

A single account can have:

  • 1 webhook endpoint on the Free plan
  • 10 webhook endpoints on the Pro plan
  • Unlimited endpoints on Enterprise

Each endpoint can subscribe to one or more events.

Events

EventFires when
meeting.transcribedA call has finished and its transcript is ready.
meeting.completedAlias for meeting.transcribed. Subscribe to one, not both.
summary.readyAn AI summary has finished generating (Pro and Enterprise only).

If you subscribe to both meeting.transcribed and meeting.completed you will receive two duplicate deliveries per call. Pick one.

Payload format

Every webhook delivery has the same envelope:

webhook.json
{
  "event": "meeting.transcribed",
  "data": {
    "id": "fd1c2b0e-08a2-4ee0-ab26-9f3a7a14b2c1",
    "title": "Q2 Revenue Review",
    "platform": "meet",
    "duration_seconds": 1842,
    "created_at": "2026-04-27T14:02:11Z",
    "ended_at": "2026-04-27T14:32:53Z",
    "language": "en",
    "transcript": [
      { "speaker": "Albert", "sequence": 1, "start_time": 4.2, "content": "Let's start with the numbers." },
      { "speaker": "Maya", "sequence": 2, "start_time": 9.1, "content": "Revenue is up 18% quarter over quarter." }
    ],
    "summary": null,
    "action_items": [
      { "text": "Send updated forecast to finance", "assignee": "Albert" }
    ]
  }
}

The envelope is intentionally minimal: just event and data. Everything you need to identify the delivery is inside data. data.id is the meeting UUID and is stable across retries, so use it as your idempotency key.

The summary.ready event has the same envelope; data.summary is populated and data.transcript is omitted.

interface WebhookPayload<T> {
  event: 'meeting.transcribed' | 'meeting.completed' | 'summary.ready'
  data: T
}

To dedupe across retries, key off data.id (the meeting UUID). If you process the same id twice for the same event, drop the second.

HMAC verification

Capvo signs every delivery with HMAC-SHA256 using the endpoint's secret. The signature is sent in the Capvo-Signature header in the form:

Capvo-Signature: t=1714224728,v1=8a3f5c…
  • t is the Unix timestamp at which the signature was generated.
  • v1 is the hex-encoded HMAC-SHA256 of t + "." + raw_body keyed with your secret.

Reject requests where the timestamp is more than 5 minutes old, or where the signature does not match.

Node.js

verify.ts
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('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

verify.py
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("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 "", 200

Always 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 exponential backoff:

AttemptDelay after previous attempt
1 — initial delivery
2 — first retry30 seconds
3 — second retry5 minutes
4 — third retry30 minutes

If all 4 attempts fail, the event is considered permanently failed for that endpoint.

After 3 consecutive permanently-failed events (i.e. three different events that each exhausted all 4 attempts in a row), the endpoint is marked inactive and no further deliveries are sent to it. You'll see a warning in the desktop app and can re-enable the endpoint once you've fixed the issue. Events that occurred during the dormant period are not replayed automatically — fetch them via the API if you need to backfill.