Guías7 de 11

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:

EventoPor qué no se puede saber de otra forma
invoice.resolvedLa respuesta dijo PENDING o UNKNOWN; la resolución llega después
connection.establishedEl trámite en ARCA lo completa una persona, cuando quiere; nadie te avisa
credential.expiringEs 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ó:

CasoNodePython
La firma real, reloj dentro de la ventanatrueTrue
Reloj a +299 s (dentro de la tolerancia)trueTrue
Un dígito del CAE cambiado en el cuerpofalseFalse
Secreto equivocadofalseFalse
Reloj a +301 s (fuera de la tolerancia)falseFalse
Cabecera sin v1falseFalse
Cabecera vacíafalseFalse

⚠️ 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 verificarBytesResultado
El crudo, tal como llegó351true
JSON.stringify(JSON.parse(crudo))351true
Reserializado con indentación411false
Con una clave del sobre reordenada351false

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 intentoEspera hasta el siguiente
130 s
22 min
310 min
41 h
56 h
6no 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:

  1. Verificar antes de parsear. Un cuerpo no confiable no se interpreta.
  2. Deduplicar antes de actuar, no después. Actuar dos veces es el daño.
  3. Contestar 200 rá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 (PENDINGUNKNOWNAUTHORIZED) y cada transición es un evento distinto con su propio id. Deduplicar por invoiceId te haría perder el que importa.

Qué sigue