Ir al contenido

Desarrolladores

Webhooks

Recibe un aviso firmado en tu servidor cada vez que cambia algo en tu cuenta, sin consultar la API cada poco.

Qué son

Un webhook es una dirección de tu servidor a la que BMC envía una petición POST cuando ocurre un evento: una factura emitida, un cobro registrado, un sobre de firma completado. Cada aviso es fino: lleva el tipo de evento, el identificador del recurso y unos pocos campos no personales. Para el detalle, pide el recurso a la API con ese identificador.

Con webhooks no necesitas consultar la API cada pocos minutos para saber qué cambió, y gastas menos cupo (Límites de uso). Hay 44 eventos en 9 áreas.

Cómo se crea un webhook

Hay dos vías:

  • En el área de cliente, en Desarrolladores › Webhooks: indica un nombre, la URL de destino y los eventos.
  • Con la API, con POST https://app.bm.consulting/api/v1/webhooks/subscriptions.
Crear una suscripción
curl -X POST "https://app.bm.consulting/api/v1/webhooks/subscriptions" \
  -H "Authorization: Bearer $BMC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Facturas emitidas hacia mi ERP",
    "url": "https://erp.example.com/webhooks/bmc",
    "events": ["invoice.issue", "invoice.pay"]
  }'
Respuesta (201)
{
  "id": "…",
  "name": "Facturas emitidas hacia mi ERP",
  "url": "https://erp.example.com/webhooks/bmc",
  "events": ["invoice.issue", "invoice.pay"],
  "secret": "whsec_…",
  "message": "Save the secret now: it will not be shown again. …"
}

Guarda el secreto ahora. El campo secret (whsec_…) solo se muestra al crear la suscripción y al rotarlo. BMC lo conserva cifrado y no puede volver a enseñártelo.

Requisitos

  • La URL debe ser https y resolver a una dirección pública. No se admiten credenciales en la URL, nombres internos ni direcciones IP privadas.
  • El token necesita el permiso developers:webhooks.create y, además, el permiso de lectura de cada evento al que se suscribe (columna «Permiso exigido» del catálogo). Si falta alguno, la respuesta es 403 y lista los permisos que faltan.
  • Un token ligado a una cuenta solo recibe eventos de esa cuenta.

Rutas de la API de webhooks

RutaQué hacePermiso
GET /webhooks/eventsCatálogo de eventos que tu token puede suscribir (?lang=es).developers:webhooks.read
GET /webhooks/subscriptionsLista tus suscripciones.developers:webhooks.read
POST /webhooks/subscriptionsCrea una suscripción y devuelve el secreto.developers:webhooks.create
GET /webhooks/subscriptions/{id}Consulta una suscripción.developers:webhooks.read
PATCH /webhooks/subscriptions/{id}Cambia el nombre, la URL, los eventos o el estado activo.developers:webhooks.update
DELETE /webhooks/subscriptions/{id}Elimina la suscripción.developers:webhooks.delete
POST /webhooks/subscriptions/{id}/testEnvía un evento de prueba webhook.test con un solo intento.developers:webhooks.test
POST /webhooks/subscriptions/{id}/rotate-secretGenera un secreto nuevo; el anterior deja de servir.developers:webhooks.update
GET /webhooks/subscriptions/{id}/deliveriesRegistro de entregas con su estado.developers:webhooks.read
POST /webhooks/deliveries/{id}/redeliverReenvía una entrega concreta.developers:webhooks.update

Qué recibe tu servidor

Cuerpo

JSON con Content-Type: application/json. Las claves llegan siempre en el mismo orden.

{
  "id": "evt_…",
  "type": "invoice.issue",
  "occurred_at": "2026-10-02T09:15:00.000Z",
  "account_id": "…",
  "data": {
    "object": "invoice",
    "id": "…"
  }
}
CampoQué contiene
idIdentificador del evento. Es el mismo en los reintentos y los reenvíos.
typeTipo de evento, por ejemplo invoice.issue.
occurred_atMomento del evento, en ISO 8601.
account_idCuenta a la que pertenece el evento, o null.
data.objectTipo de recurso (invoice, contact…), el que se pide a la API.
data.idIdentificador del recurso. Algunos eventos añaden campos no personales, como merged_id en las fusiones.

Cabeceras

