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
/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"]
}'
{
"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"
}
}
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. |
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"
}
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-Signature | sha256= seguido del HMAC-SHA256 del cuerpo en hexadecimal minusculas. |
X-FFS-Event | Nombre del evento. |
X-FFS-Delivery | Identificador 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.
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);
});
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.
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.