Saltar al contenido principal

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.

Solo para Partners

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 201 trae id y signingSecret. El secreto se muestra una sola vez: instálalo en tu receptor antes de recibir eventos.
  • Repetir el POST con la misma URL devuelve 200 y replayed: true sin el secreto; no lo recupera ni lo rota.
  • Omite eventTypes para recibir todos los eventos, o envía una lista no vacía. Para cambiar los eventos de un webhook existente usa PATCH /webhook-endpoints/{endpointId} con eventTypes.

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:

CabeceraContenido
Zarela-Event-IdID único del evento. Igual en todos los reintentos.
Zarela-Event-TypeTipo de evento.
Zarela-Signaturet=<unix>,v1=<hex>; durante una rotación trae dos v1.

Para verificar:

  1. Calcula HMAC-SHA256 de {timestamp}.{rawBody} con tu signingSecret, usando el body crudo (antes de parsear el JSON).
  2. Rechaza timestamps con más de 5 minutos de diferencia.
  3. Compara en tiempo constante y acepta si cualquier v1 coincide con alguno de tus secretos.
No pierdas la segunda firma

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 externalTenantId y fiscalAccountId correspondan al mismo tenant de tu ERP.
  • Guarda Zarela-Event-Id con restricción única antes de aplicar cambios; un duplicado responde 2xx sin repetir efectos.
  • Usa stateVersion para ignorar eventos atrasados, comparándolo solo dentro del mismo recurso y ambiente.
EventoCuándo llega
certificate.validatedSe instaló un certificado (uno por ambiente).
certificate.expiring / certificate.expiredEl certificado está por vencer o venció.
certification.state_changedEl caso de certificación cambió de estado.
certification.run_completedUn run de pruebas terminó correctamente. Si falla, llega certification.state_changed con state: "failed".
production.state_changedCambió la autorización eCF de la cuenta.
document.state_changedCambió 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_receivedEl 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_dueUn 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_changedLa 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_restoredLa 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.receivedUn 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 receptorQué hace ZarelaFact
Cualquier 2xxsucceeded; no reenvía.
Error de red, timeout, 408, 429 o 5xxReintenta con backoff; al agotar intentos, failed.
3xx (no se siguen redirecciones), otros 4xx, respuesta demasiado grande o destino no públicofailed 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-Id y el mismo cuerpo.

Mantenimiento​

NecesitasCómo
Ver entregas fallidasGET /partner/v1/webhook-deliveries?status=failed&limit=50; sigue nextCursor sin modificarlo.
Reenviar una fallidaPOST /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.
PausarPATCH /webhook-endpoints/{endpointId} con {"status":"paused"}. Las entregas pendientes esperan y continúan al reactivar.
Cambiar la URLPausa, espera a que no haya entregas running, cambia url con otro PATCH, prepara el receptor nuevo y reactiva.
Retirar el endpointDELETE /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 secretoPOST /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 perdidoPausa el endpoint, rota el secreto, instálalo y reactiva.