// Merchant Integration · signing toolkit for Node.js 18+ (built-in crypto only). // Signs requests you send to the Merchant API and verifies the Wallet API requests we send to you. // Spec: Integration Guide › Authentication. Self-test against the published test vector: node merchant-sign.js 'use strict'; const crypto = require('crypto'); const HEADER_MERCHANT_CODE = 'X-Merchant-Code'; const HEADER_REQUEST_ID = 'X-Request-Id'; const HEADER_TIMESTAMP = 'X-Timestamp'; const HEADER_SIGNATURE = 'X-Signature'; const TIMESTAMP_WINDOW_MS = 300000; // |now - X-Timestamp| must be within 300 s const OK = 'OK'; const INVALID_SIGNATURE = 'INVALID_SIGNATURE'; const TIMESTAMP_EXPIRED = 'TIMESTAMP_EXPIRED'; /** Lower-case hex SHA-256 of the exact body bytes (Buffer or string as UTF-8). */ function bodySha256(rawBody) { return crypto.createHash('sha256').update(rawBody).digest('hex'); } function stringToSign(method, path, merchantCode, timestamp, requestId, rawBody) { return [method.toUpperCase(), path, merchantCode, String(timestamp), requestId, bodySha256(rawBody)].join('\n'); } /** X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign). */ function sign(secretKey, method, path, merchantCode, timestamp, requestId, rawBody) { return crypto.createHmac('sha256', secretKey) .update(stringToSign(method, path, merchantCode, timestamp, requestId, rawBody), 'utf8') .digest('hex'); } /** A fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included. */ function newRequestId() { return crypto.randomBytes(16).toString('hex'); } /** Headers for one signed request. Send exactly `rawBody` — the bytes that were signed. */ function signedHeaders(merchantCode, secretKey, method, path, rawBody, nowMs, requestId) { const ts = String(nowMs === undefined ? Date.now() : nowMs); const rid = requestId || newRequestId(); return { 'Content-Type': 'application/json; charset=utf-8', [HEADER_MERCHANT_CODE]: merchantCode, [HEADER_REQUEST_ID]: rid, [HEADER_TIMESTAMP]: ts, [HEADER_SIGNATURE]: sign(secretKey, method, path, merchantCode, ts, rid, rawBody), }; } /** * Check one incoming request (our Wallet API calls to you). Returns OK, INVALID_SIGNATURE or TIMESTAMP_EXPIRED, * usable as the envelope code. `headers` may be Node's lower-cased req.headers. Verify the raw body BEFORE JSON.parse. */ function verify(secretKey, merchantCode, headers, method, path, rawBody, nowMs) { const h = {}; for (const k of Object.keys(headers)) h[k.toLowerCase()] = String(headers[k]); const code = h['x-merchant-code'] || ''; const rid = h['x-request-id'] || ''; const ts = h['x-timestamp'] || ''; const sig = (h['x-signature'] || '').toLowerCase(); if (!code || !rid || !ts || !sig || code !== merchantCode) return INVALID_SIGNATURE; if (!/^\d+$/.test(ts)) return TIMESTAMP_EXPIRED; const now = nowMs === undefined ? Date.now() : nowMs; if (Math.abs(now - Number(ts)) > TIMESTAMP_WINDOW_MS) return TIMESTAMP_EXPIRED; const want = Buffer.from(sign(secretKey, method, path, code, ts, rid, rawBody)); const got = Buffer.from(sig); return want.length === got.length && crypto.timingSafeEqual(want, got) ? OK : INVALID_SIGNATURE; } /** A response body: {code, message, data}. Business results are always HTTP 200. */ function envelope(code, message, data) { const out = { code, message: message || code }; if (data !== undefined) out.data = data; return JSON.stringify(out); } module.exports = { HEADER_MERCHANT_CODE, HEADER_REQUEST_ID, HEADER_TIMESTAMP, HEADER_SIGNATURE, TIMESTAMP_WINDOW_MS, OK, INVALID_SIGNATURE, TIMESTAMP_EXPIRED, bodySha256, stringToSign, sign, newRequestId, signedHeaders, verify, envelope, }; if (require.main === module) { const assert = require('assert'); const key = 'test-secret-key-0001', path = '/wallet/debit', code = 'M0001', ts = '1792000000000', rid = 'req-0001'; const body = '{"txnId":"T7K2M9QXH4ZP8WNA","amount":"10.00"}'; assert.strictEqual(bodySha256(body), 'b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8'); const sig = sign(key, 'POST', path, code, ts, rid, body); assert.strictEqual(sig, 'e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589'); const hdr = { 'x-merchant-code': code, 'x-request-id': rid, 'x-timestamp': ts, 'x-signature': sig }; const now = 1792000000000; assert.strictEqual(verify(key, code, hdr, 'POST', path, body, now), OK); assert.strictEqual(verify(key, code, hdr, 'POST', path, body.replace('10.00', '10.01'), now), INVALID_SIGNATURE); assert.strictEqual(verify(key, 'M0002', hdr, 'POST', path, body, now), INVALID_SIGNATURE); assert.strictEqual(verify(key, code, hdr, 'POST', path, body, now + TIMESTAMP_WINDOW_MS + 1), TIMESTAMP_EXPIRED); assert.strictEqual(verify(key, code, signedHeaders(code, key, 'POST', path, body, now, rid), 'POST', path, body, now), OK); console.log('merchant-sign.js: self-test passed', sig); }