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

Eventos de webhook de BDC AI
EventoCuándo llegaQué trae data
message.receivedMensaje recibidoUn 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 creadaNació 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 etapaLa 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ónLa 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 terminadaUna 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.

json
{
  "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.

http
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...c02a

Verificar la firma

Node.js
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'],
)
Python
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.

WhatsApp de soporte