Avisos (webhooks)
Un aviso es una petición que Zenci le envía a tu tienda cuando cambia algo del catálogo (un producto, sus existencias o su precio) o cuando le pasa algo a una venta que registró tu tienda: se facturó, la factura no se pudo emitir, se anuló o se devolvió. Así tu tienda no tiene que consultar la API a cada rato para enterarse.
El aviso dice qué cambió, no cómo quedó: trae solo identificadores. Al recibirlo, consulta la API para leer el estado actual.
Configurar un aviso
Los avisos se configuran en el panel, en Administración → Integraciones → Avisos a tu tienda. No se crean por la API.
- Haz clic en Nuevo aviso.
- En Dirección de tu tienda, escribe la URL que recibirá los avisos.
- En Qué avisar, marca los eventos que quieres recibir.
- Haz clic en Crear aviso.


La dirección debe:
- empezar con
https://; - ser un dominio público: no se aceptan direcciones IP,
localhost, nombres sin punto ni dominios.localo.internal; - no llevar usuario ni contraseña.
Cada comercio puede tener hasta 5 avisos. Un aviso empieza a enviar los cambios desde que se crea; los anteriores no se envían.
La clave de firma
Al crear el aviso, Zenci muestra una sola vez su clave de firma, que empieza con whsec_. Guárdala en tu tienda: sirve para comprobar que cada aviso viene de Zenci. Si la pierdes o se filtró, usa .


Eventos
| Evento | En el panel | Cuándo se envía | data |
|---|---|---|---|
product.updated | Producto actualizado | Cambió la ficha de un producto, una de sus variantes, sus opciones o sus imágenes | productId |
product.deleted | Producto eliminado | Se eliminó un producto | productId |
stock.updated | Existencias actualizadas | Cambiaron las existencias de una variante en una bodega | variantId, sku, warehouseId |
price.updated | Precio actualizado | Cambió el precio de una variante en el catálogo o en una lista de precios | variantId, sku, priceListId |
order.invoiced | Venta facturada | La DGI aceptó la factura de una venta de tu tienda. Llega otra vez si la factura se corrige y se emite una nueva | orderId, invoiceId |
order.invoice_failed | Factura de una venta no emitida | La factura no se pudo emitir (ver reason abajo) | orderId, invoiceId, reason |
order.invoice_cancelled | Factura de una venta anulada | El comercio anuló la factura de una venta de tu tienda | orderId, invoiceId |
order.returned | Venta devuelta | El comercio registró una devolución de una venta de tu tienda | orderId, returnId |
catalog.resync_required | Volver a leer el catálogo | Tu tienda debe releer el catálogo completo. Se envía siempre, aunque no lo marques | reason |
webhook.test | Aviso de prueba | Alguien del comercio pidió . Solo responde 2xx | subscriptionId |
Los eventos order.* solo se envían por ventas registradas por la API, de cualquier clave del comercio, nunca por las ventas del POS.
Qué hacer con cada uno:
product.updated: lee el producto con y actualízalo en tu tienda.product.deleted: deja de vender ese producto.stock.updated: consulta . Si la variante es la base de una presentación enlazada (la caja de 12, por ejemplo), también llega un aviso por la presentación.price.updated: con unpriceListId, cambió el precio en esa lista; si no es la de tu clave (en ), puedes descartarlo. ConpriceListId: null, vuelve a leer siempre el precio con tu clave: llega cuando cambia el precio del catálogo, cuando se quita un precio de una lista o por otras ediciones de la variante, aunque el precio final no haya cambiado.order.invoicedyorder.invoice_cancelled: consulta para leer la factura y su enlace al PDF.order.invoice_failed: segúnreason.rejected(la DGI la rechazó) yreview_required(respuesta ambigua) los resuelve el comercio en Zenci.customer_missing_dvycustomer_incompletedicen que faltan datos del cliente (el DV, o el pasaporte, el país o el correo de un extranjero): corrígelos con y Zenci vuelve a intentar la factura sola en las horas siguientes.merchant_setupes un problema de la configuración de facturación del comercio. En los tres últimos,invoiceIdesnull.order.returned: la devolución ya se registró en Zenci; si tu tienda lleva sus propias devoluciones, márcala.catalog.resync_required: consulta .
Qué recibe tu tienda
Zenci hace un POST a tu dirección con este cuerpo:
{
"id": "e8r2t6y0u4i7o1p5a9s3d7f1",
"event": "stock.updated",
"createdAt": "2026-10-06T15:31:04.000Z",
"data": {
"variantId": "v3q7w1e5r9t2y6u0i4o8p2a6",
"sku": "MB-029-CRU-38",
"warehouseId": "w2e6r0t4y8u1i5o9p3a7s1d5"
}
}| Campo | Descripción |
|---|---|
id | Identificador del aviso. Es el mismo en todos sus reintentos |
event | El evento |
createdAt | Cuándo se generó el aviso |
data | Los identificadores de lo que cambió, según el evento |
Otros ejemplos de data:
{ "productId": "p4k8m2x9q7w1e5r3t6y0u2i8" }{ "variantId": "v3q7w1e5r9t2y6u0i4o8p2a6", "sku": "MB-029-CRU-38", "priceListId": null }{ "reason": "change_history_expired" }{ "orderId": "o5h9j3k7l1z4x8c2v6b0n4m8", "invoiceId": null, "reason": "customer_missing_dv" }Y estos encabezados:
| Encabezado | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Zenci-Webhooks/1.0 |
X-Zenci-Event | El evento, igual que event |
X-Zenci-Delivery | El identificador del aviso, igual que id |
X-Zenci-Signature | t=<segundos>,v1=<firma> |
Comprobar la firma
X-Zenci-Signature tiene dos partes:
t: el momento del envío, en segundos desde 1970 (hora Unix).v1: HMAC-SHA256, en hexadecimal, del texto<t>.<cuerpo>con tu clave de firmawhsec_…completa.
Para comprobarla:
- Toma el cuerpo tal como llegó, sin convertirlo a objeto y volver a texto: un espacio o un orden de campos distinto cambia la firma.
- Calcula el HMAC-SHA256 de
<t>.<cuerpo>con tu clave y compáralo conv1en tiempo constante. - Rechaza los avisos con un
tmuy viejo, por ejemplo de más de 5 minutos, para que nadie pueda reenviarte un aviso capturado. Zenci no aplica esta tolerancia por ti; la decides tú. Mantén sincronizado el reloj de tu servidor.
Ejemplo en Node.js, sin dependencias:
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.ZENCI_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function isValidSignature(rawBody, header) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((part) => part.trim().split("=")),
);
const timestamp = Number(parts.t);
if (!parts.t || !parts.v1 || !Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", SECRET)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const received = Buffer.from(parts.v1);
const computed = Buffer.from(expected);
return received.length === computed.length && timingSafeEqual(received, computed);
}
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!isValidSignature(rawBody, req.headers["x-zenci-signature"])) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end();
handleEvent(JSON.parse(rawBody));
});
}).listen(3000);Si usas un framework, pide el cuerpo crudo: en Express, por ejemplo, con express.raw({ type: "application/json" }) en esa ruta.
Responder al aviso
- Responde con un estado 2xx en menos de 5 segundos. Pasado ese tiempo, Zenci da el intento por fallido.
- Responde primero y procesa después, como en el ejemplo. Consultar la API puede tomar más que eso.
- Zenci no sigue redirecciones: un
3xxcuenta como fallo definitivo. Configura la dirección final.
Reintentos
| Respuesta de tu tienda | Qué hace Zenci |
|---|---|
2xx | Lo da por Entregado |
408, 429, 5xx, o ninguna respuesta (error de red, tiempo agotado) | Lo reintenta más tarde |
Cualquier otro 4xx, o un 3xx | Lo da por Fallido y no lo reintenta |
Un aviso se intenta hasta 8 veces. Entre un intento y el siguiente espera cada vez más: 1 minuto, 3, 9, 27, 81 minutos (1 h 21 min), unas 4 horas y 12 horas. Si el octavo intento falla, el aviso queda Fallido, unas 18 horas después del primero.
Ten en cuenta:
- Un aviso puede llegar más de una vez. Cada reintento lleva el mismo
idy untnuevo. Guarda losidque ya procesaste y descarta los repetidos. - El orden no está garantizado. Un reintento puede llegar después de un aviso más nuevo. Por eso conviene consultar la API al recibirlo, en vez de aplicar lo que traiga el aviso.
- No es instantáneo. Zenci envía los avisos cada minuto, así que un cambio llega en uno o dos minutos.
- Si a la API del comercio se le desactiva el acceso, o su suscripción queda restringida, los avisos esperan sin gastar intentos y se envían cuando se restablece.
Administrar un aviso
En la lista de avisos ves la dirección, los eventos y el estado de cada uno: Activa, Pausada o Requiere resincronización. El menú ⋯ de cada aviso tiene estas acciones:


