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 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 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.
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.
Una entrega real, cabeceras y cuerpo
Una entrega real, cabeceras y cuerpo
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
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.
Los dos, corridos contra la entrega real
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ó: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:
4 · Reintentos
Si tu endpoint no contesta2xx, 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.
La medición: un endpoint caído y uno sano, con el mismo evento
La medición: un endpoint caído y uno sano, con el mismo evento
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 contestar200, 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ó:
De dónde sale el id de un evento
id, así que guardar los ids procesados
alcanza.
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.
El esqueleto completo
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.Qué sigue
Cuando el CAE viene rechazado
El caso que hace útil a
invoice.resolved.Todos los errores
La tabla de consulta.