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 ruta | Qué hace |
|---|---|
POST /v1/webhook-endpoints | Crea el endpoint. Devuelve el secret, una sola vez. |
GET /v1/webhook-endpoints | Lista los endpoints, todos o filtrados con status. |
GET /v1/webhook-endpoints/:id | Devuelve un endpoint. |
PUT /v1/webhook-endpoints/:id | Cambia la URL o el estado. |
POST /v1/webhook-endpoints/:id/rotate | Genera un secret nuevo. |
POST /v1/webhook-endpoints/:id/test | Manda un evento de prueba firmado. |
GET /v1/webhook-endpoints/:id/deliveries | Lista 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"
}
| Campo | Descripción |
|---|---|
url | Adónde se mandan los eventos. |
status | active o inactive. Un endpoint inactivo no recibe eventos de comprobantes; el evento test sí le llega, para probarlo antes de activarlo. |
secret | La 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
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
| Evento | Cuándo | data |
|---|---|---|
invoice.approved | ARCA aprobó el comprobante, con o sin observaciones. | El comprobante, con cae, number y pdf_url. Las observaciones, si hay, en observations. |
invoice.rejected | ARCA lo rechazó. | El comprobante, con los motivos en errors. |
invoice.failed | No se pudo autorizar: se agotaron los reintentos o hubo un error de configuración. | El comprobante, con la causa en last_error. |
test | Lo 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
}
}
}
| Campo | Descripción |
|---|---|
event_id | Identificador único del evento. Si recibís dos entregas con el mismo event_id, es la misma: procesala una vez. |
type | El evento. |
created | Cuándo se generó. |
data | El comprobante en el momento del evento. GET /v1/invoices/:id es siempre la fuente de verdad. |
Cada entrega lleva estos headers:
| Header | Valor |
|---|---|
Content-Type | application/json |
User-Agent | FacturaSDK-Webhooks/1.0 (+https://afipsdk.com) |
X-FacturaSDK-Signature | La firma. Ver abajo. |
X-FacturaSDK-Event | El tipo de evento, igual que type. |
X-FacturaSDK-Delivery | El 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:
- Separá
tyv1del header. - Calculá el HMAC-SHA256 de
t+.+ cuerpo con tusecret. - Compará el resultado con
v1en tiempo constante. - Rechazá la entrega si
tdifiere de tu hora actual en más de 5 minutos: evita que una solicitud capturado se reenvíe más tarde.
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_idde lo que ya procesaste: una entrega se puede repetir (un reintento después de un2xxque no llegó a tiempo, por ejemplo) y no tiene que duplicar nada en tu sistema. - El comprobante que viene en
dataes 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ámetro | Tipo | Descripción |
|---|---|---|
status | string | pending, delivered o failed. |
invoice_id | string | Entregas de un comprobante. |
limit | integer | Cantidad por página, hasta 100. |
cursor | string | El next_cursor de la página anterior. |
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
}
| Campo | Descripción |
|---|---|
status | pending (esperando un reintento), delivered o failed. |
http_status | Código que respondió tu endpoint en el último intento, o null si no respondió. |
attempts | Intentos hechos. |
next_retry_at | Cuándo es el próximo intento, mientras está pending. |
last_error | Qué 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_at | Cuá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.
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.
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
| HTTP | Cuándo |
|---|---|
400 | La 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. |
401 | Access token inválido, ausente o revocado. |
403 | Desde el dashboard, tu rol en el proyecto solo permite consultar. |
404 | El endpoint no existe. |
409 | Ya hay un endpoint activo, o ya hay uno con esa URL. |
El formato de cada código está en Errores y respuestas.