Primeros 10 minutos con la Hamirach Partner API

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.

Lo primero, porque cambia todo lo demás: la autenticación es DPoP (RFC 9449), no 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.
Producción y sandbox no autentican igual, y conviene saberlo antes de escribir una línea. El sandbox (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.

1.Credenciales

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

2.Generar un token (client_credentials + DPoP)

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.

Los curls: una prueba nueva por comando

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.

Las cuatro trampas que hacen fallar el primer intento (las cinco, con la del htu, están explicadas y medidas en el encabezado de primer-token-dpop.mjs):
Qué está vivo hoy en producción. La autenticación de arriba es real y funciona. De los endpoints, hoy responden 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.

3.Crear un cliente (KYC hosted)

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"
}

4.Abrir una cuenta USD

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"
🪤 Ojo con la query string: el 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.

El 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.
Los datos del banco están incompletos y no lo disfrazamos. 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.

5.Consultar el balance

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"
}
Montos: 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.

6.Recibir tu primer webhook

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
En producción: usá comparación de tiempo constante (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.