Factura SDK
Webhooks

Webhooks

Recibí el resultado de cada comprobante en tu servidor: endpoint, eventos, firma, reintentos y entregas.


Un webhook es una URL tuya a la que Factura SDK le hace un POST firmado cada vez que un comprobante llega a un estado final. Es la forma de enterarte del resultado de cada emisión, que por defecto ocurre en segundo plano (la API responde 202), sin consultar GET /v1/invoices/:id.

Endpoints

Método y rutaQué hace
POST /v1/webhook-endpointsCrea el endpoint. Devuelve el secret, una sola vez.
GET /v1/webhook-endpointsLista los endpoints, todos o filtrados con status.
GET /v1/webhook-endpoints/:idDevuelve un endpoint.
PUT /v1/webhook-endpoints/:idCambia la URL o el estado.
POST /v1/webhook-endpoints/:id/rotateGenera un secret nuevo.
POST /v1/webhook-endpoints/:id/testManda un evento de prueba firmado.
GET /v1/webhook-endpoints/:id/deliveriesLista las entregas del endpoint: qué se mandó, cuándo y con qué resultado.

El endpoint

{
  "id": "5d2b8f1c-9a3e-4c7b-b6d1-0e8f2a4c6b9d",
  "url": "https://api.tusistema.com/webhooks/facturasdk",
  "status": "active",
  "created_at": "2026-10-14T14:10:00.000Z",
  "updated_at": "2026-10-14T14:10:00.000Z"
}
CampoDescripción
urlAdónde se mandan los eventos.
statusactive o inactive. Un endpoint inactivo no recibe eventos de comprobantes; el evento test sí le llega, para probarlo antes de activarlo.
secretLa clave con la que se firma cada entrega. Solo viene en la respuesta de crear y de rotar.

Hay un endpoint activo por ambiente: ahí llegan todos los eventos. Si necesitás cambiar la URL, modificá el endpoint en vez de crear otro.

Crear un endpoint

Solicitud
const response = await fetch('https://app.afipsdk.com/api/v1/webhook-endpoints', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer <ACCESS_TOKEN>',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    url: 'https://api.tusistema.com/webhooks/facturasdk'
  })
});

const endpoint = await response.json();

console.log(endpoint.secret); // Guardalo: no se vuelve a mostrar

La respuesta es 201 con el endpoint y su secret, que empieza con whsec_. Guardalo: es la única vez que lo vas a ver, y lo necesitás para verificar la firma.

{
  "id": "5d2b8f1c-9a3e-4c7b-b6d1-0e8f2a4c6b9d",
  "url": "https://api.tusistema.com/webhooks/facturasdk",
  "status": "active",
  "created_at": "2026-10-14T14:10:00.000Z",
  "updated_at": "2026-10-14T14:10:00.000Z",
  "secret": "whsec_3fQ9kLm2Zx8vB1nC4pR7tY0uW6aE5dG3hJ9sK2lM8oP"
}

La URL tiene que ser https (http solo en sandbox), de hasta 500 caracteres, apuntar a un host público (nada de localhost, direcciones privadas o de enlace local) y no llevar usuario y contraseña. Si ya hay un endpoint activo, la respuesta es 409: modificá ese o desactivalo antes. Si hay uno inactivo con la misma URL, también 409: reactivalo con PUT.

Eventos

EventoCuándodata
invoice.approvedARCA aprobó el comprobante, con o sin observaciones.El comprobante, con cae, number y pdf_url. Las observaciones, si hay, en observations.
invoice.rejectedARCA lo rechazó.El comprobante, con los motivos en errors.
invoice.failedNo se pudo autorizar: se agotaron los reintentos o hubo un error de configuración.El comprobante, con la causa en last_error.
testLo pediste con POST /v1/webhook-endpoints/:id/test.message y endpoint_id.

Los comprobantes emitidos con sync: true no disparan webhooks: el resultado viaja en la respuesta de la solicitud. Tampoco los de la verificación de un emisor, ni los que cancelás con POST /v1/invoices/:id/cancel.

El cuerpo de cada entrega es un evento con el comprobante tal como lo devuelve GET /v1/invoices/:id:

