Webhooks salientes de BDC AI
Recibe un POST firmado cuando llega un mensaje, nace o cambia una conversación o termina una campaña: los 5 eventos, el sobre, los headers, cómo verificar la firma, reintentos y límites.
· API
En vez de preguntar cada minuto, BDC AI te avisa: registra un endpoint en Ajustes y recibe un POST firmado cada vez que pasa uno de estos 5 eventos, con el mismo shape que ya devuelve la API.
Los 5 eventos
| Evento | Cuándo llega | Qué trae data |
|---|---|---|
message.receivedMensaje recibido | Un contacto mandó un mensaje por WhatsApp. | El mensaje tal como lo devuelve GET /api/v1/conversations/{id}/messages, más conversation_id. |
conversation.createdConversación creada | Nació una conversación, entrante o iniciada desde la plataforma. | La conversación como la devuelve la API, sin los mensajes embebidos. |
conversation.stage_changedCambio de etapa | La conversación avanzó de etapa en su pipeline. | La conversación ya actualizada, más previous_stage_id (la etapa de la que viene, o null). |
conversation.assignedCambio de asignación | La conversación cambió de dueño. | La conversación, más assigned_to (nuevo dueño o null) y previous_assigned_to. |
campaign.completedCampaña terminada | Una campaña de WhatsApp o email terminó de enviarse, con éxito o con fallas. | El detalle de la campaña con sus contadores, igual que GET /api/v1/campaigns/{id}. |
El sobre
Todo evento llega con la misma forma. id es el mismo valor que trae el header X-BDCAI-Delivery-Id: úsalo para deduplicar, porque el mismo evento puede llegar más de una vez.
{
"id": "018f2e40-2b3a-7c11-9a4e-2f6b6b1c9a10",
"type": "conversation.stage_changed",
"created_at": "2026-09-03T18:04:12.000Z",
"organization_id": "3f9a2e10-...",
"data": {
"id": "b2c1...",
"previous_stage_id": "stage_nuevo",
"...": "resto de la conversación, igual que /api/v1"
}
}Headers de cada entrega
Cada POST lleva estos cinco headers. La firma es del esquema de Stripe: HMAC-SHA256 sobre "<timestamp>.<cuerpo crudo>", nunca sobre el objeto reserializado.
Content-Type: application/json
User-Agent: BDCAI-Webhooks/1
X-BDCAI-Event: conversation.stage_changed
X-BDCAI-Delivery-Id: 018f2e40-2b3a-7c11-9a4e-2f6b6b1c9a10
X-BDCAI-Timestamp: 1798999452
X-BDCAI-Signature: t=1798999452,v1=5b3f...c02aVerificar la firma
import crypto from 'node:crypto'
function verifyWebhook(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
)
const timestamp = Number(parts.t)
const signature = parts.v1
if (!timestamp || !signature) return false
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - timestamp) > toleranceSeconds) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex')
const a = Buffer.from(signature, 'utf8')
const b = Buffer.from(expected, 'utf8')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// rawBody debe ser el cuerpo SIN parsear (el string tal cual llegó),
// nunca JSON.stringify(JSON.parse(rawBody)): un reorden de llaves
// invalida la firma.
const ok = verifyWebhook(
process.env.BDCAI_WEBHOOK_SECRET,
rawBody,
req.headers['x-bdcai-signature'],
)import hashlib
import hmac
import time
def verify_webhook(secret, raw_body, header, tolerance_seconds=300):
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.{raw_body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
# raw_body es el cuerpo SIN parsear (bytes o str tal cual llegó).
ok = verify_webhook(
os.environ["BDCAI_WEBHOOK_SECRET"],
raw_body,
request.headers["X-BDCAI-Signature"],
)Reintentos, pausa y límites
Reintentos
Al menos una vez. Un 2xx cierra la entrega. Una redirección (3xx) o un 4xx (salvo 408, 425 y 429) se dan por rechazados sin reintentar: tu endpoint no va a cambiar de opinión. Un timeout, un error de red, un 5xx o un 408/425/429 se reintentan con backoff exponencial: 1 intento y hasta 8 reintentos; al agotarse, la entrega queda dead.
Pausa automática
Si un endpoint acumula 50 entregas fallidas consecutivas, o pasan 72 horas sin un solo 2xx, se pausa solo (paused_reason: failures) y deja de recibir eventos. Cuenta la entrega, no cada reintento: una entrega que agota sus 8 reintentos suma 1. Un 2xx resetea el contador. Reanudarlo es manual, desde Ajustes → Canales → API → Webhooks.
Idempotencia
id (= X-BDCAI-Delivery-Id) es estable entre reintentos y entre endpoints: el mismo evento nunca cambia de id aunque tu servidor lo reciba más de una vez. Deduplica guardando los id ya procesados.
Dónde se configura
Ajustes → Canales → API → Webhooks (requiere el módulo API + MCP encendido en tu organización y rol de administrador). Ahí creas el endpoint, ves el secreto una vez, rotas, pausas/reanudas, mandas un evento de prueba y revisas las últimas 50 entregas con su estado y error.
Límites
10 endpoints activos por organización, solo https:// (se rechazan hosts privados, loopback y *.bdcai.com), payload máximo 256 KB. Un endpoint es de la organización completa: filtros por línea o sucursal quedan para una fase futura.
¿Te sirvió este artículo?
¿No se resolvió?
Escríbenos por WhatsApp con una captura de lo que ves: cada mensaje abre un ticket con nuestro equipo.