Webhooks
Emissa avisa por HTTP cuando pasa algo que no podés saber de otra forma. Son cuatro eventos, y el criterio para que algo sea un evento es exactamente ése.
Todo lo que sigue se ejecutó: se registró un endpoint, se recibió una entrega real en un receptor de prueba, y la firma se verificó con el código que está publicado más abajo —incluidos los casos que tienen que fallar—.
Antes de escribir un receptor: puede que no lo necesites
No existe invoice.authorized, y es deliberado. POST /v1/invoices/authorize
te devuelve el comprobante con su CAE en la misma respuesta. Un evento por eso te
haría procesar dos veces lo mismo y resolver cuál llegó primero. Por el mismo motivo
no hay invoice.cancelled.
Los cuatro que existen:
| Evento | Por qué no se puede saber de otra forma |
|---|---|
invoice.resolved | La respuesta dijo PENDING o UNKNOWN; la resolución llega después |
connection.established | El trámite en ARCA lo completa una persona, cuando quiere; nadie te avisa |
credential.expiring | Es una fecha futura: ningún pedido la revela |
api_key.expiring | Ídem |
connection.established es el que evita encuestar /conexion/verificar: se emite
sólo en la transición a CONECTADO, así que no repite mientras alguien verifica
varias veces seguidas esperando que ARCA lo tome.
Si tu integración emite y lee la respuesta, y no te importa que un certificado esté por vencer, podés no usar webhooks. El único caso que los hace necesarios es el comprobante irresuelto — ver qué hacer cuando el CAE viene rechazado.
1 · Registrar el endpoint
El alta se hace desde el panel, en Integración › Webhooks: se elige la URL, se
marcan los eventos y el secreto de firma se muestra una sola vez. El endpoint que los
crea no es parte de la API pública /v1 — es de la superficie interna del panel, que
exige sesión y segundo factor.
El alta no se puede hacer con una API key, y eso no va a cambiar: una key que pudiera reapuntar los webhooks podría desviarse los eventos de la organización a sí misma, y desde afuera todo se vería normal —"los eventos se entregan bien", sólo que a otro lado—. Es la misma razón por la que una key no puede cargar certificados. Ver el reparto.
Al crearlo se elige la URL y a qué eventos suscribirse, y la respuesta trae el secreto de firma:
url : https://tu-app.com/webhooks/emissa
events : ["invoice.resolved"]
secret : whsec_83a8dce4dd9a4964f59f824afe6ab51c43b1b7d9417462f2fd1e3c520471095d
El secreto se muestra una sola vez. Después sólo queda su prefijo
(whsec_83a8dce4) para poder identificarlo. Si lo perdés, se rota y se reemplaza —
no se recupera.
La URL tiene que ser pública y https
El servidor la visita, así que se valida al registrarla y antes de cada entrega.
Se rechazan las IP privadas, localhost, *.local, *.internal y las URL con
credenciales embebidas. Es protección contra SSRF: una URL apuntando al metadata de
la nube devolvería credenciales del servidor.
En desarrollo se permite http y apuntar a tu máquina, que es donde vas a levantar
el receptor de prueba.
2 · Lo que llega
Una entrega real, capturada en un receptor de prueba. Las cabeceras:
POST /hook
content-type: application/json
user-agent: Emissa-Webhooks/1
x-emissa-event-id: inv_cms6oum2g0007ury83svv2kc2_authorized
x-emissa-event-type: invoice.resolved
x-emissa-delivery-attempt: 1
x-emissa-signature: t=1785366068,v1=2d5bc7569556a2e523ee4f1ce6144ff24acdf7a331615d945ef98829cf7104ed
Y el cuerpo, tal cual llegó:
{
"id": "inv_cms6oum2g0007ury83svv2kc2_authorized",
"data": {
"cae": "86300697244993",
"status": "AUTHORIZED",
"invoiceId": "cms6oum2g0007ury83svv2kc2",
"taxpayerId": "cms6ojquf0008ure83cgkbr0x",
"comprobante": "0001-00000136",
"previousStatus": "UNKNOWN"
},
"type": "invoice.resolved",
"createdAt": "2026-07-29T23:01:08.296Z",
"organizationId": "cms6ojqr50000ure8kg7t0615"
}
El sobre es igual para los cuatro eventos: id, type, createdAt,
organizationId y data. Eso te permite tener un solo handler que verifica la
firma, deduplica por id y recién después mira el type.
organizationId va siempre porque un receptor multi-tenant necesita saber de quién
es el evento.
3 · Verificar la firma
Sin esto tu endpoint acepta cualquier POST de cualquiera. Un tercero podría avisarte de un comprobante que nunca existió, y actuar sobre eso —marcar algo como cobrado, disparar un envío— es peor que no recibir nada.
El esquema es el de Stripe y GitHub: se firma ${timestamp}.${cuerpo} con
HMAC-SHA256 y el secreto, y va en X-Emissa-Signature como t=<unix>,v1=<hex>.
El timestamp entra en la firma a propósito. Firmando sólo el cuerpo, la firma sería válida para siempre y cualquiera que capture un pedido legítimo podría repetirlo mañana. La tolerancia recomendada es de 5 minutos.
Node, sin dependencias
const { createHmac, timingSafeEqual } = require('crypto');
const TOLERANCIA_S = 300; // 5 minutos
function verificarFirma(
secreto,
cuerpoCrudo,
cabecera,
ahoraS = Math.floor(Date.now() / 1000),
) {
if (typeof cabecera !== 'string') return false;
// 1. Partir `t=...,v1=...`
const partes = new Map(
cabecera.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(partes.get('t'));
const v1 = partes.get('v1');
if (!Number.isFinite(t) || !v1) return false;
// 2. Rechazar lo viejo ANTES de comparar: una firma vieja es válida
// criptográficamente, y aceptarla habilita un replay.
if (Math.abs(ahoraS - t) > TOLERANCIA_S) return false;
// 3. El material firmado es `timestamp.cuerpo`, con el cuerpo TAL CUAL llegó.
const esperado = createHmac('sha256', secreto)
.update(`${t}.${cuerpoCrudo}`)
.digest('hex');
// 4. Comparación en tiempo constante.
const a = Buffer.from(v1, 'hex');
const b = Buffer.from(esperado, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
Python, sin dependencias
import hmac, hashlib, time
TOLERANCIA_S = 300 # 5 minutos
def verificar_firma(secreto: str, cuerpo_crudo: bytes, cabecera: str, ahora_s: int | None = None) -> bool:
if ahora_s is None:
ahora_s = int(time.time())
if not cabecera:
return False
partes = {}
for p in cabecera.split(","):
if "=" not in p:
return False
k, v = p.split("=", 1)
partes[k.strip()] = v.strip()
try:
t = int(partes["t"])
v1 = partes["v1"]
except (KeyError, ValueError):
return False
if abs(ahora_s - t) > TOLERANCIA_S:
return False
material = f"{t}.".encode() + cuerpo_crudo
esperado = hmac.new(secreto.encode(), material, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, esperado)
Los dos, corridos contra la entrega de arriba
No alcanza con que la firma buena dé true: un verificador que devuelve true
siempre también lo haría. Estos son los resultados reales de los dos, sobre la
misma entrega capturada. El código se extrajo de esta misma página y se
ejecutó, así que lo que leés es lo que corrió:
| Caso | Node | Python |
|---|---|---|
| La firma real, reloj dentro de la ventana | true | True |
| Reloj a +299 s (dentro de la tolerancia) | true | True |
| Un dígito del CAE cambiado en el cuerpo | false | False |
| Secreto equivocado | false | False |
| Reloj a +301 s (fuera de la tolerancia) | false | False |
Cabecera sin v1 | false | False |
| Cabecera vacía | false | False |
⚠️ Usá el cuerpo crudo, y por un motivo que no es el que parece
La regla es conocida: verificá sobre los bytes que llegaron, antes de que tu
framework parsee el JSON. En Express, express.raw({ type: 'application/json' });
en FastAPI, await request.body().
Lo que sorprende es lo que pasa si no la seguís. Medido sobre la entrega real:
| Cuerpo usado para verificar | Bytes | Resultado |
|---|---|---|
| El crudo, tal como llegó | 351 | true |
JSON.stringify(JSON.parse(crudo)) | 351 | true |
| Reserializado con indentación | 411 | false |
| Con una clave del sobre reordenada | 351 | false |
El caso del medio es la trampa. Reparsear y volver a serializar dio un resultado byte a byte idéntico, así que la firma verificó igual. Es casualidad: nuestro cuerpo no tiene espaciado raro y el motor conservó el orden de las claves. Cualquier framework que indente, escape unicode distinto o reordene, la rompe.
La consecuencia práctica, y es la razón por la que esto está acá: que tu receptor pase la prueba con el cuerpo reparseado no demuestra que esté bien. Puede estar funcionando por la misma casualidad, y romperse el día que cambie una versión de tu framework. Usá el crudo.
4 · Reintentos
Si tu endpoint no contesta 2xx, se reintenta. Hay 6 intentos en total —el
primero más 5 reintentos— con esperas crecientes:
| Después del intento | Espera hasta el siguiente |
|---|---|
| 1 | 30 s |
| 2 | 2 min |
| 3 | 10 min |
| 4 | 1 h |
| 5 | 6 h |
| 6 | no hay siguiente: la entrega queda como DEAD |
La ventana total es de unas 7,2 horas, no de un día. Vale la aclaración porque el
comentario del código dice "poco más de un día": la última espera de la lista (24 h)
nunca se usa, porque el sexto intento ya es el último y la entrega pasa a DEAD
antes de programarla. Está anotado como deuda.
El timeout de cada intento es de 10 segundos. Si tu handler tarda más, la entrega cuenta como fallida aunque la hayas procesado bien.
Una entrega en DEAD no se pierde: queda registrada y se puede reenviar a mano
desde el panel, en la tabla de entregas del endpoint. El botón aparece en toda
entrega que no esté DELIVERED, no sólo en las agotadas: como los reintentos los
dispara el tráfico entrante, una que dice "va a reintentar" puede quedarse quieta
días si la organización dejó de facturar.
Medido con un endpoint apuntando a un puerto donde no había nada escuchando, y otro que sí respondía, con el mismo evento:
url status attempts lastStatusCode lastError
http://127.0.0.1:4599/caido FAILED 1 (ninguno) TypeError: fetch failed
http://127.0.0.1:4567/hook DELIVERED 1 200
Y la espera hasta el reintento: 29,999 s, o sea los 30 segundos de la tabla.
Dos cosas que se ven ahí: un endpoint caído no afecta al otro, y el estado de cada entrega queda registrado con su motivo.
⚠️ Los reintentos dependen del tráfico
Esto hay que decirlo como es: Emissa no tiene un scheduler. El barrido de reintentos se dispara con los pedidos que entran a la API.
Si a la API no le entra ningún pedido, los reintentos no corren. Un backlog de entregas puede quedarse quieto un fin de semana entero en una organización que no factura. No es "entrega eventualmente garantizada": es "cuando haya tráfico, o cuando alguien la reenvíe a mano".
Para un sistema que factura seguido, el tráfico alcanza. Si tu caso no lo garantiza, no te apoyes en el reintento automático como única red.
5 · Hacer el receptor idempotente
La garantía es al menos una vez, y está dicho así a propósito. Exactamente una
vez no existe sobre HTTP: tu receptor puede procesar el evento y morirse antes de
contestar 200, y desde acá eso es indistinguible de que nunca lo haya recibido. Se
reintenta, y te llega dos veces.
Por eso hay que deduplicar, y el id del evento está hecho para eso: es
determinístico. No es un UUID al azar — se deriva de lo que lo causó:
inv_cms6oum2g0007ury83svv2kc2_authorized
│ │ └── el estado al que se resolvió
│ └───────────────────────────── el id del comprobante
└───────────────────────────────── el tipo
El mismo hecho produce siempre el mismo id, así que guardar los ids procesados
alcanza.
El esqueleto de un receptor correcto, en orden. verificarFirma es la función de
más arriba, la que se ejecutó; el resto del esqueleto es ilustrativo y no se corrió
como aplicación:
app.post(
'/webhooks/emissa',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verificar ANTES de mirar el contenido.
if (
!verificarFirma(
SECRETO,
req.body.toString('utf8'),
req.get('X-Emissa-Signature'),
)
) {
return res.sendStatus(401);
}
const evento = JSON.parse(req.body.toString('utf8'));
// 2. Deduplicar por el id del evento.
if (await yaLoProcesamos(evento.id)) return res.sendStatus(200);
// 3. Contestar rápido y procesar aparte. Si tardás, se reintenta.
await encolar(evento);
await marcarProcesado(evento.id);
res.sendStatus(200);
},
);
Tres decisiones que están en ese orden a propósito:
- Verificar antes de parsear. Un cuerpo no confiable no se interpreta.
- Deduplicar antes de actuar, no después. Actuar dos veces es el daño.
- Contestar
200rápido. Si tu handler tarda más que nuestro timeout, la entrega cuenta como fallida y se reintenta — aunque la hayas procesado bien.
Y el detalle que más se olvida: el id del evento no es el invoiceId. Un
comprobante puede resolverse más de una vez (PENDING → UNKNOWN → AUTHORIZED) y
cada transición es un evento distinto con su propio id. Deduplicar por invoiceId
te haría perder el que importa.
Qué sigue
- Qué hacer cuando el CAE viene rechazado — el caso que
hace útil a
invoice.resolved. - Todos los errores — la tabla de consulta.