Webhooks

Webhooks

Como la DGII resuelve la mayoría de los envíos de forma asíncrona, los webhooks son la manera natural de enterarte del veredicto sin sondear. FiscalFlow hace un POST a tu URL con el evento en el cuerpo y una firma HMAC en la cabecera.

Requieren un plan Full Service.

Registrar un webhook

POST /api/v1/webhooks
curl -X POST https://api.fiscalflowsupport.com/api/v1/webhooks \
  -H "X-API-Key: ffs_live_ejemplodeclavenoreal000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
    "company_uuid": "b3f1c0a2-5d47-4e91-8c26-0af3d9e17b45",
    "name": "ERP producción",
    "url": "https://erp.ejemplo.com/hooks/fiscalflow",
    "events": ["document.emission.completed", "document.reception.received"]
  }'
201 Response
{
  "success": true,
  "message": "Webhook created. Save the secret - it will not be shown again.",
  "data": {
    "uuid": "6d2a9b71-3e84-4c5f-9a02-7b1c8d4e6f30",
    "company_uuid": "b3f1c0a2-5d47-4e91-8c26-0af3d9e17b45",
    "name": "ERP producción",
    "url": "https://erp.ejemplo.com/hooks/fiscalflow",
    "events": ["document.emission.completed", "document.reception.received"],
    "is_active": true,
    "secret": "3f9c1d7a5e2b804613cf8a2d90e47b5c16ad38f2049e7c6b1d5a8309f42e6b7c"
  }
}
El secret se devuelve una sola vez, al crear el webhook. Guardalo en ese momento. Despues solo verás una versión enmascarada, que no sirve para verificar firmas: si lo pierdes, borra el webhook y crea otro.
Parameter Type Required Description
company_uuid string Required Empresa a la que se suscribe, por uuid — el mismo que devuelve GET /v1/companies.
name string Required Nombre para identificarlo, hasta 255 caracteres.
url string Required URL de destino, hasta 500 caracteres. Usa HTTPS.
events array Required Al menos un evento de la lista de abajo.
headers object Optional Cabeceras adicionales para tus entregas, hasta 10, cada valor de 256 caracteres como máximo.
Cambio incompatible en 2.0.0. Este endpoint tomaba company_id, el identificador numérico interno, que ninguna respuesta de la API publica: no había forma soportada de averiguar qué valor mandar. Ahora toma company_uuid, como todos los demás endpoints. Cambia el campo en tu integración; el uuid lo saca de GET /v1/companies. Enviar company_id devuelve 422.
headers se aplica después de las cabeceras propias de FiscalFlow, así que una cabecera tuya con el mismo nombre las sobrescribe. No definas X-FFS-Signature, X-FFS-Event ni X-FFS-Delivery ahí: dejarias tu propio endpoint sin forma de verificar la firma.

Eventos

Evento Cuando se dispara
document.emission.completed Un documento que emitiste llegó a un estado terminal, aceptado o rechazado. El evento principal.
document.reception.received Entro un e-CF a tu buzón.
document.approval.created Se registro una aprobación o rechazo comercial sobre un documento recibido.
dgii.forward.failed Un reenvío a la DGII fallo. Util para alertas de operación.
cert.expiry.warning El certificado digital de una empresa está por vencer. Trae days_remaining.
ping Prueba manual desde el panel. Sirve para verificar tu endpoint sin emitir nada.
dgii.status.changed La disponibilidad de los ambientes de la DGII cambió. Se dispara en la transición, no en cada chequeo: una caída es un webhook, no uno por minuto mientras dura. Trae previous_status, status y el detalle por ambiente en environments. Emitido de verdad desde 2.0.0; antes se podía suscribir y no lo mandaba nada.

El cuerpo de la entrega

El cuerpo es el evento en si, sin envoltorio. Todos traen event y timestamp. Este es document.emission.completed en su forma completa:

{
  "event": "document.emission.completed",
  "document_id": 4821,
  "type": "31",
  "encf": "E310000000001",
  "status": "accepted",
  "track_id": "4A8F2C1E9B",
  "amount": 5900.00,
  "currency": "DOP",
  "buyer_rnc": "101000001",
  "environment": "ecf",
  "qr_url": "https://api.fiscalflowsupport.com/api/v1/documents/01JZ8K4QM2N5P7R9T1V3W5X7Y9/qr",
  "error_message": null,
  "accepted_at": "2026-08-14T15:06:02+00:00",
  "timestamp": "2026-08-14T15:06:02+00:00"
}
Dos avisos sobre este evento.

