Estados y polling

Cómo enterarte de que algo cambió, hoy. La respuesta corta: los webhooks están declarados en el contrato y todavía no se emiten, así que el único mecanismo disponible es consultar. Esta guía dice qué se puede consultar, con qué techo de tasa, qué transiciones esperar — y, sobre todo, qué hoy no se puede saber por ninguna vía, para que no diseñes la conciliación alrededor de un dato que no vas a recibir.

Para qué existe esta guía.

Si estás dimensionando la integración, lo que sigue es la lista de lo que hoy hay y lo que hoy no. Preferimos que descubras el faltante ahora — cuando cambiar el diseño cuesta una reunión — y no en UAT con el modelo de conciliación ya aprobado por riesgo operativo.

1.Los webhooks no se emiten todavía

La referencia declara diez eventos (customer.kyc.completed, customer.kyc.rejected, account.opened, payment.completed, payment.failed, payment.returned, payout.completed, payout.failed, order.filled, quote.lock.expired), firmados con HMAC-SHA256 en Hamirach-Signature: t=<unix>,v1=<hex>, entrega at-least-once con deduplicación por event_id y backoff de 8 intentos.

Todo eso es contrato: lo que la API se va a comprometer a entregar. Hoy no hay emisor.

Lo medido, para que no quede como matiz.

El header Hamirach-Signature aparece diez veces en la especificación — una por evento — y ni una sola vez en código que corra. No existe el firmador, ni la cola de entrega, ni el almacén de eventos, ni la máquina de reintentos. GET /v1/events no tiene de dónde leer, y el servicio real responde 404.

Medido sobre el repositorio y contra producción el 2026-08-11.

No se puede reusar lo que ya existe

Hamirach tiene otros emisores de webhooks para integraciones internas, y no sirven como anticipo de éste. Usan otro formato: X-Hamirach-Signature (con prefijo X-) llevando sólo el hex, el timestamp en un header aparte (X-Hamirach-Timestamp) en vez de dentro del mismo valor, y 3 intentos de entrega en lugar de 8. Su catálogo de eventos es otro y no tiene un solo nombre en común con los diez de arriba.

Lo decimos explícitamente porque el parecido invita al error: un verificador escrito contra ese formato no valida una firma del contrato público — el mensaje que se firma es distinto, el header es distinto y el parseo es distinto.

Qué hacer con esto

🪤 El sandbox te va a decir que sí.

sandbox-api.hamirach.com es un mock generado desde la misma especificación: responde con el ejemplo del contrato también para rutas que no tienen runtime. Medido el 2026-08-11: GET /v1/events devuelve 200 con el ejemplo en sandbox, y 404 en producción.

Un 200 del sandbox prueba que la forma está acordada, nunca que el mecanismo exista. Para saber qué está vivo, mirá la tabla del punto 2 o pegale a producción.

2.Qué se puede consultar hoy

Cuatro lecturas, todas sobre https://partners.hamirach.com/v1:

EndpointScopeQué devuelve
GET /v1/partners/me partners:read Tu organización: nombre y límites (volumen diario y exposición). No devuelve endpoints de webhook: no existe el registro, y devolver una lista se leería como «tus webhooks están configurados y andando», que es justo lo que no hay que creer al planificar.
GET /v1/accounts accounts:read Página cursor de cuentas de tus clientes finales: {data, next_cursor}.
GET /v1/balances?account_id= accounts:read Saldo available / pending de una cuenta. Leé el punto 5 antes de conciliar contra esto.
GET /v1/rails accounts:read Catálogo de rieles de liquidación con su banco de liquidación. Hoy dos, uno wire y uno ach.

El resto de las rutas declaradas responde 404. No es un error de tu integración ni de tus credenciales: la ruta todavía no existe del lado del servidor.

Podés verificarlo vos, sin credenciales. Una ruta viva responde 401 missing_authorization a una petición sin token — porque existe y exige autenticarse. Una ruta que todavía no existe responde 404 not_found, porque muere antes de llegar a la autenticación. Medido el 2026-08-11 contra producción: /v1/partners/me, /v1/accounts y /v1/balances dan 401; /v1/customers, /v1/payments, /v1/transactions, /v1/quotes, /v1/orders, /v1/beneficiaries, /v1/payouts, /v1/webhooks, /v1/events y /v1/reports/reconciliation dan 404.

Distinguí los dos 404 — no son lo mismo

