API v2 (beta)
Webhooks
Un endpoint de webhook es una URL de tu servidor a la que WbizTool llama cuando le pasa algo a un mensaje: se envió, falló, se canceló o caducó. Te enteras al momento en lugar de consultar una y otra vez, y cada solicitud va firmada para que puedas comprobar que viene de WbizTool.
| Operación | Endpoint | Permiso |
|---|---|---|
| Añadir un endpoint | POST /api/v2/webhooks | messages:read |
| Listar endpoints | GET /api/v2/webhooks | messages:read |
| Obtener un endpoint | GET /api/v2/webhooks/{id} | messages:read |
| Quitar un endpoint | DELETE /api/v2/webhooks/{id} | messages:read |
| Cambiar el secreto de firma | POST /api/v2/webhooks/{id}/rotate-secret | messages:read |
| Enviar un evento de prueba | POST /api/v2/webhooks/{id}/test | messages:read |
Los endpoints pertenecen al espacio de trabajo y reciben eventos de todos sus mensajes, los haya enviado quien los haya enviado: la API, el panel, las campañas, los recordatorios o las integraciones. Por eso una clave limitada a algunos números no puede gestionarlos: todas las llamadas responden 403 number_not_allowed.
Eventos#
| Tipo | Cuándo |
|---|---|
message.sent | Un mensaje salió de tu número. |
message.failed | Un mensaje no se pudo enviar; error.message dice por qué. |
message.cancelled | Se canceló un mensaje en espera: con la API, desde el panel o porque se quitó su número o su campaña. |
message.expired | Un mensaje no se envió antes de su expires_at. |
ping | Un evento de prueba que pediste. |
Los eventos de números (conectado, sesión cerrada) y de mensajes entrantes llegarán más adelante. Los tics de entregado y leído no envían eventos; obtén el mensaje para verlos.
Añadir un endpoint#
https://wbiztool.com/api/v2/webhooksCuerpo: JSON (application/json)
Cuerpo
urlstringobligatorioTu endpoint: una dirección pública
https://, de hasta 500 caracteres. Se rechazan las direccioneshttp://, privadas y locales (comolocalhosto192.168.x.x).eventsarray of stringsopcionalLos tipos de evento que recibe: cualquiera de
message.sent,message.failed,message.cancelledymessage.expired. Predeterminado: los cuatro.pingsiempre llega cuando lo pides.descriptionstringopcionalUna nota para ti, de hasta 100 caracteres, como
Order system.
Un espacio de trabajo puede tener hasta 5 endpoints. La respuesta es 201 con el endpoint y su secret de firma, que solo se muestra esta vez. Guárdalo donde tu receptor pueda leerlo, como una variable de entorno. Las respuestas posteriores solo muestran secret_last4; si pierdes el secreto, cámbialo.
curl -X POST https://wbiztool.com/api/v2/webhooks \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/wbiztool/events",
"events": ["message.sent", "message.failed"],
"description": "Order system"
}'import os
import requests
response = requests.post(
"https://wbiztool.com/api/v2/webhooks",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
json={
"url": "https://example.com/wbiztool/events",
"events": ["message.sent", "message.failed"],
"description": "Order system",
},
timeout=30,
)
endpoint = response.json()
print("Store this secret now:", endpoint["secret"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/webhooks", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.com/wbiztool/events",
events: ["message.sent", "message.failed"],
description: "Order system",
}),
});
const endpoint = await response.json();
console.log("Store this secret now:", endpoint.secret);<?php
$ch = curl_init('https://wbiztool.com/api/v2/webhooks');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://example.com/wbiztool/events',
'events' => ['message.sent', 'message.failed'],
'description' => 'Order system',
]),
CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
curl_close($ch);
echo 'Store this secret now: ' . $endpoint['secret'];Respuesta (201):
{
"id": 41,
"url": "https://example.com/wbiztool/events",
"description": "Order system",
"events": ["message.sent", "message.failed"],
"secret_last4": "9c1e",
"active": true,
"disabled_reason": null,
"created_at": "2026-10-18T11:20:05.733Z",
"last_delivery": null,
"secret": "3f0b…9c1e"
}
El secret real tiene 64 caracteres hexadecimales. Un reintento de esta solicitud con la misma Idempotency-Key no añade un segundo endpoint, y su respuesta no incluye el secreto.
Errores al añadir un endpoint#
| HTTP | Código | Cuándo |
|---|---|---|
400 | validation_failed | Falta url o es demasiado larga, events está vacío o tiene un tipo desconocido, o un campo es desconocido |
403 | webhook_limit_reached | El espacio de trabajo ya tiene 5 endpoints |
403 | number_not_allowed | La clave está limitada a algunos números |
422 | invalid_webhook_url | url no es una dirección pública https:// |
Listar endpoints#
https://wbiztool.com/api/v2/webhooksLos endpoints del espacio de trabajo que reciben eventos, del más antiguo al más reciente, en una sola lista data.
curl https://wbiztool.com/api/v2/webhooks \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"
La lista no incluye los endpoints que se detuvieron. Los Propietarios también ven los endpoints, y pueden quitarlos, en Configuración → Claves API, en Endpoints de webhooks.
Obtener un endpoint#
https://wbiztool.com/api/v2/webhooks/{id}Un endpoint. last_delivery muestra cómo fue su última entrega. Un endpoint que no existe o que se quitó responde 404 webhook_not_found; uno detenido se muestra con active: false.
Quitar un endpoint#
https://wbiztool.com/api/v2/webhooks/{id}No se le envía nada más, tampoco los reintentos que siguen en espera. La respuesta es {"id": 41, "deleted": true}.
Cambiar el secreto de firma#
https://wbiztool.com/api/v2/webhooks/{id}/rotate-secretCrea un secreto nuevo para el endpoint. La respuesta es 200 con el endpoint y su nuevo secret, que solo se muestra esta vez. El secreto anterior deja de funcionar al instante: los eventos enviados a partir de ese momento se firman con el nuevo, incluidos los reintentos de eventos anteriores. Actualiza tu receptor de inmediato.
curl -X POST https://wbiztool.com/api/v2/webhooks/41/rotate-secret \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"
Enviar un evento de prueba#
https://wbiztool.com/api/v2/webhooks/{id}/testPone en cola un evento ping para este endpoint, firmado como todos los eventos, para que puedas probar tu receptor. La respuesta es 202:
{ "event_id": "evt_8a2f4c1d9b7e6a5f4c3b2a19", "type": "ping", "queued": true }
Tu endpoint recibe:
{
"id": "evt_8a2f4c1d9b7e6a5f4c3b2a19",
"type": "ping",
"created_at": "2026-10-18T11:21:40.002Z",
"data": { "webhook_id": 41 }
}
El contenido del evento#
Cada evento es un POST con un cuerpo JSON:
{
"id": "evt_c41d7e2a9f3b8c6d5e4f3a21",
"type": "message.sent",
"created_at": "2026-10-18T10:15:09.912Z",
"data": {
"message": {
"id": 9817263,
"status": "sent",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": "2026-10-18T10:15:09.870Z",
"error": null
}
}
}
| Campo | Descripción |
|---|---|
id | El id del evento: evt_ y 24 caracteres. El mismo evento conserva su id en cada reintento. |
type | Uno de los tipos de evento. |
created_at | Cuándo ocurrió el evento. |
data.message | En los eventos message.*: el Message tal como era en ese momento. Un media.url que contenga funciona al menos 26 horas. |
data.webhook_id | En ping: el id del endpoint. |
Cabeceras:
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
User-Agent | WbizTool-Webhooks/2 |
WbizTool-Event-Id | El id del evento, el mismo en cada reintento |
WbizTool-Timestamp | Cuándo se envió este intento, en segundos Unix, como 1792310409 |
WbizTool-Signature | v1= y el HMAC-SHA256 en hexadecimal de <timestamp>.<raw body>, hecho con el secreto del endpoint |
Comprobar la firma#
Comprueba cada solicitud antes de fiarte de ella: calcula la firma a partir del cuerpo sin procesar (antes de que tu framework analice el JSON), compárala con la cabecera en tiempo constante y rechaza las marcas de tiempo con más de 5 minutos de diferencia, para que no se pueda repetir una solicitud antigua. Es el mismo esquema que las firmas de webhooks de la API v1, con el secreto propio del endpoint.
Después usa WbizTool-Event-Id para descartar los repetidos: el mismo evento puede llegar más de una vez, por ejemplo cuando tu respuesta se perdió y volvimos a intentarlo.
import hashlib, hmac, json, os, time
SECRET = os.environ["WBIZTOOL_WEBHOOK_SECRET"]
def verify(secret: str, body: bytes, headers) -> bool:
ts, sig = headers.get("WbizTool-Timestamp", ""), headers.get("WbizTool-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = "v1=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sig)
# Flask
from flask import Flask, request
app = Flask(__name__)
seen = set() # use your database in production
@app.post("/wbiztool/events")
def events():
body = request.get_data() # the raw bytes
if not verify(SECRET, body, request.headers):
return "", 401
event_id = request.headers["WbizTool-Event-Id"]
if event_id in seen:
return "", 200 # a repeat: already handled
seen.add(event_id)
event = json.loads(body)
if event["type"] == "message.failed":
message = event["data"]["message"]
print("Failed:", message["id"], message["error"]["message"])
return "", 200En Django, pasa request.body y request.headers.
const crypto = require("crypto");
const express = require("express");
const SECRET = process.env.WBIZTOOL_WEBHOOK_SECRET;
function verify(secret, rawBody, headers) {
const ts = String(headers["wbiztool-timestamp"] || ""), sig = String(headers["wbiztool-signature"] || "");
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = "v1=" + crypto.createHmac("sha256", secret).update(ts + ".").update(rawBody).digest("hex");
return sig.length === mac.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac));
}
const app = express();
const seen = new Set(); // use your database in production
// express.raw keeps the body as a Buffer: the signature covers the exact bytes.
app.post("/wbiztool/events", express.raw({ type: "*/*" }), (req, res) => {
if (!verify(SECRET, req.body, req.headers)) return res.sendStatus(401);
const eventId = req.headers["wbiztool-event-id"];
if (seen.has(eventId)) return res.sendStatus(200); // a repeat: already handled
seen.add(eventId);
const event = JSON.parse(req.body);
if (event.type === "message.failed") {
const message = event.data.message;
console.log("Failed:", message.id, message.error.message);
}
res.sendStatus(200);
});
app.listen(3000);Responde 2xx rápido y haz el trabajo lento después (en una cola, por ejemplo).
Reintentos#
- Responde con cualquier
2xxen 10 segundos. Todo lo demás es un fallo: otro código de estado (incluidos los4xx), ninguna respuesta en 10 segundos o ninguna conexión en 3. Se siguen las redirecciones, 3 como máximo, y cada una debe llevar a una dirección pública. - Una entrega fallida se vuelve a intentar pasados 1, 5 y 15 minutos, y luego 1, 2, 4, 8 y 8 horas: 9 intentos en unas 23 horas. Tras el último, ese evento se descarta.
- El reintento tiene el mismo cuerpo y el mismo
WbizTool-Event-Id, con unWbizTool-Timestampy una firma nuevos. - El orden no está garantizado. Un
message.sentreintentado puede llegar después de un evento posterior. Usa elstatusdel mensaje, u obtén el mensaje, cuando el orden importe.
last_delivery en el endpoint muestra cómo fue la última entrega: state (pending, delivered o failed), el status_code con el que respondió tu servidor y cuándo se creó el evento.
Cuándo se detienen los endpoints#
Un endpoint deja de recibir eventos cuando ocurre una de estas cosas. Sigue apareciendo en GET /webhooks con active: false y un disabled_reason, para que veas por qué; todavía se puede leer y quitar, pero no probar ni darle un secreto nuevo (404).
- Lleva 24 horas fallando. Si no se le ha entregado nada en 24 horas mientras sus entregas seguían fallando, se desactiva y se descartan sus reintentos en espera. Configuración → Claves API lo muestra entonces como Desactivado. Arregla tu servidor y vuelve a añadir el endpoint.
- La clave que lo creó se revoca o caduca. Revocar una clave detiene todo lo que configuró. Vuelve a añadir el endpoint con otra clave.
- Se quita, con Quitar un endpoint o en Configuración → Claves API.
Los endpoints detenidos no cuentan para los 5.
El objeto webhook endpoint#
| Campo | Tipo | Descripción |
|---|---|---|
id | integer | El id del endpoint. |
url | string | Adonde se envían los eventos. |
description | string | Tu nota; "" si no hay ninguna. |
events | array | Los tipos de evento que recibe. |
secret | string | El secreto de firma: solo en las respuestas de Añadir un endpoint y Cambiar el secreto de firma. |
secret_last4 | string | Los 4 últimos caracteres del secreto, para saber cuál tienes. |
active | boolean | false cuando ya no recibe eventos; consulta Cuándo se detienen los endpoints. |
disabled_reason | string or null | Por qué se detuvo (24 horas fallando, o la clave que lo creó se revocó o caducó); null mientras está activo. |
created_at | string | Cuándo se añadió. |
last_delivery | object or null | La última entrega intentada: state, status_code (o null si tu servidor no respondió) y created_at. null antes de la primera. |
Relacionado#
- Mensajes: el objeto Message en
data.message - Firmas de webhooks de la API v1
- Errores
