QAPI está en beta — la API puede cambiar sin previo aviso. Usa sandbox para pruebas.
QAPIQAPIDocs · by DTEVIA
Guías

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

EventoCuándo se dispara
invoice.certifiedLa factura fue certificada ante la SAT
invoice.failedLa certificación falló (firma o certificación)
invoice.cancelledLa factura fue anulada
invoice.testDisparado 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

HeaderValor
X-QAPI-Signaturesha256=<hmac-hex> — firma HMAC-SHA256 del body con tu webhook secret
X-QAPI-EventEl tipo de evento (ej. invoice.certified)
User-AgentQAPI-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:

IntentoEspera previa
1inmediato
21 segundo
33 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). localhost o 192.168.x.x no 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.

On this page