codeSignificaQué hacer
not_found La ruta no existe todavía en el servidor. Sacar la llamada del loop. Reintentar no la va a hacer aparecer.
account_not_found
partner_not_found
La ruta existe; el recurso no existe o no es de tu organización. Es una respuesta legítima de negocio. Tratala como dato, no como caída.

Medido en producción — 2026-08-11

$ curl -sS https://partners.hamirach.com/v1/events
{"type":"about:blank","title":"Not Found","status":404,
 "code":"not_found","detail":"Cannot GET /v1/events",
 "request_id":"cfc7fd86-3bc9-4ca5-84d7-6d9bd59f3b2a"}

$ curl -sS https://partners.hamirach.com/v1/customers/cus_9f2k
{"type":"about:blank","title":"Not Found","status":404,
 "code":"not_found","detail":"Cannot GET /v1/customers/cus_9f2k",
 "request_id":"571e2f78-070b-4eee-a5ef-c7d2687cb597"}

Todo error sale como application/problem+json (RFC 9457) con code estable y request_id. Guardá el request_id en tu log: es lo que te vamos a pedir para diagnosticar cualquier respuesta que no entiendas.

Y los dos 503, que necesitan una rama propia

No son ni 429 ni 404, y tratarlos como cualquiera de los dos te deja mal parado.

codeQué pasóQué hacer
service_unavailable La API no pudo atender la lectura en ese momento. Reintentar con backoff. Es transitorio.
deposit_instruction_unverified No pudimos resolver el banco de liquidación de alguna de tus cuentas contra nuestro registro, así que preferimos no devolverte instrucciones de depósito que no podemos garantizar. Reintentar no lo arregla: lo resolvemos nosotros. Escribinos con el request_id.
🔴 El segundo tumba la página entera, no una fila. GET /v1/accounts serializa la página completa, así que una sola cuenta sin resolver hace fallar el listado con todas las demás adentro — si tenés 20 cuentas y una está en ese estado, no recibís las 19 buenas.

Es deliberado: preferimos no darte instrucciones de depósito antes que darte unas que no podemos garantizar — el dinero de un wire mal ruteado no vuelve solo. Pero significa que tu poller no puede tratar el 503 como «la API está caída» ni reintentar en loop: si persiste más de unos minutos, es nuestro y hay que avisarnos.

3.Cómo consultar: intervalo, techo y backoff

Intervalo sugerido: 30 segundos

A 30 s, cada lectura son 2 peticiones por minuto. Pero el total no es una constante: depende de cuántas cuentas tengas, y ése es el número que hay que dimensionar.

🔴 GET /v1/balances cuesta una petición POR CUENTA, no una por ciclo. El parámetro account_id es obligatorio y no hay lectura en lote: el objeto Account no lleva saldo. Con N cuentas, un ciclo son 3 + N peticiones.

Es el error de dimensionamiento más fácil de cometer acá, porque en el sandbox hay una sola cuenta: la cuenta da bien, entra holgado, y el techo recién se toca en producción con clientes reales.

El techo: 100 peticiones por minuto y por organización

Es un token bucket con capacidad 100 (PARTNER_API_RATE_LIMIT_PER_MINUTE, valor por defecto 100) aplicado por organización, no por credencial ni por IP.

CuentasCada 30 sCada 10 s
1 (el sandbox)8/min24/min
1740/min120/min → 429
50106/min → 429318/min → 429
200406/min → 4× el techo1.218/min → 12×

La fórmula es peticiones/min = ciclos_por_minuto × (3 + N), con 2 ciclos/min a 30 s y 6 a 10 s. A 30 s el techo se toca en 48 cuentas: con 47 dan 100 exactos y con 48, 102.

Ese 3 es el caso naíf, barriendo las tres lecturas fijas en cada ciclo. Si seguís lo de Qué no poner en el loop —partners/me una vez al arrancar y rails una vez por día— el ciclo pasa a 1 + N y el quiebre se corre a 50 cuentas. La tabla usa el caso naíf a propósito: si dimensionás con ella y después optimizás, te sobra techo; al revés, no.

Pasado ese punto hay que escalonar las consultas de saldo en vez de barrerlas todas en cada ciclo — o consultar sólo las cuentas con actividad esperada. Si necesitás más techo, decínoslo con el volumen estimado: es un número configurable, no un límite del diseño.

