Cómo funciona
- Registras una URL https de tu sistema y eliges los eventos. Te damos un secreto de firma (
whsec_…) una sola vez. - Cuando ocurre uno de esos eventos en tu empresa, Wizerp manda un
POSTcon un JSON pequeño a tu URL, firmado con ese secreto. - Tu sistema verifica la firma, contesta
2xxen menos de 8 segundos y, si necesita el detalle, lo consulta con la liga que trae el aviso. - Si tu servidor no contesta
2xx, reintentamos con espera creciente durante unas 21 horas.
Los avisos salen en uno o dos minutos: se reparten cada minuto, y los agrupados (existencias y «actualizado») esperan además a que pase un minuto sin cambios del mismo recurso. No garantizamos el orden entre eventos: si te importa el estado final, consulta el recurso.
Crear un webhook
Desde Wizerp, en Configuración › Desarrolladores › Llaves de API y webhooks (con «Mandar prueba» y el registro de entregas), o por la API con una llave que tenga el permiso webhooks:write:
curl -X POST https://api.wizerp.com/api/v1/webhooks \ -H "Authorization: Bearer wzk_live_…" \ -H "X-Wizerp-Key-Id: 208_a3f91c7d" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9b2f6c1e-5a7d-4e3b-8c0f-2d1a6e9b7c40" \ -d '{"url":"https://tu-sistema.com/wizerp/webhooks","eventos":["venta.creada","factura.timbrada","inventario.existencia_cambiada"],"descripcion":"Mi tienda"}'
La respuesta trae el webhook y su secreto. Guárdalo: no se vuelve a mostrar (repetir la misma Idempotency-Key lo devuelve en null). Por la API, cada evento exige además el permiso de lectura de su recurso: venta.creada pide ventas:read, factura.timbrada pide facturas:read, etc. — un webhook no es un atajo para enterarte de lo que tu llave no puede consultar.
Operaciones: GET /webhooks · POST /webhooks · PUT /webhooks/{clave} (editar, pausar con "activo": false o reactivar) · DELETE /webhooks/{clave} · POST /webhooks/{clave}/secreto · POST /webhooks/{clave}/probar · GET /webhooks/{clave}/entregas · POST /webhooks/{clave}/entregas/{entrega}/reenviar.
El aviso
Un POST con Content-Type: application/json y estas cabeceras:
| Cabecera | Qué es |
|---|---|
X-Wizerp-Firma | t=<unix>,v1=<hex>. HMAC-SHA256 de <t>.<cuerpo crudo> con tu secreto. Durante una rotación trae dos v1. |
X-Wizerp-Evento | El tipo, p. ej. venta.creada. |
X-Wizerp-Evento-Id | El id del evento (evt_…). El MISMO en todos los reintentos y en un reenvío: úsalo para no procesar dos veces. |
X-Wizerp-Entrega | La clave de esta entrega (la de GET /webhooks/{clave}/entregas). |
X-Wizerp-Intento | 1 en el primer intento, 2 en el primer reintento… |
User-Agent | Wizerp-Webhooks/1.0 |
El cuerpo:
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "venta.creada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"venta": 48213,
"tipo": "pedido"
},
"recurso": {
"tipo": "ventas",
"clave": 48213,
"url": "https://api.wizerp.com/api/v1/ventas?clave=48213&tipo=pedido"
}
}empresa es la clave de tu empresa en Wizerp (la misma que abre tu key id): sirve si un mismo servidor recibe avisos de varias empresas. datos trae claves y poco más — nunca nombres, correos ni importes de terceros —; el detalle está en recurso.url, que consultas con tu llave.
Verificar la firma
Siempre verifica antes de confiar en un aviso: cualquiera puede mandar un POST a tu URL. Los tres pasos son iguales en cualquier lenguaje:
- Toma el cuerpo crudo, tal cual llegó (no el JSON ya interpretado y vuelto a escribir).
- Calcula
HMAC-SHA256(secreto, t + "." + cuerpo)en hexadecimal y compáralo, en tiempo constante, con cadav1de la cabecera. Basta con que uno cuadre. - Rechaza avisos con
tde hace más de 5 minutos (anti-replay).
<?php // Tu endpoint de webhooks. Usa el cuerpo CRUDO: re-serializar el JSON cambia los bytes y la firma ya no cuadra. $secreto = getenv('WIZERP_WEBHOOK_SECRET'); // whsec_… (el que te dimos al crear el webhook) $cuerpo = file_get_contents('php://input'); $cabecera = isset($_SERVER['HTTP_X_WIZERP_FIRMA']) ? $_SERVER['HTTP_X_WIZERP_FIRMA'] : ''; $t = null; $firmas = array(); foreach (explode(',', $cabecera) as $parte) { $kv = explode('=', $parte, 2); if (count($kv) !== 2) { continue; } if ($kv[0] === 't') { $t = $kv[1]; } if ($kv[0] === 'v1') { $firmas[] = $kv[1]; } } $esperada = hash_hmac('sha256', $t . '.' . $cuerpo, $secreto); $valida = false; foreach ($firmas as $f) { if (hash_equals($esperada, $f)) { $valida = true; } } // Anti-replay: rechaza avisos con más de 5 minutos. if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > 300 || !$valida) { http_response_code(400); exit; } $evento = json_decode($cuerpo, true); // Idempotencia: si ya procesaste $evento['id'], contesta 200 y no lo repitas. // Encola el trabajo y contesta YA: tienes 8 segundos. http_response_code(200);
// Node.js + Express. express.raw: la firma es sobre los bytes tal cual llegaron. const crypto = require("crypto"); const express = require("express"); const app = express(); app.post("/wizerp/webhooks", express.raw({ type: "application/json" }), (req, res) => { const cabecera = req.get("X-Wizerp-Firma") || ""; const partes = cabecera.split(",").map((p) => p.split("=")); const t = (partes.find(([k]) => k === "t") || [])[1]; const firmas = partes.filter(([k]) => k === "v1").map(([, v]) => v); const esperada = crypto .createHmac("sha256", process.env.WIZERP_WEBHOOK_SECRET) // whsec_… .update(`${t}.${req.body}`) .digest("hex"); const igual = (f) => f.length === esperada.length && crypto.timingSafeEqual(Buffer.from(f), Buffer.from(esperada)); const fresca = t && Math.abs(Date.now() / 1000 - Number(t)) <= 300; // anti-replay if (!fresca || !firmas.some(igual)) return res.sendStatus(400); const evento = JSON.parse(req.body); // Idempotencia por evento.id; encola y contesta rápido. res.sendStatus(200); });
# Python + Flask. request.get_data(): los bytes crudos, sin re-serializar. import hashlib, hmac, os, time from flask import Flask, abort, request app = Flask(__name__) @app.post("/wizerp/webhooks") def wizerp_webhooks(): cuerpo = request.get_data() partes = [p.split("=", 1) for p in request.headers.get("X-Wizerp-Firma", "").split(",") if "=" in p] t = next((v for k, v in partes if k == "t"), None) firmas = [v for k, v in partes if k == "v1"] secreto = os.environ["WIZERP_WEBHOOK_SECRET"].encode() # whsec_… esperada = hmac.new(secreto, (t or "").encode() + b"." + cuerpo, hashlib.sha256).hexdigest() fresca = t is not None and t.isdigit() and abs(time.time() - int(t)) <= 300 # anti-replay if not fresca or not any(hmac.compare_digest(esperada, f) for f in firmas): abort(400) evento = request.get_json() # Idempotencia por evento["id"]; encola y contesta rápido. return "", 200
Para probar tu código
Con el secreto whsec_5d0c1b8e2f4a49d7b3e6a1c0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0, t = 1791742924 y este cuerpo exacto (una línea):
{"id":"evt_3f9c1a7be0d24c55a91e6b02","tipo":"venta.creada","creado":"2026-10-11T18:22:04Z","empresa":208,"datos":{"venta":48213,"tipo":"pedido"},"recurso":{"tipo":"ventas","clave":48213,"url":"https://api.wizerp.com/api/v1/ventas?clave=48213&tipo=pedido"}}la cabecera es:
X-Wizerp-Firma: t=1791742924,v1=d0d3913769dc99f5c8c60476591ada9e8a6180af04b8598e3ae59ad5fd928dc7
(Para esta prueba ignora el anti-replay: ese t es fijo.) En Wizerp, «Mandar prueba» manda un evento webhook.prueba real a tu URL.
Responder y reintentos
- Contesta
2xxen menos de 8 segundos. Haz el trabajo pesado después (una cola): si tardas, cuenta como fallo y lo reintentamos. - No seguimos redirecciones: un
301/302es un fallo. Usa la URL final. - Cualquier otro código, un timeout o un error de red se reintenta: a 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas y 12 horas (7 intentos en unas 21 horas, con un poco de variación). Después la entrega queda como
fallida; puedes reenviarla. - Puede llegarte el mismo evento más de una vez (un reintento después de un timeout tuyo, o un reenvío): deduplica por
X-Wizerp-Evento-Id. - Desactivación automática: si un webhook acumula 15 entregas fallidas seguidas durante más de 24 horas, lo desactivamos, descartamos sus pendientes y avisamos por correo a quien lo creó. Lo reactivas en Wizerp o con
PUT /webhooks/{clave}y{"activo": true}. Lo que pasó mientras estuvo desactivado sigue disponible en la API.
Seguridad
- Sólo
https, al puerto 443, con nombre de dominio (no una IP). El certificado debe ser válido. - No mandamos avisos a direcciones privadas, locales ni reservadas: el dominio se resuelve en cada entrega y, si apunta a una red interna, la entrega falla con
destino_bloqueado. - El secreto se guarda cifrado y sólo se muestra al crearlo o rotarlo. Si sospechas que se filtró, rótalo: el inmediato anterior sigue firmando 24 horas (dos
v1en la cabecera) para que lo cambies sin perder avisos. Si rotas dos veces en ese lapso, el más viejo deja de firmar. - Cada webhook es de una empresa y sólo recibe eventos de esa empresa.
Límites y cupo
- Hasta 10 webhooks por empresa. Un webhook puede recibir varios eventos: agrégalos al mismo en vez de crear otro.
- Las entregas no cuentan contra el cupo mensual de la API. Es el punto: un aviso te evita cientos de consultas. Las llamadas que haces para administrar webhooks o para leer el detalle de un aviso sí cuentan, como cualquier petición.
- Los eventos y el registro de entregas se guardan 30 días.
Catálogo de eventos
venta.creadaVenta creadaSe creó un documento de venta: cotización, pedido, remisión (venta), factura o anticipo, desde cualquier canal (pantalla, punto de venta, marketplace, tienda en línea o la API). Convertir una cotización en pedido o un pedido en factura NO es una venta nueva: no emite este evento (la factura emite factura.timbrada).
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "venta.creada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"venta": 48213,
"tipo": "pedido"
},
"recurso": {
"tipo": "ventas",
"clave": 48213,
"url": "https://api.wizerp.com/api/v1/ventas?clave=48213&tipo=pedido"
}
}factura.timbradaFactura timbradaEl SAT (a través del PAC) timbró una factura de venta: ya tiene folio fiscal (UUID). Sólo facturas de ingreso; las notas de crédito y los complementos de pago no emiten este evento.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "factura.timbrada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"factura": 99120,
"venta": 48213,
"uuid": "6F1C2B9E-3D4A-4F7B-9A21-0C5E8D7B1A23"
},
"recurso": {
"tipo": "facturas",
"clave": 99120,
"url": "https://api.wizerp.com/api/v1/facturas?clave=99120"
}
}factura.canceladaFactura canceladaLa cancelación de una factura quedó firme: el PAC la aceptó en facturas que no requieren aceptación del receptor, o el SAT confirmó la cancelación en las que sí la requieren. Una solicitud rechazada por el receptor no emite este evento.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "factura.cancelada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"factura": 99120,
"venta": 48213,
"uuid": "6F1C2B9E-3D4A-4F7B-9A21-0C5E8D7B1A23"
},
"recurso": {
"tipo": "facturas",
"clave": 99120,
"url": "https://api.wizerp.com/api/v1/facturas?clave=99120"
}
}cxc.pago_aplicadoPago aplicado a una cuenta por cobrarSe aplicó un pago a un documento de tu cartera: un depósito identificado (pantalla de Bancos o Cobranza, conciliación, pasarela de pago o la API), un saldo a favor o un anticipo. Un depósito que paga varias cuentas emite un evento por cuenta.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "cxc.pago_aplicado",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"pago": 77031,
"cxc": 51220,
"importe": "1160.00"
},
"recurso": {
"tipo": "cxc-pagos",
"clave": 77031,
"url": "https://api.wizerp.com/api/v1/cxc-pagos?clave=77031"
}
}cliente.creadoCliente creadoSe dio de alta un cliente en tu cartera (pantalla, importación de pedidos de marketplace o la API). Los prospectos del CRM no emiten este evento.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "cliente.creado",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"cliente": 10342
},
"recurso": {
"tipo": "clientes",
"clave": 10342,
"url": "https://api.wizerp.com/api/v1/clientes?clave=10342"
}
}cliente.actualizadoCliente actualizadoSe editaron los datos de un cliente. Los cambios del mismo cliente dentro de un minuto llegan como un solo evento: consulta el cliente para ver cómo quedó.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "cliente.actualizado",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"cliente": 10342
},
"recurso": {
"tipo": "clientes",
"clave": 10342,
"url": "https://api.wizerp.com/api/v1/clientes?clave=10342"
}
}producto.creadoProducto creadoSe dio de alta un producto en tu catálogo (pantalla, sincronización de marketplace o la API).
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "producto.creado",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"producto": 20581,
"sku": "CAM-ROJA-M"
},
"recurso": {
"tipo": "productos",
"clave": 20581,
"url": "https://api.wizerp.com/api/v1/productos?clave=20581"
}
}producto.actualizadoProducto actualizadoSe editaron los datos de un producto. Los cambios del mismo producto dentro de un minuto llegan como un solo evento. Los precios y las existencias tienen sus propias consultas (GET /precios, GET /inventario).
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "producto.actualizado",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"producto": 20581,
"sku": "CAM-ROJA-M"
},
"recurso": {
"tipo": "productos",
"clave": 20581,
"url": "https://api.wizerp.com/api/v1/productos?clave=20581"
}
}inventario.existencia_cambiadaExistencia cambiadaCambió la existencia o lo apartado de un producto en un almacén (venta, compra, traspaso, ajuste, pedido). AGRUPADO: todos los cambios del mismo renglón de existencia dentro de un minuto llegan como UN evento, así que no recibirás uno por pieza vendida. El evento no trae la cantidad a propósito: consulta GET /inventario con la clave y obtendrás la existencia vigente, no una que ya cambió.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "inventario.existencia_cambiada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"existencia": 335871,
"producto": 20581,
"almacen": "MTZ"
},
"recurso": {
"tipo": "inventario",
"clave": 335871,
"url": "https://api.wizerp.com/api/v1/inventario?clave=335871"
}
}oportunidad.creadaOportunidad creadaSe creó una oportunidad en el embudo del CRM (pantalla o la API).
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "oportunidad.creada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"oportunidad": 811,
"etapa": 3
},
"recurso": {
"tipo": "oportunidades",
"clave": 811,
"url": "https://api.wizerp.com/api/v1/oportunidades?clave=811"
}
}oportunidad.etapa_cambiadaOportunidad cambió de etapaUna oportunidad se movió de etapa del embudo, incluidas ganarla y perderla. Trae la etapa anterior y la nueva (claves de GET /etapas-oportunidad) y el estatus resultante. Reordenar tarjetas dentro de la misma etapa no emite este evento.
Payload de ejemplo
{
"id": "evt_3f9c1a7be0d24c55a91e6b02",
"tipo": "oportunidad.etapa_cambiada",
"creado": "2026-10-11T18:22:04Z",
"empresa": 208,
"datos": {
"oportunidad": 811,
"etapa_anterior": 3,
"etapa": 5,
"estatus": "ganada"
},
"recurso": {
"tipo": "oportunidades",
"clave": 811,
"url": "https://api.wizerp.com/api/v1/oportunidades?clave=811"
}
}