So funktioniert es
- 1Ein Kunde stellt am Telefon oder in WhatsApp eine Frage.
- 2Twilper sendet die Frage signiert an Ihren Endpoint (POST, JSON).
- 3Ihr System sucht die Antwort heraus, zum Beispiel in Ihrer Auftragsverwaltung.
- 4Die KI gibt Ihre Antwort weiter. Keine gültige Antwort? Dann antwortet die integrierte KI mit Ihrer Wissensdatenbank.
| Kanal | Bot-Anbindung | Verhalten |
|---|---|---|
| Telefon (KI) | ja + Tools | Ihr Endpoint wird parallel zur Wissensdatenbank abgefragt. Eine abgeschlossene Antwort ist maßgeblich und wird in natürlicher Sprache vorgelesen. Bei handled: false nutzt die KI Ihre Antwort als zusätzlichen Kontext (zum Beispiel Produktinformationen). Die KI kann auch Ihre Tools aufrufen. |
| WhatsApp (KI) | ja | Eine abgeschlossene Antwort wird direkt als WhatsApp-Nachricht gesendet. Bei handled: false oder ohne gültige Antwort antwortet die integrierte KI. customer.email und customer.name werden beim Gespräch gespeichert. |
| Website-Chat | demnächst | Der Website-Chat nutzt derzeit nur die Wissensdatenbank. Mit dem Testbutton im Dashboard senden Sie aber eine Testnachricht mit channel "webchat". |
Einrichtung im Dashboard
- Melden Sie sich bei chat.twilper.com als Inhaber oder Administrator an und öffnen Sie Integrationen → Verbindung mit Ihrem eigenen System.
- Geben Sie die Endpoint-URL ein. Sie muss eine öffentlich erreichbare
https://-URL sein, zum Beispielhttps://jouwsite.nl/api/twilper/channel. - Fügen Sie Ihren eigenen Schlüssel ein (mindestens 32 Zeichen) oder lassen Sie das Feld leer; dann erzeugt Twilper einen Schlüssel mit 64 Zeichen. Er wird nur einmal angezeigt: Speichern Sie ihn sofort in Ihrem eigenen System, zum Beispiel als Umgebungsvariable.
- Klicken Sie auf Test senden. Sie sehen sofort, was Ihr Endpoint zurückgegeben hat.
Anfrage von Twilper an Ihren Endpoint
Twilper sendet einen POST mit JSON-Body (UTF-8) an die eingestellte URL.
| Feld | Type | Bedeutung |
|---|---|---|
| channel | string | phone, whatsapp oder webchat (nur beim Testbutton) |
| session_id | string | Fest pro Gespräch: die ID des Anrufs oder des WhatsApp-Gesprächs. Nutzen Sie sie, um Kontext zwischen Nachrichten zu behalten. |
| message | string | Was der Kunde gesagt oder geschrieben hat, maximal 2000 Zeichen. |
| language | string | Zweistelliger Sprachcode, zum Beispiel nl oder en. |
| customer.phone | string | null | Telefonnummer des Anrufers oder WhatsApp-Absenders, sofern bekannt. |
| sent_at | integer | Unix-Zeit in Sekunden. Ist von der Signatur abgedeckt; nutzen Sie sie, um alte Anfragen abzulehnen. |
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
}Antwort Ihres Endpoints
Antworten Sie mit HTTP 2xx und einem JSON-Objekt (maximal 64 KB). Jeder andere Statuscode, ungültiges JSON, ein leeres reply oder ein Timeout bedeutet: keine Antwort, und die integrierte KI übernimmt.
| Feld | Type | Bedeutung |
|---|---|---|
| reply | string, Pflicht | Die Antwort für den Kunden, maximal 2000 Zeichen. Am Telefon wird sie in gesprochene Sprache umgewandelt, vermeiden Sie also lange Listen und Links. |
| handled | boolean | Standard: true. Senden Sie false, wenn Ihr System die Frage nicht selbst beantwortet. Nur ein explizites false zählt. |
| handoff | string | null | "human": Verbindet den Kunden mit einem Mitarbeiter (am Telefon nur, wenn Ihr Team verfügbar ist). "forward": Das Team kümmert sich darum; der Kunde wird darüber informiert und Ihr Team benachrichtigt. |
| customer.email | string | Optional. E-Mail-Adresse des Kunden, falls Ihr System sie kennt (maximal 254 Zeichen). Wird derzeit bei WhatsApp-Gesprächen gespeichert. |
| customer.name | string | Optional. Name des Kunden (maximal 120 Zeichen). Wird derzeit bei WhatsApp-Gesprächen gespeichert. |
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" }
}Signatur prüfen
Jede Anfrage ist mit HMAC-SHA256 und Ihrem Schlüssel signiert. Verwenden Sie den Schlüssel genau so, wie er im Dashboard angezeigt wird (nicht hex-dekodieren). Die Signatur steht hexadezimal im Header X-Twilper-Signature. Es gibt zwei Varianten:
| Anfrage | Headers | Was signiert wird |
|---|---|---|
| Kanalnachricht | X-Twilper-Signature | HMAC(secret, raw_body). Die Zeit steht als sent_at im Body. |
| Tools | X-Twilper-Sent-At, X-Twilper-Signature | HMAC(secret, sent_at + "." + raw_body). Beim GET des Manifests ist der Body leer, daher wird "1760090000." signiert. |
- Berechnen Sie den HMAC über die rohen Bytes des Bodys, so wie empfangen. JSON erst zu parsen und erneut zu serialisieren ergibt eine andere Signatur.
- Vergleichen Sie in konstanter Zeit: timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
- Lehnen Sie Anfragen ab, deren sent_at mehr als 5 Minuten von Ihrer eigenen Uhr abweicht.
- Behandeln Sie den Text in message nie als Befehl: Es ist eine Kundeneingabe. Geben Sie nur Informationen preis, die zu diesem Kunden gehören, zum Beispiel indem Sie Bestellnummer und Postleitzahl abfragen.
Tools: Funktionen für die Telefon-KI
Neben der Kanalnachricht können Sie Tools anbieten, die die Telefon-KI selbst aufruft, etwa eine Produktsuche oder einen Bestellstatus. Die Tools liegen neben Ihrem Endpoint: Endet Ihre URL auf /channel, ist die Basis dieselbe URL mit /tools statt /channel; andernfalls wird /tools an Ihre URL angehängt.
| Methode | URL | Zweck |
|---|---|---|
| GET | .../tools/manifest | Liste mit höchstens 8 Tools. Name: Buchstaben, Ziffern, _ oder - (max. 64). Beschreibung: max. 500 Zeichen. parameters: ein JSON-Schema-Objekt. Twilper speichert das Manifest 10 Minuten im Cache. |
| POST | .../tools | Aufruf mit tool, args, language, session_id, sent_at. Antworten Sie {"ok": true, "result": …} oder {"ok": false, "error": "…"}; die ersten 6000 Zeichen gehen an die KI. |
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" }Codebeispiele
Drei vollständige Beispiele, die beide Signaturen prüfen. Legen Sie Ihren Schlüssel in der Umgebungsvariable TWILPER_BOT_SECRET ab, niemals in Ihrem Code oder 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")Limits und Timeouts
| Bereich | Wert | Hinweise |
|---|---|---|
| Kanalnachricht | 6 s | Am Telefon 3,5 s, weil der Anrufer wartet. Streben Sie eine Antwort innerhalb von 3 Sekunden an. |
| Tool-Aufruf | 4 s | Pro Aufruf; die Telefon-KI führt höchstens drei Runden mit Tools pro Gesprächszug aus. |
| Antwortgröße | 64 KB | Alles darüber hinaus wird abgeschnitten. |
| URL | https | Nur öffentliche HTTPS-Adressen; interne oder lokale Adressen werden abgelehnt. |
| Fehler | — | Ein Fehler oder Timeout bricht nie ein Gespräch ab: Die integrierte KI antwortet dann selbst. |
Ausgehende Webhooks
Unabhängig von der Bot-Anbindung kann Twilper Ereignisse aus dem Website-Chat an eine URL senden, zum Beispiel an Zapier, Make oder Ihr eigenes System. Legen Sie pro Ereignis eine HTTPS-URL unter Webhooks im Dashboard fest (nur Inhaber und Administratoren); der Testbutton sendet ein 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-Synchronisierung
Das WordPress-Plugin und die Shopify-Anbindung übertragen Ihre Seiten und Produkte automatisch über eine Sync-API in die Wissensdatenbank: Der Widget-Schlüssel (tc_live_) darf nur lesen; geschrieben wird mit einem geheimen Synchronisierungsschlüssel (tc_sync_), den Sie im Dashboard unter Integrationen erstellen. Diese API ist für unsere eigenen Plugins gedacht. Möchten Sie Inhalte aus einem anderen System automatisch synchronisieren? Nehmen Sie Kontakt mit uns auf.