ThunderPhone 2.0 ya está disponible.Empieza por tu cuenta desde 2¢/min.Lee el anuncio

Developer cookbook

Configuración dinámica por llamada

Elige el agente que responde, o reescribe su prompt y configuración, de forma independiente para cada llamada entrante, mediante lógica personalizada en un webhook que controlas.

De forma predeterminada, a cada número de teléfono y clave publicable se le asigna un agente estático. Cuando necesitas personalización por cada persona que llama o por cada visitante — enrutamiento VIP, contexto de usuarios con sesión iniciada, pruebas A/B de prompts — cambia al modo webhook y deja que tu servidor decida.

Cómo funciona

  1. Te suscribes al evento telephony.incoming (teléfono) o web.incoming (widget). Ambos son webhooks bloqueantes: ThunderPhone espera tu respuesta antes de continuar la llamada, hasta 13 segundos para una llamada telefónica (incluido un reintento) y 10 segundos para una sesión de widget.
  2. ThunderPhone te envía {call_id, from_number, to_number} (las sesiones de widget incluyen campos específicos del widget en lugar de números; consulta el esquema de solicitud).
  3. Tu servidor responde con una configuración del agente (prompt, voz, producto, herramientas). ThunderPhone usa esa configuración para la llamada.
  4. No hay un agente de respaldo. Si devuelves {} o una configuración no válida, o si la solicitud sigue fallando después de su reintento, la llamada telefónica finaliza de inmediato y se registra con end_reason inbound_config_webhook_failed; la solicitud de una sesión de widget falla.

1. Configura el destino del webhook

Para números de teléfono, suscribe tu endpoint a telephony.incoming:

curl -X POST https://api.thunderphone.com/v1/developer/webhook-endpoints \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label":  "Prod call-incoming",
    "url":    "https://example.com/thunderphone/incoming",
    "events": ["telephony.incoming"]
  }'

La respuesta incluye un secret de una sola vez; guárdalo, ya que lo usarás para verificar la firma.

Para sesiones del widget, crea una clave publicable en mode="webhook" con la URL de tu endpoint integrada:

curl -X POST https://api.thunderphone.com/v1/publishable-key \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name":            "Dynamic widget",
    "mode":            "webhook",
    "webhook_url":     "https://example.com/thunderphone/widget-incoming",
    "allowed_domains": ["example.com"]
  }'

El widget hará POST a esta URL al inicio de cada sesión.

2. Implementa el controlador

Tres reglas generales:

  • Verifica la firma en cada solicitud (consulta Verificar firmas de webhooks). No omitas esto en desarrollo: hazlo bien una vez y reutilízalo.
  • Responde rápido. El primer intento de una llamada telefónica se corta a los 10 segundos y todo el intercambio a los 13; cada segundo es silencio para quien llama. Haz consultas a la base de datos si lo necesitas, pero no llames a LLM posteriores de forma síncrona: si quieres generación dinámica de prompts, precalcula y almacena en caché.
  • Usa una alternativa de forma limpia. Para cualquier estado inesperado, responde con un prompt y una voice predeterminados (o un agent_id guardado). Devolver {} o un estado de error termina la llamada.
FastAPI
import hashlib
import hmac
import json
import os
 
from fastapi import FastAPI, HTTPException, Request
 
app = FastAPI()
SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]
 
def verify(body: bytes, sig: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig or "")
 
@app.post("/thunderphone/incoming")
async def incoming(request: Request):
    body = await request.body()
    if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
        raise HTTPException(401)
 
    event = json.loads(body)
    if event["type"] not in ("telephony.incoming", "web.incoming"):
        return {}  # fall back to default
 
    caller = event["data"]["from_number"]
    # Cheap DB lookup: is this a known VIP?
    customer = lookup_customer(caller)
    if customer and customer.tier == "vip":
        return {
            "prompt":  f"You are a VIP concierge for {customer.name}. Be proactive…",
            "voice":   "john",
            "product": "storm-base",
        }
    return {}  # default agent handles non-VIPs
 
def lookup_customer(phone: str):
    # ... your CRM integration ...
    pass
Express
import crypto from "node:crypto";
import express from "express";
 
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
 
