Exemplo completo em Node
Este exemplo mínimo tem duas partes: um servidor que recebe webhooks e verifica a assinatura, e um cliente que obtém um token de app e chama GET /apps/v1/instalacao. Ele usa apenas a biblioteca padrão do Node (versão 24 ou superior), sem dependências. Os valores de segredo e identificadores são de exemplo.
O que você precisa
Seção intitulada “O que você precisa”- Node 24 ou superior.
- Para a parte do cliente em produção: identificador, segredo do aplicativo e identificador da instalação.
- Para a parte do receptor em produção: o segredo do webhook e uma URL https pública.
- Crie uma pasta com os três arquivos abaixo.
- Rode os testes com
node --test. - Em produção, defina
OWIEW_CLIENT_ID,OWIEW_CLIENT_SECRETeOWIEW_INSTALLATION_IDno ambiente do seu backend e chame as funções do cliente. Se precisar apontar para outros endereços, definaOWIEW_IAM_URLeOWIEW_APPS_API_URL. - Publique o receptor atrás de https e informe a URL à owiew.
receptor.mjs
Seção intitulada “receptor.mjs”O receptor lê os bytes brutos do corpo, verifica a assinatura e o relógio, e trata cada entrega uma única vez.
import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http";
const TOLERANCIA_S = 300; // 5 minutos para mais ou para menosconst LIMITE_CORPO = 256 * 1024; // o owiew nunca envia mais que 256 KiB
/** * Confere a assinatura de uma entrega do owiew. * Entradas: segredos (o atual e, durante a rotação, o anterior), cabeçalhos em * minúsculas, corpo bruto (Buffer) e o relógio em segundos (injetável no teste). * Saída: { ok: true } ou { ok: false, motivo }. */export function verificar(segredos, cabecalhos, corpo, agoraS = Math.floor(Date.now() / 1000)) { const ts = cabecalhos["x-owiew-timestamp"]; const assinatura = cabecalhos["x-owiew-signature"]; if (!/^\d{1,12}$/.test(ts ?? "") || !assinatura) return { ok: false, motivo: "cabecalho" }; if (Math.abs(agoraS - Number(ts)) > TOLERANCIA_S) return { ok: false, motivo: "relogio" };
// A assinatura cobre "<timestamp>.<corpo bruto>". Use os bytes recebidos, nunca o JSON reserializado. const recebidas = assinatura.split(",").map((p) => p.trim()).filter((p) => p.startsWith("v1=")); let confere = false; for (const segredo of segredos) { const esperada = createHmac("sha256", segredo).update(`${ts}.`).update(corpo).digest("hex"); for (const r of recebidas) { const a = Buffer.from(r.slice(3)); const b = Buffer.from(esperada); if (a.length === b.length && timingSafeEqual(a, b)) confere = true; // sem curto-circuito } } return confere ? { ok: true } : { ok: false, motivo: "assinatura" };}
/** * Cria o servidor que recebe as entregas. `tratar(evento)` roda uma vez por * X-Owiew-Delivery-Id; reenvios da mesma entrega recebem 200 sem repetir o efeito. * Em produção, troque o Set por uma tabela com índice único no id da entrega. */export function criarReceptor({ segredos, tratar, agora }) { const vistos = new Set(); return createServer((req, res) => { if (req.method !== "POST" || req.url !== "/webhook") { res.writeHead(404).end(); return; } const partes = []; let total = 0; req.on("data", (c) => { total += c.length; if (total > LIMITE_CORPO) req.destroy(); else partes.push(c); }); req.on("end", async () => { const corpo = Buffer.concat(partes); const r = verificar(segredos(), req.headers, corpo, agora?.()); if (!r.ok) { res.writeHead(401).end(); // não diga o motivo a quem chama return; } const id = req.headers["x-owiew-delivery-id"]; try { if (!vistos.has(id)) { await tratar(JSON.parse(corpo.toString("utf8")), id); vistos.add(id); // só marca depois de tratar com sucesso } res.writeHead(200).end(); } catch { res.writeHead(500).end(); // o owiew repete a entrega } }); });}cliente.mjs
Seção intitulada “cliente.mjs”O cliente autentica com client_secret_basic, recebe um token de 2 minutos e chama a rota. Em uso real, guarde o token em memória e peça outro quando expiraEm se aproximar.
/** Endereços lidos na hora da chamada; o padrão é a produção. */const iam = () => process.env.OWIEW_IAM_URL ?? "https://auth.owiew.com";const api = () => process.env.OWIEW_APPS_API_URL ?? "https://api.owiew.com";
/** * Obtém um token de app (vale 2 minutos) para UM projeto da instalação. * O segredo do app vai no cabeçalho Authorization (Basic), nunca no corpo nem na URL. */export async function obterToken({ clientId, segredo, instalacaoId, projetoId }) { const corpo = new URLSearchParams({ grant_type: "client_credentials", installation_id: instalacaoId }); if (projetoId) corpo.set("project_id", projetoId); const basic = Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(segredo)}`).toString("base64"); const resp = await fetch(`${iam()}/oauth2/token`, { method: "POST", headers: { authorization: `Basic ${basic}`, "content-type": "application/x-www-form-urlencoded" }, body: corpo, }); const json = await resp.json(); if (!resp.ok) throw new Error(`token recusado: ${json.error}`); return { token: json.access_token, expiraEm: Date.now() + (json.expires_in - 10) * 1000 };}
/** Chama GET /apps/v1/instalacao. Lança erro com o código do problema em caso de falha. */export async function lerInstalacao(token) { const resp = await fetch(`${api()}/apps/v1/instalacao`, { headers: { authorization: `Bearer ${token}` } }); const json = await resp.json(); if (!resp.ok) throw new Error(`${resp.status} ${json.codigo ?? ""}`.trim()); return json;}Uso em produção:
import { obterToken, lerInstalacao } from "./cliente.mjs";
const { token } = await obterToken({ clientId: process.env.OWIEW_CLIENT_ID, segredo: process.env.OWIEW_CLIENT_SECRET, instalacaoId: process.env.OWIEW_INSTALLATION_ID,});console.log(await lerInstalacao(token));app.test.mjs
Seção intitulada “app.test.mjs”Os testes usam servidores falsos em memória, sem rede externa e sem credencial real. Eles precisam de IPv6 de retorno (::1) disponível na máquina.
import assert from "node:assert/strict";import { createHmac } from "node:crypto";import { createServer, request } from "node:http";import { test } from "node:test";import { obterToken, lerInstalacao } from "./cliente.mjs";import { criarReceptor, verificar } from "./receptor.mjs";
const SEGREDO = "segredo-de-exemplo";const corpo = Buffer.from(JSON.stringify({ tipo: "instalacao.teste.v1", dados: { mensagem: "evento de teste" } }));const assinar = (seg, ts, c) => createHmac("sha256", seg).update(`${ts}.`).update(c).digest("hex");const agora = 1_800_000_000;const cab = (ts, sig) => ({ "x-owiew-timestamp": String(ts), "x-owiew-signature": sig });
test("aceita assinatura válida e as duas durante a rotação", () => { const nova = `v1=${assinar("nova", agora, corpo)}, v1=${assinar("antiga", agora, corpo)}`; assert.equal(verificar([SEGREDO], cab(agora, `v1=${assinar(SEGREDO, agora, corpo)}`), corpo, agora).ok, true); assert.equal(verificar(["antiga"], cab(agora, nova), corpo, agora).ok, true);});
test("bate com o vetor publicado na documentação", () => { const c = Buffer.from('{"tipo":"instalacao.teste.v1"}'); assert.equal(assinar("segredo-de-exemplo", 1800000000, c), "328f40657fa58788f8633269a9d7c02fed3dfc84ef8f127f41c9e437fbc46807");});
test("recusa corpo alterado, segredo errado, relógio fora e cabeçalho ausente", () => { const sig = `v1=${assinar(SEGREDO, agora, corpo)}`; assert.equal(verificar([SEGREDO], cab(agora, sig), Buffer.from("{}"), agora).motivo, "assinatura"); assert.equal(verificar(["outro"], cab(agora, sig), corpo, agora).motivo, "assinatura"); assert.equal(verificar([SEGREDO], cab(agora - 301, `v1=${assinar(SEGREDO, agora - 301, corpo)}`), corpo, agora).motivo, "relogio"); assert.equal(verificar([SEGREDO], {}, corpo, agora).motivo, "cabecalho");});
/** POST sem literal de endereço: o Node usa o nome local por padrão. */function postar(porta, cabecalhos, c) { return new Promise((ok, falha) => { const r = request({ port: porta, path: "/webhook", method: "POST", headers: cabecalhos }, (res) => { res.resume(); res.on("end", () => ok(res.statusCode)); }); r.on("error", falha); r.end(c); });}
test("servidor: 200 válido, idempotente por delivery id, 401 inválido", async () => { const tratados = []; const s = criarReceptor({ segredos: () => [SEGREDO], tratar: (e, id) => tratados.push(id), agora: () => agora }); await new Promise((r) => s.listen(0, r)); const porta = s.address().port; const h = { ...cab(agora, `v1=${assinar(SEGREDO, agora, corpo)}`), "x-owiew-delivery-id": "entrega-1" }; assert.equal(await postar(porta, h, corpo), 200); assert.equal(await postar(porta, h, corpo), 200); // reenvio assert.deepEqual(tratados, ["entrega-1"]); assert.equal(await postar(porta, { ...h, "x-owiew-signature": "v1=00" }, corpo), 401); s.close();});
test("cliente: troca credenciais por token e chama a rota", async () => { const visto = {}; const iam = createServer((req, res) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { visto.auth = req.headers.authorization; visto.corpo = b; res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ access_token: "token-de-exemplo", expires_in: 120 })); }); }); const api = createServer((req, res) => { visto.bearer = req.headers.authorization; res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ installation_id: "ins_EXEMPLO", escopos: ["instalacao:read"] })); }); await Promise.all([new Promise((r) => iam.listen(0, "::1", r)), new Promise((r) => api.listen(0, "::1", r))]); process.env.OWIEW_IAM_URL = `http://[::1]:${iam.address().port}`; process.env.OWIEW_APPS_API_URL = `http://[::1]:${api.address().port}`; const t = await obterToken({ clientId: "app_EXEMPLO", segredo: "owa_EXEMPLO", instalacaoId: "ins_EXEMPLO" }); const inst = await lerInstalacao(t.token); assert.equal(inst.installation_id, "ins_EXEMPLO"); assert.match(visto.auth, /^Basic /); assert.match(visto.corpo, /grant_type=client_credentials/); assert.equal(visto.bearer, "Bearer token-de-exemplo"); iam.close(); api.close();});Resultado esperado
Seção intitulada “Resultado esperado”node --test termina com 5 testes passando: assinatura válida (inclusive durante a rotação), vetor da documentação, recusas, receptor idempotente e cliente.
Se algo der errado
Seção intitulada “Se algo der errado”- A assinatura nunca confere em produção. Confirme que você usa o corpo bruto e o segredo certo, e que nenhum middleware leu o corpo antes.
- Todas as entregas chegam com
401. Verifique o relógio do servidor. A tolerância é de 5 minutos. - O teste do cliente falha ao abrir a porta. A máquina não tem
::1. Ajuste a interface do teste para o endereço de retorno disponível. invalid_clientno token. O segredo está errado, vencido ou revogado. Emita outro.invalid_grantno token. A instalação não existe, foi desinstalada ou oproject_idnão é de um projeto consentido.