{
  "event_id": "evt_9f1e2d3c4b5a69788a9b0c1d2e3f4a5b",
  "type": "invoice.approved",
  "created": "2026-10-14T14:05:04.000Z",
  "data": {
    "id": "7c1d9e4b-2f3a-4b8c-9d0e-1a2b3c4d5e6f",
    "idempotency_key": "venta-0002",
    "verification": false,
    "status": "approved",
    "invoice_type": 1,
    "sales_point": 1,
    "number": 482,
    "invoice_date": "2026-10-14",
    "total_amount": 12100,
    "currency": "PES",
    "cae": "76412345678901",
    "cae_expiration": "2026-10-24",
    "payload": { "...": "los datos del comprobante" },
    "observations": null,
    "errors": null,
    "last_error": null,
    "attempts": 1,
    "next_retry_at": null,
    "created_at": "2026-10-14T14:05:00.000Z",
    "updated_at": "2026-10-14T14:05:03.000Z",
    "processed_at": "2026-10-14T14:05:03.000Z",
    "pdf_url": "https://app.afipsdk.com/api/v1/invoices/7c1d9e4b-2f3a-4b8c-9d0e-1a2b3c4d5e6f/pdf?token=kX3v9Qw2LmN8pR5tY7uZ1aB4cD6eF8gH0jK2lM4nO6p",
    "pdf_generated_at": "2026-10-14T14:05:04.000Z",
    "arca_request_url": "https://app.afipsdk.com/api/v1/invoices/7c1d9e4b-2f3a-4b8c-9d0e-1a2b3c4d5e6f/arca-request?token=kX3v9Qw2LmN8pR5tY7uZ1aB4cD6eF8gH0jK2lM4nO6p",
    "arca_response_url": "https://app.afipsdk.com/api/v1/invoices/7c1d9e4b-2f3a-4b8c-9d0e-1a2b3c4d5e6f/arca-response?token=kX3v9Qw2LmN8pR5tY7uZ1aB4cD6eF8gH0jK2lM4nO6p",
    "email_status": null,
    "email_sent_at": null,
    "issuer": {
      "id": "0f8f3c2a-5b1e-4c7d-9a2e-6d4b8e1f2a3c",
      "tax_id": "20111111112",
      "business_name": "Emisor de prueba",
      "vat_condition": 1
    }
  }
}
CampoDescripción
event_idIdentificador único del evento. Si recibís dos entregas con el mismo event_id, es la misma: procesala una vez.
typeEl evento.
createdCuándo se generó.
dataEl comprobante en el momento del evento. GET /v1/invoices/:id es siempre la fuente de verdad.

Cada entrega lleva estos headers:

HeaderValor
Content-Typeapplication/json
User-AgentFacturaSDK-Webhooks/1.0 (+https://afipsdk.com)
X-FacturaSDK-SignatureLa firma. Ver abajo.
X-FacturaSDK-EventEl tipo de evento, igual que type.
X-FacturaSDK-DeliveryEl id de la entrega, el mismo de GET /v1/webhook-endpoints/:id/deliveries.

Verificar la firma

Cualquiera que conozca tu URL puede mandarle un POST. La firma demuestra que el evento lo generó Factura SDK y que el cuerpo no cambió en el camino: verificala siempre antes de procesar.

El header X-FacturaSDK-Signature tiene la forma t=<timestamp>,v1=<firma>, donde t es la hora de la entrega en segundos Unix y v1 es el HMAC-SHA256, en hexadecimal, de la cadena <timestamp>.<cuerpo> calculado con el secret del endpoint. El cuerpo es el texto exacto de la solicitud, byte a byte: usá el cuerpo crudo, no el JSON ya parseado y vuelto a serializar.

Para verificarla:

  1. Separá t y v1 del header.
  2. Calculá el HMAC-SHA256 de t + . + cuerpo con tu secret.
  3. Compará el resultado con v1 en tiempo constante.
  4. Rechazá la entrega si t difiere de tu hora actual en más de 5 minutos: evita que una solicitud capturado se reenvíe más tarde.
Tu endpoint
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/webhooks/facturasdk', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-FacturaSDK-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  const timestamp = parseInt(parts.t, 10);
  const received = parts.v1 || '';

  const expected = crypto
    .createHmac('sha256', process.env.FACTURASDK_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');

  const fresh = Math.abs(Math.floor(Date.now() / 1000) - timestamp) <= 300;
  const valid = fresh && received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

  if (!valid) {
    return res.status(400).send('Firma inválida');
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Respondé enseguida y procesá después
  res.sendStatus(200);

  handleEvent(event);
});

Responder a una entrega

Una entrega se da por recibida cuando tu endpoint responde cualquier código 2xx dentro de los 10 segundos. Cualquier otro código, un timeout o un error de conexión cuentan como intento fallido. Las redirecciones no se siguen: un 301 o 302 también es un fallo.

  • Respondé primero y procesá después: si tu lógica tarda, encolala.
  • Guardá el event_id de lo que ya procesaste: una entrega se puede repetir (un reintento después de un 2xx que no llegó a tiempo, por ejemplo) y no tiene que duplicar nada en tu sistema.
  • El comprobante que viene en data es una foto del momento. Si necesitás el estado actual, consultá GET /v1/invoices/:id.

Reintentos

Si un intento falla, Factura SDK vuelve a intentar con esperas crecientes: 1 minuto, 5 minutos, 30 minutos, 2 horas y 8 horas después de cada fallo. Los reintentos los hace un proceso que corre cada unos 10 minutos, así que cada espera real es la programada más lo que falte hasta la siguiente corrida. Si el intento después de la última espera también falla, o si pasaron 24 horas desde el primero, la entrega queda failed y los dueños del proyecto reciben un email con el detalle. Mientras tanto, el comprobante no se ve afectado: el webhook es una notificación, no parte de la emisión.

Los eventos test no se reintentan. Si desactivás el endpoint, sus entregas pendientes pasan a failed en la siguiente corrida de reintentos, con “El endpoint está inactivo” en last_error. Los reintentos se firman con el secret vigente al momento de cada intento.

Entregas

GET /v1/webhook-endpoints/:id/deliveries lista las entregas del endpoint, de la más nueva a la más vieja, con paginación por cursor. Sirve para ver qué se mandó, con qué resultado, y qué quedó pendiente de reintento.

ParámetroTipoDescripción
statusstringpending, delivered o failed.
invoice_idstringEntregas de un comprobante.
limitintegerCantidad por página, hasta 100.
cursorstringEl next_cursor de la página anterior.
Solicitud
const response = await fetch('https://app.afipsdk.com/api/v1/webhook-endpoints/<ENDPOINT_ID>/deliveries?status=failed', {
  headers: {
    'Authorization': 'Bearer <ACCESS_TOKEN>'
  }
});

const { data, next_cursor, total_count } = await response.json();
{
  "data": [
    {
      "id": "d4f6a8b0-c2e4-4a6b-9c8d-1e3f5a7b9c0d",
      "endpoint_id": "5d2b8f1c-9a3e-4c7b-b6d1-0e8f2a4c6b9d",
      "invoice_id": "7c1d9e4b-2f3a-4b8c-9d0e-1a2b3c4d5e6f",
      "event_id": "evt_9f1e2d3c4b5a69788a9b0c1d2e3f4a5b",
      "event_type": "invoice.approved",
      "status": "pending",
      "http_status": 503,
      "attempts": 2,
      "next_retry_at": "2026-10-14T14:41:04.000Z",
      "last_error": "HTTP 503",
      "created_at": "2026-10-14T14:05:04.000Z",
      "updated_at": "2026-10-14T14:11:04.000Z",
      "delivered_at": null
    }
  ],
  "next_cursor": null,
  "total_count": 1
}
CampoDescripción
statuspending (esperando un reintento), delivered o failed.
http_statusCódigo que respondió tu endpoint en el último intento, o null si no respondió.
attemptsIntentos hechos.
next_retry_atCuándo es el próximo intento, mientras está pending.
last_errorQué falló en el último intento: HTTP 503, “Sin respuesta en 10 segundos”, un error de conexión, o “El endpoint está inactivo” si lo desactivaste con entregas pendientes.
delivered_atCuándo se entregó.

Probar un endpoint

POST /v1/webhook-endpoints/:id/test manda un evento test, firmado como uno real, y responde con el resultado de ese único intento. Sirve para comprobar que tu URL responde y que verificás bien la firma.

Solicitud
const response = await fetch('https://app.afipsdk.com/api/v1/webhook-endpoints/<ENDPOINT_ID>/test', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer <ACCESS_TOKEN>'
  }
});

