> ## Documentation Index
> Fetch the complete documentation index at: https://www.emissa.com.ar/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Los cinco eventos, la verificación de la firma HMAC ejecutada en dos lenguajes, y cómo hacer el receptor idempotente.

Emissa avisa por HTTP cuando pasa algo que **no podés saber de otra forma**. Son
cinco eventos, y el criterio para que algo sea un evento es exactamente ése.

> [!NOTE]
>
> 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

> [!TIP]
>
> 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](https://www.emissa.com.ar/docs/guias/cae-rechazado.md).

**No existe un evento de «comprobante autorizado», y es deliberado.** La emisión
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 uno de anulación.

Los cinco que sí existen:

| Evento | Por qué no se puede saber de otra forma |
| - | - |
| `invoice.resolved` | La respuesta quedó sin resolver; 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 |
| `quota.exceeded` | Pasaste los comprobantes incluidos del plan y nadie te frena: se sigue emitiendo |

`quota.exceeded` **no es un aviso de bloqueo**: la emisión que lo disparó se
autorizó y las siguientes también. Lo que cambia es que desde ahí cada comprobante
se factura al precio unitario del excedente. Sale **una sola vez por organización
y por período**, no una por emisión, y el payload lo dice con `bloqueada: false` (los valores son de ejemplo):

```json theme={null}
{
  "type": "quota.exceeded",
  "data": {
    "period": "2026-10",
    "limite": 500,
    "usada": 501,
    "excedente": 1,
    "excedenteUnitarioArs": 120,
    "bloqueada": false
  }
}
```

`connection.established` es el que evita encuestar la verificación de conexión: se
emite **sólo en la transición**, así que no repite mientras alguien verifica varias
veces seguidas esperando que ARCA lo tome.

## 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.

> [!WARNING]
>
> **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](https://www.emissa.com.ar/docs/guias/integracion.md).

El secreto de firma **se muestra una sola vez**. Después sólo queda su prefijo
para poder identificarlo. Si lo perdés, se rota y se reemplaza — no se recupera.

### Un endpoint por producto

Si más de un producto tuyo (`inmos`, `define`…) opera dentro de **una sola
organización**, cada uno registra **su propio endpoint** y le pone su **Producto**
(en el alta, o después con «Cambiar producto»). Sin eso, el endpoint de uno
recibiría los comprobantes del otro.

**Un contribuyente puede ser de varios productos** (el mismo CUIT lo facturan los
dos), así que el reparto de un comprobante **no mira al contribuyente: mira a quien
lo emitió**. El comprobante lleva el `producto` de la request que lo creó (el de la
API key o el de `X-Emissa-Producto`), y ese producto —leído de nuestra base, no del
cuerpo del evento— decide a quién le llega:

| El evento es… | Lo reciben |
| - | - |
| de un **comprobante** con `producto: "inmos"` (`invoice.resolved`) | los endpoints de `inmos` **y** los generales (sin producto) |
| de un comprobante **sin producto** | **sólo** los endpoints generales |
| de un **contribuyente** (`credential.expiring`, `connection.established`) | los endpoints de **cualquiera** de sus productos **y** los generales |
| de un contribuyente **sin productos** | **sólo** los endpoints generales |
| de la **organización** (`quota.exceeded`, `api_key.expiring`) | **todos** los endpoints suscriptos al evento |

**Por qué lo que no es un comprobante sigue al contribuyente.** Un certificado por
vencer o una conexión establecida no los emitió ningún producto: son del
contribuyente, y le interesan a **todos** los que lo operan. Por eso van a los
endpoints de cada uno de sus productos. `quota.exceeded` y `api_key.expiring` son de
la organización: la cuota es una sola, y una key no pertenece a un contribuyente.

* **Un endpoint sin producto es general** y recibe los eventos de toda la
  organización, como siempre. Es lo que quedan siendo los endpoints que ya
  existían: no se les adivina un producto.
* **Un comprobante emitido sin producto** (por ejemplo desde el panel, o con una key
  sin producto que no mandó la cabecera) **no le llega a ningún endpoint de
  producto**, sólo a los generales. Y **un contribuyente sin productos** tampoco. Es
  el lado seguro: no se adivina de quién es.
* **Cambiar el producto de un endpoint** cambia qué eventos recibe **desde ese
  momento**. Lo que ya estaba encolado se entrega igual. Queda en la auditoría
  con el valor anterior.

> [!WARNING]
>
> **Dejar general un endpoint de un producto le manda los eventos de los otros
> productos.** Es lo que significa «general». Si querés que un endpoint reciba
> sólo lo suyo, dejale producto.

### 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.

> [!NOTE]
>
> 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

El sobre es igual para los cinco 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.

> [accordion] Una entrega real, cabeceras y cuerpo
>
> ```
> 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ó:
>
> ```json theme={null}
> {
>   "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"
> }
> ```
>
> Esa captura es anterior a la etiqueta de producto. **Hoy `data` trae además
> `producto`**: el valor de `X-Emissa-Producto` con el que se emitió el comprobante,
> o `null` si no se mandó. Es un campo nuevo y aditivo: un receptor que no lo conoce
> lo ignora.
>
> Cuando el estado final es `REJECTED`, `data` trae además `rechazoArca`, con el motivo
> de ARCA y la misma forma que `details.arca` del error:
> `{ "errores": [{ "codigo": …, "mensaje": … }], "observaciones": [ … ] }`. En cualquier otro
> estado viaja `null`. Un comprobante que el reconciliar no encontró en ARCA llega con las dos
> listas vacías.

## 3 · Verificar la firma

> [!WARNING]
>
> 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**.

```js Node theme={null}
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 Python theme={null}
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)
```

> [accordion] Los dos, corridos contra la entrega real
>
> 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` |