POST /webhooks/bmc HTTP/1.1
Content-Type: application/json
User-Agent: BMC-Webhooks/1.0 (+https://bm.consulting)
X-BMC-Webhook-Event: invoice.issue
X-BMC-Webhook-Id: evt_…
X-BMC-Webhook-Delivery: …
X-BMC-Webhook-Date: 2026-10-02T09:15:01.204Z
X-BMC-Webhook-Account-Id: …
X-BMC-Webhook-Version: v1
X-BMC-Webhook-Signature: t=1791191701,sha256=9f2c…
CabeceraContenido
X-BMC-Webhook-EventTipo del evento.
X-BMC-Webhook-IdIdentificador del evento. Úsalo para descartar duplicados.
X-BMC-Webhook-DeliveryIdentificador de esta entrega concreta.
X-BMC-Webhook-DateFecha de este intento, en ISO 8601.
X-BMC-Webhook-Account-IdCuenta a la que pertenece el evento; vacía si ninguna.
X-BMC-Webhook-VersionVersión del contrato: v1.
X-BMC-Webhook-SignatureFirma: t=<unix>,sha256=<hex>.

Verifica la firma

Cualquiera que conozca tu URL puede enviarle peticiones. Comprueba siempre la firma antes de fiarte del contenido:

  1. Lee la cabecera X-BMC-Webhook-Signature y separa t (segundos Unix) y sha256.
  2. Rechaza la entrega si t se aleja más de 300 segundos de tu reloj. Así una entrega capturada no se puede reenviar más tarde.
  3. Calcula un HMAC-SHA256 con tu secreto completo (whsec_…) como clave sobre el texto {t}.{cuerpo}, donde el cuerpo son los bytes exactos recibidos, sin volver a serializar el JSON.
  4. Compara con sha256 en tiempo constante.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.BMC_WEBHOOK_SECRET; // whsec_…
const TOLERANCE = 300; // segundos

app.post("/webhooks/bmc", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-BMC-Webhook-Signature") ?? "";
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE) {
    return res.sendStatus(400);
  }

  // req.body es un Buffer con los bytes exactos recibidos
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest("hex");
  const received = parts.sha256 ?? "";
  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  const eventId = req.get("X-BMC-Webhook-Id");
  // 1. Si ya procesaste eventId, responde 200 sin hacer nada.
  // 2. Si no, guarda eventId y encola el trabajo.
  res.sendStatus(200);
});
Python (Flask)
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["BMC_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE = 300  # segundos


def verify(payload: bytes, header: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, received = int(parts["t"]), parts["sha256"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCE:
        return False
    expected = hmac.new(SECRET.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


@app.post("/webhooks/bmc")
def bmc_webhook():
    payload = request.get_data()  # bytes exactos, antes de parsear
    if not verify(payload, request.headers.get("X-BMC-Webhook-Signature", "")):
        return "", 400
    event_id = request.headers["X-BMC-Webhook-Id"]
    # 1. Si ya procesaste event_id, responde 200 sin hacer nada.
    # 2. Si no, guarda event_id y encola el trabajo.
    return "", 200

Respuesta esperada y reintentos

  • Responde con cualquier código 2xx para confirmar la recepción. Hazlo rápido: BMC espera como máximo 10 segundos. Si el trabajo es largo, guarda el evento, responde y procésalo después.
  • Cualquier otra respuesta se considera un fallo, incluidas las redirecciones: BMC no las sigue. También es un fallo no responder a tiempo o no poder conectar.
  • Tras un fallo, BMC reintenta con espera creciente: 1 min, 5 min, 30 min, 2 h, 12 h. Son 6 intentos en total, contando el primero. Pasados todos, la entrega queda como fallida.
  • Las entregas y su estado se ven en Desarrolladores › Webhooks y en GET /webhooks/subscriptions/{id}/deliveries. Una entrega fallida se puede reenviar a mano.

Idempotencia

Con los reintentos, tu servidor puede recibir el mismo evento más de una vez. X-BMC-Webhook-Id (y el campo id del cuerpo) es el mismo en todos los intentos y reenvíos del evento: guárdalo al procesarlo y, si llega otra vez, responde 200 sin repetir el trabajo. X-BMC-Webhook-Delivery cambia en cada entrega y sirve para seguirla en el registro.

El orden de llegada no está garantizado. Si el orden importa, compara occurred_at o consulta el estado actual del recurso en la API.

Prueba y rotación del secreto

  • Enviar prueba. Desde el área de cliente o con POST /webhooks/subscriptions/{id}/test se envía un evento webhook.test con un solo intento y se muestra el resultado. No es un evento suscribible.
  • Rotar el secreto. Genera uno nuevo; el anterior deja de servir. Actualiza tu servidor con el valor nuevo y hazlo si sospechas que se ha expuesto.
  • Pausar. Una suscripción desactivada no recibe eventos.

Catálogo de eventos

Cada evento exige el permiso de lectura del recurso al que se refiere, el mismo que pide la ruta de la API que lo devuelve. Los nombres de los permisos están en el catálogo de permisos. Una suscripción solo puede incluir los eventos cuyo permiso tenga el token.

Contactos

EventoCuándo se envíaRecursoPermiso exigido
contact.createContacto creadocontactcontacts:contacts.read
contact.updateContacto modificadocontactcontacts:contacts.read
contact.deleteContacto borradocontactcontacts:contacts.read
contact.mergeContactos fusionadoscontactcontacts:contacts.read
account.createCuenta creadaaccountcontacts:accounts.read
account.updateCuenta modificadaaccountcontacts:accounts.read
account.deleteCuenta dada de bajaaccountcontacts:accounts.read
account.mergeCuentas fusionadasaccountcontacts:accounts.read

CRM y expedientes

EventoCuándo se envíaRecursoPermiso exigido
case.createExpediente abiertocasecrm:cases.read
case.updateExpediente modificadocasecrm:cases.read

Ventas

EventoCuándo se envíaRecursoPermiso exigido
invoice.createDocumento de venta creadoinvoicesales:invoices.read
invoice.updateDocumento de venta modificadoinvoicesales:invoices.read
invoice.deleteBorrador de venta borradoinvoicesales:invoices.read
invoice.issueFactura emitidainvoicesales:invoices.read
invoice.sendFactura enviada por correoinvoicesales:invoices.read
invoice.payCobro registradoinvoicesales:invoices.read
invoice.voidFactura anuladainvoicesales:invoices.read

Compras

EventoCuándo se envíaRecursoPermiso exigido
supplier_document.createGasto recibidosupplier_documentpurchases:documents.read
supplier_document.updateGasto modificadosupplier_documentpurchases:documents.read
supplier.createProveedor creadosupplierpurchases:suppliers.read

Contabilidad

EventoCuándo se envíaRecursoPermiso exigido
journal_entry.createAsiento creadojournal_entryaccounting:journal.read
journal_entry.updateAsiento modificadojournal_entryaccounting:journal.read
journal_entry.postAsiento contabilizadojournal_entryaccounting:journal.read
journal_entry.deleteAsiento borradojournal_entryaccounting:journal.read
fixed_asset.createInmovilizado dado de altafixed_assetaccounting:fixed_assets.read
fixed_asset.updateInmovilizado modificadofixed_assetaccounting:fixed_assets.read

Tesorería

EventoCuándo se envíaRecursoPermiso exigido
bank_account.createCuenta bancaria creadabank_accounttreasury:bank_accounts.read
bank_account.updateCuenta bancaria modificadabank_accounttreasury:bank_accounts.read
bank_movement.createMovimientos bancarios importadosbank_movement_importtreasury:bank_movements.read
reconciliation.confirmMovimiento conciliadobank_movementtreasury:reconciliation.read

Fiscal

EventoCuándo se envíaRecursoPermiso exigido
tax_return.updateDeclaración preparadatax_returntax:returns.read
tax_return.submitDeclaración presentadatax_returntax:returns.read
tax_profile.updatePerfil fiscal modificadotax_profiletax:profile.read
notice.createNotificación de la Administración recibidanoticetax:notices.read

Documentos

EventoCuándo se envíaRecursoPermiso exigido
file.createArchivo subidofiledocuments:files.read
file.updateArchivo modificadofiledocuments:files.read
file.deleteArchivo borradofiledocuments:files.read
data_room_file.createArchivo añadido al data roomdata_room_filedocuments:data_room.read

Firma

EventoCuándo se envíaRecursoPermiso exigido
envelope.sentSobre enviado a firmarenvelopesign:envelopes.read
envelope.completedSobre firmado por todosenvelopesign:envelopes.read
envelope.declinedFirma rechazadaenvelopesign:envelopes.read
envelope.voidedSobre anuladoenvelopesign:envelopes.read
envelope.reissuedSobre reemitidoenvelopesign:envelopes.read
envelope.reassignedFirmante delegadoenvelopesign:envelopes.read
Email
Contacto