El rechazo llega en forma reducida. Cuando el documento falla antes de salir — validación contra el esquema, o intentos agotados — el cuerpo solo trae event, document_id, type, encf, status, error_message y timestamp. Trata todo lo demás como opcional o tu receptor reventara justo en el caso que más te importa.

document_id es el id numérico interno, no el ULID que devuelve la API REST. Para cruzarlo con tu sistema usa el encf, o guarda el ULID al emitir y relaciona por ahí.

Verificar la firma

Cada entrega llega con estas cabeceras:

Cabecera Contenido
X-FFS-Signaturesha256= seguido del HMAC-SHA256 del cuerpo en hexadecimal minusculas.
X-FFS-EventNombre del evento.
X-FFS-DeliveryIdentificador de la entrega. El mismo valor se repite en los reintentos.

Lo que se firma es el cuerpo crudo de la petición, byte a byte. No lo deserialices y lo vuelvas a serializar antes de verificar: cualquier diferencia de espaciado o de orden de claves rompe la firma.

POST Tu endpoint receptor
import hmac, hashlib
from flask import Flask, request, abort

SECRET = "3f9c1d7a5e2b804613cf8a2d90e47b5c16ad38f2049e7c6b1d5a8309f42e6b7c"

app = Flask(__name__)

@app.post("/hooks/fiscalflow")
def fiscalflow():
    raw = request.get_data()  # bytes crudos, sin parsear
    expected = "sha256=" + hmac.new(
        SECRET.encode(), raw, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get("X-FFS-Signature", "")):
        abort(401)

    event = request.get_json()
    if event["event"] == "document.emission.completed":
        marcar_documento(event["encf"], event["status"], event.get("error_message"))

    return "", 200
<?php

$secret = '3f9c1d7a5e2b804613cf8a2d90e47b5c16ad38f2049e7c6b1d5a8309f42e6b7c';

$raw = file_get_contents('php://input');           // cuerpo crudo
$expected = 'sha256='.hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_FFS_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);

if ($event['event'] === 'document.emission.completed') {
    marcarDocumento($event['encf'], $event['status'], $event['error_message'] ?? null);
}

http_response_code(200);
import crypto from "node:crypto";
import express from "express";

const SECRET = "3f9c1d7a5e2b804613cf8a2d90e47b5c16ad38f2049e7c6b1d5a8309f42e6b7c";
const app = express();

// express.raw, no express.json: hay que firmar sobre los bytes originales
app.post("/hooks/fiscalflow", express.raw({ type: "application/json" }), (req, res) => {
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const received = req.get("X-FFS-Signature") ?? "";

  if (
    expected.length !== received.length ||
    !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  ) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString());
  if (event.event === "document.emission.completed") {
    marcarDocumento(event.encf, event.status, event.error_message);
  }

  res.sendStatus(200);
});
La firma no incluye marca de tiempo ni nonce, así que no protege contra reenvío: una entrega capturada se puede repetir y la firma seguirá siendo valida. Haz tu receptor idempotente — guarda el X-FFS-Delivery ya procesado, o comprueba el estado actual del documento antes de actuar.

Reintentos

Una entrega se considera exitosa con cualquier respuesta 2xx. El tiempo máximo de espera es de 10 segundos: si tu procesamiento tarda más, encolalo y responde de una vez.

Ante un fallo hay tres intentos en total: el original, uno al minuto y otro cinco minutos después. Al tercero la entrega se marca como fallida y no se vuelve a intentar.

Perder una entrega no significa perder el dato. El estado real del documento siempre está en GET /api/v1/documents/{id}, y una conciliación diaria por ?status= y ?date_from= cubre cualquier hueco. Trata el webhook como un aviso, no como la fuente de la verdad.

Ver el historial de entregas

curl -H "X-API-Key: ffs_live_ejemplodeclavenoreal000000000000000000" \
  https://api.fiscalflowsupport.com/api/v1/webhooks/6d2a9b71-3e84-4c5f-9a02-7b1c8d4e6f30/deliveries

Devuelve las entregas de la más reciente a la más antigua, de 25 en 25, con el código de respuesta que dio tu endpoint, el cuerpo que devolvió, el número de intentos y next_retry_at. Aqui también el paginador va dentro de data, así que las entregas están en data.data.

Los demás endpoints son los habituales: GET /api/v1/webhooks lista los de toda la organización, PUT sobre /api/v1/webhooks/{uuid} los modifica — incluido is_active para desactivar uno sin borrarlo — y DELETE lo elimina.