API
Solicita acceso →
Empezar / Autenticación y firma

Autenticación y firma

Cada vez que le pides a Alibanca que mueva dinero, la API necesita estar segura de dos cosas: quién eres y que el mensaje llegó tal cual lo enviaste, sin que nadie lo tocara en el camino. Para eso, cada request lleva tu llave de API (te identifica) y una firma criptográfica RSA-SHA256 (prueba que el mensaje es auténtico e íntegro). Esta guía te explica, paso a paso y sin dar nada por sabido, cómo funcionan tus llaves, cómo se arma y se firma el envelope canónico, cómo el nonce evita que un request se reenvíe dos veces, y cómo depurar el temido error 401 cuando la firma no cuadra.

i
La idea en una frase. Tu llave dice "soy yo"; la firma dice "y este mensaje exacto salió de mí". Las dos juntas hacen que mover dinero sea seguro incluso sobre una red que no controlas.

Cómo funciona la autenticación en Alibanca

La autenticación de Alibanca tiene dos capas independientes que se refuerzan entre sí. Entenderlas por separado te ahorra horas de depuración, porque cuando algo falla casi siempre es una de las dos, no ambas.

Capa 1 — Identidad (tu llave de API). En cada request mandas el header x-api-key con tu llave secreta. Es como el nombre de usuario del request: le dice a Alibanca de qué cuenta viene la operación y qué permisos y límites aplican. Pero una llave, por sí sola, puede ser copiada; si alguien la ve, podría hacerse pasar por ti. Por eso existe la segunda capa.

Capa 2 — Autenticidad e integridad (la firma). Además de la llave, cada request lleva una firma digital en el header x-signature. Esa firma se calcula con una llave privada que solo tú tienes y que nunca viaja por la red. Alibanca la verifica con tu llave pública (que sí conoce). Si aunque sea un byte del mensaje cambió, o si quien lo firmó no tenía tu llave privada, la verificación falla y el request se rechaza. Esta capa es la que hace que mover dinero sea seguro: aun si alguien roba tu x-api-key, no puede firmar por ti sin tu privada.

i
Antes de firmar nada, saluda. Llama primero a GET /v2/version (es público, no requiere llave ni firma). Te confirma que estás apuntando al ambiente correcto y te devuelve los enlaces al openapi y al jwks. Es la forma barata de descartar problemas de red antes de meterte con la criptografía.

Tus llaves: sk_test_ y sk_live_

Una llave de API (API key) es un texto secreto que te identifica ante Alibanca. Tienes dos tipos, y la diferencia importa muchísimo porque una mueve dinero de verdad y la otra no.

PrefijoAmbienteQué hace
sk_test_SandboxVive en https://alibanca-api-production.up.railway.app. Corre contra un proveedor simulado (MockProvider): no mueve un solo céntimo real. Es tu campo de pruebas para desarrollar e integrar sin riesgo.
sk_live_ProducciónVive en https://api.alibanca.com. Cada request que aceptes aquí mueve dinero real. Trátala con el mismo cuidado que la clave de una bóveda.

El prefijo (sk_ = secret key) te recuerda que son secretas por diseño: no son un identificador público, son una credencial. En sandbox, además, tienes a tu disposición los montos mágicos → para disparar escenarios como unknown o el trap del doble-pago, y así probar que tu integración se comporta bien sin arriesgar dinero.

Pruébalo. Empieza siempre con sk_test_. Integra completo contra el sandbox, ejercita los montos mágicos, verifica tu manejo de unknown, y solo cuando todo el flujo esté sólido cambias la base URL, la llave a sk_live_ y el par de llaves RSA con el que firmas — uno por ambiente, como explicamos más abajo. El código no cambia; solo la configuración.

Seguridad de llaves: siempre del lado del servidor

Tu x-api-key y, sobre todo, tu llave privada RSA son secretos server-side. Eso significa que viven y se usan únicamente en tu backend — nunca en el navegador, nunca en una app móvil, nunca en código que el usuario final pueda descargar o inspeccionar.

La razón es simple: cualquier cosa que llegue al cliente, el cliente la puede leer. Si pones tu llave en el JavaScript de tu web o dentro de tu app, cualquiera con las herramientas de desarrollador puede extraerla y empezar a mover dinero como si fuera tú. La API de Alibanca siempre se llama desde tu servidor.

!
Nunca comitees una llave. No pongas sk_live_ ni tu llave privada en el repositorio, ni en un archivo .env que subas por accidente, ni pegada en un ticket, un chat o un PR. Guárdalas en un gestor de secretos o en variables de entorno del servidor. Si una llave se filtra, rótala de inmediato: asume que ya fue comprometida.

Buenas costumbres mínimas para dormir tranquilo:

1

Guárdalas fuera del código

Variables de entorno o un secret manager. Nunca hardcodeadas.

2

Sepáralas por ambiente

Que sk_test_ y sk_live_ vivan en configuraciones distintas, para que sea imposible mandar a producción algo que pensabas probar. Y lo mismo con la firma: usa un par de llaves RSA distinto en cada ambiente. Es lo que recomendamos y lo que soportamos — cada ambiente guarda su propia llave pública, así que no hay nada que compartir entre los dos, y si una privada de sandbox se filtra no toca tu producción: rotas solo esa. Confirma cuál tiene registrada cada ambiente con el publicKeyFingerprint de GET /v2/me, una llamada por ambiente; si las dos huellas coinciden, estás usando el mismo par en los dos.

3

Protege la llave privada más que nada

Tu privada RSA es la joya de la corona. Con ella se firma el movimiento de dinero. Restringe quién y qué proceso puede leerla.

4

Rota ante la duda

Si sospechas exposición, rota. Es más barato rotar una llave sana que limpiar un fraude.

Firma asimétrica RSA-SHA256: qué es y por qué

