Webhooks
Recibe notificaciones en tiempo real sobre el estado de tus facturas con los webhooks de QAPI.
Webhooks
Los webhooks de QAPI te permiten recibir notificaciones automáticas cuando el estado de una factura cambia — sin hacer polling.
Eventos disponibles
| Evento | Cuándo se dispara |
|---|---|
invoice.certified | La factura fue certificada ante la SAT |
invoice.failed | La certificación falló (firma o certificación) |
invoice.cancelled | La factura fue anulada |
invoice.test | Disparado manualmente desde el dashboard (botón "Probar webhook") con datos de ejemplo fijos |
Las pruebas también disparan webhooks — distínguelas con data.test
Las emisiones de prueba (certificador MOCK) sí disparan webhooks: tanto las de
POST /v1/sandbox/invoices como cualquier emisión de una cuenta aún sin certificador real.
Todas llevan data.test: true. Las emisiones reales llevan data.test: false. Usa ese campo
para no confundir una notificación de prueba con una factura con validez fiscal.
El webhook del sandbox usa tu webhook global (el de la cuenta) o el webhook_url que envíes
en el body del request.
Configuración
Puedes configurar webhooks de dos formas:
1. Por factura (webhook_url)
Incluye webhook_url en el body de POST /v1/invoices para recibir eventos
solo de esa factura específica:
{
"tipo_dte": "FACT",
"webhook_url": "https://tu-sistema.com/webhooks/qapi",
...
}2. Global (dashboard)
Configura un endpoint global desde el dashboard de QAPI que recibe eventos de todas las facturas de tu tenant.
Estructura del payload
Todos los eventos siguen la misma estructura:
Prop
Type
data.test — distingue prueba de emisión real
data.test es true cuando el documento se emitió con el certificador MOCK (sandbox o cuenta
sin activar): no tiene validez fiscal. En emisiones reales es false. Está presente en
invoice.certified, invoice.failed e invoice.cancelled.
El campo del UUID se llama uuid (no uuid_sat)
En el payload del webhook el identificador SAT viaja como data.uuid. El nombre uuid_sat
solo se usa en las respuestas de la API REST (GET /v1/invoices/:id).
Headers de cada entrega
| Header | Valor |
|---|---|
X-QAPI-Signature | sha256=<hmac-hex> — firma HMAC-SHA256 del body con tu webhook secret |
X-QAPI-Event | El tipo de evento (ej. invoice.certified) |
User-Agent | QAPI-Webhook/1.0 |
Ejemplo — invoice.certified
{
"event": "invoice.certified",
"data": {
"id": "inv_V1StGXR8_Z5jdHi6",
"status": "CERTIFIED",
"test": false,
"uuid": "550E8400-E29B-41D4-A716-446655440000",
"serie": "A",
"numero_dte": 42,
"pdf_url": "https://print.totaldoc.io/pdf?uuid=550E8400...",
"certified_at": "2026-06-08T16:00:02.000Z"
}
}Ejemplo — invoice.failed
{
"event": "invoice.failed",
"data": {
"id": "inv_V1StGXR8_Z5jdHi6",
"status": "FAILED",
"test": false,
"error_code": "CERT_FAILED",
"error_message": "NIT del emisor no encontrado en el padrón SAT"
}
}Implementar el endpoint receptor
Responde 200 rápidamente
QAPI espera una respuesta 2xx en menos de 10 segundos. Si tu servidor tarda más,
QAPI marcará la entrega como fallida y reintentará.
El body de la respuesta es ignorado — solo importa el código HTTP.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/qapi', async (req, res) => {
// Responde 200 INMEDIATAMENTE antes de procesar
res.sendStatus(200);
const { event, data } = req.body;
// Procesa de forma asíncrona
setImmediate(() => handleQapiEvent(event, data));
});from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
app = FastAPI()
class WebhookPayload(BaseModel):
event: str
data: dict
@app.post("/webhooks/qapi")
async def qapi_webhook(payload: WebhookPayload, background_tasks: BackgroundTasks):
# Responde 200 inmediatamente
background_tasks.add_task(handle_qapi_event, payload.event, payload.data)
return {"ok": True}Verifica la firma (opcional pero recomendado)
QAPI firma cada entrega con HMAC-SHA256. El header X-QAPI-Signature contiene
sha256=<hex> calculado sobre el raw body con tu webhook secret.
import crypto from 'crypto';
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = `sha256=${crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')}`;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post('/webhooks/qapi', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-qapi-signature'] as string;
if (!verifySignature(req.body.toString(), sig, process.env.QAPI_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Firma inválida' });
}
res.sendStatus(200);
const payload = JSON.parse(req.body.toString());
setImmediate(() => handleQapiEvent(payload));
});Deduplica por factura + evento
QAPI puede reenviar un evento si tu endpoint no respondió 2xx a tiempo. El payload
no incluye un ID de entrega — usa la combinación data.id + event como clave de
deduplicación (cada factura emite cada evento una sola vez):
const processed = new Set<string>();
async function handleQapiEvent(payload: any) {
const dedupKey = `${payload.data.id}:${payload.event}`;
if (processed.has(dedupKey)) {
console.log(`Evento duplicado ignorado: ${dedupKey}`);
return;
}
processed.add(dedupKey);
switch (payload.event) {
case 'invoice.certified':
await markInvoiceCertified(payload.data.id, payload.data.uuid);
break;
case 'invoice.failed':
await notifyFailure(payload.data.id, payload.data.error_message);
break;
}
}En producción usa Redis o una tabla de base de datos en lugar de un Set en memoria.
Reintentos
Si tu endpoint no responde 2xx, QAPI hace hasta 3 intentos en total con backoff corto:
| Intento | Espera previa |
|---|---|
| 1 | inmediato |
| 2 | 1 segundo |
| 3 | 3 segundos |
Es decir, dos reintentos tras el primer envío, separados 1 s y 3 s. Después del tercer intento
fallido la entrega se abandona — no hay reintentos horas después. Si tu sistema estuvo caído,
recupera el estado con polling:
GET /v1/invoices?status=CERTIFIED o GET /v1/invoices/:id.
Diseña tu receptor asumiendo entregas perdidas
El webhook es una optimización para evitar polling constante, no un canal garantizado.
La fuente de verdad del estado de una factura es siempre GET /v1/invoices/:id.
Restricciones de la URL del webhook
- Debe ser una URL pública — QAPI bloquea IPs privadas, loopback y rangos internos
(protección SSRF).
localhosto192.168.x.xno van a recibir nada. - QAPI no sigue redirects: si tu endpoint responde
301/302, la entrega cuenta como fallida. - Tu endpoint debe responder en menos de 10 segundos.