Documentación
Webhooks
Avisos firmados cuando un comprobante cambia de estado, con la verificación de firma.
Crear un webhook
En Webhooks agrega la URL de tu servidor y elige los eventos, o créalo por la API. El secreto whsec_… se muestra una sola vez: guárdalo para verificar la firma.
curl -X POST "https://api.devfact.fonles.com/v1/webhooks" \
-H "Authorization: Bearer $DEVFACT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tu-erp.ejemplo.com/webhooks/devfact",
"eventos": [
"comprobante.autorizado",
"comprobante.no_autorizado",
"comprobante.devuelto"
]
}'Eventos
- comprobante.autorizadoEl SRI autorizó el comprobante (también los que respondieron 202).
- comprobante.no_autorizadoEl SRI no lo autorizó (error de autorización).
- comprobante.devueltoLa recepción del SRI lo devolvió.
- comprobante.anuladoSe marcó como anulado.
- comprobante.atascadoLleva varias horas sin estado final (por ejemplo, con el SRI caído).
- comprobante.correo_fallidoEl correo al comprador rebotó o no se pudo enviar.
- certificado.por_vencerTu firma vence en 30, 15, 7 o 1 día.
- certificado.vencidoTu firma venció: no puedes emitir en producción hasta subir otra.
- aprobacion.requeridaUn agente pidió emitir algo que necesita tu visto bueno.
| Evento | Cuándo |
|---|---|
| comprobante.autorizado | El SRI autorizó el comprobante (también los que respondieron 202). |
| comprobante.no_autorizado | El SRI no lo autorizó (error de autorización). |
| comprobante.devuelto | La recepción del SRI lo devolvió. |
| comprobante.anulado | Se marcó como anulado. |
| comprobante.atascado | Lleva varias horas sin estado final (por ejemplo, con el SRI caído). |
| comprobante.correo_fallido | El correo al comprador rebotó o no se pudo enviar. |
| certificado.por_vencer | Tu firma vence en 30, 15, 7 o 1 día. |
| certificado.vencido | Tu firma venció: no puedes emitir en producción hasta subir otra. |
| aprobacion.requerida | Un agente pidió emitir algo que necesita tu visto bueno. |
Sin eventos (o con "*") recibes todos, también los que se agreguen después. Los de certificado no son de un comprobante: su data trae certificadoId, ruc y diasRestantes.
{
"type": "comprobante.autorizado",
"timestamp": "2026-11-04T15:15:09.612Z",
"apiVersion": "2026-11-01",
"data": {
"id": "e9adcca8-8817-4212-911f-9a9f511f8a02",
"estado": "AUTORIZADO",
"tipo": "factura",
"numero": "001-001-000004521",
"claveAcceso": "0411202601179999999000110010010000045211234567813",
"importeTotal": "11.50",
"correoEstado": "ENVIADO",
"ultimoError": null,
"simulado": false
}
}El payload no lleva datos del comprador: sólo ids, estado, número, clave de acceso e importe.
Verificar la firma
Cada entrega trae las cabeceras webhook-id, webhook-timestamp y webhook-signature (estándar Standard Webhooks). La firma es v1,base64(HMAC-SHA256(secreto, "id.timestamp.cuerpo")), con el secreto sin el prefijo whsec_ decodificado de base64. Verifica sobre el cuerpo crudo, antes de parsear el JSON.
import crypto from "node:crypto";
import express from "express";
const SECRETO = process.env.DEVFACT_WEBHOOK_SECRET; // whsec_…
function firmaValida(cuerpo, cabeceras) {
const id = cabeceras["webhook-id"];
const ts = cabeceras["webhook-timestamp"];
const firmas = cabeceras["webhook-signature"] ?? "";
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const clave = Buffer.from(SECRETO.replace(/^whsec_/, ""), "base64");
const esperada = crypto.createHmac("sha256", clave).update(`${id}.${ts}.${cuerpo}`).digest();
return firmas.split(" ").some((f) => {
const [version, firma] = f.split(",");
const recibida = Buffer.from(firma ?? "", "base64");
return version === "v1" && recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
});
}
const app = express();
// El cuerpo crudo: la firma se calcula sobre los bytes exactos que llegaron.
app.post("/webhooks/devfact", express.raw({ type: "application/json" }), (req, res) => {
const cuerpo = req.body.toString("utf8");
if (!firmaValida(cuerpo, req.headers)) return res.status(401).end();
const evento = JSON.parse(cuerpo);
// Deduplica por req.headers["webhook-id"] y encola el trabajo.
if (evento.type === "comprobante.autorizado") {
console.log("Autorizado:", evento.data.numero, evento.data.claveAcceso);
}
res.status(204).end();
});
app.listen(3000);Buenas prácticas
- Responde
2xxen menos de 10 segundos y procesa después (una cola). Si no, se reintenta hasta 12 veces con espera creciente. - La entrega es al menos una vez: deduplica por
webhook-id, que no cambia entre reintentos. - Rechaza entregas con un
webhook-timestampde hace más de 5 minutos. - Los eventos pueden llegar desordenados: si te importa el estado actual, consulta el comprobante.

