Firma de requests
Toda request a la API PSP debe ir firmada. La firma es un HMAC-SHA256 calculado sobre un
canonical string determinístico de la request, usando tu signature_secret. El servidor
reconstruye el mismo canonical string a partir de la request que recibe y compara las firmas: si
no coinciden (o el timestamp está fuera de la ventana), rechaza con 401.
Si usás Postman, el script hace todo esto por vos. Esta página describe el contrato exacto para quien quiera implementarlo en su propio backend.
Headers
Cada request lleva estos headers:
| Header | Descripción |
|---|---|
x-signature-key | Tu signature_key. |
x-signature-value | La firma HMAC-SHA256 en hexadecimal. |
x-signature-timestamp | Timestamp epoch en milisegundos usado al firmar. |
x-idempotency | Clave de idempotencia (mínimo 5 caracteres). Ver Idempotencia. |
Headers firmados (allowlist)
Solo estos headers entran en el canonical string. Cualquier otro header de la request se ignora a los efectos de la firma:
content-typehostx-idempotency
Se normalizan así: nombre en minúsculas, valor con .trim(), ordenados alfabéticamente por
nombre. Para host se quita el esquema (http:// / https://).
Los headers x-signature-* no se firman (no pueden: la firma es uno de ellos). Y como la
lista es un allowlist, agregar headers extra a la request (por ejemplo accept o headers
custom) no afecta la firma: el servidor los descarta. Si tu implementación los incluye en el
canonical, la firma no va a coincidir.
Canonical string
Se arma con estas seis líneas unidas por \n, en este orden exacto:
{METHOD}
{normalizedPath}
{queryString}
{headersString}
{signedHeaders}
{bodyHash}
Y luego, antes de firmar, se le agrega una línea más con el timestamp:
{canonical}
{timestamp}
Cada parte:
METHOD— método HTTP en mayúsculas (GET,POST,PUT,DELETE).normalizedPath— el path (sin labase_urlni el query string), colapsando barras repetidas (//→/) y quitando la barra final. Ejemplo:/api/v1/account/cvu/→/api/v1/account/cvu.queryString— parámetros de query con las claves ordenadas alfabéticamente, cada par comoencodeURIComponent(k)=encodeURIComponent(v), unidos por&. Si no hay query, es"".headersString— los headers firmados (ver arriba) comonombre:valor, uno por línea, unidos por\n.signedHeaders— los nombres de los headers firmados, ordenados, unidos por;(ejemplo:content-type;host;x-idempotency).bodyHash— SHA-256 en hex del body serializado (ver abajo). EnGETo sin body:"".
Serialización del body
El body debe serializarse de forma determinística: se parsea el JSON y se re-serializa con las claves ordenadas alfabéticamente de forma recursiva (objetos anidados incluidos), sin espacios. Ese string es el que se hashea con SHA-256.
Este es el error de integración más común. El servidor hashea el body con las claves ordenadas. Si tu cliente firma el JSON con las claves en el orden en que las escribiste (sin ordenar) pero el servidor las ordena, los hashes difieren y la firma falla. Serializá siempre con claves ordenadas recursivamente antes de hashear.
Firma
signature = HMAC_SHA256( canonical + "\n" + timestamp, signature_secret ) // en hex
timestampes epoch en milisegundos (Date.now()).- El mismo
timestampviaja en el headerx-signature-timestamp.
Ventana de validez y anti-replay
- El servidor acepta la firma solo si el
timestampestá dentro de una ventana de 5 minutos respecto de su hora actual. - No se aceptan timestamps en el futuro (tolerancia cero de skew hacia adelante). Si el reloj de tu servidor adelanta respecto del de PSP, las requests se rechazan. Mantené el reloj sincronizado (NTP).
- La protección anti-replay se basa en esa ventana de 5 minutos combinada con el header
x-idempotency.
Diagrama del flujo
Ejemplo de canonical string
Para un POST /api/v1/transfer con body {"to":"...","from":"...","amount":"100.00",...}:
POST
/api/v1/transfer
content-type:application/json
host:api.psp.stg.stickbank.net
x-idempotency:mi-clave-unica-123
content-type;host;x-idempotency
9f2c...e1 (SHA-256 hex del body con claves ordenadas)
1721822400000
(La tercera línea está vacía porque no hay query params.)