const { delivered, http_status, error } = await response.json();
{
  "delivered": true,
  "http_status": 200,
  "error": null,
  "delivery": { "id": "d4f6a8b0-c2e4-4a6b-9c8d-1e3f5a7b9c0d", "event_type": "test", "status": "delivered", "...": "..." }
}

El evento de prueba llega con type: "test" y en data un message y el endpoint_id.

Rotar el secret

POST /v1/webhook-endpoints/:id/rotate genera un secret nuevo y lo devuelve, una sola vez, junto con el endpoint. Desde ese momento todas las entregas, reintentos incluidos, se firman con el nuevo. Actualizá tu servidor enseguida: las entregas que fallen durante el cambio se reintentan y, al reintentarse, ya van firmadas con el secret nuevo.

Solicitud
const response = await fetch('https://app.afipsdk.com/api/v1/webhook-endpoints/<ENDPOINT_ID>/rotate', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer <ACCESS_TOKEN>'
  }
});

const { secret } = await response.json();

Modificar un endpoint

PUT /v1/webhook-endpoints/:id acepta url y status (active o inactive); cambia solo lo que mandás. La URL nueva tiene que cumplir las mismas reglas que al crear. Desactivar el endpoint corta las entregas: las pendientes pasan a failed en la siguiente corrida de reintentos y los eventos nuevos no se generan. Para reactivarlo, status: "active"; si mientras tanto creaste otro endpoint activo, la respuesta es 409. Una URL que ya tiene otro endpoint también responde 409, con data_errors.url.

{
  "status": "inactive"
}

Errores

HTTPCuándo
400La URL no es válida (no es https, o http fuera del sandbox; pasa los 500 caracteres; apunta a un host privado o lleva usuario y contraseña), el PUT no trae campos, o la suscripción está vencida o impaga.
401Access token inválido, ausente o revocado.
403Desde el dashboard, tu rol en el proyecto solo permite consultar.
404El endpoint no existe.
409Ya hay un endpoint activo, o ya hay uno con esa URL.

El formato de cada código está en Errores y respuestas.