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.
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.
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.
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.
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.
event_id, la cola de reintentos
ni el runbook de reentrega. Hoy no hay contra qué probar ninguna de esas
piezas: no existe un evento de prueba que podamos mandarte.
event_id, ventana anti-replay de
|now - t| ≤ 300s, y comparación de firma en tiempo
constante. Eso es diseño, no código, y no se desperdicia.
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.
Cuatro lecturas, todas sobre https://partners.hamirach.com/v1:
| Endpoint | Scope | Qué 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.
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.
code | Significa | Qué 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_foundpartner_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.
503, que necesitan una rama propia
No son ni 429 ni 404, y tratarlos como cualquiera
de los dos te deja mal parado.
code | Qué 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. |
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.
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 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.
| Cuentas | Cada 30 s | Cada 10 s |
|---|---|---|
| 1 (el sandbox) | 8/min | 24/min |
| 17 | 40/min | 120/min → 429 |
| 50 | 106/min → 429 | 318/min → 429 |
| 200 | 406/min → 4× el techo | 1.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 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.
La especificación todavía no declara cabeceras de respuesta. Éstas sí viajan hoy, y podés programar contra ellas:
| Cabecera | Cuándo | Qué 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.
Retry-After tal cual viene (segundos enteros).429 no es pérdida de datos — es una lectura, no una
escritura. Volver a leer devuelve lo mismo.
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.
GET /v1/partners/me — una vez al arrancar. Es barato y
confirma credencial y scopes; no cambia cada 30 s.
GET /v1/rails — a lo sumo una vez por día. El catálogo es
cerrado y chico (hoy dos rieles) y los id son estables por
diseño.
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.
Los valores de abajo salen de los enum del contrato, y son los
que tu modelo de datos tiene que poder representar.
Customer.kyc.statuspending ──▶ in_review ──▶ approved
└──▶ rejected
| Estado | Significa | Tiempo 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. | — |
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.
Account.statuspending ──▶ active
| Estado | Significa | Tiempo |
|---|---|---|
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.
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.
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.
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.
Explícito, para que no quede implícito en un endpoint que devuelve
404:
| Lo que querés saber | Por 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.
| Construilo ahora | Diseñalo, no lo construyas | No 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
|