Webhooks de la API Partner
ZarelaFact avisa a tu backend cuando cambia el estado de un certificado, una certificación, la producción o un documento, y cuando un proveedor envía un e-CF a uno de tus clientes. Los webhooks aceleran tu ERP, pero la fuente de verdad sigue siendo la consulta: GET .../production y status/batch.
Esta guía cubre /partner/v1. Los webhooks de una cuenta fiscal directa usan otro payload y otra alta.
1. Registra y prueba tu receptor
Desde el dashboard: en Desarrolladores → Webhooks pulsa Agregar endpoint, escribe la URL HTTPS de tu backend y elige todos los eventos o solo los que usarás (necesitas el correo verificado y el rol owner o developer). El secreto se muestra una sola vez. En el detalle de cada endpoint ves sus entregas con el motivo de cada fallo y su payload, y puedes enviar un evento de prueba, editar, pausar, rotar el secreto, reenviar entregas fallidas y eliminarlo. Cada cambio queda en la auditoría con tu usuario.
Desde tu backend, con una clave que tenga webhooks:manage:
POST /partner/v1/webhook-endpoints
X-PARTNER-KEY: <clave-backend>
Content-Type: application/json
{"name":"ERP producción","url":"https://erp.example.com/api/webhooks/psfe/partner"}
- La respuesta
201traeidysigningSecret. El secreto se muestra una sola vez: instálalo en tu receptor antes de recibir eventos. - Repetir el POST con la misma URL devuelve
200yreplayed: truesin el secreto; no lo recupera ni lo rota. - Omite
eventTypespara recibir todos los eventos, o envía una lista no vacía. Para cambiar los eventos de un webhook existente usaPATCH /webhook-endpoints/{endpointId}coneventTypes.
Para probar, envía POST /partner/v1/webhook-endpoints/{endpointId}/test con {"fiscalAccountId":"<uuid>","environment":"certecf"}. 202 significa encolado; consulta GET /partner/v1/webhook-deliveries/{deliveryId} hasta succeeded o failed. La prueba no envía nada a la DGII.
2. Verifica cada entrega
Cada entrega trae tres cabeceras:
| Cabecera | Contenido |
|---|---|
Zarela-Event-Id | ID único del evento. Igual en todos los reintentos. |
Zarela-Event-Type | Tipo de evento. |
Zarela-Signature | t=<unix>,v1=<hex>; durante una rotación trae dos v1. |
Para verificar:
- Calcula HMAC-SHA256 de
{timestamp}.{rawBody}con tusigningSecret, usando el body crudo (antes de parsear el JSON). - Rechaza timestamps con más de 5 minutos de diferencia.
- Compara en tiempo constante y acepta si cualquier
v1coincide con alguno de tus secretos.
No conviertas la cabecera con Object.fromEntries, URLSearchParams ni un mapa clave→valor: se quedan con un solo v1 y rechazarían entregas válidas durante una rotación.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: Buffer con los bytes exactos recibidos (p. ej. express.raw()).
// secrets: secretos instalados; durante una rotación puedes tener dos.
export function verificarWebhookPartner({ rawBody, signatureHeader, secrets, toleranceSeconds = 300 }) {
const header = String(signatureHeader || "");
if (header.length > 512) return false;
const parts = header.split(",").map((part) => part.trim());
const timestamps = parts.filter((part) => /^t=\d+$/.test(part));
const firmas = parts.filter((part) => /^v1=[a-f0-9]{64}$/i.test(part));
if (timestamps.length !== 1 || firmas.length < 1 || firmas.length > 2
|| parts.length !== timestamps.length + firmas.length) return false;
const timestamp = Number(timestamps[0].slice(2));
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const recibidas = firmas.map((part) => Buffer.from(part.slice(3), "hex"));
return secrets.some((secret) => {
const esperada = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return recibidas.some((firma) => timingSafeEqual(esperada, firma));
});
}
// Express: la ruta debe recibir el body crudo, no JSON ya parseado.
app.post("/api/webhooks/psfe/partner", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verificarWebhookPartner({
rawBody: req.body,
signatureHeader: req.get("Zarela-Signature"),
secrets: [process.env.PSFE_WEBHOOK_SECRET, process.env.PSFE_WEBHOOK_SECRET_NEXT].filter(Boolean),
});
if (!ok) return res.status(401).end();
const evento = JSON.parse(req.body.toString("utf8"));
// Inserta el evento con restricción única; si ya existía, responde 2xx sin repetir efectos.
await guardarEventoUnaVez(evento, req.get("Zarela-Event-Id"));
return res.status(204).end();
});
3. Aplica el evento una sola vez
El payload (schemaVersion: "partner.webhook.v1") incluye id, type, createdAt, externalTenantId, fiscalAccountId, environment, stateVersion, correlationId y resource.
- Comprueba que
externalTenantIdyfiscalAccountIdcorrespondan al mismo tenant de tu ERP. - Guarda
Zarela-Event-Idcon restricción única antes de aplicar cambios; un duplicado responde2xxsin repetir efectos. - Usa
stateVersionpara ignorar eventos atrasados, comparándolo solo dentro del mismo recurso y ambiente.
| Evento | Cuándo llega |
|---|---|
certificate.validated | Se instaló un certificado (uno por ambiente). |
certificate.expiring / certificate.expired | El certificado está por vencer o venció. |
certification.state_changed | El caso de certificación cambió de estado. |
certification.run_completed | Un run de pruebas terminó correctamente. Si falla, llega certification.state_changed con state: "failed". |
production.state_changed | Cambió la autorización eCF de la cuenta. |
document.state_changed | Cambió el estado de un e-CF emitido o apareció un motivo nuevo por el que no avanza. Trae resource.printing desde la respuesta de la DGII (TrackID o respuesta del resumen RFCE; resource.printingStatus dice por qué aún no), con resource.printing.pdfUrl para descargar el PDF con tu credencial (documents:read; null en CerteCF) y resource.lastError (por ejemplo DGII_CERTIFICATE_NOT_DELEGATED con su hint, y retrying), null sin error o con estado final. Con resource.status: transmission_failed el envío falló de forma definitiva y no se reintenta solo; ver Por qué un documento no avanza. |
document.commercial_approval_received | El comprador aceptó o rechazó comercialmente un e-CF del cliente. resource.state es accepted o rejected; data.rejectionReason trae el motivo y data.correspondence si el ACECF corresponde con el e-CF emitido (matched, mismatch con mismatches, o ecf_not_found). |
document.commercial_approval_due | Un e-CF que un proveedor envió al cliente sigue sin respuesta comercial y faltan 3 días o menos para el plazo (día 15 del mes siguiente a su FechaEmision). Una sola vez por e-CF. resource.kind es inbound_document, resource.id el inboundDocumentId y resource.state commercial_approval_due; data trae issuerRnc, issuerName, issueDate, deadline (AAAA-MM-DD) y daysRemaining. Los webhooks creados antes no lo reciben hasta que lo agregues con PATCH. |
annulment.state_changed | La DGII respondió una anulación (ANECF) del cliente. resource.id es el annulmentId y resource.state su resultado (accepted, rejected, failed o uncertain). |
dgii.service_degraded / dgii.service_restored | La DGII dejó de responder en el ambiente del cliente (sus envíos quedan en contingencia y se reintentan solos) o volvió a responder. Una vez por episodio, no por documento. resource.kind es dgii_service, resource.state es degraded o restored; data trae since, code, dgiiEnvironment y, al restablecerse, degradedSince. Los webhooks creados antes no los reciben hasta que los agregues con PATCH. |
document.received | Un proveedor envió un e-CF al cliente. resource.id es el inboundDocumentId para responder la aprobación comercial. Los webhooks creados antes de este evento no lo reciben hasta que lo agregues con PATCH; lo mismo aplica a document.commercial_approval_received y annulment.state_changed. |
Reintentos automáticos
| Respuesta de tu receptor | Qué hace ZarelaFact |
|---|---|
Cualquier 2xx | succeeded; no reenvía. |
Error de red, timeout, 408, 429 o 5xx | Reintenta con backoff; al agotar intentos, failed. |
3xx (no se siguen redirecciones), otros 4xx, respuesta demasiado grande o destino no público | failed de inmediato. |
- Intentos: hasta 8 por entrega. Esperas de ~10 s, 20 s, 40 s… hasta ~640 s (
min(1200 s, 5 s × 2^n + jitter)): unos 21 minutos en total. - Timeout: 10 segundos por solicitud.
- Respuesta: se leen como máximo 64 KiB; responde con un cuerpo corto o vacío.
- Cada intento lleva timestamp y firma nuevos, con el mismo
Zarela-Event-Idy el mismo cuerpo.
Mantenimiento
| Necesitas | Cómo |
|---|---|
| Ver entregas fallidas | GET /partner/v1/webhook-deliveries?status=failed&limit=50; sigue nextCursor sin modificarlo. |
| Reenviar una fallida | POST /webhook-deliveries/{deliveryId}/replay. Conserva el evento y el contador de intentos: si ya agotó los 8, hace un solo intento más. 409 WEBHOOK_REPLAY_NOT_ALLOWED si no está failed o el endpoint no está activo. |
| Pausar | PATCH /webhook-endpoints/{endpointId} con {"status":"paused"}. Las entregas pendientes esperan y continúan al reactivar. |
| Cambiar la URL | Pausa, espera a que no haya entregas running, cambia url con otro PATCH, prepara el receptor nuevo y reactiva. |
| Retirar el endpoint | DELETE /webhook-endpoints/{endpointId}. Conserva el historial; las entregas en cola pasan a failed y un PATCH posterior responde 409 WEBHOOK_ENDPOINT_RETIRED. Para volver a usar la URL, regístrala de nuevo (secreto nuevo). |
| Rotar el secreto | POST /webhook-endpoints/{endpointId}/rotate-secret. Durante la ventana overlapSeconds llegan dos firmas: despliega primero la verificación de dos firmas, instala el secreto nuevo y prueba una entrega. Otra rotación durante esa ventana devuelve 409 WEBHOOK_ROTATION_IN_PROGRESS. |
| Recuperar un secreto perdido | Pausa el endpoint, rota el secreto, instálalo y reactiva. |