Saltar al contenido principal

Script de Postman

Este es el pre-request script que genera la firma automáticamente en cada request. Está alineado con el CanonicalRequestManager + SignManager del canal PSP, así que produce exactamente el canonical string que el servidor espera.

Pegalo en Pre-request Script de la colección (para que aplique a todas las requests) y configurá las variables base_url, signature_key y signature_secret (ver Setup de Postman).

/**
* Firma requests PSP API (psp-api)
* Alineado con CanonicalRequestManager + SignManager del canal.
*/

const ALLOWED_HEADERS = new Set(["content-type", "host", "x-idempotency"]);
const SIGNATURE_HEADERS = new Set([
"x-signature-key",
"x-signature-value",
"x-signature-timestamp",
]);

const normalizePath = (path) =>
path.replace(/\/+/g, "/").replace(/\/$/, "").trim() || "/";

const normalizeHeaders = (headers) => {
const out = {};
const normalized = headers
.filter((h) => h && h.key)
.map((h) => ({ key: h.key.toLowerCase(), value: String(h.value || "").trim() }))
.filter((h) => ALLOWED_HEADERS.has(h.key))
.sort((a, b) => a.key.localeCompare(b.key));

normalized.forEach(({ key, value }) => {
if (key === "host") {
out[key] = value.replace(/^https?:\/\//, "");
} else {
out[key] = value;
}
});

return out;
};

const sortObject = (obj) => {
const out = {};
Object.keys(obj || {})
.sort()
.forEach((k) => {
out[k] = obj[k];
});
return out;
};

const sortObjectKeys = (obj) => {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
const sorted = {};
Object.keys(obj)
.sort()
.forEach((key) => {
sorted[key] = sortObjectKeys(obj[key]);
});
return sorted;
};

const deterministicJsonStringify = (obj) => {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
return JSON.stringify(sortObjectKeys(obj));
};

const hashSHA256 = (content) =>
CryptoJS.SHA256(content).toString(CryptoJS.enc.Hex);

const hmac = (content, secret) =>
CryptoJS.HmacSHA256(content, secret).toString(CryptoJS.enc.Hex);

const buildCanonical = ({ method, path, headers, query, bodyString }) => {
const normalizedPath = normalizePath(path);
const normalizedHeaders = normalizeHeaders(headers);
const sortedQuery = sortObject(query || {});

const queryString = Object.entries(sortedQuery)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v ?? ""))}`)
.join("&");

const headersString = Object.entries(normalizedHeaders)
.map(([k, v]) => `${k}:${v}`)
.join("\n");

const signedHeaders = Object.keys(normalizedHeaders).join(";");
const bodyHash = bodyString ? hashSHA256(bodyString) : "";

const canonicalBase = [
method.toUpperCase(),
normalizedPath,
queryString,
headersString,
signedHeaders,
bodyHash,
].join("\n");

return { canonicalBase, signedHeaders, bodyHash };
};

// -------- Request actual --------
const req = pm.request;
const method = req.method;

const baseUrl = (
pm.environment.get("base_url") ||
pm.collectionVariables.get("base_url") ||
""
).replace(/\/+$/, "");

const fullPath = req.url.getPath();
const path = fullPath.startsWith(baseUrl)
? fullPath.slice(baseUrl.length) || "/"
: fullPath;

const queryObj = {};
req.url.query.all().forEach(({ key, value }) => {
if (key) queryObj[key] = value;
});

// Idempotency (mínimo 5 caracteres en el backend)
let idempotency =
pm.variables.get("idempotency_key") ||
pm.environment.get("idempotency_key") ||
pm.collectionVariables.get("idempotency_key");

if (!idempotency) {
idempotency = `${Date.now()}`;
pm.environment.set("idempotency_key", idempotency);
}
pm.request.headers.upsert({ key: "x-idempotency", value: idempotency });

// Body canónico (claves ordenadas recursivamente)
let bodyString = "";
if (method !== "GET" && req.body) {
if (req.body.mode === "raw" && req.body.raw) {
const raw = req.body.raw.trim();
if (raw) {
const rawObj = JSON.parse(raw);
bodyString = deterministicJsonStringify(rawObj);
}
} else if (req.body.mode === "urlencoded") {
bodyString = req.body.urlencoded.toString();
} else if (req.body.mode === "formdata") {
const entries = (req.body.formdata.all() || []).map(
({ key, value }) => `${key}=${value}`
);
bodyString = entries.join("&");
}
}

const signatureKey =
pm.variables.get("signature_key") ||
pm.environment.get("signature_key") ||
pm.collectionVariables.get("signature_key");

const signatureSecret =
pm.variables.get("signature_secret") ||
pm.environment.get("signature_secret") ||
pm.collectionVariables.get("signature_secret");

if (!signatureKey || !signatureSecret) {
throw new Error("Faltan signature_key o signature_secret en variables");
}

// Host para el canonical (sin http/https)
const urlHost = baseUrl.replace(/^https?:\/\//, "");
const headersArr = req.headers
.all()
.filter((h) => h && h.key && !SIGNATURE_HEADERS.has(h.key.toLowerCase()));

if (!headersArr.some((h) => h.key?.toLowerCase() === "host")) {
headersArr.push({ key: "host", value: urlHost });
}

if (!headersArr.some((h) => h.key?.toLowerCase() === "content-type") && bodyString) {
headersArr.push({ key: "Content-Type", value: "application/json" });
}

const { canonicalBase, signedHeaders, bodyHash } = buildCanonical({
method,
path,
headers: headersArr,
query: queryObj,
bodyString,
});

const ts = Date.now();
const canonicalWithTs = `${canonicalBase}\n${ts}`;
const signature = hmac(canonicalWithTs, signatureSecret);

pm.variables.set("signature_value", signature);
pm.variables.set("timestamp", ts);
pm.variables.set("signed_headers", signedHeaders);
pm.variables.set("body_hash", bodyHash);

pm.request.headers.upsert({ key: "x-signature-key", value: signatureKey });
pm.request.headers.upsert({ key: "x-signature-value", value: signature });
pm.request.headers.upsert({ key: "x-signature-timestamp", value: String(ts) });

console.log("[bodyString]", bodyString);
console.log("[bodyHash]", bodyHash);
console.log("[canonical]", canonicalWithTs);
console.log("[signature]", signature);

Qué hace, paso a paso

  1. Path relativo — quita la base_url del path completo para firmar solo la parte relativa.
  2. Query — arma un objeto con los parámetros de query; el canonical los ordena y url-encodea.
  3. Idempotency — reutiliza idempotency_key si existe; si no, genera una a partir de Date.now(). Agrega el header x-idempotency. Ver Idempotencia.
  4. Body — parsea el JSON raw y lo re-serializa con deterministicJsonStringify (claves ordenadas recursivamente), que es lo que el servidor hashea.
  5. Headers — filtra los x-signature-*, agrega host (derivado de base_url, sin esquema) y Content-Type: application/json si hay body. El canonical usa solo el allowlist.
  6. Firma — calcula HMAC-SHA256(canonical + "\n" + timestamp) en hex con el Date.now() en milisegundos y hace upsert de los tres headers x-signature-*.
  7. Logs — imprime bodyString, bodyHash, canonical y signature en la consola de Postman para diagnóstico.
Timestamp en milisegundos

El script usa Date.now() (milisegundos), que es lo que el servidor espera. Ignorá cualquier referencia a "timestamp en segundos" de documentación anterior: el contrato es en milisegundos.

Portarlo a tu backend

Si vas a firmar desde tu servidor en vez de Postman, replicá la misma lógica en tu lenguaje. Los puntos que no podés cambiar: allowlist de headers (content-type, host, x-idempotency), orden recursivo de claves del body antes del SHA-256, timestamp en ms, y HMAC sobre canonical + "\n" + timestamp. El contrato completo está en Firma de requests.