La recarga es continua, no por ventana fija: se reponen 100/60 ≈ 1,67 tokens por segundo. En la práctica, una ráfaga de 100 pasa entera, la 101.ª del mismo segundo no, y ~600 ms después ya hay un token otra vez. Denegar no consume: un 429 no te empuja más atrás en la cola, así que un reintento razonable no se auto-castiga.

🪤 El techo es por proceso, no por clúster.

El contador vive en la memoria del proceso que atiende. Con N réplicas del servicio el techo efectivo pasa a ser N × 100, y no es determinístico cuál te atiende. Dimensioná contra 100, que es el único número que se sostiene sin importar cuántas réplicas haya — el N × 100 es un efecto de despliegue, no una promesa del contrato, y desaparece el día que el contador pase a un almacén compartido.

Cabeceras de respuesta

La especificación todavía no declara cabeceras de respuesta. Éstas sí viajan hoy, y podés programar contra ellas:

CabeceraCuándoQué trae
X-RateLimit-Limit Toda respuesta autenticada El techo vigente (100).
X-RateLimit-Remaining Toda respuesta autenticada Tokens que quedan después de esta petición.
X-RateLimit-Reset Toda respuesta autenticada Significa dos cosas distintas según la respuesta, y la diferencia es de 60×. En un 200, segundos hasta que el balde vuelva a estar lleno (hasta 60). En un 429, segundos hasta que haya un token — o sea ~1, el mismo valor que Retry-After. Justo donde más se lo usa es donde no indica la recuperación completa: si esperás ese número y reanudás a plena velocidad, volvés a chocar. Para reanudar del todo, contá 60 s desde el último 429.
Retry-After Sólo en 429 Segundos hasta que haya un token otra vez (mínimo 1).
X-Request-Id Toda respuesta Id de la petición. Coincide con el request_id del cuerpo en los errores. Si mandás uno propio se te devuelve tal cual, siempre que sea de 1 a 64 caracteres [A-Za-z0-9-]; si no, se reemplaza por un UUID nuestro sin avisar — o sea que no des por hecho que el tuyo sobrevivió: leelo de la respuesta.

Las tres X-RateLimit-* viajan en toda respuesta autenticada, no sólo en el 429 — o sea que podés vigilar el margen sin esperar a chocarte con el techo.

Backoff ante 429

🪤 Hay un segundo 429, y se comporta distinto.

El techo por organización sólo aplica a peticiones que ya se autenticaron. Las que mueren antes — sin token, con token vencido, con una prueba DPoP reusada, o contra una ruta que no existe — pasan por un techo separado y por IP, de 300 por minuto por defecto (PARTNER_API_UNAUTH_RATE_LIMIT_PER_MINUTE). Ese 429 llega sin ninguna cabecera X-RateLimit-*: sólo Retry-After y X-Request-Id. Es deliberado — no le publicamos el techo a quien no se autenticó.

La consecuencia operativa es la que importa: si a tu poller se le vence el token y sigue pegando, deja de contar contra tu techo de organización y empieza a contar contra el de tu IP de salida, que compartís con todo lo demás que salga por ahí. Un renovador de token roto se ve exactamente así: un 429 sin X-RateLimit-*. Vale la pena alertar sobre esa diferencia.

Qué no poner en el loop

GET /v1/accounts no tiene filtros. Acepta cursor y limit (1..100, por defecto 20) y nada más — cualquier otro parámetro de query se rechaza con 400. No hay ?status= ni ?updated_since=, así que detectar un cambio hoy significa releer la página y comparar contra tu copia. Tenelo en cuenta al dimensionar: el costo crece con el número de cuentas, no con el número de cambios.

4.Transiciones de estado que podés esperar

Los valores de abajo salen de los enum del contrato, y son los que tu modelo de datos tiene que poder representar.

Cliente final — Customer.kyc.status

pending ──▶ in_review ──▶ approved
                      └──▶ rejected
EstadoSignificaTiempo hasta el siguiente
pending Cliente creado. Todavía sin evidencia, o el flujo alojado sin completar por el usuario. No medido — depende de cuándo el usuario complete el flujo, no de nosotros.
in_review Datos recibidos, en evaluación. Es también lo que devuelve el 202 de POST /v1/customers/{id}/kyc (en kyc.status). No medido. No publicamos un tiempo que no medimos.
approved / rejected Terminales para ese intento. —
Hoy esta transición no se puede observar. GET /v1/customers/{id} responde 404 (medido en producción el 2026-08-11) y no hay evento que la anuncie. Es decir: la máquina de estados es contrato firme para tu modelo de datos, y no es una fuente de datos todavía.

