Assinatura dos webhooks
Toda entrega de webhook vem assinada, para você ter certeza de que foi a Synergy quem enviou, e de que o corpo não foi alterado no caminho. Há dois cabeçalhos de assinatura, com o mesmo secret do webhook:
| Cabeçalho | Formato | Protege contra | Quando usar |
|---|---|---|---|
X-Synergy-Signature | t=<segundos>,v1=<64 hex> | Corpo alterado e reenvio de uma entrega capturada (tem carimbo de tempo) | Preferido. Use sempre que ele vier |
X-Hub-Signature-256 | sha256=<64 hex> | Corpo alterado | Compatível com bibliotecas feitas para a Meta, e entregas antigas |
Os dois chegam em toda tentativa, inclusive retentativas. O secret (32 caracteres hexadecimais) aparece só na criação do webhook e em POST /v1/webhooks/{hook_id}/rotate-secret (Webhooks).
Regras que valem para os dois#
- Calcule a assinatura sobre os bytes exatos do corpo, antes de qualquer
JSON.parse. Reserializar o JSON muda os bytes e invalida a assinatura. No Express, useexpress.raw({ type: 'application/json' }); no Flask,request.get_data(). - O
secreté usado como texto UTF-8, do jeito que está. Segredo vazio é erro de configuração: nunca aceite a entrega nesse caso. - Compare em tempo constante (
timingSafeEqual,hmac.compare_digest), nunca com==. - Confira o formato antes de comparar. Assinatura inválida: responda 401 e não processe.
X-Synergy-Signature (com carimbo de tempo)#
O cabeçalho tem a forma:
t: o momento desta tentativa, em segundos desde 1970. Cada retentativa tem umtnovo.v1: o HMAC-SHA256, em hexadecimal minúsculo, do texto<t>.<X-Synergy-Delivery-Id>.<corpo cru>, com osecretcomo chave. OX-Synergy-Delivery-Identra na conta: quem troca o id invalida a assinatura.
Para validar:
- Leia
t(de 1 a 12 dígitos,^\d{1,12}$) ev1(64 hexadecimais minúsculos). Qualquer outro formato é inválido. - Recuse se
|agora − t|passar de 300 segundos: é isso que impede reenviar uma entrega capturada. - Calcule o HMAC de
<t>.<X-Synergy-Delivery-Id>.<corpo cru>e compare comv1em tempo constante. - Só depois use o
X-Synergy-Delivery-Idpara descartar entregas repetidas (Webhooks, "Idempotência").
Formato do X-Synergy-Delivery-Id#
O id entra na conta entre dois pontos (<t>.<id>.<corpo>). Por isso o valor do header só tem A-Z, a-z, 0-9, _ e -, de 1 a 200 caracteres (^[A-Za-z0-9_-]{1,200}$). Recuse a entrega antes de calcular o HMAC se o header não casar. O id do evento é normalizado pela Synergy antes de sair, com uma regra que mantém o resultado legível e nunca junta dois ids diferentes:
| No id do evento | No header |
|---|---|
_ | __ |
: | _c |
. | _d |
qualquer outro caractere fora de A-Z, a-z, 0-9 e - | _x mais os dois hexadecimais minúsculos de cada byte UTF-8 (+ é _x2b, espaço é _x20, é é _xc3_xa9) |
Por exemplo, ev-abc:1a2b3c4d chega como ev-abc_c1a2b3c4d, e ev-3f9a…:0b9c4d1e+12 chega como ev-3f9a…_c0b9c4d1e_x2b12. Se o resultado passa de 200 caracteres, o id vira os 100 primeiros, _h e os 32 primeiros hexadecimais do SHA-256 do id original.
A regra é determinística: o mesmo evento tem o mesmo id em todas as tentativas. Use o valor do header como ele chegou, tanto na conta do HMAC quanto para descartar repetidos; não precisa desfazer o escape.
Vetor de teste#
Para conferir a sua implementação: com o secret 0123456789abcdef0123456789abcdef, o X-Synergy-Delivery-Id ping-3f9a1c0b-7d2e-4a6f-8b1c-2d3e4f5a6b7c, t igual a 1767225600 e o corpo
o cabeçalho esperado é:
(No teste, ignore a tolerância de 300 s: o t é fixo. O vetor é o TIMESTAMPED_SIGNATURE_EXAMPLE publicado em x-webhook-delivery do openapi.json.)
X-Synergy-Signature: t=1767225600,v1=9baa189aceecd03d75cb0a92954f222a09db72a2a786d079af44beee79609372JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: os bytes EXATOS do corpo (Buffer); headers: req.headers (nomes em minúsculas)
export function isValidTimestampedSignature(rawBody, headers, secret, toleranceSeconds = 300) {
if (!secret || !secret.trim()) throw new Error('webhook secret is empty');
const header = headers['x-synergy-signature'];
const deliveryId = headers['x-synergy-delivery-id'];
const match = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header ?? '');
if (!match || !deliveryId) return false;
const t = Number(match[1]);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${deliveryId}.`)
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}Python
import hashlib
import hmac
import re
import time
def is_valid_timestamped_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
if not secret.strip():
raise ValueError("webhook secret is empty")
match = re.fullmatch(r"t=(\d{1,12}),v1=([0-9a-f]{64})", headers.get("X-Synergy-Signature", ""))
delivery_id = headers.get("X-Synergy-Delivery-Id")
if not match or not delivery_id:
return False
t = int(match.group(1))
if abs(int(time.time()) - t) > tolerance_seconds:
return False
signed = f"{t}.{delivery_id}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(2)){"object":"whatsapp_business_account","entry":[{"id":"0","time":1767225600,"changes":[{"field":"synergy_ping","value":{"messaging_product":"whatsapp","ping":true,"timestamp":"1767225600"}}]}]}X-Synergy-Signature: t=1767225600,v1=9baa189aceecd03d75cb0a92954f222a09db72a2a786d079af44beee79609372X-Hub-Signature-256 (formato da Meta)#
sha256= mais o HMAC-SHA256, em hexadecimal minúsculo, só do corpo, com o secret. Sem carimbo de tempo: quem captura uma entrega pode reenviá-la, e por isso este cabeçalho vale como segunda opção.
Com o mesmo secret e o mesmo corpo acima, o cabeçalho é:
O código de validação (JavaScript e Python) está em Webhooks, "Validar a assinatura".
X-Hub-Signature-256: sha256=b34c62852bf9eb4132dbac56d4b909a1e62d3df61b21807329b6c1f4d59ec77eQual cabeçalho conferir#
Use o X-Synergy-Signature quando ele estiver presente, e caia para o X-Hub-Signature-256 quando não estiver (um receptor de terceiros, uma entrega antiga):
Não aceite "qualquer um dos dois": se o X-Synergy-Signature veio e falhou, a entrega é inválida, mesmo que o outro cabeçalho confira. O SDK (webhooks.constructEvent) e o n8n já fazem exatamente isso.
function verifyDelivery(rawBody, headers, secret) {
if (headers['x-synergy-signature']) {
return { ok: isValidTimestampedSignature(rawBody, headers, secret), timestamped: true };
}
return { ok: isValidSignature(rawBody, headers['x-hub-signature-256'], secret), timestamped: false };
}Erros comuns#
| Sintoma | Causa | O que fazer |
|---|---|---|
| Nenhuma assinatura confere | O corpo foi interpretado e reserializado, ou o framework removeu espaços | Leia o corpo cru, antes do JSON |
Só X-Synergy-Signature falha | Você assinou t.corpo, sem o X-Synergy-Delivery-Id, ou usou o id errado | Monte <t>.<delivery-id>.<corpo> |
Falha só em retentativas, com t velho | O relógio do seu servidor está adiantado ou atrasado | Sincronize (NTP). A tolerância é de 300 s |
| Tudo falhou depois de trocar o segredo | Você rotacionou com rotate-secret e ainda usa o antigo | Atualize o segredo no seu servidor |
X-Synergy-Signature ausente | Entrega de um receptor que não passa o cabeçalho adiante, ou entrega antiga | Caia para X-Hub-Signature-256 |
Ficou com dúvida? Escreva para suporte@synergyconnect.com.br.