Ir al contenido

Firma ECDSA-SHA256

Cada request a la API requiere headers firmados con ECDSA-SHA256 (secp256k1). No hay tokens Bearer ni sesiones.

HeaderDescripciónEjemplo
x-client-idID de tu API Keyrivo_abc123xyz
x-timestampFecha/hora actual ISO 8601 (±5 min)2026-03-18T14:00:00.000Z
x-nonceString único aleatorio por requesta3f9b2c1d8e7...
x-signatureFirma ECDSA-SHA256 en Base64MEYCIQDx...
x-content-sha256SHA-256 del body en hex minúsculae3b0c442...
HeaderDescripción
x-idempotency-keyObligatoria en los 6 POST que mueven dinero; el resto de peticiones no la necesitan. No entra en el string a firmar. Ver Idempotencia

El string a firmar es un texto plano separado por saltos de línea (\n), no un objeto JSON:

{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{SHA256_HEX_DEL_BODY}

Ejemplo concreto:

POST
/v1/co/breb/payin/qr
2026-03-18T14:00:00.000Z
a3f9b2c1d8e7f4a1b2c3d4e5f6a7b8c9
9f2b8e1c0a4d7f36c5b1e8a09d4f27c3b6a5e0d918c7f42a3b6d5e8c1f0a9b74

Pasos:

  1. Serializa el body una sola vez y calcula su SHA-256 en hex minúscula → ese es x-content-sha256
  2. Construye el string a firmar concatenando método, path, timestamp, nonce y el digest, separados por \n
  3. Firma ese string con ECDSA-SHA256 usando tu clave privada secp256k1
  4. Encode la firma en Base64 → ese es x-signature
  5. Envía el mismo string que hasheaste como body — no lo vuelvas a serializar
  • El digest es sobre los bytes exactos que se envían. Si haces JSON.stringify dos veces con distinto orden de campos o espaciado, el digest no cuadra y la respuesta es 401. Esta es la causa nº 1 de errores de integración.
  • Sin body (GET, DELETE) el digest es el del string vacío: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • Quitar la cabecera x-content-sha256 invalida la firma: el digest forma parte del string firmado.
  • Un body que no corresponde al digest firmado devuelve 401 con código UNAUTHORIZED.

Todas estas comprobaciones ocurren antes de tocar la lógica de negocio. Cualquiera que falle responde 401 UNAUTHORIZED:

ReglaDetalle
x-timestamp dentro de ±5 minutosSincroniza el reloj del servidor por NTP: un desfase mayor invalida todas tus firmas
x-nonce no reutilizadoTTL de 5 minutos
Llave activeUna llave revocada deja de firmar
Llave de la región del pathUna key CO solo firma /v1/co/*; cruzar regiones es 401, no 403
IP de origen dentro de ipWhitelistObligatoria: sin al menos una IP configurada, la llave no firma desde ningún origen
Perfil regional ACTIVE o SANDBOX y habilitadoVer Modo Sandbox
Body coincide con el digest firmadoCausa nº 1 de errores de integración

El digest del body forma parte del string firmado: cualquier intermediario que vea el tráfico en claro —un proxy corporativo, un balanceador que termine TLS— no puede alterar el amount o la llave destino sin invalidar la firma.

const crypto = require('crypto');
function buildRequest(method, path, body, privateKeyPem, keyId) {
const timestamp = new Date().toISOString();
const nonce = crypto.randomUUID();
// Serializar UNA sola vez: lo que se hashea tiene que ser lo que se envía.
const rawBody = body === undefined ? '' : JSON.stringify(body);
const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex');
const stringToSign = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyHash}`;
const signature = crypto.createSign('SHA256').update(stringToSign).sign(privateKeyPem, 'base64');
return {
headers: {
'Content-Type': 'application/json',
'x-client-id': keyId,
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-signature': signature,
'x-content-sha256': bodyHash,
},
// Enviar `rawBody` tal cual, sin volver a serializar.
body: rawBody,
};
}
import base64
import hashlib
import json
import secrets
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
def build_request(method, path, body=None, private_key_pem=None, key_id=None):
timestamp = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
nonce = secrets.token_hex(16)
# Serializar UNA sola vez: lo que se hashea tiene que ser lo que se envía.
raw_body = '' if body is None else json.dumps(body, separators=(',', ':'))
body_hash = hashlib.sha256(raw_body.encode('utf-8')).hexdigest()
string_to_sign = f'{method}\n{path}\n{timestamp}\n{nonce}\n{body_hash}'
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
signature = private_key.sign(string_to_sign.encode('utf-8'), ec.ECDSA(hashes.SHA256()))
headers = {
'Content-Type': 'application/json',
'x-client-id': key_id,
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-signature': base64.b64encode(signature).decode(),
'x-content-sha256': body_hash,
}
# Enviar `raw_body` tal cual, sin volver a serializar.
return headers, raw_body
Ventana de terminal
# Variables
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
NONCE=$(openssl rand -hex 16)
KEY_ID="rivo_abc123xyz"
METHOD="POST"
PATH_="/v1/co/breb/payin/qr"
BODY='{"amount":50000,"reference":"orden-8842"}'
# SHA-256 del body exacto que se envía
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')
# String a firmar: campos separados por saltos de línea
SIGNATURE=$(printf '%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_" "$TIMESTAMP" "$NONCE" "$BODY_HASH" \
| openssl dgst -sha256 -sign private.pem | base64 | tr -d '\n')
# Hacer el request con el MISMO body que se hasheó
curl -X POST "https://api.sandbox.rivopaylatam.com$PATH_" \
-H "Content-Type: application/json" \
-H "x-client-id: $KEY_ID" \
-H "x-timestamp: $TIMESTAMP" \
-H "x-nonce: $NONCE" \
-H "x-signature: $SIGNATURE" \
-H "x-content-sha256: $BODY_HASH" \
-d "$BODY"