> [!WARNING]
>
> **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: **que tu receptor pase la prueba con el cuerpo reparseado no
> demuestra que esté bien.** Puede estar funcionando por esa casualidad, y
> romperse el día que cambie una versión de tu framework.

## 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 muerta |

**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 24 h
**nunca se usa**, porque el sexto intento ya es el último. 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 muerta 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 se haya entregado, no sólo en las agotadas.

> [!WARNING]
>
> **Los reintentos dependen del tráfico.** Emissa no tiene un scheduler: el
> barrido 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
> 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.

> [accordion] La medición: un endpoint caído y uno sano, 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.

## 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.

**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ó:

![El id del evento se arma con el tipo, el id del comprobante y el estado al que se resolvió.](https://mintcdn.com/emissa/4QpgM24I6Fz3ej59/images/diagramas/id-de-evento.light.svg?fit=max\&auto=format\&n=4QpgM24I6Fz3ej59\&q=85\&s=0077a55f2a5172a05d4eb11212b482dd)

![](https://mintcdn.com/emissa/4QpgM24I6Fz3ej59/images/diagramas/id-de-evento.dark.svg?fit=max\&auto=format\&n=4QpgM24I6Fz3ej59\&q=85\&s=59d0776c7b66273cc6b3fbc6b41b4b12)

De dónde sale el id de un evento

El mismo hecho produce siempre el mismo `id`, así que guardar los ids procesados
alcanza.

> [!WARNING]
>
> **El `id` del evento no es el `invoiceId`.** Un comprobante puede resolverse
> más de una vez y cada transición es un evento distinto con su propio id.
> Deduplicar por `invoiceId` te haría perder el que importa.

### El orden del handler, que no es casual

**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 rápido y procesar aparte**

Si tu handler tarda más que el timeout de 10 s, la entrega cuenta como
fallida y se reintenta — aunque la hayas procesado bien.

> [accordion] El esqueleto completo
>
> `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.
>
> ```js theme={null}
> 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);
>   },
> );
> ```

## Qué sigue

> [card] [Cuando el CAE viene rechazado](https://www.emissa.com.ar/docs/guias/cae-rechazado.md)
>
> El caso que hace útil a `invoice.resolved`.

> [card] [Todos los errores](https://www.emissa.com.ar/docs/guias/errores.md)
>
> La tabla de consulta.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.