Aquí está el corazón de la seguridad de Alibanca, así que vamos con calma y una analogía.

Firmar asimétricamente es como poner un sello de cera personal e infalsificable sobre un sobre cerrado. En criptografía asimétrica tienes dos llaves que forman una pareja: una privada (que solo tú tienes) y una pública (que puedes repartir sin miedo). Lo que se firma con la privada, solo se verifica con la pública correspondiente — y viceversa. Son matemáticamente inseparables, pero de la pública no se puede deducir la privada.

Aplicado a Alibanca: tú firmas cada request con tu llave privada, y Alibanca verifica esa firma con tu llave pública (que le registraste al dar de alta tu integración). El resultado tiene dos garantías:

Autenticidad — solo quien posee tu llave privada pudo producir esa firma, así que Alibanca sabe que el request salió realmente de ti, no de un impostor que copió tu x-api-key.

Integridad — la firma se calcula sobre el contenido exacto del mensaje. Si un intermediario cambia un solo carácter del monto o del destino, la firma deja de cuadrar y el request se rechaza. Nadie puede alterar un pago en tránsito sin que se note.

El SHA-256 de "RSA-SHA256" es la función de hash: antes de firmar, el mensaje se resume en una huella digital de tamaño fijo. Firmar esa huella con RSA es lo que produce la firma. No necesitas implementar esto a mano — cualquier librería de criptografía estándar (la nativa de tu lenguaje) lo hace por ti; solo tienes que darle el texto correcto a firmar. Y ese "texto correcto" es lo más importante de toda esta página: el envelope canónico.

Requisitos de tu llave pública

Alibanca no acepta cualquier llave: la valida al registrarla y la rechaza en ese momento, no cuando falle tu primer request. Son cinco condiciones, y te conviene verificarlas antes de mandárnosla.

