Webhooks
Un webhook es un aviso que Comprobado le manda a tu servidor cuando algo cambia, en lugar de que tengas que consultar una y otra vez. Sirve, por ejemplo, para enterarte de que un comprobante incierto se resolvió o de que un lote terminó.
Los webhooks son una función del plan: si el tuyo no los incluye, la consola la muestra con un candado, la API responde
403conAS-2027y no se envían avisos. Ver Plan, cupo y pagos.
Cada aviso trae solo ids y estados. Nunca datos del receptor ni importes: con el id consultás el detalle por la API.
Crear un webhook
En Integraciones → Webhooks → Nuevo webhook (lo puede hacer el Titular o un Administrador), o por la API con una clave «completa»:
curl -X POST https://TU-DOMINIO/v1/webhooks \
-H "Authorization: Bearer $COMPROBADO_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://tu-servidor.com/webhooks/comprobado", "eventos": ["invoice.authorized", "invoice.reconciled"] }'
La respuesta trae el secreto de firma (whsec_…). Se muestra una sola vez: guardalo. Sin eventos (o con ["*"]) recibís todos. Podés tener hasta 5 webhooks.
La dirección tiene que ser https y pública. Por seguridad no se aceptan localhost, redes internas (10.x, 192.168.x…), direcciones con usuario y contraseña, ni se siguen redirecciones. Para probar en tu máquina, exponela con un túnel (ngrok, cloudflared).
Eventos
| Evento | Cuándo |
|---|---|
invoice.authorized |
ARCA autorizó un comprobante |
invoice.rejected |
Un comprobante fue rechazado (por ARCA o antes de enviarlo) |
invoice.uncertain |
Quedó incierto: no se sabe si ARCA lo registró. No lo reemitas: esperá el siguiente aviso |
invoice.deferred |
ARCA no respondía y el comprobante quedó en espera (?diferir=true): todavía no tiene número ni CAE. Después llega invoice.authorized o invoice.rejected |
invoice.reconciled |
Un incierto se resolvió (estado: autorizado o rechazado) |
batch.finished |
Un lote terminó |
batch.paused |
Un lote se pausó (motivo: cuota, cuenta, ambiente, incierto o error) |
certificate.expiring |
Tu certificado de ARCA vence en 30, 7 o 1 días (una vez por umbral) |
subscription.generated |
Un abono generó su lote del período |
payment.registered |
Se registró un cobro (comprobante emitido) o un pago (recibido), parcial o total, o se marcó como saldado |
payment.voided |
Se anuló un cobro o pago registrado |
ping |
La prueba que mandás con el botón Probar |
El aviso
POST /webhooks/comprobado HTTP/1.1
Content-Type: application/json
X-Comprobado-Event: invoice.reconciled
X-Comprobado-Delivery: evt_3de669e1c5944de68b5251102947c65b
X-Comprobado-Signature: t=1761256637,v1=5b0c…e41f
User-Agent: Comprobado-Webhooks/1.0
{
"id": "evt_3de669e1c5944de68b5251102947c65b",
"tipo": "invoice.reconciled",
"creado": "2026-10-23T21:57:14Z",
"data": {
"id": "inv_a780f155…",
"estado": "autorizado",
"tipoCodigo": 11,
"puntoVenta": 1,
"numero": 14,
"cae": "86431019635161",
"ambiente": "produccion",
"origen": "suite"
}
}
Los avisos de cobros y pagos llevan solo ids y estado, nunca importes: data es { "objeto": "comprobante" | "recibido", "id": "inv_…" | "rcv_…", "pagoId": "pag_…", "estado": "adeudada" | "parcial" | "saldada" }. Para el detalle (importe, medio, saldo) consultá GET /v1/invoices/{id}/pagos o GET /v1/purchase-invoices/{id}/pagos.
Respondé cualquier 2xx dentro de los 10 segundos. Hacé el trabajo pesado después de responder.
Verificar la firma (obligatorio)
Cualquiera que conozca tu URL podría mandarte un aviso falso. La firma prueba que lo mandó Comprobado: es un HMAC-SHA256 de "<t>.<cuerpo exacto>" con tu secreto.
// Node.js (Express): usá el cuerpo CRUDO, sin parsear
import crypto from 'node:crypto';
function verificar(cuerpoCrudo, cabecera, secreto) {
const t = cabecera.split(',')[0].slice(2);
const v1 = cabecera.split('v1=')[1];
const esperado = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpoCrudo}`).digest('hex');
const ok =
v1.length === esperado.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado));
return ok && Math.abs(Date.now() / 1000 - Number(t)) < 300; // y que no sea viejo (anti-replay)
}
# Python
import hmac, hashlib, time
def verificar(cuerpo_crudo: bytes, cabecera: str, secreto: str) -> bool:
t = cabecera.split(",")[0][2:]
v1 = cabecera.split("v1=")[1]
esperado = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo_crudo, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, esperado) and abs(time.time() - int(t)) < 300
// C#
static bool Verificar(string cuerpoCrudo, string cabecera, string secreto)
{
var t = cabecera.Split(',')[0][2..];
var v1 = cabecera.Split("v1=")[1];
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secreto), Encoding.UTF8.GetBytes($"{t}.{cuerpoCrudo}"));
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(v1), Encoding.ASCII.GetBytes(Convert.ToHexString(mac).ToLowerInvariant()))
&& Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) < 300;
}
Reintentos: no se pierde nada
El aviso se guarda antes de enviarse. Si tu servidor está caído o responde con error, reintentamos con espera creciente:
| Intento | 2.º | 3.º | 4.º | 5.º | 6.º | 7.º | 8.º en adelante |
|---|---|---|---|---|---|---|---|
| Espera | 30 s | 2 min | 10 min | 30 min | 1 h | 3 h | 6–12 h hasta el 12.º intento (~3 días) |
Pasado eso el aviso queda fallido, pero no se borra: en Entregas lo ves y lo podés reenviar cuando tu servidor vuelva (también si ya se entregó, por ejemplo para volver a procesarlo). Si desactivás un webhook, los avisos esperan hasta que lo reactives.
Buenas prácticas
- Idempotencia: un mismo aviso puede llegar más de una vez (reintentos, reenvíos). Guardá el
X-Comprobado-Deliveryy descartá los repetidos. - Sin orden garantizado: un
invoice.reconciledpuede llegar antes que elinvoice.uncertainsi este último tuvo que reintentarse. Usá elestadodel aviso y, ante la duda, consultá el comprobante. - No confíes solo en el aviso: es una señal para actuar; el estado oficial es
GET /v1/invoices/{id}. - Rotá el secreto si se filtra: Cambiar secreto lo reemplaza al instante (el anterior deja de valer).
- Los avisos de una cuenta en el simulador funcionan igual: sirve para probar todo el ciclo sin ARCA.
Si algo falla
| Respuesta | Qué pasó |
|---|---|
400 AS-6001 |
La dirección no es válida (hace falta https público) |
400 AS-6002 |
Un evento no existe (ver GET /v1/webhooks/events) |
409 AS-6003 |
Llegaste al máximo de 5 webhooks |
404 AS-6004 |
El webhook no existe o ya lo borraron |
403 AS-2014 |
La clave no tiene el permiso de configuración (hace falta una clave «completa») |