Готовый Pre-request Script для Postman: считает подпись v2 для реального запроса и проставляет 4 заголовка — X-Client-Id, X-Timestamp, X-Nonce, X-Signature. Алгоритм и спецификация — Подпись запросов.
const CLIENT_SECRET = '...' (или замените на pm.environment.get('clientSecret'), если держите секрет в Environment).X-Client-Id — без него скрипт падает (нечего подписывать от имени мерчанта).После этого при каждой отправке к запросу автоматически добавятся 4 заголовка подписи.
Body → raw). Для form-data / x-www-form-urlencoded подпись недоступна: байтовое представление тела (boundary, порядок полей) Postman формирует сам непосредственно перед отправкой, и из Pre-request Script оно недоступно.crypto/Buffer, поэтому используются crypto-js (HMAC-SHA256, SHA256) и uuid (nonce) — оба доступны через require(...) из коробки, ставить ничего не нужно.{{baseUrl}}, {{var}} и т.п. резолвятся (pm.variables.replaceIn) и для URL, и для body — подпись считается от уже подставленных значений.stringToSign-компоненты, bodyHash, signature, проставленные заголовки) пишутся в Postman Console (View → Show Postman Console, Cmd+Alt+C) — удобно при отладке несовпадающих подписей.lastSignature, lastNonce, lastTimestamp.// =====================================================================
// IDS v2 Signature — Postman Pre-request Script
//
// Что делает скрипт:
// 1. Подписывает РЕАЛЬНЫЙ запрос Postman: method + path + query + body.
// 2. body хэшируется в СЫРОМ виде (sha256 от тех же байт, что уйдут в
// запрос) — check-access хэширует полученные байты как есть, без
// нормализации. Подписываем и отправляем одно и то же.
// 3. clientId — из заголовка X-Client-Id; clientSecret — захардкожен.
// =====================================================================
const CryptoJS = require('crypto-js');
const { v4: uuidv4 } = require('uuid');
const CLIENT_SECRET = '';
// ---------------------------------------------------------------------
// Path normalization
// ---------------------------------------------------------------------
function isAllowedPathByte(c) {
return (
(c >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z
(c >= 0x30 && c <= 0x39) || // 0-9
c === 0x2F || c === 0x2D || c === 0x5F // / - _
);
}
function normalizePath(rawPath) {
if (!rawPath) return '/';
let p = String(rawPath);
const sch = p.indexOf('://');
if (sch >= 0) {
const rest = p.substring(sch + 3);
const slash = rest.indexOf('/');
p = slash >= 0 ? rest.substring(slash) : '/';
}
const qi = p.indexOf('?'); if (qi >= 0) p = p.substring(0, qi);
const fi = p.indexOf('#'); if (fi >= 0) p = p.substring(0, fi);
if (p === '') return '/';
if (!p.startsWith('/')) throw new Error('v2 sign: path must start with /');
for (const seg of p.split('/')) {
if (seg === '.' || seg === '..') {
throw new Error('v2 sign: path contains dotted segment: ' + seg);
}
}
for (let i = 0; i < p.length; i++) {
if (!isAllowedPathByte(p.charCodeAt(i))) {
throw new Error('v2 sign: invalid path char at ' + i + ': ' + JSON.stringify(p[i]));
}
}
while (p.includes('//')) p = p.replace(/\/\//g, '/');
if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
return p || '/';
}
// ---------------------------------------------------------------------
// Query canonicalization
// ---------------------------------------------------------------------
function isUnreservedByte(b) {
return (
(b >= 0x41 && b <= 0x5A) ||
(b >= 0x61 && b <= 0x7A) ||
(b >= 0x30 && b <= 0x39) ||
b === 0x2D || b === 0x2E || b === 0x5F || b === 0x7E
);
}
function utf8Bytes(str) {
const out = [];
for (let i = 0; i < str.length; i++) {
let c = str.charCodeAt(i);
if (c < 0x80) out.push(c);
else if (c < 0x800) out.push(0xC0 | (c >> 6), 0x80 | (c & 0x3F));
else if (c >= 0xD800 && c <= 0xDBFF) {
const lo = str.charCodeAt(++i);
const cp = 0x10000 + ((c - 0xD800) << 10) + (lo - 0xDC00);
out.push(0xF0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3F),
0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
} else out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F));
}
return out;
}
function bytesToUtf8(bytes) {
let out = '', i = 0;
while (i < bytes.length) {
const b1 = bytes[i++];
if (b1 < 0x80) { out += String.fromCharCode(b1); continue; }
if ((b1 & 0xE0) === 0xC0) {
const b2 = bytes[i++];
out += String.fromCharCode(((b1 & 0x1F) << 6) | (b2 & 0x3F));
} else if ((b1 & 0xF0) === 0xE0) {
const b2 = bytes[i++], b3 = bytes[i++];
out += String.fromCharCode(((b1 & 0x0F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F));
} else if ((b1 & 0xF8) === 0xF0) {
const b2 = bytes[i++], b3 = bytes[i++], b4 = bytes[i++];
const cp = ((b1 & 0x07) << 18) | ((b2 & 0x3F) << 12) | ((b3 & 0x3F) << 6) | (b4 & 0x3F);
const hi = 0xD800 + ((cp - 0x10000) >> 10);
const lo = 0xDC00 + ((cp - 0x10000) & 0x3FF);
out += String.fromCharCode(hi) + String.fromCharCode(lo);
}
}
return out;
}
function percentEncode(str) {
const bytes = utf8Bytes(str);
let out = '';
for (const b of bytes) {
if (isUnreservedByte(b)) out += String.fromCharCode(b);
else out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
}
return out;
}
function unhexNibble(c) {
if (c >= 0x30 && c <= 0x39) return c - 0x30;
if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10;
if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10;
return -1;
}
function percentDecode(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c === 0x25) {
if (i + 2 >= str.length) throw new Error('invalid percent-encoding');
const hi = unhexNibble(str.charCodeAt(i + 1));
const lo = unhexNibble(str.charCodeAt(i + 2));
if (hi < 0 || lo < 0) throw new Error('invalid percent-encoding');
bytes.push((hi << 4) | lo);
i += 2;
} else if (c === 0x2B) bytes.push(0x20);
else if (c > 0x7F) for (const b of utf8Bytes(str[i])) bytes.push(b);
else bytes.push(c);
}
return bytesToUtf8(bytes);
}
function canonicalizeQuery(rawQuery) {
if (!rawQuery) return '';
let q = String(rawQuery);
if (q.startsWith('?')) q = q.substring(1);
const hi = q.indexOf('#');
if (hi >= 0) q = q.substring(0, hi);
if (q === '') return '';
const parts = q.split('&');
const pairs = [];
for (const part of parts) {
if (part === '') throw new Error("v2 sign: empty fragment between '&'");
const eq = part.indexOf('=');
const rk = eq >= 0 ? part.substring(0, eq) : part;
const rv = eq >= 0 ? part.substring(eq + 1) : '';
if (rk === '') throw new Error('v2 sign: empty query key');
pairs.push({
encKey: percentEncode(percentDecode(rk)),
encVal: percentEncode(percentDecode(rv)),
});
}
pairs.sort((a, b) => {
if (a.encKey !== b.encKey) return a.encKey < b.encKey ? -1 : 1;
return a.encVal === b.encVal ? 0 : (a.encVal < b.encVal ? -1 : 1);
});
return pairs.map(p => p.encKey + '=' + p.encVal).join('&');
}
// ---------------------------------------------------------------------
// Sign
// ---------------------------------------------------------------------
function sha256Hex(s) {
return CryptoJS.SHA256(CryptoJS.enc.Utf8.parse(s || '')).toString(CryptoJS.enc.Hex);
}
function hmacHex(payload, secret) {
return CryptoJS.HmacSHA256(
CryptoJS.enc.Utf8.parse(payload),
CryptoJS.enc.Utf8.parse(secret),
).toString(CryptoJS.enc.Hex);
}
function signV2() {
// ── credentials ──────────────────────────────────────────────────
const clientId =
pm.request.headers.get('X-Client-Id') ||
pm.request.headers.get('x-client-id');
if (!clientId) {
throw new Error('IDS v2 sign: заголовок X-Client-Id не найден');
}
const secret = CLIENT_SECRET;
// ── outer request ────────────────────────────────────────────────
const outerMethod = (pm.request.method || 'GET').toUpperCase();
const outerUrl = pm.variables.replaceIn(pm.request.url.toString());
let outerPath = outerUrl, outerQuery = '';
const qIdx = outerPath.indexOf('?');
if (qIdx >= 0) {
outerQuery = outerPath.substring(qIdx + 1);
outerPath = outerPath.substring(0, qIdx);
}
let outerBody = '';
if (pm.request.body && pm.request.body.mode === 'raw' && pm.request.body.raw) {
outerBody = pm.variables.replaceIn(pm.request.body.raw);
}
// Подписываем РЕАЛЬНЫЙ запрос, который шлёт Postman.
const method = outerMethod, path = outerPath, query = outerQuery;
const bodyBytes = outerBody;
// ── body хэшируется в СЫРОМ виде ────────────────────────────────
// check-access хэширует полученные байты тела как есть. Подписываем и
// отправляем одно и то же; никакой нормализации (компактизации) — она
// сломала бы подпись.
const bodyForHash = bodyBytes;
// ── канонизация ─────────────────────────────────────────────────
const canonPath = normalizePath(path);
const canonQuery = canonicalizeQuery(query);
const bodyHash = sha256Hex(bodyForHash);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = uuidv4();
const stringToSign = [
method, canonPath, canonQuery, bodyHash, clientId, timestamp, nonce,
].join('\n');
const signature = hmacHex(stringToSign, secret);
// ── debug ────────────────────────────────────────────────────────
console.log('--- IDS v2 sign ---');
console.log('method :', method);
console.log('path :', canonPath);
console.log('canonQuery :', canonQuery);
console.log('bodyHash :', bodyHash);
console.log('bodyForHash[..80]:', String(bodyForHash).substring(0, 80));
console.log('clientId :', clientId);
console.log('timestamp :', timestamp);
console.log('nonce :', nonce);
console.log('signature :', signature);
// ── headers ──────────────────────────────────────────────────────
pm.request.headers.upsert({ key: 'X-Client-Id', value: clientId });
pm.request.headers.upsert({ key: 'X-Timestamp', value: timestamp });
pm.request.headers.upsert({ key: 'X-Nonce', value: nonce });
pm.request.headers.upsert({ key: 'X-Signature', value: signature });
pm.environment.set('lastSignature', signature);
pm.environment.set('lastNonce', nonce);
pm.environment.set('lastTimestamp', timestamp);
}
signV2();