Skip to main content
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.
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

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.
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: 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):
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.
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.
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: 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.
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.
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.
Y el cuerpo, tal cual llegó:
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

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

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

De dónde sale el id de un evento

El mismo hecho produce siempre el mismo id, así que guardar los ids procesados alcanza.
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.
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.

Qué sigue

Cuando el CAE viene rechazado

El caso que hace útil a invoice.resolved.

Todos los errores

La tabla de consulta.