OTL ShoesAPI
Exemplos em
Menu da documentação

Conferir a assinatura

Como o seu sistema sabe que um webhook veio mesmo da OTL e não foi alterado no caminho.

O seu endereço de webhook é público: qualquer um que o descubra pode mandar um POST para ele. A assinatura é o que separa um aviso da OTL de uma requisição forjada. Não processe um webhook sem conferi-la.

Como funciona

Cada endereço tem um segredo (otl_whsec_…), mostrado uma única vez no painel quando o webhook é cadastrado. Toda entrega traz o cabeçalho:

X-OTL-Assinatura: t=1790701380,v1=5f3c…a91e
  • t é o instante do envio, em segundos (Unix).
  • v1 é o HMAC-SHA256, em hexadecimal, do texto t + . + corpo cru da requisição, usando o segredo como chave.

Para conferir:

  1. Separe t e os valores v1 do cabeçalho.
  2. Calcule HMAC-SHA256(segredo, t + "." + corpo).
  3. Compare com o v1 recebido, com uma comparação de tempo constante.
  4. Recuse se t tiver mais de 5 minutos — é o que impede alguém de reenviar uma entrega antiga que tenha capturado.
Use o corpo cru

A conta é feita sobre os bytes exatos que chegaram. Se o seu framework já transformou o JSON em objeto e você o serializar de novo, a ordem das chaves ou os espaços podem mudar, e a assinatura não confere. Leia o corpo como texto antes de interpretá-lo.

Exemplos

Node.js

import crypto from 'node:crypto';

export function assinaturaValida(corpoCru, cabecalho, segredo) {
  const partes = String(cabecalho ?? '').split(',');
  const t = partes.find(p => p.startsWith('t='))?.slice(2);
  const recebidas = partes.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
  if (!t || !recebidas.length) return false;

  // Entrega antiga: recusa.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const esperada = crypto.createHmac('sha256', segredo).update(`${t}.${corpoCru}`).digest('hex');
  return recebidas.some(
    v => v.length === esperada.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(esperada)),
  );
}

// Express: o corpo precisa chegar CRU nesta rota.
app.post('/webhook/otl', express.raw({ type: 'application/json' }), (req, res) => {
  const corpo = req.body.toString('utf8');
  if (!assinaturaValida(corpo, req.get('X-OTL-Assinatura'), process.env.OTL_WEBHOOK_SEGREDO)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // responde primeiro
  fila.adicionar(JSON.parse(corpo)); // processa depois
});

PHP

<?php
$corpo = file_get_contents('php://input'); // corpo cru
$cabecalho = $_SERVER['HTTP_X_OTL_ASSINATURA'] ?? '';
$segredo = getenv('OTL_WEBHOOK_SEGREDO');

$t = null;
$recebidas = [];
foreach (explode(',', $cabecalho) as $parte) {
    if (str_starts_with($parte, 't=')) $t = substr($parte, 2);
    if (str_starts_with($parte, 'v1=')) $recebidas[] = substr($parte, 3);
}

$valida = false;
if ($t !== null && abs(time() - (int) $t) <= 300) {
    $esperada = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
    foreach ($recebidas as $v) {
        if (hash_equals($esperada, $v)) $valida = true;
    }
}

if (!$valida) {
    http_response_code(401);
    exit;
}

http_response_code(200);
$evento = json_decode($corpo, true);
// guarde o evento e processe depois

Python

import hashlib, hmac, os, time

def assinatura_valida(corpo_cru: bytes, cabecalho: str, segredo: str) -> bool:
    partes = (cabecalho or "").split(",")
    t = next((p[2:] for p in partes if p.startswith("t=")), None)
    recebidas = [p[3:] for p in partes if p.startswith("v1=")]
    if not t or not recebidas:
        return False
    if abs(time.time() - int(t)) > 300:  # entrega antiga
        return False
    esperada = hmac.new(segredo.encode(), f"{t}.".encode() + corpo_cru, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(esperada, v) for v in recebidas)

# Flask
@app.post("/webhook/otl")
def webhook_otl():
    corpo = request.get_data()  # bytes crus
    if not assinatura_valida(corpo, request.headers.get("X-OTL-Assinatura"), os.environ["OTL_WEBHOOK_SEGREDO"]):
        return "", 401
    fila.adicionar(corpo)  # processa depois
    return "", 200

n8n

No nó Webhook, em Options, ligue Raw Body — sem isso o n8n entrega o JSON já interpretado e a conta não fecha. Depois, um nó Code como o abaixo. Trate-o como ponto de partida: o nome do campo com o corpo cru varia com a versão do n8n, e em instalações próprias o módulo crypto precisa estar liberado (NODE_FUNCTION_ALLOW_BUILTIN=crypto). A lógica é a mesma dos outros exemplos.

const crypto = require('crypto');

const item = $input.first();
const corpo = Buffer.from(item.binary.data.data, 'base64').toString('utf8');
const cabecalho = item.json.headers['x-otl-assinatura'] ?? '';
const segredo = $env.OTL_WEBHOOK_SEGREDO;

const partes = cabecalho.split(',');
const t = partes.find(p => p.startsWith('t='))?.slice(2);
const recebidas = partes.filter(p => p.startsWith('v1=')).map(p => p.slice(3));

const esperada = crypto.createHmac('sha256', segredo).update(`${t}.${corpo}`).digest('hex');
const recente = t && Math.abs(Date.now() / 1000 - Number(t)) <= 300;

if (!recente || !recebidas.includes(esperada)) {
  throw new Error('Assinatura do webhook OTL inválida');
}

return [{ json: JSON.parse(corpo) }];

Guarde o segredo numa variável de ambiente do n8n (ou numa credencial), nunca dentro do fluxo.

Trocar o segredo

No painel, Novo segredo gera outro e mostra uma vez. Para a troca não perder avisos, durante 24 horas as entregas saem assinadas com os dois segredos:

X-OTL-Assinatura: t=1790701380,v1=<assinatura com o novo>,v1=<assinatura com o anterior>

Por isso os exemplos acima aceitam a entrega quando qualquer v1 confere. Atualize o segredo no seu sistema dentro dessas 24 horas; depois disso só o novo assina.

Erros comuns

Sintoma Causa provável
A assinatura nunca confere O corpo foi interpretado e serializado de novo. Use o corpo cru
Confere no teste e falha em produção O segredo é por endereço: cada webhook cadastrado tem o seu
Passou a falhar depois de um tempo O relógio do servidor está errado, e a entrega parece ter mais de 5 minutos
Falha só depois de “Novo segredo” Passaram as 24 horas e o sistema ainda usa o segredo anterior