- Ver entregas muestra los últimos 50 avisos enviados a esa dirección, del más nuevo al más viejo, con su evento, estado (Pendiente, Entregado o Fallido), intentos, la última respuesta de tu tienda y la fecha. Es el primer lugar donde mirar si tu tienda no recibe avisos. Un aviso Fallido tiene el botón Reintentar, que lo vuelve a la cola con sus 8 intentos desde cero: úsalo cuando ya corregiste lo que hacía fallar a tu tienda.
- Enviar aviso de prueba y Cambiar clave de firma se explican abajo.
- Pausar deja de enviar avisos a esa dirección. Los cambios no se pierden: al Activar de nuevo, se envían los que ocurrieron mientras estuvo pausado.
- Eliminar detiene los avisos de inmediato. Los que estaban pendientes ya no se envían.




Enviar un aviso de prueba
Enviar aviso de prueba manda un webhook.test a la dirección por el mismo camino que un aviso real: firmado, con sus reintentos y visible en Ver entregas. Sirve para comprobar que tu tienda recibe los avisos y valida la firma antes de esperar un cambio real. Sale en menos de un minuto. Un aviso pausado no se puede probar.
Cambiar la clave de firma
Cambiar clave de firma genera una clave whsec_ nueva y la muestra una sola vez. La anterior deja de firmar en ese momento, también en los reintentos de avisos pendientes: hasta que copies la nueva en tu tienda, tu tienda va a rechazar los avisos y Zenci los va a reintentar. Hazlo cuando la clave se haya perdido o filtrado.
Resincronizar
Zenci guarda el historial de cambios durante 30 días. Si un aviso queda pausado o sin enviar más tiempo que eso, ya no puede saber todo lo que cambió. Entonces:
- Envía
catalog.resync_required, aunque no lo hayas marcado en Qué avisar. - El aviso pasa a Requiere resincronización y deja de enviar cambios.
Para volver a la normalidad:
- Tu tienda relee el catálogo completo con , sin
updatedSince, y las existencias conGET /stock. - Cuando termine, alguien del comercio hace clic en Reanudar en ese aviso, en el panel.
Al reanudar, Zenci envía los cambios ocurridos desde que pidió la resincronización. Un cambio hecho mientras tu tienda releía puede llegar repetido, pero no se pierde.
Siguientes pasos
- — Lo que tu tienda consulta al recibir un aviso.
- — Formato de los datos y errores.