How it works
- 1A customer asks a question on the phone or in WhatsApp.
- 2Twilper sends the question, signed, to your endpoint (POST, JSON).
- 3Your system looks up the answer, for example in your order system.
- 4The AI passes your answer on. No valid answer? The built-in AI answers from your knowledge base.
| Channel | Bot link | Behaviour |
|---|---|---|
| Phone (AI) | yes + tools | Your endpoint is queried in parallel with the knowledge base. A handled answer is authoritative and is spoken in natural language. With handled: false the AI uses your answer as extra context (for example product information). The AI can also call your tools. |
| WhatsApp (AI) | yes | A handled answer is sent straight away as the WhatsApp message. With handled: false or no valid answer the built-in AI replies. customer.email and customer.name are saved on the conversation. |
| Website chat | coming soon | Website chat currently uses the knowledge base only. The test button in the dashboard does send a test message with channel "webchat". |
Setup in the dashboard
- Sign in at chat.twilper.com as owner or admin and open Integrations → Connect your own system.
- Enter the endpoint URL. It must be a publicly reachable
https://URL, for examplehttps://jouwsite.nl/api/twilper/channel. - Paste your own key (at least 32 characters) or leave the field empty; Twilper then generates a 64-character key. It is shown once: store it in your own system right away, for example as an environment variable.
- Click Send test. You will see right away what your endpoint returned.
Request from Twilper to your endpoint
Twilper makes a POST with a JSON body (UTF-8) to the configured URL.
| Field | Type | Meaning |
|---|---|---|
| channel | string | phone, whatsapp or webchat (test button only) |
| session_id | string | Stable per conversation: the call ID or the WhatsApp conversation ID. Use it to keep context between messages. |
| message | string | What the customer said or wrote, at most 2000 characters. |
| language | string | Two-letter language code, for example nl or en. |
| customer.phone | string | null | Phone number of the caller or WhatsApp sender, when known. |
| sent_at | integer | Unix time in seconds. Covered by the signature; use it to reject old requests. |
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
}Response from your endpoint
Respond with HTTP 2xx and a JSON object (at most 64 KB). Any other status code, invalid JSON, an empty reply or a timeout means: no answer, and the built-in AI takes over.
| Field | Type | Meaning |
|---|---|---|
| reply | string, required | The answer for the customer, at most 2000 characters. On the phone it is turned into spoken language, so avoid long lists and links. |
| handled | boolean | Default true. Send false if your system does not handle the question itself. Only an explicit false counts. |
| handoff | string | null | "human": connect the customer to a person (on the phone only when your team is available). "forward": the team follows up; the customer is told so and your team is notified. |
| customer.email | string | Optional. The customer's email if your system knows it (at most 254 characters). Currently saved on WhatsApp conversations. |
| customer.name | string | Optional. The customer's name (at most 120 characters). Currently saved on WhatsApp conversations. |
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" }
}Verifying the signature
Every request is signed with HMAC-SHA256 and your key. Use the key exactly as shown in the dashboard (do not hex-decode it). The signature is sent as hex in the X-Twilper-Signature header. There are two variants:
| Request | Headers | What is signed |
|---|---|---|
| Channel message | X-Twilper-Signature | HMAC(secret, raw_body). The time is sent_at inside the body. |
| Tools | X-Twilper-Sent-At, X-Twilper-Signature | HMAC(secret, sent_at + "." + raw_body). For the manifest GET the body is empty, so "1760090000." is signed. |
- Compute the HMAC over the raw body bytes as received. Parsing and re-serialising the JSON gives a different signature.
- Compare in constant time: timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
- Reject requests whose sent_at differs from your own clock by more than 5 minutes.
- Never treat the message text as a command: it is customer input. Only reveal information that belongs to this customer, for example by asking for order number and postcode.
Tools: functions for the phone AI
Besides the channel message you can offer tools that the phone AI calls by itself, such as a product search or order status. The tools live next to your endpoint: if your URL ends in /channel, the base is the same URL with /tools instead of /channel; otherwise /tools is appended to your URL.
| Method | URL | Purpose |
|---|---|---|
| GET | .../tools/manifest | List of at most 8 tools. Name: letters, digits, _ or - (max 64). Description: max 500 characters. parameters: a JSON Schema object. Twilper caches the manifest for 10 minutes. |
| POST | .../tools | Call with tool, args, language, session_id, sent_at. Respond {"ok": true, "result": …} or {"ok": false, "error": "…"}; the first 6000 characters are passed to the 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" }Code samples
Three complete samples that verify both signatures. Put your key in the TWILPER_BOT_SECRET environment variable, never in your code or 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 and timeouts
| Item | Value | Notes |
|---|---|---|
| Channel message | 6 s | 3.5 s on the phone, because the caller is waiting. Aim to answer within 3 seconds. |
| Tool call | 4 s | Per call; the phone AI does at most three rounds of tool calls per turn. |
| Response size | 64 KB | Anything beyond this is cut off. |
| URL | https | Public HTTPS addresses only; internal or local addresses are refused. |
| Errors | — | An error or timeout never breaks a conversation: the built-in AI answers on its own. |
Outgoing webhooks
Separately from the bot link, Twilper can send website chat events to a URL, for example Zapier, Make or your own system. Set an HTTPS URL per event under Webhooks in the dashboard (owners and admins only); the test button sends a "test" event.
// 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
The WordPress plugin and the Shopify connection put your pages and products into the knowledge base automatically, through a sync API: the widget key (tc_live_) can only read; writing uses a secret sync key (tc_sync_) that you create in the dashboard under Integrations. That API is meant for our own plugins. Want content from another system to sync automatically? Get in touch.