Firma de solicitudes
La mayoría de los endpoints exige una firma RSA-SHA256 además del access token. Firmas con la clave privada que te entregaron cuando se aprovisionó tu organización — la que se revela una sola vez y de la que no guardamos copia para ti. Nosotros verificamos con la pública que le corresponde.
Esta página está escrita a mano: la firma de requests no es un security scheme estándar de OpenAPI, así que la referencia de API no puede describirla.
Los dos headers
| Header | Valor |
|---|---|
signature | Firma RSA-SHA256 en base64 del mensaje que se construye abajo |
nonce | Epoch actual en milisegundos, como string |
El nonce tiene que caer dentro de ±5 minutos de nuestro reloj, así que
mantén sincronizada por NTP la máquina que firma. Es una cota de frescura, no
una clave de deduplicación — para eso está X-Idempotency-Key.
Qué endpoints van firmados
Hay dos modos, y el modo depende del endpoint:
| Endpoint | Modo |
|---|---|
POST /quotations | body |
POST /orders | body |
GET /quotations/{id} | query |
GET /orders/{id} | query |
GET /orders | query |
GET /me/wallets/{currency} | query |
GET /me/wallets/{currency}/transactions/{type} | query |
GET /me/routes | query |
POST /oauth2/token es la única excepción, y tiene que serlo: es donde
consigues el token. Todo lo demás va firmado, así que trata la firma como parte
de tu cliente HTTP y no como algo que agregas endpoint por endpoint.
Construir el mensaje — modo query
Concatenas el path, ?, el query string y el nonce. Nada más, y sin separador
antes del nonce:
{path}?{queryString}{nonce}
Para GET /me/routes?sourceCurrency=USDR con nonce 1787260853525:
/me/routes?sourceCurrency=USDR1787260853525
El ? va incluso cuando el query string está vacío.
Construir el mensaje — modo body
Aquí está la parte que le cuesta una tarde a cualquiera. El mensaje no es el body crudo más el nonce. El body crudo se trata como un valor string de JSON y se codifica otra vez, y después se le pega el nonce:
JSON.stringify(rawBody) + nonce
Así que para el body {"quotation_id":"a1b2c3d4"} y el nonce 1787260853525, el
mensaje que firmas es este — 46 caracteres, comillas externas incluidas:
"{\"quotation_id\":\"a1b2c3d4\"}"1787260853525
Dos consecuencias:
- Firma los bytes que envías. No parsees el JSON para re-serializarlo antes de firmar: cambiarían el orden de las claves y los espacios, y la firma no coincidiría. Captura el string del body una vez, firma ese, envía ese.
- No te saltes la segunda codificación. Firmar
{"quotation_id":"a1b2c3d4"}+ nonce directo produce una firma que rechazamos.
Ejemplo completo
import { createSign } from "node:crypto";
const nonce = String(Date.now());
const rawBody = JSON.stringify({quotation_id: "a1b2c3d4"}); // send THIS exact string
const message = JSON.stringify(rawBody) + nonce; // body mode
// query mode: const message = `${path}?${queryString}${nonce}`;
const signer = createSign("RSA-SHA256");
signer.update(message);
signer.end();
const signature = signer.sign(privateKeyPem, "base64");
await fetch("https://api.gateway.sandbox.retorna.app/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
signature,
nonce,
},
body: rawBody,
});
En Python hay que pasar ensure_ascii=False:
import json, time, base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
nonce = str(int(time.time() * 1000))
raw_body = json.dumps({"names": "María"}, ensure_ascii=False)
# ensure_ascii=False here too: by default Python turns "María" into
# "Mar\u00eda", which signs a different message than we verify.
message = json.dumps(raw_body, ensure_ascii=False) + nonce
signature = base64.b64encode(
private_key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256())
).decode()
Ese default es la segunda forma más común de fallar esto: Python escapa los
caracteres no-ASCII y nosotros no, así que un beneficiario llamado María firma
un mensaje distinto del que verificamos.
Firmar desde la referencia
No hace falta que construyas un firmador para probar un endpoint. El explorador de la
referencia de API tiene un campo Private
key (PEM) al lado del bearer token: pegas ahí una clave PKCS#8 y calcula el
signature y el nonce de cada request que manda. La clave se queda en esa
pestaña del browser — no se guarda, no se envía y no aparece en los code samples.
Usa una clave de sandbox. Pegar una clave privada de producción en una página web no es un buen hábito, ni en la nuestra.
Cuando falla
Toda falla de firma es el mismo 401 B2B_UNAUTHORIZED — firma equivocada,
firma malformada, nonce vencido, clave no aprovisionada. No decimos cuál, a
propósito.
Igual puedes distinguirla de un problema de token: el 401 de firma trae code y
category, mientras el de token trae error y message como string. Si ves
code, deja de mirar el token y baja por esta lista:
- ¿El
nonceestá en milisegundos y la máquina que firma tiene la hora bien? - ¿Estás firmando los bytes exactos del body y no una copia re-serializada?
- En modo body, ¿codificaste el string del body una segunda vez?
- En modo query, ¿tu query string coincide con el que realmente mandaste, incluido el orden de los parámetros?
- ¿La firma va en base64 y no en hex?
- ¿Estás firmando con la clave privada de tu propio aprovisionamiento?