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
- Path relativo — quita la
base_urldel path completo para firmar solo la parte relativa. - Query — arma un objeto con los parámetros de query; el canonical los ordena y url-encodea.
- Idempotency — reutiliza
idempotency_keysi existe; si no, genera una a partir deDate.now(). Agrega el headerx-idempotency. Ver Idempotencia. - Body — parsea el JSON raw y lo re-serializa con
deterministicJsonStringify(claves ordenadas recursivamente), que es lo que el servidor hashea. - Headers — filtra los
x-signature-*, agregahost(derivado debase_url, sin esquema) yContent-Type: application/jsonsi hay body. El canonical usa solo el allowlist. - Firma — calcula
HMAC-SHA256(canonical + "\n" + timestamp)en hex con elDate.now()en milisegundos y haceupsertde los tres headersx-signature-*. - Logs — imprime
bodyString,bodyHash,canonicalysignatureen la consola de Postman para diagnóstico.
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.