Hoe het werkt
- 1Een klant stelt een vraag aan de telefoon of in WhatsApp.
- 2Twilper stuurt de vraag ondertekend naar jouw endpoint (POST, JSON).
- 3Jouw systeem zoekt het antwoord op, bijvoorbeeld in je orderadministratie.
- 4De AI brengt jouw antwoord over. Geen geldig antwoord? Dan antwoordt de ingebouwde AI met je kennisbank.
| Kanaal | Botkoppeling | Gedrag |
|---|---|---|
| Telefoon (AI) | ja + tools | Jouw endpoint wordt tegelijk met de kennisbank bevraagd. Een afgehandeld antwoord is leidend en wordt in spreektaal uitgesproken. Bij handled: false gebruikt de AI je antwoord als extra context (bijvoorbeeld productinformatie). De AI kan ook je tools aanroepen. |
| WhatsApp (AI) | ja | Een afgehandeld antwoord wordt direct als WhatsApp-bericht verstuurd. Bij handled: false of geen geldig antwoord antwoordt de ingebouwde AI. customer.email en customer.name worden bij het gesprek opgeslagen. |
| Websitechat | binnenkort | De websitechat gebruikt nu alleen de kennisbank. Met de testknop in het dashboard stuur je wel een testbericht met channel "webchat". |
Instellen in het dashboard
- Log in op chat.twilper.com als eigenaar of beheerder en open Integraties → Koppeling met je eigen systeem.
- Vul de endpoint-URL in. Dit moet een openbaar bereikbare
https://-URL zijn, bijvoorbeeldhttps://jouwsite.nl/api/twilper/channel. - Plak je eigen sleutel (minimaal 32 tekens) of laat het veld leeg; dan maakt Twilper een sleutel van 64 tekens. Die wordt één keer getoond: bewaar hem meteen in je eigen systeem, bijvoorbeeld als omgevingsvariabele.
- Klik op Verstuur test. Je ziet direct wat jouw endpoint terugstuurde.
Verzoek van Twilper naar jouw endpoint
Twilper doet een POST met een JSON-body (UTF-8) naar de ingestelde URL.
| Veld | Type | Betekenis |
|---|---|---|
| channel | string | phone, whatsapp of webchat (alleen bij de testknop) |
| session_id | string | Vast per gesprek: het gespreks-ID van de telefoonoproep of het WhatsApp-gesprek. Gebruik het om context te bewaren tussen berichten. |
| message | string | Wat de klant zei of schreef, maximaal 2000 tekens. |
| language | string | Taalcode van twee letters, bijvoorbeeld nl of en. |
| customer.phone | string | null | Telefoonnummer van de beller of WhatsApp-afzender, als dat bekend is. |
| sent_at | integer | Unix-tijd in seconden. Valt onder de handtekening; gebruik het om oude verzoeken te weigeren. |
POST https://jouwsite.nl/api/twilper/channel
Content-Type: application/json
User-Agent: Twilper-BotLink/1.0
X-Twilper-Signature: 6f1c…e9 (hex HMAC-SHA256 of the raw body)
{
"channel": "phone",
"session_id": "CA8f3e…",
"message": "Waar is mijn bestelling 10452?",
"language": "nl",
"customer": { "phone": "+31612345678" },
"sent_at": 1760090000
}Antwoord van jouw endpoint
Antwoord met HTTP 2xx en een JSON-object (maximaal 64 KB). Elke andere statuscode, ongeldige JSON, een lege reply of een time-out betekent: geen antwoord, en de ingebouwde AI neemt het over.
| Veld | Type | Betekenis |
|---|---|---|
| reply | string, verplicht | Het antwoord voor de klant, maximaal 2000 tekens. Aan de telefoon wordt het in spreektaal omgezet, dus vermijd lange lijsten en links. |
| handled | boolean | Standaard true. Stuur false als jouw systeem de vraag niet zelf afhandelt. Alleen een expliciete false telt. |
| handoff | string | null | "human": verbind de klant met een medewerker (aan de telefoon alleen als je team beschikbaar is). "forward": het team volgt het op; de klant hoort of leest dat, en je team krijgt een melding. |
| customer.email | string | Optioneel. E-mailadres van de klant als jouw systeem dat kent (maximaal 254 tekens). Wordt nu bij WhatsApp-gesprekken opgeslagen. |
| customer.name | string | Optioneel. Naam van de klant (maximaal 120 tekens). Wordt nu bij WhatsApp-gesprekken opgeslagen. |
HTTP/1.1 200 OK
Content-Type: application/json
{
"reply": "Bestelling 10452 is gisteren verzonden met PostNL. Track & trace: 3SABC123.",
"handled": true,
"handoff": null,
"customer": { "email": "[email protected]", "name": "Jan de Vries" }
}Ondertekening controleren
Elk verzoek is ondertekend met HMAC-SHA256 en jouw sleutel. Als sleutel gebruik je de tekst precies zoals in het dashboard getoond (niet hex-decoderen). De handtekening staat hexadecimaal in de header X-Twilper-Signature. Er zijn twee varianten:
| Verzoek | Headers | Ondertekend wordt |
|---|---|---|
| Kanaalbericht | X-Twilper-Signature | HMAC(secret, raw_body). De tijd staat als sent_at in de body. |
| Tools | X-Twilper-Sent-At, X-Twilper-Signature | HMAC(secret, sent_at + "." + raw_body). Bij GET van het manifest is de body leeg, dus wordt "1760090000." ondertekend. |
- Bereken de HMAC over de ruwe bytes van de body, zoals ontvangen. JSON eerst parsen en opnieuw serialiseren geeft een andere handtekening.
- Vergelijk in constante tijd: timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
- Weiger verzoeken waarvan sent_at meer dan 5 minuten afwijkt van je eigen klok.
- Vertrouw de tekst in message nooit als opdracht: het is invoer van een klant. Geef alleen informatie vrij die bij deze klant hoort, bijvoorbeeld door ordernummer én postcode te vragen.
Tools: functies voor de telefoon-AI
Naast het kanaalbericht kun je tools aanbieden die de telefoon-AI zelf aanroept, zoals een productzoeker of een orderstatus. De tools staan naast je endpoint: eindigt je URL op /channel, dan is de basis dezelfde URL met /tools in plaats van /channel; anders wordt /tools aan je URL toegevoegd.
| Methode | URL | Doel |
|---|---|---|
| GET | .../tools/manifest | Lijst met maximaal 8 tools. Naam: letters, cijfers, _ of - (max. 64). Beschrijving: max. 500 tekens. parameters: een JSON Schema-object. Twilper bewaart het manifest 10 minuten. |
| POST | .../tools | Aanroep met tool, args, language, session_id, sent_at. Antwoord {"ok": true, "result": …} of {"ok": false, "error": "…"}; de eerste 6000 tekens gaan naar de AI. |
GET https://jouwsite.nl/api/twilper/tools/manifest
X-Twilper-Sent-At: 1760090000
X-Twilper-Signature: hex HMAC-SHA256 of "1760090000." (empty body)
200 OK
{
"tools": [
{
"name": "order_status",
"description": "Zoek de status van een bestelling op ordernummer of postcode.",
"parameters": {
"type": "object",
"properties": {
"order_number": { "type": "string", "description": "Ordernummer, bijv. 10452" },
"postcode": { "type": "string" }
},
"required": ["order_number"]
}
}
]
}POST https://jouwsite.nl/api/twilper/tools
Content-Type: application/json
X-Twilper-Sent-At: 1760090003
X-Twilper-Signature: hex HMAC-SHA256 of "1760090003.<raw body>"
{
"tool": "order_status",
"args": { "order_number": "10452" },
"language": "nl",
"session_id": "CA8f3e…",
"sent_at": 1760090003
}
200 OK
{ "ok": true, "result": { "status": "verzonden", "carrier": "PostNL", "eta": "2026-10-12" } }
// or, on failure:
{ "ok": false, "error": "order niet gevonden" }Codevoorbeelden
Drie complete voorbeelden die beide handtekeningen controleren. Zet je sleutel in de omgevingsvariabele TWILPER_BOT_SECRET, nooit in je code of repository.
// npm install express
import express from "express";
import crypto from "node:crypto";
const SECRET = process.env.TWILPER_BOT_SECRET; // the literal key shown in the dashboard
const app = express();
// Always verify the RAW bytes: re-serialising JSON breaks the signature.
const raw = express.raw({ type: "application/json", limit: "64kb" });
const hmac = (data) => crypto.createHmac("sha256", SECRET).update(data).digest("hex");
function safeEqual(a, b) {
const x = Buffer.from(a, "utf8"), y = Buffer.from(String(b || ""), "utf8");
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
const fresh = (ts) => Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
// 1) Channel message: signature over the raw body; sent_at is inside the body.
app.post("/api/twilper/channel", raw, (req, res) => {
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!safeEqual(hmac(body), req.get("X-Twilper-Signature"))) return res.sendStatus(401);
const msg = JSON.parse(body.toString("utf8"));
if (!fresh(msg.sent_at)) return res.sendStatus(401);
const order = msg.message.match(/\b\d{5,}\b/)?.[0];
if (!order) {
// Not ours to answer: let Twilper's built-in AI reply.
return res.json({ reply: "Geen ordernummer gevonden.", handled: false });
}
res.json({ reply: `Bestelling ${order} is onderweg.`, handled: true });
});
// 2) Tools: signature over "<sent_at>.<raw body>"; sent_at is in a header.
function verifyTool(req) {
const sentAt = req.get("X-Twilper-Sent-At") || "";
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const expected = hmac(Buffer.concat([Buffer.from(sentAt + "."), body]));
return fresh(sentAt) && safeEqual(expected, req.get("X-Twilper-Signature"));
}
app.get("/api/twilper/tools/manifest", (req, res) => {
if (!verifyTool(req)) return res.sendStatus(401);
res.json({ tools: [{
name: "order_status",
description: "Status van een bestelling op ordernummer.",
parameters: { type: "object", properties: { order_number: { type: "string" } }, required: ["order_number"] },
}] });
});
app.post("/api/twilper/tools", raw, (req, res) => {
if (!verifyTool(req)) return res.sendStatus(401);
const { tool, args } = JSON.parse(req.body.toString("utf8"));
if (tool === "order_status") return res.json({ ok: true, result: { status: "verzonden" } });
res.json({ ok: false, error: "onbekende tool" });
});
app.listen(3000);<?php
// twilper-channel.php (served as https://jouwsite.nl/api/twilper/channel)
$secret = getenv('TWILPER_BOT_SECRET'); // the literal key shown in the dashboard
$raw = file_get_contents('php://input'); // raw bytes; never re-encode the JSON
// 1) Channel message: HMAC over the raw body
$sig = $_SERVER['HTTP_X_TWILPER_SIGNATURE'] ?? '';
if (!hash_equals(hash_hmac('sha256', $raw, $secret), $sig)) {
http_response_code(401);
exit;
}
$msg = json_decode($raw, true);
if (abs(time() - (int)($msg['sent_at'] ?? 0)) > 300) {
http_response_code(401);
exit;
}
header('Content-Type: application/json');
if (preg_match('/\b(\d{5,})\b/', $msg['message'], $m)) {
echo json_encode([
'reply' => "Bestelling {$m[1]} is onderweg.",
'handled' => true,
]);
} else {
echo json_encode(['reply' => 'Geen ordernummer gevonden.', 'handled' => false]);
}
// 2) Tools (manifest and tool call): HMAC over "<sent_at>.<raw body>"
function twilper_tool_request_is_valid(string $raw, string $secret): bool {
$sentAt = $_SERVER['HTTP_X_TWILPER_SENT_AT'] ?? '';
$sig = $_SERVER['HTTP_X_TWILPER_SIGNATURE'] ?? '';
if ($sentAt === '' || abs(time() - (int)$sentAt) > 300) {
return false;
}
// For GET .../tools/manifest $raw is empty, so "<sent_at>." is what gets signed.
return hash_equals(hash_hmac('sha256', $sentAt . '.' . $raw, $secret), $sig);
}# pip install flask
import hashlib, hmac, json, os, re, time
from flask import Flask, request, jsonify, abort
SECRET = os.environ["TWILPER_BOT_SECRET"].encode() # the literal key, as bytes
app = Flask(__name__)
def sign(data: bytes) -> str:
return hmac.new(SECRET, data, hashlib.sha256).hexdigest()
def fresh(ts) -> bool:
return abs(time.time() - int(ts or 0)) <= 300
# 1) Channel message: HMAC over the raw body
@app.post("/api/twilper/channel")
def channel():
raw = request.get_data() # raw bytes
if not hmac.compare_digest(sign(raw), request.headers.get("X-Twilper-Signature", "")):
abort(401)
msg = json.loads(raw)
if not fresh(msg.get("sent_at")):
abort(401)
m = re.search(r"\b(\d{5,})\b", msg["message"])
if not m:
return jsonify(reply="Geen ordernummer gevonden.", handled=False)
return jsonify(reply=f"Bestelling {m.group(1)} is onderweg.", handled=True)
# 2) Tools: HMAC over "<sent_at>.<raw body>"
def tool_request_is_valid() -> bool:
sent_at = request.headers.get("X-Twilper-Sent-At", "")
expected = sign(sent_at.encode() + b"." + request.get_data())
return fresh(sent_at) and hmac.compare_digest(expected, request.headers.get("X-Twilper-Signature", ""))
@app.get("/api/twilper/tools/manifest")
def manifest():
if not tool_request_is_valid():
abort(401)
return jsonify(tools=[{
"name": "order_status",
"description": "Status van een bestelling op ordernummer.",
"parameters": {"type": "object",
"properties": {"order_number": {"type": "string"}},
"required": ["order_number"]},
}])
@app.post("/api/twilper/tools")
def tools():
if not tool_request_is_valid():
abort(401)
call = json.loads(request.get_data())
if call["tool"] == "order_status":
return jsonify(ok=True, result={"status": "verzonden"})
return jsonify(ok=False, error="onbekende tool")Limieten en time-outs
| Onderdeel | Waarde | Toelichting |
|---|---|---|
| Kanaalbericht | 6 s | Aan de telefoon 3,5 s, omdat de beller wacht. Mik op een antwoord binnen 3 seconden. |
| Tool-aanroep | 4 s | Per aanroep; de telefoon-AI doet maximaal drie rondes met tools per beurt. |
| Antwoordgrootte | 64 KB | Daarna wordt het antwoord afgekapt. |
| URL | https | Alleen openbare HTTPS-adressen; interne of lokale adressen worden geweigerd. |
| Fouten | — | Een fout of time-out breekt nooit een gesprek af: de ingebouwde AI antwoordt dan zelf. |
Uitgaande webhooks
Los van de botkoppeling kan Twilper gebeurtenissen uit de websitechat naar een URL sturen, bijvoorbeeld naar Zapier, Make of je eigen systeem. Stel per gebeurtenis een HTTPS-URL in via Webhooks in het dashboard (alleen eigenaren en beheerders); met de testknop stuur je een event "test".
// new_chat (new website chat conversation)
{
"event": "new_chat",
"conversation_id": "5d0c…",
"visitor_message": "Hebben jullie dit ook in maat 42?",
"page_url": "https://jouwsite.nl/schoenen/runner",
"timestamp": "2026-10-10T09:12:44.512Z",
"dashboard_url": "https://chat.twilper.com/inbox"
}
// escalation (visitor asks for the team)
{ "event": "escalation", "conversation_id": "5d0c…", "type": "…",
"visitor_message": "…", "timestamp": "…" }
// new_lead (visitor left contact details)
{ "event": "new_lead", "conversation_id": "5d0c…", "name": "Jan",
"email": "[email protected]", "phone": "", "timestamp": "…" }Content-sync
De WordPress-plugin en de Shopify-koppeling zetten je pagina's en producten automatisch in de kennisbank via een sync-API: de widgetsleutel (tc_live_) mag alleen lezen, schrijven gebeurt met een geheime sync key (tc_sync_) die je in het dashboard onder Integraties aanmaakt. Die API is bedoeld voor onze eigen plugins. Wil je content uit een ander systeem automatisch laten synchroniseren, neem dan contact met ons op.