HMAC em Node.js

Receita crypto + fetch nativo (Node 18+) ou axios

HMAC-SHA256 em Node.js (CommonJS + ESM + axios)

Receita reutilizável em Node 18+ usando o módulo nativo crypto e fetch embutido. Versões anteriores podem usar axios sem mudar a assinatura.

Pré-requisitos

1. Versão CommonJS — Node 18+ com fetch nativo

const crypto = require('node:crypto');

const API_KEY    = 'svp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const API_SECRET = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

function signRequest(method, urlOrPath, body = '') {
    const ts = String(Math.floor(Date.now() / 1000));

    // new URL(path, base) aceita absoluta OU relativa -- extraimos
    // sempre pathname + search, identico ao REQUEST_URI no servidor.
    const u     = new URL(urlOrPath, 'http://placeholder.local');
    const pathQ = u.pathname + u.search;

    const payload = `${ts}.${method.toUpperCase()} ${pathQ}\n${body}`;

    const signature = crypto
        .createHmac('sha256', API_SECRET)
        .update(payload, 'utf8')
        .digest('hex');   // hex lowercase por padrao no Node

    return {
        'X-API-Key':   API_KEY,
        'X-Timestamp': ts,
        'X-Signature': signature,
    };
}

// Uso com fetch nativo (Node 18+):
(async () => {
    const url     = 'https://api.sivoe.med.br/v1/ping';
    const headers = signRequest('GET', url);

    const resp = await fetch(url, { headers });
    const data = await resp.json();

    console.log(resp.status, data);
})();

Saída esperada

200 {
  data: { ok: true, service: 'Sivoe API Gateway', version: 'v1', ... },
  meta: { request_id: '...', duracao_ms: 12 },
  error: null
}

2. Versão ESM (import)

import { createHmac } from 'node:crypto';

const API_KEY    = 'svp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const API_SECRET = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

export function signRequest(method, urlOrPath, body = '') {
    const ts    = String(Math.floor(Date.now() / 1000));
    const u     = new URL(urlOrPath, 'http://placeholder.local');
    const pathQ = u.pathname + u.search;

    const payload = `${ts}.${method.toUpperCase()} ${pathQ}\n${body}`;
    const signature = createHmac('sha256', API_SECRET)
        .update(payload, 'utf8')
        .digest('hex');

    return {
        'X-API-Key':   API_KEY,
        'X-Timestamp': ts,
        'X-Signature': signature,
    };
}

// package.json: { "type": "module" }  -- ou arquivo .mjs
const url     = 'https://api.sivoe.med.br/v1/ping';
const headers = signRequest('GET', url);
const resp    = await fetch(url, { headers });
console.log(resp.status, await resp.json());

3. POST com body JSON

const url  = 'https://api.sivoe.med.br/v1/ping';

// Serialize ANTES de assinar -- garante igualdade byte a byte
// entre o que voce assinou e o que vai pela rede.
const body = JSON.stringify({ hello: 'world', numero: 42 });

const headers = {
    ...signRequest('POST', url, body),
    'Content-Type': 'application/json',
};

const resp = await fetch(url, {
    method:  'POST',
    headers,
    body,                 // exatamente a string que foi assinada
});
console.log(resp.status, await resp.json());

4. Alternativa com axios (Node 14/16/18+)

const axios = require('axios');
const crypto = require('node:crypto');

// signRequest() identico a secao 1

(async () => {
    const url     = 'https://api.sivoe.med.br/v1/ping';
    const headers = signRequest('GET', url);

    try {
        const resp = await axios.get(url, { headers, timeout: 10000 });
        console.log(resp.status, resp.data);
    } catch (err) {
        // axios eleva 4xx/5xx -- inspecione err.response
        console.error(err.response?.status, err.response?.data);
    }
})();
axios e transformRequest: axios por padrão transforma data: com JSON.stringify(). Se você assinar uma string e passar como objeto, a transformação muda o body. Para POSTs assinados, sempre passe a string já serializada em data.

Pegadinhas comuns

Troubleshooting

Erro recebidoCausa provável
AUTH_INVALID_SIGNATURE Body passado como objeto (não-string); query string fora do pathQ; Date.now() sem dividir por 1000
AUTH_TIMESTAMP_EXPIRED Relógio fora ±300s — sincronize via NTP
fetch is not defined Node < 18 — atualize ou use node-fetch/axios

Catálogo completo: Códigos de erro.