RequisitoValorDetalle
AlgoritmoRSASolo RSA, porque la firma que verificamos es RSA-SHA256. Una llave de curva elíptica (P-256, Ed25519) se rechaza aunque sea perfectamente válida.
Módulo≥ 2048 bits2048 es el mínimo, no el máximo: 3072 y 4096 también se aceptan, así que si tu política interna pide 4096, úsala sin consultarnos. Por debajo de 2048 se rechaza.
Exponente público65537Exactamente 65537 (F4), que es el que generan por defecto openssl y toda librería estándar — normalmente no tienes que hacer nada. Un exponente chico como 3 se rechaza a propósito: permite falsificar firmas conociendo solo la llave pública.
CodificaciónPEMPEM de texto, con cualquiera de sus dos cabeceras: -----BEGIN PUBLIC KEY----- (SPKI) o -----BEGIN RSA PUBLIC KEY----- (PKCS#1). No aceptamos JWK, ni DER binario, ni el base64 del DER pelado sin cabeceras.
Mitad del parla públicaSolo la mitad pública. Si nos mandas por error un bloque PRIVATE KEY, lo detectamos y lo rechazamos: tu llave privada no debe salir nunca de tu servidor, ni siquiera hacia nosotros.
GENERAR UN PAR QUE CUMPLE
# cambia 2048 por 4096 si tu política lo pide; el exponente 65537 lo pone openssl solo
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out privada.pem
openssl pkey -in privada.pem -pubout -out publica.pem
!
Nos mandas publica.pem, nunca privada.pem. Son dos archivos parecidos y confundirlos es el error más caro de este paso. El correcto empieza con -----BEGIN PUBLIC KEY-----; si el tuyo dice PRIVATE, ese no es. Y si ya nos lo enviaste, genera un par nuevo y trata el viejo como comprometido.
i
Registramos la SPKI canónica, no tu archivo. Si nos la envías en PKCS#1, la convertimos a SPKI y guardamos eso: es la misma llave y firma igual. Por eso el publicKeyFingerprint que te devuelve GET /v2/me es siempre el del SPKI, aunque tú hayas mandado la otra cabecera.

El envelope canónico: qué se firma exactamente

No firmas "el request" en abstracto. Firmas un texto muy concreto y construido con reglas fijas, llamado envelope canónico. "Canónico" significa que se arma siempre igual, en un orden determinístico, para que tú y Alibanca lleguen exactamente al mismo texto y por tanto a la misma verificación. Si tú lo armas distinto a como Alibanca lo reconstruye, la firma no cuadra — aunque tu criptografía sea perfecta.

El envelope es la concatenación en este orden exacto de seis piezas:

OrdenPiezaQué es
1reqMétodo HTTPEl verbo del request, tal cual: POST, GET, PATCH, DELETE. En mayúsculas.
2reqRequest-targetEl request-target tal como le llega al servidor: el path con su query string y sin el dominio — /v2/payments, o /v2/payments?limit=2&status=completed si mandas parámetros. Alibanca lo usa literal: no lo decodifica, no lo reordena y no le quita nada. Firma el que sale al cable, no el que arma tu código (ver el aviso de abajo).
3reqAPI keyTu x-api-key (sk_test_… o sk_live_…). Atarla al envelope amarra la firma a tu identidad.
4reqIdempotency-keyLa ranura existe siempre; lo que va adentro depende de la ruta. En POST /v2/payments y POST /v2/quotes —las dos rutas que piden ese header— va el valor de tu idempotency-key: el identificador único y determinístico de esa operación lógica. En toda otra ruta firmada va vacía, incluso si la ruta lleva cuerpo. Vacía no es omitida: el separador va igual.
5reqRaw bodyEl cuerpo crudo del request: el string JSON exacto, byte a byte, que vas a enviar. Ni re-serializado, ni con espacios distintos.
6reqNonceUn número creciente por llave que evita reenvíos (ver la sección del nonce). Viaja dentro del envelope firmado.
!
El detalle que rompe todo: el raw body. Debes firmar el mismísimo string que envías por la red, no un objeto que vuelves a serializar. Si firmas JSON.stringify(objeto) y luego tu cliente HTTP re-serializa el objeto con otro orden de claves u otros espacios, el body que llega a Alibanca ya no es el que firmaste, y obtienes 401. Regla de oro: serializa una sola vez, guarda ese string, fírmalo y envía ese mismo string.
!
El otro detalle que rompe todo: el ? vacío. Alibanca arma la pieza 2 con el request-target tal como le llegó, sin tocarlo. Así que lo que firmas no es el string que arma tu código, sino el que tu cliente HTTP pone de verdad en la red — y en un caso no son el mismo. Medido: fetch (Node) y requests (Python) borran el ? final cuando no lleva parámetros, así que /v2/balance? viaja como /v2/balance y firmar el ? te da 401 siempre; curl y urllib sí lo envían, y ahí el ? sí queda dentro del sobre. Por eso la regla no es "con ?" ni "sin ?", sino la que funciona en los dos casos: si no hay parámetros, no hay ? — nunca armes el request-target como path + "?" + query cuando el query viene vacío.
i
Forma de origen, siempre. El request-target viaja en dos formas: la de origen (/v2/payments?limit=2) y la absoluta (https://api.alibanca.com/v2/payments?limit=2), que es la que manda un cliente configurado contra un proxy en HTTP plano. Firma y envía siempre la de origen. Como el API es HTTPS, un proxy normal abre un túnel con CONNECT y tu petición ya viaja así; y si tu librería igual emite la absoluta, pásala a forma de origen antes de firmar. Medido contra el sandbox: una petición en forma absoluta llega a nuestro servidor ya normalizada a la forma de origen, así que la firma hecha sobre la cadena con esquema y dominio no verifica.

Cómo armar y firmar el envelope

El proceso completo, del lado de tu servidor, en cuatro pasos:

1

Serializa el body una sola vez

Convierte tu objeto a JSON y guarda ese string. Ese es tu raw body — el que firmas y el que envías, sin volver a tocarlo.

2

Concatena las seis piezas en orden

Método, ruta, api-key, idempotency-key, raw body, nonce. En ese orden, con un separador consistente entre cada una.

3

Firma el envelope con tu llave privada

Aplica RSA-SHA256 sobre el envelope y codifica el resultado en base64. Esa es tu firma.

4

Manda los headers y el body

x-api-key, x-signature y x-nonce en los headers, siempre. El idempotency-key sólo en POST /v2/payments y POST /v2/quotes. El raw body como cuerpo del request.

En pseudo-código (Node.js con la librería nativa crypto), se ve así:

NodeCopiar
// 0. Prepara los datos de la operación
const method  = "POST";
const path    = "/v2/payments";
const apiKey  = process.env.ALIBANCA_KEY;        // sk_live_...  (server-side)
const idemKey = "pay-2026-07-22-inv-8842";     // único y determinístico por operación
const nonce   = nextNonce();              // único por llave; creciente para escrituras

// 1. Serializa el body UNA sola vez — este string es el que firmas Y envías
const rawBody = JSON.stringify({
  amountMinor: "150000",
  currency:    "VES",
  rail:        "PM",
  bankCode:    "0138",
  docType:     "V",
  docNumber:   "12345678",
  beneficiary: "04141234567"
});

// 2. Une las 6 piezas EN ORDEN con un byte NUL (0x00), NO con un salto de línea
const envelope = [method, path, apiKey, idemKey, rawBody, nonce].join("\u0000");

// 3. Firma el envelope con TU llave privada → base64
const signature = crypto
  .createSign("RSA-SHA256")
  .update(envelope)
  .sign(privateKeyPem, "base64");

// 4. Envía. OJO: manda EXACTAMENTE rawBody como cuerpo, no re-serialices el objeto.
await fetch("https://api.alibanca.com/v2/payments", {
  method,
  headers: {
    "content-type":    "application/json",
    "x-api-key":       apiKey,
    "x-signature":     signature,
    "x-nonce":     nonce,
    "idempotency-key": idemKey
  },
  body: rawBody
});
i
El nonce va en los dos lados. El nonce entra en la pieza 6 del envelope que firmas y además viaja como header x-nonce. Las dos cosas son obligatorias: el servidor lee el header para saber qué nonce presentaste, y verifica que sea el mismo que va sellado dentro de la firma. Si mandas uno y no el otro, recibes 401.

El nonce high-water: anti-replay para principiantes

Imagina que un atacante no logra romper tu firma, pero sí copia un request válido tuyo y lo reenvía tal cual, muchas veces. Como la firma es auténtica, ¿pasaría? A ese ataque se le llama replay (reenvío), y el nonce existe justo para bloquearlo.

Un nonce es un número que acompaña a cada request. Alibanca lleva, por cada llave, una marca de agua (high-water mark): recuerda el nonce más alto que ya vio, y además cuáles de los 64 anteriores ya usaste. Esa marca es una sola por llave —la comparten todas tus rutas, lecturas y escrituras— y es durable: vive en nuestra base de datos, sobrevive reinicios y despliegues, y nunca baja. De ahí salen las dos reglas:

· En las rutas que mueven datos (POST, PUT, PATCH, DELETE) —y también en HEAD, ver más abajo— el nonce debe superar la marca. Uno igual o menor es un reenvío, y se rechaza.
· En las lecturas (GET) también aceptamos un nonce dentro de esos 64 por debajo de la marca que no hayamos visto todavía, para que tus consultas en paralelo no se pisen entre sí — siempre que tus nonces sean consecutivos: esos 64 se cuentan en números de nonce, no en tiempo. Repetir uno ya usado sigue siendo un reenvío, y se rechaza igual.

La analogía: es como el número de cheque de una chequera. Los cheques van 1001, 1002, 1003… Si al banco le llega otra vez el cheque 1002 después de haber procesado el 1003, sabe que es un duplicado y no lo paga. El nonce es ese número correlativo, pero por llave. Con una diferencia: para las consultas, el banco también acepta un cheque de numeración algo anterior si nunca lo cobró — nunca uno que ya cobró.

!
Tus lecturas empujan la marca que después tienen que superar tus escrituras. No hay una marca para los GET y otra para los pagos: es la misma. Una ráfaga de consultas con nonces altos deja la marca arriba, y el siguiente POST /v2/payments tiene que superar ese valor, no el del último pago. La consecuencia práctica: todo lo que firmes con una llave debe salir del mismo generador de nonce. Si tus pantallas usan el reloj y tu emisor de pagos lleva un contador aparte, el contador queda por debajo de la marca y tus pagos se van todos en 401.
!
No confundas nonce con idempotency-key. Hacen cosas opuestas a propósito. El nonce impide que se reenvíe un request (siempre debe subir). La idempotency-key permite reintentar con seguridad una operación (se mantiene igual para devolverte el mismo resultado, no un pago nuevo). Reintentar bien = misma idempotency-key, nonce mayor.

Implementación práctica del lado tuyo. El nonce tiene que ser un entero decimal único por llave y, para escrituras, estrictamente creciente. Elige un esquema por llave una sola vez, al principio, y no lo cambies después: lee el aviso que sigue a la lista. Tres formas que funcionan:

  • Un contador persistente por llave. Sobrevive reinicios, no depende del reloj. Es la opción más simple de razonar.
  • La hora en MICROsegundos. Sirve y no necesita estado, pero ojo con la resolución (ver el aviso de abajo).
  • Las dos mezcladas, con greatest. Si quieres el contador y la robustez del reloj, emite greatest(último + 1, ahora en microsegundos) y guarda el resultado como el nuevo último. Es monótono por construcción: nunca retrocede, aunque el reloj se atrase, cambies de máquina o reinicies el proceso. Es exactamente lo que hace el helper de abajo, y es la única forma segura de mezclar reloj y contador.
!
La marca de agua es durable y no baja nunca: cambiar de esquema inutiliza la llave para siempre. La marca vive en nuestra base de datos, no en memoria: sobrevive reinicios, despliegues y meses de inactividad, y solo sube. No la reiniciamos ni te la podemos bajar — bajarla volvería aceptable cualquier request firmado que ya emitiste, que es justo lo que el anti-replay existe para impedir. El caso concreto: si arrancas con el reloj en microsegundos (hoy, un número de 16 dígitos) y después cambias a un contador que empieza en 1, ninguno de esos nonces vuelve a superar la marca y todos tus requests con esa llave reciben 401, para siempre. La única salida es que te emitamos una llave nueva. Por eso el esquema se elige una vez; y si necesitas moverte de contador a reloj o al revés, hazlo con el greatest de arriba, que nunca retrocede.
!
Un nonce mal formado responde nonce replay, que es la causa equivocada. Si el valor no calza con ^[1-9][0-9]{0,37}$, la API contesta 401 con {"error":"nonce replay"}el mismo texto exacto que un reenvío de verdad. Así que si el 401 te sale en la primera llamada, o en todas, no busques un nonce repetido: revisa el formato. Rechazamos, entre otros, un UUID (550e8400-e29b-41d4-a716-446655440000) y una fecha ISO-8601 (2026-09-06T12:00:00Z); el 0; los ceros a la izquierda que deja un padStart(20, "0") (007); el signo delante (+7, -7); el hexadecimal (0x10); el punto decimal (1.5); los espacios o saltos de línea alrededor (" 7"); el valor vacío; cualquier número de 39 dígitos o más; y la notación científica (1e+21), que en JavaScript es lo que devuelve String() para cualquier número de 22 dígitos o más. Manda el número tal cual, sin rellenar y sin formatear: si lo construyes con BigInt y lo pasas con .toString() —como el helper de abajo— ninguno de estos casos te puede pasar.
!
Milisegundos NO alcanzan si mandas peticiones en paralelo. Dos llamadas disparadas juntas caen en el mismo milisegundo, o sea traen el mismo nonce, y el segundo es un duplicado de verdad: lo rechazamos. Medido: con Date.now() y 8 peticiones en paralelo pasa una sola. Usa microsegundos, o un contador. Y si corres varios procesos o workers detrás de una misma llave, ni los micros te salvan: dos workers pueden coincidir. Ahí necesitas una llave por worker, rangos de nonce asignados, o un asignador compartido.
NodeCopiar
// Implementación de referencia. Monotónica dentro del proceso incluso si el reloj
// se atrasa (NTP), porque nunca devuelve un valor menor al anterior.
let ultimo = 0n;

export function nextNonce() {
  // Epoch en MICROsegundos. Date.now() da milisegundos: x1000 para no colisionar
  // con tus propias peticiones en paralelo.
  const ahora = BigInt(Date.now()) * 1000n;
  ultimo = ahora > ultimo ? ahora : ultimo + 1n;
  return ultimo.toString();
}
!
Tampoco los separes de más: la ventana de lectura sólo cubre 64. Si acuñas cada nonce leyendo el reloj, dos peticiones armadas con un milisegundo de diferencia quedan a 1000 de distancia, y 1000 no cabe en 64. Como el flujo de arriba acuña el nonce antes de firmar, y una firma RSA-2048 no es gratis, armar 8 peticiones cruza varios milisegundos: si la de nonce más alto llega primero, las otras siete quedan fuera de la ventana y reciben 401 aunque ninguna se haya repetido. Medido con 8 lecturas en paralelo: releyendo el reloj en cada llamada pasa una sola; sembrando una vez y subiendo de a uno pasan las ocho. Si usas tu propio generador, la regla es nonces consecutivos, no marcas de tiempo.
i
Las lecturas toleran desorden; las escrituras no. En los GET aceptamos cualquier nonce que no hayamos visto dentro de una ventana de los 64 por debajo del más alto que recibimos de tu llave, así que puedes pintar una pantalla con varias consultas en paralelo sin que se pisen entre sí — siempre que tus nonces sean consecutivos. Esos 64 se cuentan en números de nonce, no en tiempo: si los acuñas de un reloj, dos consultas seguidas ya distan más de 64 y el desorden deja de tolerarse. En las rutas que mueven datos (POST, PUT, PATCH, DELETE) la regla sigue siendo estricta: el nonce debe superar al más alto ya usado. Es a propósito: así una orden firmada que se perdió en el camino no puede ejecutarse tarde.
!
No pongas más de 64 lecturas en vuelo a la vez. La ventana son 64 números de nonce, así que —acuñándolos juntos, como muestra el ejemplo de arriba— un lote de 64 entra completo (el más viejo queda a 63 del más nuevo) y uno de 65 deja al más viejo justo afuera: 401 nonce replay sin que ninguno se haya repetido, y sin nada que te indique que la causa fue el tamaño del lote. Si tienes más URLs, pártelas en tandas de 64 y espera cada tanda.
// tandas de 64: la ventana no da para más
for (let i = 0; i < urls.length; i += 64) {
  await Promise.all(urls.slice(i, i + 64).map((u) => pedir(u, nextNonce()));
}
En las escrituras esto no aplica: van de a una y con nonce estrictamente creciente.

Los headers requeridos

Un request firmado a Alibanca lleva estos headers. Los cuatro primeros son la columna vertebral de la seguridad y van siempre; el content-type es obligatorio en toda petición con cuerpo — sin él te rechazamos con 415 antes de mirar la firma, así que no es un detalle de estilo.

HeaderTipoDescripción
x-api-keyreqstringTu llave de API (sk_test_… o sk_live_…). Te identifica y determina permisos y límites.
x-signaturereqstring (base64)La firma RSA-SHA256 del envelope canónico, hecha con tu llave privada. Prueba autenticidad e integridad.
x-noncereqstring (entero decimal)Número estrictamente creciente por llave. Corta los reenvíos. Decimal puro^[1-9][0-9]{0,37}$: sin ceros a la izquierda, sin signo, sin punto, sin notación científica, máximo 38 dígitos. Un nonce mal formado se rechaza con el mismo 401 {"error":"nonce replay"} que un reenvío de verdad. Va como header y como pieza 6 del envelope firmado: los dos, siempre.
idempotency-keyreq POST /v2/payments · POST /v2/quotesstringObligatorio en POST /v2/payments y en POST /v2/quotes, donde identifica de forma única y determinística la operación lógica: reintentar con el mismo valor te devuelve el mismo resultado —el mismo pago, o la misma cotización— y no uno nuevo. En las dos va también en la ranura 4 del envelope firmado. Las demás rutas firmadas no lo piden y su ranura 4 va vacía: si le pones un valor, tu sobre deja de coincidir con el nuestro y recibes 401.
content-typereq con cuerpostringapplication/json. Obligatorio en toda petición con cuerpo — hoy POST /v2/payments, POST /v2/quotes, POST /v2/beneficiaries, PATCH /v2/beneficiaries/:id y PUT /v2/webhook. Si lo omites te devolvemos 415 { "error": "content-type must be application/json" } y la petición ni siquiera llega a autenticarse: no se verifica tu firma ni se mueve la marca de agua de tu nonce. En los GET, que no llevan cuerpo, no hace falta.
i
Endpoints públicos no piden nada de esto. GET /v2/version, GET /v2/openapi.json, GET /v2/webhook/keys (el JWKS) y GET /health son públicos: puedes llamarlos sin llave ni firma. Todo lo demás — mover dinero, listar pagos, gestionar beneficiarios, tu /v2/me — exige llave y firma.

Ejemplo de un request firmado completo

Así se ve, extremo a extremo, una emisión de pago firmada: primero el request con sus headers y su body, luego la respuesta.

HTTPCopiar
POST /v2/payments HTTP/1.1
Host: api.alibanca.com
content-type: application/json
x-api-key: sk_live_9f2c…a71b
x-nonce: 1786288800000123
x-signature: Qm9nT2xh… (RSA-SHA256 del envelope, en base64)
idempotency-key: pay-2026-07-22-inv-8842

{"amountMinor":"150000","currency":"VES","rail":"PM","bankCode":"0138","docType":"V","docNumber":"12345678","beneficiary":"04141234567"}
RESPUESTA 201 application/json
{
  "status": "success",
  "traceId": "trc_7f3a91c0e2",
  "payload": {
    "id": "4807",
    "state": "completed",
    "amountMinor": "150000",
    "currency": "VES"
  }
}

Fíjate en que el body de la respuesta sigue el sobre estándar de Alibanca: status, traceId (guárdalo: es tu número de referencia para soporte) y payload con el resultado. El campo state te dice cómo quedó el pago — profundiza en Estados de pago → para entender completed, unknown y failed.

Cómo Alibanca verifica tu firma

Entender qué hace Alibanca del otro lado te vuelve mucho más rápido depurando, porque sabes exactamente qué tiene que coincidir. Al recibir tu request, Alibanca:

1

Identifica tu llave

Lee x-api-key y busca la llave pública que registraste para esa cuenta.

2

Reconstruye el envelope

Vuelve a armar las seis piezas en el mismo orden — método, ruta, tu api-key, tu idempotency-key, el raw body que recibió y el nonce.

3

Verifica la firma

Comprueba, con tu llave pública, que la x-signature corresponde a ese envelope. Si un byte no cuadra, la verificación falla.

4

Chequea el nonce

Si es una escritura, confirma que el nonce supera la marca de agua de tu llave. Si es una lectura, le basta con que no lo haya visto y esté dentro de la ventana de 64. Un nonce ya usado es replay y se rechaza. Dos cosas que se olvidan justo acá: la marca es una sola por llave, así que tus GET también la empujan; y solo GET abre la ventana — un HEAD se chequea con la regla estricta.

Solo si las cuatro cosas pasan, Alibanca procesa la operación. Por eso la firma es una prueba tan fuerte: cualquier alteración del método, la ruta, la llave, la idempotency-key, el cuerpo o el nonce rompe la coincidencia.

Errores de firma (401) y cómo depurarlos

Cuando la autenticación falla, Alibanca responde con código 401 y el sobre de error { "error": "mensaje" }. Casi siempre la causa es que tu envelope no es idéntico al que Alibanca reconstruyó. Esta es tu lista de sospechosos, ordenada por frecuencia:

SíntomaCausa probableCómo arreglarlo
401 al azar en algunos requestsraw bodyEstás firmando un string pero enviando otro (tu cliente HTTP re-serializa el objeto). Serializa una vez, firma ese string y envía ese string.
401 constanteorden del envelopeLas piezas no están en el orden exacto método, ruta, api-key, idem-key, body, nonce. Revisa la concatenación.
401 constanterequest-targetFirmaste /payments en vez de /v2/payments, o incluiste el dominio, o le quitaste el query string, o le dejaste un ? final que tu cliente borra antes de enviarlo. El query string entra en la firma: usa el request-target exacto que sale al cable.
401 tras rotar llavespar de llavesEstás firmando con una privada cuya pública no es la que Alibanca tiene registrada. Verifica que el par coincida (ver el fingerprint más abajo).
401 en reintentosnonce repetido o menorReusaste o bajaste el nonce. En las escrituras cada request debe llevar un nonce estrictamente mayor.
401 nonce replay con un nonce que subeformato del nonceUn nonce mal formado se rechaza con ese mismo mensaje, así que el texto te manda en la dirección contraria. Verifica que sea decimal puro (^[1-9][0-9]{0,37}$): sin los ceros a la izquierda de un padStart, sin signo, sin punto, sin notación científica ("1e+21"), sin espacios y con 38 dígitos o menos.
401 con la llave bien escritaambienteMandaste una sk_test_ a producción o al revés, o un prefijo que no es ninguno de los dos. El servidor lo rechaza con un mensaje propio, distinto del de llave desconocida, y lo evalúa antes de buscar tu llave. Verifica base URL y prefijo juntos.
i
Confirma qué llave está activa. Llama a GET /v2/me con tu llave firmada: te devuelve tu keyType, el environment y el publicKeyFingerprint (un SHA256:… de tu llave pública registrada). Si ese fingerprint no coincide con el de la pública que corresponde a tu privada, ahí está tu problema. /v2/me nunca devuelve secretos, así que es seguro para depurar.

Técnica de depuración recomendada. Cuando pelees con un 401, imprime en tu log el envelope exacto que estás firmando (con separadores visibles) y el body exacto que sale por la red. El 90% de los casos se resuelve al ver que esos dos strings no son los que creías. Compara carácter por carácter contra lo que esperas.

Buenas prácticas

Para cerrar, el resumen de hábitos que hacen tu integración segura y fácil de operar en el tiempo:

Serializa el body una sola vez. Es el error número uno. Guarda el string, fírmalo, envíalo. No dejes que ninguna capa lo re-serialice.

La privada nunca sale del servidor. Ni en logs, ni en el cliente, ni en el repo. Si dudas si se filtró, rota.

Nonce único por llave: semilla en MICROsegundos y de ahí +1. Milisegundos colisionan consigo mismos si mandas peticiones en paralelo, y leer el reloj en cada request te separa los nonces mucho más de los 64 que tolera la ventana de las lecturas. Que no se repita, y que para escrituras nunca retroceda — ni siquiera al reiniciar tu servicio.

Idempotency-key determinística por operación. Derívala de algo estable de tu lado (por ejemplo, el id de tu factura), no de un aleatorio nuevo en cada intento. Así un reintento de red reusa la misma clave y no genera un pago duplicado.

Saluda antes de firmar. GET /v2/version te confirma ambiente y conectividad sin criptografía de por medio; descarta problemas triviales antes de los complejos.

Separa ambientes con rigor. Config distinta para sk_test_ y sk_live_, base URLs distintas, y ojalá una salvaguarda que impida mandar test a producción.

Pruébalo. Antes de tocar sk_live_, monta todo el circuito de firma contra el sandbox: arma el envelope, fírmalo, emite un pago con un monto normal (queda completed) y luego con uno terminado en .02 para ver un unknown. Si tu firma cuadra en sandbox, cuadra en producción — la criptografía es idéntica; solo cambia que ahí el dinero es real.

Vector de prueba: valida tu firma sin credenciales

Implementar una firma a mano tiene una clase entera de errores que no dan mensaje útil: el separador equivocado, el nonce en el lugar equivocado, el padding equivocado. Todos se ven igual desde afuera —un 401— y se depuran a ciegas.

Este vector los cierra de raíz. Con la llave privada reproduces la firma con tu propio código y comparas contra la esperada: eso prueba tu ruta de firmado entera, sin credenciales y sin tocar el API. Con la pública verificas que entendiste el sobre.

!
Esta pareja es de juguete y existe solo para este vector. Está publicada a propósito. Nunca la uses para nada real.
LLAVE PÚBLICA
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkKi7NRfgfL6Q1+FWotIe
+bsBJDroh478ozVnJl7aEK1GEvxxPINFndHv2riTiPm5uwXAJtPq/HOXL/TTpWll
Sq/Q9fslrXgRZUH71wFMlRmyD7Lp9imUHqO3fSHFnfOl78BmrTYUWkhPoMCI7VLW
Jk6MdlzL/ZAVTumA88f0uUFFvKeS5J3Sqnevhv8mNqwiz8v0F4y3k/Sv1uCemOqg
GzRqsLRftVVeL93FIRT4s16WaBg1gc3leBXoz9OSAko4gOeXnQKxnhHa3Icwl6w1
fGdqAoz8/VrfBBz5ItO8VPigr4glBY0G/930cpq3dEPgYKA7rre0D8qJtBRNkQoL
pQIDAQAB
-----END PUBLIC KEY-----
LLAVE PRIVADA de juguete
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCQqLs1F+B8vpDX
4Vai0h75uwEkOuiHjvyjNWcmXtoQrUYS/HE8g0Wd0e/auJOI+bm7BcAm0+r8c5cv
9NOlaWVKr9D1+yWteBFlQfvXAUyVGbIPsun2KZQeo7d9IcWd86XvwGatNhRaSE+g
wIjtUtYmTox2XMv9kBVO6YDzx/S5QUW8p5LkndKqd6+G/yY2rCLPy/QXjLeT9K/W
4J6Y6qAbNGqwtF+1VV4v3cUhFPizXpZoGDWBzeV4FejP05ICSjiA55edArGeEdrc
hzCXrDV8Z2oCjPz9Wt8EHPki07xU+KCviCUFjQb/3fRymrd0Q+BgoDuut7QPyom0
FE2RCgulAgMBAAECggEAQu1d172ZRf2g49BElgYjAKVtbMa4aGdWKWh+tbjyqP6R
yhzzguy1G8RSdV4qHBh1vrp1YUFwmaYdD7n05bWygHCBfBzbHLvNSIzTqHDYbq85
u5uqNRKdjeNu9DzYwjBE2HyqxH6dwftso9IQR8SogYab4/MhCcpMoXrVB3S+m6qO
G30kqlIQwLEidybv+yPU+++KrsN3SBJ3L1JT8V7P91lp8jtvX8SrASSSR7rQKKrO
G2K3A7niKZ4/eaqdn8TShIf6ILdwoFYhilpvrvNZUA1n/f7IVwepgkynBc5oIULF
zz3WStr6FU48QQLc7uWDraDd5W2wnai04GRZgBXVDQKBgQDHdF40cGGLWtnSb4L3
XCKKm5w4IixdQD7vlBwLR7KIg1TokvQHZRVoO0Ru8ZBWnZLPCl1jrugr5TkGeTwe
m92UHEezBinlugu6F4HRJXou1zXQtibQGWS+JUP6YmZBSIJjjvKf4zuLZysCTxK4
2iU6JAjP0Oz3Gf8llI6RUMP+uwKBgQC5q4X/LeyrQLUQS8tit+OWXYCFHmH7YYiF
DUkeqbh7XwXwMu59P/aEX5vqqVuvgWTRgSqgdlzutat5ljVzVE1MojQe+aIvLHvX
XLU/nwTctshqZxS0wbd1v4xQKeCEWON/u+Mw6vSPSqY3sKnPJ4k8v6w8wqsIyKFV
ADsTKwLpHwKBgCJwYWPEk9MwGLRRNNfpPL2aKNs4a1ieoz9S35TDCHyx0JNn7GLq
fUvGEAboBvgttQ+yxnVT2hraNYJ8pHjUiOnuCCNNSSa1fRjgvjWStwonds9W5FTC
TbbXUGmHXAihsIHoSNR6s+laIv7/EEiCwjLLzgm0FXaFMp0WjZdhzrXJAoGBALLr
ORsodDKrDlB/2aRttCEIRXsCRkVqPZaJsPadcqtgbGaFjhCgfLwfLi65uSKtPCwv
npY2uWNHKdDnEObsS/kXLcbTpJh083obWoXOhW7zrLnsT6XILzSGu33cfQDMb559
vnc7UyRqOTbdRSXc4YDq6905cnvqap1wtXzJK+rdAoGACrCdiMnddsRKKqrlRCaW
G6aZ/C5bgkb/eYfElu4T1J5S4d2UB7o6pBdWgjNg9CXSLtyIhPKEtmpcRRVaVtvv
Cuf7V5MqYxZo+3/dbiW/Fb1u9iTHETMKDg4R/cF3WMSkwsiRx53pl+QJyFX2ZfWL
RqG/sfIazyfUkiK1n9UWDbw=
-----END PRIVATE KEY-----

Las seis piezas del sobre canónico, en orden:

#PiezaValor
1métodoPOST
2request-target/v2/payments — tal cual viaja, sin decodificar y sin reordenar el query string
3api-keysk_test_VECTOR0000000000000000
4idempotency-keyvector-pago-001 — este vector es POST /v2/payments, la única ruta que llena esta ranura; en toda otra ruta firmada va vacía, pero la ranura siempre existe
5cuerpo crudo{"amountMinor":"150000","currency":"VES","rail":"PM","bankCode":"0138","docType":"V","docNumber":"12345678","beneficiary":"04141234567"}
6nonce1786288800000

Las seis se unen con un byte NUL (0x00), no con un salto de línea. Acá está el sobre completo en hexadecimal, para que el separador sea inequívoco — los cinco 00 son las uniones:

SOBRE CANÓNICO hex
504f5354002f76322f7061796d656e747300736b5f746573745f564543544f52
3030303030303030303030303030303000766563746f722d7061676f2d303031
007b22616d6f756e744d696e6f72223a22313530303030222c2263757272656e
6379223a22564553222c227261696c223a22504d222c2262616e6b436f646522
3a2230313338222c22646f6354797065223a2256222c22646f634e756d626572
223a223132333435363738222c2262656e6566696369617279223a2230343134
31323334353637227d0031373836323838383030303030
FIRMA ESPERADA base64 · RSA-SHA256 · PKCS#1 v1.5
cpGMRpWTgi8L7m0paczSm6bMXMao51wBS3dur/khycRcUuyN7NrdxQoeXGvSIi2FMqn7yxCVkp0e6AoKmI2hfcU1r+PIAJCC
WDhH3CxYud62NVAzURDhQZ4XPSboc5mLlc2qGZWp7GiFNLX6KOOb/XHeweO3l87dp1TMRNxapGIYQBdWFxneaERA8AJVOWk7
RdRGD7HDFKl2VdKmYg4pwiGHM5yQO6iHCZAo1RQFprdbbpafLiuYHBXpibT3+iBtaqnku1B6C5EEXZyAdD2yKqntS9iULIin
4FN8ReTumnBVaKAICg2jR007FJ68J2ll97zwXbETdebCySQ/LjXvWQ==

Los tres controles negativos, medidos: el mismo sobre unido con \n no verifica; sin el nonce en la pieza 6 no verifica; y la misma firma hecha con padding PSS no verifica. O sea que el vector prueba algo: si tu implementación reproduce esa firma exacta, tu firma es correcta.

Dos vectores más: la ranura vacía y el query string

Los pidió un integrador y son los dos casos donde más se equivoca la gente. Misma llave privada de arriba, así que puedes reusarla tal cual.

Vector 2 · Ranura 4 vacía — una lectura firmada

Una lectura no lleva idempotency-key ni cuerpo, pero las dos ranuras siguen existiendo: en el hex vas a ver dos NUL seguidos donde van las piezas 4 y 5. Omitirlas en vez de dejarlas vacías cambia el sobre y la firma no verifica.

!
La ranura 4 vacía no es cosa sólo de las lecturas. Sólo POST /v2/payments y POST /v2/quotes la llenan — son las dos rutas que piden el header idempotency-key. Las demás escrituras firmadas la dejan vacía aunque sí manden cuerpo en la ranura 5: es el caso de POST /v2/beneficiaries, PATCH /v2/beneficiaries/:id, PUT /v2/webhook y POST /v2/webhook/test. Si firmas una de ellas metiendo tu idempotency-key en la ranura 4, recibes 401 con bad signature.
#PiezaValor
1métodoGET
2request-target/v2/balance
3api-keysk_test_VECTOR0000000000000000
4idempotency-keyvacía
5cuerpo crudovacía
6nonce1786288800001
SOBRE CANÓNICO hex · 5 separadores NUL
474554002f76322f62616c616e636500736b5f746573745f564543544f52303030303030303030303030303030300000
0031373836323838383030303031
FIRMA ESPERADA base64 · RSA-SHA256 · PKCS#1 v1.5
Yfe+fsweOgRAwr8lVVi8ewdZBtrItGCKEnKACTbaH8SovP80P/93bqQwxfM/CFOhfhxXr4t6sLr9NNG+XEMspsTz09azro83
ApFYfJgTZhvRkSXS43VQFO1gTmL9BJXboCQlvNlJxMSQn7BLbjE6aHVEpKya4SX4mO/YflJQT32kV98VVNCr19ti3v1YMBd0
OuXjWy0fgIZFscOfWFL7rD9OrZVqf8kb8EQdolxmWLAtrLDG5ZtzfDqrcuwuAITE9eMpOBHlMY3sUmMN6UJbKPaYUzBlF+z6
fLBUC6DRHoV87TS8KdcdjRv18w3g8Jf7KmoLXU4PnJATFEFzrGFajA==

Vector 3 · Con query string — el request-target va crudo

La pieza 2 es el request-target tal cual viaja: con su query string, sin decodificar y sin reordenar los parámetros. Medido contra el servidor: /v2/payments?q=a%20b llega con el %20 intacto, así que si tú lo decodificas antes de firmar, tu firma no verifica.

!
Este vector lleva parámetros; el query vacío no es un vector, es una trampa. No publicamos uno con ? y nada detrás porque no todos los clientes lo mandan igual: fetch y requests lo borran, curl y urllib lo conservan, y el sobre te queda distinto según la librería. Si no tienes parámetros, no armes el ?: firma /v2/balance —el vector 2— que es lo que sale al cable en todos los casos.
#PiezaValor
1métodoGET
2request-target/v2/payments?limit=2&status=completed
3api-keysk_test_VECTOR0000000000000000
4idempotency-keyvacía
5cuerpo crudovacía
6nonce1786288800002
SOBRE CANÓNICO hex · 5 separadores NUL
474554002f76322f7061796d656e74733f6c696d69743d32267374617475733d636f6d706c6574656400736b5f746573
745f564543544f523030303030303030303030303030303000000031373836323838383030303032
FIRMA ESPERADA base64 · RSA-SHA256 · PKCS#1 v1.5
VNxKchbn4Uf9/+NG1L3KiXklrH81JAGN/BRctbQC0FyLhPa9JxeKoQu5+63f58Myl6AlcZ1u3mf2rbdn66bgcmz91MfkLBmW
B9NHTOygmhrb4ve4SinGzNYGeE3wN4F367gJHKEhbM+QwEtemRvtozZcW/sytE4jza67hXYKw46pfaLDyAElq2MIFtu3iLco
NcJlO/1Whc/vIIFX0FJqBNiVQdDC5FgElyseIYnfZInWb6ZFn4jHrNc1EPUSkBzQhshkfTFxfdiWTng+QmH0LioX0j2TNUxC
0WUA6fHGUgLzJexiGjo/VqYQls4qMZ9fkGvKzl/Xhc86zq/2BFAqHw==