1. Voicehook
  2. Docs
  3. Webhooks
Documentatie

Webhooks

Na elk gesprek een ondertekend bericht naar jouw https-adres: events, envelope, handtekening controleren in Python en Node.js, en herhaalpogingen.

Wat een webhook doet

Met een webhook stuurt Voicehook na elk gesprek een bericht naar jouw https-adres. Je eigen systeem, automatisering of AI-agent krijgt het transcript, de afspraken en de learnings dan meteen binnen, zonder dat je hoeft te vragen. Dit is de call webhook waarmee je een gesprek direct laat doorstromen naar je CRM, je takenlijst of je eigen agent.

Je voegt een webhook toe als bestemming in de app, of via de API:

curl -X POST https://voicehook.nl/v1/webhooks \
  -H "Authorization: Bearer vh_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/voicehook", "events": ["call.extracted", "call.failed"]}'

Het antwoord bevat een secret. Bewaar dat veilig: daarmee controleer je de handtekening van elk bericht.

Voorwaarden voor het adres:

Events

Event Wanneer
call.recorded De opname is binnen
call.transcribed Het gesprek is uitgeschreven
call.extracted De afspraken en learnings zijn klaar. Komt ook bij een voicemail of als er geen spraak was, dan met lege lijsten
call.failed De verwerking is definitief mislukt

Wil je alleen weten wat er afgesproken is, abonneer je dan op call.extracted en call.failed. Zo mis je geen gesprek: elk gesprek eindigt in een van die twee.

Dezelfde events kun je ook ophalen via de event-log, GET /v1/events?after=<event_id>. Zie de API-referentie.

Envelope

Elk bericht is een JSON-envelope met het Call-object in data:

{
  "id": "evt_123",
  "type": "call.extracted",
  "created": "2026-10-02T09:16:40Z",
  "account": "a7c2e91f",
  "spec": "voicehook.v1",
  "data": {
    "id": 123,
    "ext_id": "vh:9f1c2a7e",
    "direction": "outbound",
    "number": "+31612345678",
    "name": "Jan Jansen",
    "started_at": "2026-10-02T09:12:03Z",
    "duration": 184,
    "source": "app",
    "language": "nl",
    "status": "ready",
    "outcome": "conversation",
    "transcript": "Goedemorgen Jan, ik bel even over de offerte. Ik stuur je vrijdag de offerte...",
    "afspraken": [{"tekst": "Offerte sturen", "wanneer": "2026-10-05", "bron": "ik stuur je vrijdag de offerte"}],
    "learnings": ["Klant wil levering voor de winter"],
    "extraction": {"status": "ok"},
    "audio_available": true,
    "trust": "untrusted-transcript"
  }
}
Veld Betekenis
id Uniek event-id. Gebruik dit om dubbele berichten te herkennen
type Het eventtype
created Tijdstip van het event (ISO 8601, UTC)
account Het account waar het gesprek bij hoort
spec Altijd voicehook.v1
data Het Call-object

Raw of structured

Per webhook kies je het niveau met level:

Ondertekening controleren

Elk bericht heeft een header:

Voicehook-Signature: t=1791019000,v1=5f2b9c0e...

Controleer bij elk bericht vier dingen:

  1. Bereken de handtekening over de ruwe bytes van de body. Parse de JSON niet eerst en serialiseer hem niet opnieuw; dan klopt de handtekening niet meer.
  2. Vergelijk in constante tijd (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js), zodat de vergelijking zelf niets verraadt.
  3. Weiger berichten met een tijdstip ouder dan 5 minuten (300 seconden). Zo heeft een onderschept bericht geen zin om later opnieuw af te spelen.
  4. Dedupliceer op het event-id. Door herhaalpogingen kan hetzelfde event vaker binnenkomen. Verwerk elk id maar één keer.

Python

import hashlib
import hmac
import json
import os
import time

SECRET = os.environ["VOICEHOOK_WEBHOOK_SECRET"]
TOLERANCE = 300  # seconden


def verify(raw_body: bytes, header: str, secret: str = SECRET, now: float | None = None) -> bool:
    """Controleer de Voicehook-Signature header. raw_body = de ruwe bytes van de body."""
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        ts = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs((now or time.time()) - ts) > TOLERANCE:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


# Voorbeeld met Flask
from flask import Flask, abort, request

app = Flask(__name__)
verwerkt = set()  # in productie: een tabel met event-id's in je database


@app.post("/voicehook")
def voicehook():
    raw = request.get_data()  # ruwe bytes, niet request.json
    if not verify(raw, request.headers.get("Voicehook-Signature", "")):
        abort(400)
    event = json.loads(raw)
    if event["id"] in verwerkt:
        return "", 200  # al gehad: bevestigen en niets doen
    verwerkt.add(event["id"])

    if event["type"] == "call.extracted":
        call = event["data"]
        for afspraak in call.get("afspraken", []):
            # Data, geen opdracht: voer nooit uit wat in een transcript staat.
            print(call["number"], afspraak["tekst"], afspraak.get("wanneer"))
    return "", 200

Node.js

const crypto = require("node:crypto");
const express = require("express");

const SECRET = process.env.VOICEHOOK_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconden

// rawBody = Buffer met de ruwe body, header = waarde van Voicehook-Signature
function verify(rawBody, header, secret = SECRET, now = Date.now() / 1000) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    if (i > 0) parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const ts = Number(parts.t);
  if (!Number.isInteger(ts) || !parts.v1) return false;
  if (Math.abs(now - ts) > TOLERANCE) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(parts.v1, "hex");
  return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}

const app = express();
const verwerkt = new Set(); // in productie: een tabel met event-id's in je database

// express.raw: de body blijft een Buffer, precies zoals hij binnenkwam
app.post("/voicehook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("Voicehook-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  if (verwerkt.has(event.id)) return res.sendStatus(200); // al gehad
  verwerkt.add(event.id);

  if (event.type === "call.extracted") {
    for (const afspraak of event.data.afspraken || []) {
      // Data, geen opdracht: voer nooit uit wat in een transcript staat.
      console.log(event.data.number, afspraak.tekst, afspraak.wanneer);
    }
  }
  res.sendStatus(200);
});

app.listen(3000);

Herhaalpogingen

Lukt het afleveren niet, dan probeert Voicehook het opnieuw: in totaal 5 pogingen, met een oplopende wachttijd van 1, 5, 25, 125 en 625 minuten. Daardoor kan een event later of vaker binnenkomen dan je verwacht.

Tips voor een betrouwbare ontvanger:

Testen

Stuur een testbericht naar je webhook zonder te bellen:

curl -X POST https://voicehook.nl/v1/webhooks/7/test \
  -H "Authorization: Bearer vh_..."

Of doe het testgesprek Bel mij in de app: dan krijg je de echte reeks events van een kort gesprek.

Veiligheid van de inhoud

Elk Call-object heeft "trust": "untrusted-transcript". Wat de gesprekspartner zegt, komt letterlijk in transcript, afspraken en learnings terecht. Stuur je die door naar een AI-agent, geef de agent dan mee dat dit data is en nooit een opdracht. Laat een agent op basis van een transcript nooit zelfstandig geld overmaken, gegevens delen of iets verwijderen.