De cero a tu primer cliente con una cuenta USD activa: credenciales, token DPoP, alta de cliente, apertura de cuenta, balance y tu primer webhook.
Bearer, y una prueba
DPoP no se puede escribir a mano — hay que firmarla, y una distinta por
cada petición. Por eso el Paso 2 no es un curl suelto: son dos archivos que
corren tal cual,
primer-token-dpop.mjs (el camino completo, comentado) y
dpop-proof.mjs
(el generador que usan los curls de esta guía). No hay SDK todavía; estos
dos archivos son el reemplazo, y están medidos contra producción.
sandbox-api.hamirach.com) es un mock generado desde
el OpenAPI: sirve las respuestas de ejemplo de la
referencia y no implementa DPoP. Medido el
2026-08-11: el mock acepta Authorization: Bearer <cualquier
cosa> y rechaza Authorization: DPoP
con un 401; producción (partners.hamirach.com) hace
exactamente lo contrario. O sea que el mock sirve para ver
formas, nunca para validar tu autenticación: lo que
funcione ahí no prueba nada sobre producción, y lo que falle ahí tampoco.
Todos los curls de esta guía están escritos para producción.
Pedí acceso a tu contacto en Hamirach. Vas a recibir un
client_id y un client_secret — tratalos como una
contraseña, no hay forma de volver a ver el secret una vez emitido. Son las
credenciales que usa el authorization server real
(auth.hamirach.com); el mock de sandbox no valida ninguna.
export HAMIRACH_CLIENT_ID="clt_sandbox_9f21ac"
export HAMIRACH_CLIENT_SECRET="•••••••••••••••••••••" # no lo pegues en texto plano en ningún lado
La API usa OAuth 2.1 client_credentials con tokens
sender-constrained vía DPoP (RFC 9449, perfil FAPI 2.0):
el token queda ligado a un par de claves tuyo (claim cnf.jkt)
y cada petición autenticada necesita, además del token, un header
DPoP con una prueba firmada para esa petición
puntual. Un token bearer simple no se acepta.
Bajate los dos archivos y instalá la única dependencia
(jose). Necesitás
Node 20 o superior:
curl -sSO https://docs.hamirach.com/ejemplos/primer-token-dpop.mjs
curl -sSO https://docs.hamirach.com/ejemplos/dpop-proof.mjs
npm i jose
primer-token-dpop.mjs hace el camino entero —genera la clave,
firma la prueba, pide el token, verifica que quedó atado a tu clave y llama
a la API— y está comentado paso por paso. Corrélo primero: si termina bien,
tu integración es viable y ya sabés dónde toca cada pieza.
CLIENT_ID="$HAMIRACH_CLIENT_ID" CLIENT_SECRET="$HAMIRACH_CLIENT_SECRET" \
node primer-token-dpop.mjs
Salida
✓ Token obtenido
token_type: DPoP (tiene que decir DPoP, no Bearer)
scope: partners:read accounts:read
✓ El token está atado a tu clave (cnf.jkt coincide con tu thumbprint)
✓ Tu prueba DPoP fue ACEPTADA — esto es lo que lo demuestra, no el 200 solo
invalid_client no significa que tu DPoP esté
bien. Es el malentendido que más tiempo cuesta acá, así que va
medido: el authorization server valida las credenciales antes
de mirar la prueba. Contra auth.hamirach.com el 2026-08-11, con
un client_id inexistente, las tres dan
lo mismo:
sin header DPoP ......................... 401 invalid_client
DPoP basura (ni siquiera es un JWT) ..... 401 invalid_client
DPoP bien firmada y con htu correcto .... 401 invalid_client
Lo que sí prueba que tu prueba fue aceptada es el
éxito: 200 con
"token_type": "DPoP" y un cnf.jkt igual al
thumbprint de tu clave. Y si la prueba falla, con credenciales buenas el
error es 400 invalid_dpop_proof y el campo
error_description te dice exactamente cuál de las trampas
te tocó — leelo siempre.
Una prueba DPoP lleva adentro el método y la
URL de la petición que acompaña, más un jti
que el servidor recuerda para rechazar la segunda vez que lo vea. O sea que
no se puede guardar una prueba en una variable de entorno y reusarla
: la segunda llamada devuelve 401 dpop_replay. Por eso
los curls de abajo la generan en el momento, con
dpop-proof.mjs. Definí esto una vez por sesión de shell:
export HAMIRACH_BASE="https://partners.hamirach.com/v1"
export HAMIRACH_TOKEN=$(CLIENT_ID="$HAMIRACH_CLIENT_ID" \
CLIENT_SECRET="$HAMIRACH_CLIENT_SECRET" node dpop-proof.mjs token)
# Arma la prueba correcta para cada llamada. Uso: hamirach GET /accounts
hamirach() {
local metodo="$1" ruta="$2"; shift 2
local url="$HAMIRACH_BASE$ruta"
curl -sS -X "$metodo" "$url" \
-H "Authorization: DPoP $HAMIRACH_TOKEN" \
-H "DPoP: $(node dpop-proof.mjs proof "$metodo" "$url" "$HAMIRACH_TOKEN")" \
"$@"
}
El token expira en ≤10 minutos: cuando empieces a ver
401 token_expired, volvé a correr la línea de
HAMIRACH_TOKEN. La clave privada queda en
./dpop-key.json (permisos 0600) y hay que
conservarla mientras uses ese token — el token está atado a
ella. Trátala como una credencial.
htu, están explicadas y medidas en el
encabezado de
primer-token-dpop.mjs):
DPoP, no Bearer.
Bearer contra producción devuelve
401 use_dpop_scheme aunque el token sea válido.401 dpop_replay; usar la del token para otra llamada falla
por htm/htu.htu va sin query string: para
GET /v1/accounts?limit=1 se firma
.../v1/accounts pelado.iat se valida con ±300s. Un reloj
corrido más de 5 minutos rompe todas las pruebas, y el error no dice
«tu reloj»: dice DPoP proof iat missing or outside the acceptance
window.GET /v1/partners/me, GET /v1/accounts,
GET /v1/balances y GET /v1/rails. Las escrituras
de los pasos 3, 4 y 6 (POST /v1/customers,
POST /v1/accounts, POST /v1/webhooks) todavía no
están habilitadas en producción: lo que ves abajo es el
contrato —el que sirve el mock y el que vas a integrar—,
no algo que puedas ejecutar hoy contra partners.hamirach.com.
Lo decimos acá y no al final para que no descubras el orden a los golpes.
Con kyc_mode=hosted, Hamirach te devuelve un
verification_url: un link de Rillis que le reenviás a tu
cliente final para que complete el KYC ahí. El resultado te llega después
por webhook (Paso 6).
hamirach POST /customers \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_id": "cli-00123",
"type": "individual",
"kyc_mode": "hosted",
"email": "ana@example.cr"
}'
Respuesta — 201
{
"id": "cus_9f2k",
"external_id": "cli-00123",
"type": "individual",
"kyc_mode": "hosted",
"kyc_status": "pending",
"verification_url": "https://verify.rillis.io/s/abc123",
"created_at": "2026-08-10T14:00:00Z"
}
Mandale verification_url a Ana. En sandbox, la verificación
se resuelve sola en segundos para que puedas seguir el tutorial sin
esperar. Confirmá el estado con:
hamirach GET /customers/cus_9f2k
Respuesta — 200
{
"id": "cus_9f2k",
"external_id": "cli-00123",
"type": "individual",
"kyc_mode": "hosted",
"kyc_status": "approved",
"verification_url": null,
"created_at": "2026-08-10T14:00:00Z"
}
Solo se puede abrir una cuenta para un cliente con
kyc_status=approved — por eso el orden importa: primero el
KYC, después la cuenta.
Una cuenta vive sobre un riel de liquidación. Un mismo
cliente puede tener varias cuentas, nunca dos en el mismo riel.
El catálogo de rieles de la plataforma está en
GET /v1/rails?currency=USD — es el mismo para todos los
partners, no está filtrado por tu organización, así que un riel que aparezca
acá no garantiza que lo tengas habilitado:
hamirach GET "/rails?currency=USD"
htu de la
prueba se firma sin ?currency=USD. La función
hamirach ya lo resuelve (dpop-proof.mjs descarta
la query), pero si armás la prueba por tu cuenta y firmás la URL completa,
la respuesta es 401 invalid_dpop_proof — y es la trampa que no
aparece hasta tu segunda llamada, porque la primera casi nunca lleva query.
Respuesta — 200
{
"data": [
{
"id": "usd_wire_us",
"currency": "USD",
"schemes": ["wire"],
"settlement_country": "US",
"bank": { "name": "Lead Bank", "country": "US", "address": null, "swift_bic": null }
},
{
"id": "usd_ach_us",
"currency": "USD",
"schemes": ["ach"],
"settlement_country": "US",
"bank": { "name": "SSB Bank", "country": "US", "address": null, "swift_bic": null }
}
]
}
Hoy son dos, y no anuncian lo mismo: uno recibe por
wire y el otro por ach. Por eso elegir el riel
importa — y por eso un cliente puede tener una cuenta en cada uno.
id del riel es nuestro, no el de quien nos provee
la infraestructura. Identifica una capacidad (moneda + esquemas de
liquidación) en una jurisdicción, y el mapeo a lo que hay por detrás vive
de nuestro lado. Eso es lo que nos deja cambiar quién está detrás de un
riel sin que vos toques una línea: tu código sigue pidiendo
usd_wire_us. El bank es otra cosa y sí es
visible: es el banco de liquidación, el dato que va en la
instrucción de depósito.
hamirach POST /accounts \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"customer_id": "cus_9f2k", "rail": "usd_wire_us"}'
Respuesta — 201
{
"id": "acc_7h3m",
"customer_id": "cus_9f2k",
"currency": "USD",
"rail": "usd_wire_us",
"account_number": "100000123456",
"routing_details": { "type": "wire", "routing_number": "123456780" },
"bank": { "name": "Lead Bank", "country": "US", "address": null, "swift_bic": null },
"status": "pending",
"created_at": "2026-08-10T14:05:00Z"
}
La cuenta nace pending hasta que el riel confirma el alta
(pasa a active con el evento account.opened).
El destino que le das a Ana para que reciba depósitos son las tres cosas
juntas: account_number, el routing_number de
routing_details y el nombre del bank.
account_number no es un IBAN. Medido en
producción el 2026-08-10 sobre las 7 cuentas USD vivas: 12
dígitos, y un routing_number de 9 dígitos en
formato ABA. Trátalos como strings opacos — no los parsees ni
asumas longitud fija a futuro.
bank.name y bank.country viajan siempre.
bank.address y bank.swift_bic viajan
siempre en null — hoy, en el 100% de las
cuentas y de los rieles, sin excepción: no es que falten en algunas, es que
todavía no los publicamos en ninguna. No escribas una rama "si viene la
dirección, usala": nunca se ejecutaría. Y si tu flujo de wire entrante
exige la dirección del banco, pedila por otro canal antes de
operar, porque ninguna cuenta va a calificar.
Si el cliente ya tiene una cuenta en cada riel habilitado, la llamada
devuelve 422 con
code: "no_rails_available". Omitir rail es
válido: se le asigna el siguiente riel libre.
hamirach GET "/balances?account_id=acc_7h3m"
Respuesta — 200
{
"account_id": "acc_7h3m",
"available": { "amount": "950000", "currency": "USD", "decimals": 2 },
"pending": { "amount": "0", "currency": "USD", "decimals": 2 },
"as_of": "2026-08-10T16:00:00Z"
}
amount es siempre un string entero
en la unidad mínima de la moneda — nunca un float. Con
decimals: 2, "950000" son USD 9.500,00. Esto
aplica a todo campo Money de la API.
Registrá el endpoint que va a recibir las notificaciones. El
secret que te devuelve esta llamada sale en texto plano
una sola vez — guardalo ahora en tu gestor de secretos,
no se vuelve a mostrar.
hamirach POST /webhooks \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.bancovalle.example/hamirach/webhooks",
"events": ["payout.completed", "payout.failed"]
}'
Respuesta — 201
{
"id": "whk_3q9v",
"url": "https://api.bancovalle.example/hamirach/webhooks",
"events": ["payout.completed", "payout.failed"],
"secret": "whsec_7f0a9d2e4b1c8836f5a02d17e9c4b7a1",
"active": true,
"created_at": "2026-08-10T16:35:00Z"
}
Este ejemplo se suscribe a eventos de payouts; para tu integración
elegí del catálogo completo lo que te interesa recibir:
customer.kyc.completed, customer.kyc.rejected,
account.opened, payment.completed,
payment.failed, payment.returned,
payout.completed, payout.failed,
order.filled, quote.lock.expired.
Cuando Ana termine el KYC hosted del Paso 3, tu endpoint recibe un
POST como este (evento customer.kyc.completed):
{
"event_id": "evt_1a2b3c",
"type": "customer.kyc.completed",
"created_at": "2026-08-10T14:30:00Z",
"data": {
"id": "cus_9f2k",
"external_id": "cli-00123",
"type": "individual",
"kyc_mode": "hosted",
"kyc_status": "approved",
"verification_url": null,
"created_at": "2026-08-10T14:00:00Z"
}
}
Cada entrega llega firmada con HMAC-SHA256 en el header
Hamirach-Signature: t=<unix>,v1=<hex>, calculado
sobre t + "." + body con tu secret
(whsec_...). Rechazá si |now - t| > 300s
(anti-replay). La entrega es at-least-once: deduplicá por
event_id. Backoff exponencial, 8 intentos si tu endpoint
responde distinto de 200.
Verificar la firma (bash + openssl)
export HAMIRACH_WEBHOOK_SECRET="whsec_ejemplo_00000000000000000000" # ⚠️ PLACEHOLDER — usá el secret real que te devolvió POST /webhooks (no se vuelve a mostrar)
# $BODY = cuerpo crudo tal cual llegó (sin re-serializar)
# $HEADER = valor completo del header Hamirach-Signature, ej: "t=1770735000,v1=9a3f..."
T="${HEADER%%,*}"; T="${T#t=}"
V1="${HEADER##*v1=}"
EXPECTED=$(printf '%s' "${T}.${BODY}" \
| openssl dgst -sha256 -hmac "$HAMIRACH_WEBHOOK_SECRET" -hex \
| sed 's/^.* //')
NOW=$(date +%s)
DIFF=$(( NOW - T )); DIFF=${DIFF#-} # valor absoluto: cubre reloj adelantado Y atrasado
if [ "$V1" = "$EXPECTED" ] && [ "$DIFF" -le 300 ]; then
echo "firma válida"
else
echo "RECHAZAR: firma inválida o request vieja"
fi
crypto.timingSafeEqual en Node / hmac.compare_digest
en Python) en vez de == / [ "$V1" = "$EXPECTED" ]
— la comparación de strings común filtra timing y facilita adivinar la
firma byte a byte.