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:
- Alleen
https. - Alleen publieke adressen. Interne adressen, zoals
localhostof een adres in een privénetwerk, worden geweigerd.
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:
structured(standaard): het volledige Call-object, metafsprakenenlearnings.raw: alleen het transcript en de metadata, zonderafsprakenenlearnings. Kies dit als je de extractie liever helemaal zelf doet.
Ondertekening controleren
Elk bericht heeft een header:
Voicehook-Signature: t=1791019000,v1=5f2b9c0e...
tis het tijdstip van ondertekenen, in Unix-seconden.v1is een HMAC-SHA256 in hex, berekend met je webhook-secretover de tekst<t>.<body>: het tijdstip, een punt, en daarna de ruwe body precies zoals die binnenkwam.
Controleer bij elk bericht vier dingen:
- 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.
- Vergelijk in constante tijd (
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js), zodat de vergelijking zelf niets verraadt. - Weiger berichten met een tijdstip ouder dan 5 minuten (300 seconden). Zo heeft een onderschept bericht geen zin om later opnieuw af te spelen.
- Dedupliceer op het event-
id. Door herhaalpogingen kan hetzelfde event vaker binnenkomen. Verwerk elkidmaar éé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:
- Antwoord snel met een 2xx-status en doe zwaar werk daarna, bijvoorbeeld in een wachtrij.
- Dedupliceer op het event-
id. - Ga niet uit van een vaste volgorde. Kijk naar
typeen naar destatusin het Call-object. - Was je ontvanger een tijd onbereikbaar, haal dan gemiste events op met
GET /v1/events?after=<laatste id dat je had>.
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.