Cuenta — Account.status

pending ──▶ active
EstadoSignificaTiempo
pending Cuenta creada, esperando que el riel confirme el alta. No medido.
active Operativa: podés entregarle los datos de depósito a tu cliente. —
frozen Declarado en el contrato. Ningún camino lo produce hoy. —

Dejá frozen representable en tu modelo — el contrato lo declara y algún día va a llegar — pero no construyas la operación alrededor de él: hoy no hay forma de que aparezca, así que tampoco hay forma de probar tu manejo.

🔴 Ninguna de estas transiciones ocurre hoy, y conviene decirlo entero.

La API pública todavía no tiene ninguna escritura implementada: las cuatro lecturas leen un modelo que puebla una migración, y las cuentas que ese modelo devuelve ya nacen en active. Un poller apuntado a producción hoy no va a observar jamás un cambio de estado.

En sandbox tampoco hay una máquina de estados: el mock devuelve ejemplos fijos del contrato, uno por operación. Lo que ahí parece una transición son dos ejemplos distintos, no un estado que cambió — y por eso no sirve para probar tu lógica de reintento ni tus tiempos de espera.

Eso no es un defecto de tu integración: es dónde está el producto hoy, y lo decimos antes de que armes la conciliación asumiendo lo contrario. Lo que sí podés validar hoy es todo el transporte — autenticación, DPoP, forma de Money, paginación por cursor, manejo de 429 y de los dos 404.

5.🔴 GET /v1/balances no es una fuente de conciliación

Responde 200 con la forma correcta, y el número que devuelve no se mueve. Sale de una columna que hoy escribe una sola cosa: la migración que siembra los datos. No hay ninguna ruta de escritura en el servicio, ni ningún proceso que actualice ese saldo.

Medido sobre el repositorio el 2026-08-11: cero escrituras de esa columna fuera de las migraciones.

🪤 Y as_of no es la fecha del saldo.

Es la hora en que se atendió tu petición: se calcula al armar la respuesta. Consecuencia: un saldo congelado viaja con un as_of que avanza cada 30 s y parece fresco. Si tu conciliación usa as_of como prueba de frescura, va a certificar como actual un número que no cambia desde que se sembró.

Qué hacer: no conectes GET /v1/balances a la conciliación de acreditaciones todavía. Sirve, y mucho, para lo otro: ejercitar el transporte de punta a punta con una respuesta cuya forma es la definitiva.

6.Lo que hoy no se puede saber por polling

Explícito, para que no quede implícito en un endpoint que devuelve 404:

Lo que querés saberPor qué hoy no se puede
Si el KYC de un cliente se aprobó o se rechazó GET /v1/customers/{id} → 404, y no hay evento. Sin vía programática.
Que entró un depósito a una cuenta No hay evento y no hay endpoint de movimientos. El saldo no se actualiza (punto 5).
El historial de movimientos de una cuenta GET /v1/transactions → 404.
El estado de un pago o de un payout /v1/payments y /v1/payouts → 404.
Registrar el endpoint que va a recibir tus webhooks /v1/webhooks → 404. Todavía no se pueden dar de alta contra el servicio real, coherente con que no haya emisor.
Qué eventos se entregaron y cuáles fallaron GET /v1/events → 404, y no hay nada que los produzca (punto 1).
Un reporte de conciliación diaria GET /v1/reports/reconciliation → 404.
Si una cuenta pasó a frozen Ningún camino produce ese estado todavía (punto 4).

Ninguna de estas se arregla reintentando ni cambiando el intervalo. Si alguna es bloqueante para tu diseño, es una conversación de alcance con tu contacto en Hamirach, no un parámetro del cliente HTTP.

7.Qué construir ahora

Construilo ahoraDiseñalo, no lo construyasNo lo empieces
Cliente HTTP con autenticación y renovación de token · manejo de 429 con Retry-After · registro de X-Request-Id · Money como entero-string (jamás float) · account_number como string opaco · paginación por cursor · los dos 404 tratados distinto Receptor de webhooks: la forma del contrato es firme (dedup por event_id, ventana de 300 s, at-least-once, comparación en tiempo constante). Construilo cuando podamos darte un evento de prueba. Cola de reintentos de eventos · runbook de reentrega · conciliación de acreditaciones contra GET /v1/balances · alertas sobre transiciones de estado