Cómo funciona
- 1Un cliente hace una pregunta por teléfono o en WhatsApp.
- 2Twilper envía la pregunta, firmada, a tu endpoint (POST, JSON).
- 3Tu sistema busca la respuesta, por ejemplo en tu gestión de pedidos.
- 4La IA transmite tu respuesta. ¿No hay respuesta válida? La IA integrada responde con tu base de conocimiento.
| Canal | Conexión de bot | Comportamiento |
|---|---|---|
| Teléfono (IA) | sí + herramientas | Tu endpoint se consulta en paralelo con la base de conocimiento. Una respuesta gestionada es la que prevalece y se dice en lenguaje natural. Con handled: false, la IA usa tu respuesta como contexto adicional (por ejemplo, información de producto). La IA también puede llamar a tus herramientas. |
| WhatsApp (IA) | sí | Una respuesta gestionada se envía de inmediato como mensaje de WhatsApp. Con handled: false o sin respuesta válida, responde la IA integrada. customer.email y customer.name se guardan en la conversación. |
| Chat web | próximamente | El chat web usa por ahora solo la base de conocimiento. El botón de prueba del panel sí envía un mensaje de prueba con channel "webchat". |
Configuración en el panel
- Inicia sesión en chat.twilper.com como propietario o administrador y abre Integraciones → Conexión con tu propio sistema.
- Introduce la URL del endpoint. Debe ser una URL
https://accesible públicamente, por ejemplohttps://jouwsite.nl/api/twilper/channel. - Pega tu propia clave (mínimo 32 caracteres) o deja el campo vacío; entonces Twilper genera una clave de 64 caracteres. Solo se muestra una vez: guárdala enseguida en tu propio sistema, por ejemplo como variable de entorno.
- Haz clic en Enviar prueba. Verás al instante lo que devolvió tu endpoint.
Solicitud de Twilper a tu endpoint
Twilper hace un POST con un cuerpo JSON (UTF-8) a la URL configurada.
| Campo | Type | Significado |
|---|---|---|
| channel | string | phone, whatsapp o webchat (solo con el botón de prueba) |
| session_id | string | Fijo por conversación: el ID de la llamada o de la conversación de WhatsApp. Úsalo para mantener el contexto entre mensajes. |
| message | string | Lo que el cliente dijo o escribió, como máximo 2000 caracteres. |
| language | string | Código de idioma de dos letras, por ejemplo nl o en. |
| customer.phone | string | null | Número de teléfono de quien llama o del remitente de WhatsApp, si se conoce. |
| sent_at | integer | Tiempo Unix en segundos. Está cubierto por la firma; úsalo para rechazar solicitudes antiguas. |
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
}Respuesta de tu endpoint
Responde con HTTP 2xx y un objeto JSON (como máximo 64 KB). Cualquier otro código de estado, un JSON no válido, un reply vacío o un tiempo de espera agotado significa: sin respuesta, y la IA integrada toma el control.
| Campo | Type | Significado |
|---|---|---|
| reply | string, obligatorio | La respuesta para el cliente, como máximo 2000 caracteres. Por teléfono se convierte a lenguaje hablado, así que evita listas largas y enlaces. |
| handled | boolean | Por defecto true. Envía false si tu sistema no gestiona la pregunta por sí mismo. Solo cuenta un false explícito. |
| handoff | string | null | "human": pone al cliente en contacto con una persona (por teléfono solo si tu equipo está disponible). "forward": el equipo hace el seguimiento; se le comunica al cliente y tu equipo recibe un aviso. |
| customer.email | string | Opcional. El correo del cliente si tu sistema lo conoce (como máximo 254 caracteres). Por ahora se guarda en las conversaciones de WhatsApp. |
| customer.name | string | Opcional. El nombre del cliente (como máximo 120 caracteres). Por ahora se guarda en las conversaciones de WhatsApp. |
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" }
}Verificar la firma
Cada solicitud va firmada con HMAC-SHA256 y tu clave. Usa la clave exactamente como aparece en el panel (sin decodificarla de hex). La firma se envía en hexadecimal en la cabecera X-Twilper-Signature. Hay dos variantes:
| Solicitud | Headers | Qué se firma |
|---|---|---|
| Mensaje de canal | X-Twilper-Signature | HMAC(secret, raw_body). La hora va en el cuerpo como sent_at. |
| Herramientas | X-Twilper-Sent-At, X-Twilper-Signature | HMAC(secret, sent_at + "." + raw_body). En el GET del manifiesto el cuerpo está vacío, así que se firma "1760090000.". |
- Calcula el HMAC sobre los bytes en bruto del cuerpo, tal como se reciben. Parsear y volver a serializar el JSON da una firma distinta.
- Compara en tiempo constante: timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
- Rechaza las solicitudes cuyo sent_at difiera más de 5 minutos de tu propio reloj.
- Nunca trates el texto de message como una orden: es lo que ha escrito un cliente. Revela solo información que pertenezca a ese cliente, por ejemplo pidiendo el número de pedido y el código postal.
Herramientas: funciones para la IA telefónica
Además del mensaje de canal, puedes ofrecer herramientas que la IA telefónica llama por su cuenta, como un buscador de productos o el estado de un pedido. Las herramientas están junto a tu endpoint: si tu URL termina en /channel, la base es la misma URL con /tools en lugar de /channel; si no, se añade /tools a tu URL.
| Método | URL | Finalidad |
|---|---|---|
| GET | .../tools/manifest | Lista de 8 herramientas como máximo. Nombre: letras, cifras, _ o - (máx. 64). Descripción: máx. 500 caracteres. parameters: un objeto JSON Schema. Twilper guarda el manifiesto en caché 10 minutos. |
| POST | .../tools | Llamada con tool, args, language, session_id, sent_at. Responde {"ok": true, "result": …} o {"ok": false, "error": "…"}; los primeros 6000 caracteres se pasan a la IA. |
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" }Ejemplos de código
Tres ejemplos completos que verifican ambas firmas. Guarda tu clave en la variable de entorno TWILPER_BOT_SECRET, nunca en tu código ni en tu repositorio.
// 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")Límites y tiempos de espera
| Elemento | Valor | Notas |
|---|---|---|
| Mensaje de canal | 6 s | 3,5 s por teléfono, porque quien llama está esperando. Intenta responder en menos de 3 segundos. |
| Llamada a herramienta | 4 s | Por llamada; la IA telefónica hace como máximo tres rondas de llamadas a herramientas por turno. |
| Tamaño de la respuesta | 64 KB | Lo que supere ese tamaño se corta. |
| URL | https | Solo direcciones HTTPS públicas; las direcciones internas o locales se rechazan. |
| Errores | — | Un error o un tiempo de espera agotado nunca corta una conversación: la IA integrada responde por su cuenta. |
Webhooks salientes
Aparte de la conexión de bot, Twilper puede enviar eventos del chat web a una URL, por ejemplo Zapier, Make o tu propio sistema. Configura una URL HTTPS por evento en Webhooks, en el panel (solo propietarios y administradores); el botón de prueba envía un evento "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": "…" }Sincronización de contenido
El plugin de WordPress y la conexión con Shopify añaden automáticamente tus páginas y productos a la base de conocimiento mediante una API de sincronización: la clave del widget (tc_live_) solo permite leer; la escritura se hace con una clave de sincronización secreta (tc_sync_) que creas en el panel, en Integraciones. Esa API está pensada para nuestros propios plugins. ¿Quieres sincronizar automáticamente contenido de otro sistema? Contáctanos.