function verify(body, sig) {
  const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
  return sig &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
 
app.post(
  "/thunderphone/incoming",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString("utf8"));
 
    const IMPORTANT_TYPES = new Set([
      "telephony.incoming",
      "web.incoming",
    ]);
    if (!IMPORTANT_TYPES.has(event.type)) return res.json({});
 
    const customer = await lookupCustomer(event.data.from_number);
    if (customer?.tier === "vip") {
      return res.json({
        prompt:  `You are a VIP concierge for ${customer.name}. Be proactive…`,
        voice:   "john",
        product: "storm-base",
      });
    }
    res.json({}); // fall back to default agent
  },
);

3. Esquema de respuesta

El cuerpo de la respuesta coincide exactamente con el esquema de respuesta de llamadas entrantes. Los campos de uso común:

CampoTipoDescripción
promptcadena (obligatorio)Prompt del sistema para el agente
voicecadena (obligatorio)ID de voz de GET /v1/voices
productcadenaEl valor predeterminado es spark. Los productos experimentales solo están disponibles para organizaciones autorizadas.
background_trackcadena | nuloID de audio ambiental
acknowledgement_prompt_modecadenaauto o manual (solo Storm con confirmación)
acknowledgement_promptcadenaObligatorio cuando el modo es manual
toolsmatrizEsquemas de herramientas de función en línea; consulta Herramientas de función

Conserva un agente guardado y proporciona variables

Devuelve {"agent_id": 12, "variables": {"name": "Ada"}} para usar el agente guardado de esa organización con datos por llamada. Su prompt puede contener {{name}} o {{name|Friend}}. Las variables del webhook se combinan sobre las variables a nivel de solicitud; null usa el valor predeterminado del marcador de posición o texto vacío si no se proporciona ninguno. Los valores finales y los nombres sin resolver aparecen en los detalles de la llamada y en los webhooks de finalización. Las respuestas de agente guardado solo aceptan agent_id, variables y timezone. Si prompt está presente, la respuesta usa configuración integrada e ignora agent_id (incluidos metadatos null o que no sean enteros); el prompt integrado debe seguir siendo válido. Las respuestas de configuración integrada también pueden incluir variables. Las respuestas de agente guardado usan la división A/B implementada del agente tanto en llamadas telefónicas como de widget y luego procesan las variables. Consulta variables de llamada para conocer los límites y la compatibilidad con la API de sesión. La configuración de bloqueo proviene de la URL heredada del número de teléfono o de la organización, o de una clave de widget en modo webhook; los eventos entrantes del sistema de endpoints son solo notificaciones.

Configura la zona horaria por llamada

Un agente puede responder a contactos en distintas zonas horarias. Agrega timezone, un nombre de IANA, a una respuesta de agente guardado o en línea, y el agente indicará la fecha y hora y ofrecerá horarios en esa zona para esta llamada, antes que en la propia zona horaria del agente:

{"agent_id": 12, "timezone": "America/Chicago", "variables": {"name": "Ada"}}

Envía la zona en lugar de una marca de tiempo en variables: el reloj del agente sigue avanzando durante la llamada, y una hora que envíes queda desactualizada. Se ignora un valor que no sea una zona IANA y la llamada se conecta en la zona del agente.

Patrones

Contexto de usuario con sesión iniciada

En los widgets en modo webhook, la página del visitante ya sabe quién es. Llama a tu webhook con un parámetro de cadena de consulta que el SDK del widget reenvía (?customer_id=123) y busca al cliente en el servidor.

Implementación gradual de prompts A/B

Antes de implementar esto manualmente, ten en cuenta que ThunderPhone cuenta con una función nativa de Experimentos (/dashboard/experiments y la pestaña A/B del creador de agentes) que define variantes, divide el tráfico y compara los resultados por variante — sin necesidad de webhook.

Si de todos modos necesitas control desde el webhook: aplica hash a call_id → bucket; sirve el prompt A para 0..49 y el prompt B para 50..99. Registra el bucket que elegiste en tu propia base de datos y luego correlaciónalo con la calificación de la llamada completada.

Enrutamiento basado en horario

Horario comercial → agente de "soporte en vivo"; fuera del horario comercial → agente de "tomar un mensaje". Cambio simple según new Date().getUTCHours() en tu controlador.


Próximos pasos