Cada webhook de saída do Syncro vem com assinatura HMAC SHA-256 no header X-Syncro-Signature. Validar assinatura garante que o request veio realmente do Syncro (não é hacker que descobriu sua URL e está mandando payload falso). Esse artigo cobre como funciona e como validar em PHP, Node.js e Python.
Por que validar
Sem validação, qualquer pessoa com a URL do seu endpoint pode mandar request fake:
curl -X POST "https://meusistema.com/webhook" \
-H "Content-Type: application/json" \
-d '{"event":"lead.won","data":{"value":1000000}}'
Sistema acha que é venda real, dispara fluxo (notifica Slack, atualiza dashboard) — fraude.
Com HMAC, request fake não tem assinatura válida, sistema rejeita.
Como funciona
Lado servidor (Syncro)
Ao disparar webhook:
- Calcula HMAC do body JSON com secret da subscription:
signature = HMAC_SHA256(body_json, secret)
- Adiciona header:
X-Syncro-Signature: sha256=<hex_digest>
- Faz POST.
Lado receptor (você)
Ao receber webhook:
- Lê header
X-Syncro-Signature. - Lê body raw (não parseado — bytes exatos).
- Calcula HMAC esperado do body com mesmo secret.
- Compara header vs esperado com timing-safe comparison.
- Se igual → request legítimo, processe.
- Se diferente → rejeitar (401).
Onde tá o secret
Secret é gerado no momento da criação da subscription:
POST /api/v1/webhooks
{...}
Response:
{
"id": 42,
"secret": "abc123def456..." ← SALVE!
}
Salve agora — não aparece mais. Veja Webhooks de saída.
Validação em PHP
<?php
function validateWebhookSignature(string $body, string $secret, string $signatureHeader): bool
{
// 1. Calcular HMAC esperado
$expected = 'sha256='. hash_hmac('sha256', $body, $secret);
// 2. Comparação timing-safe (previne timing attacks)
return hash_equals($expected, $signatureHeader);
}
// Uso no endpoint
$secret = 'abc123def456...'; // salvo da criação
$signature = $_SERVER['HTTP_X_SYNCRO_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // raw bytes
if (!validateWebhookSignature($body, $secret, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Aprovado — pode parse e processar
$payload = json_decode($body, true);
$event = $payload['event'];
$data = $payload['data'];
//...processa...
http_response_code(200);
echo json_encode(['received' => true]);
⚠️ Atenção: use
hash_equals(timing-safe), NÃO===. Comparação simples permite timing attacks que vazam secret byte por byte.
Validação em Node.js
const crypto = require('crypto');
const express = require('express');
const app = express;
// IMPORTANTE: precisa do raw body pra HMAC, não JSON parseado
app.use(express.raw({ type: 'application/json' }));
const SECRET = 'abc123def456...';
function validateWebhookSignature(body, secret, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
// Timing-safe comparison
const expectedBuf = Buffer.from(expected);
const actualBuf = Buffer.from(signatureHeader);
// Se tamanhos diferentes, retorna false sem comparar
if (expectedBuf.length !== actualBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-syncro-signature'] || '';
const body = req.body.toString('utf8');
if (!validateWebhookSignature(body, SECRET, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(body);
console.log(`Event: ${payload.event}`);
//...processa...
res.status(200).json({ received: true });
});
app.listen(3000, => console.log('Listening on 3000'));
⚠️ Atenção: o
express.rawé crítico. Se você usaexpress.json, o body já foi parseado e re-serializado — HMAC bate diferente.
Validação em Python (Flask)
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = b'abc123def456...' # bytes!
def validate_webhook_signature(body: bytes, secret: bytes, signature_header: str) -> bool:
expected = 'sha256=' + hmac.new(
secret,
body,
hashlib.sha256
).hexdigest
# Timing-safe comparison
return hmac.compare_digest(expected, signature_header)
@app.route('/webhook', methods=['POST'])
def webhook:
body_raw = request.get_data # raw bytes
signature = request.headers.get('X-Syncro-Signature', '')
if not validate_webhook_signature(body_raw, SECRET, signature):
return jsonify({'error': 'Invalid signature'}), 401
payload = request.get_json
print(f"Event: {payload['event']}")
#...processa...
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
⚠️ Atenção: secret deve ser bytes em Python (use
b'...'). Se forstr, sistema converte mas pode dar bug em encoding.
Validação em outros frameworks
Laravel (PHP)
Mesma lógica do PHP puro. Use middleware:
class VerifyWebhookSignature
{
public function handle($request, Closure $next)
{
$secret = config('services.syncro.webhook_secret');
$signature = $request->header('X-Syncro-Signature');
$body = $request->getContent;
$expected = 'sha256='. hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $signature)) {
abort(401, 'Invalid signature');
}
return $next($request);
}
}
// Aplique no route
Route::post('/webhook/syncro', WebhookController::class)
->middleware(VerifyWebhookSignature::class);
Django (Python)
import hmac
import hashlib
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def webhook(request):
secret = b'abc123...'
signature = request.META.get('HTTP_X_SYNCRO_SIGNATURE', '')
body = request.body # já é bytes
expected = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest
if not hmac.compare_digest(expected, signature):
return JsonResponse({'error': 'Invalid'}, status=401)
payload = json.loads(body.decode('utf-8'))
#...processa...
return JsonResponse({'received': True})
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io/ioutil"
"net/http"
)
const secret = "abc123def456..."
func validateSignature(body byte, signatureHeader string) bool {
h:= hmac.New(sha256.New, byte(secret))
h.Write(body)
expected:= "sha256=" + hex.EncodeToString(h.Sum(nil))
return hmac.Equal(byte(expected), byte(signatureHeader))
}
func handler(w http.ResponseWriter, r *http.Request) {
body, _:= ioutil.ReadAll(r.Body)
signature:= r.Header.Get("X-Syncro-Signature")
if !validateSignature(body, signature) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
//...processa...
w.WriteHeader(http.StatusOK)
}
Por que timing-safe?
Comparação simples (==):
"sha256=abc" == "sha256=xyz"
Compara byte por byte e para no primeiro diferente. Tempo de execução varia conforme posição do primeiro mismatch.
Hacker pode medir tempo de resposta:
- Tentativa 1:
sha256=aaa...→ resposta em 5ms (mismatch no 1º char). - Tentativa 2:
sha256=baa...→ resposta em 10ms (mismatch no 2º char — passou pelo 1º). - Etc.
Aos poucos, descobre cada char do secret real. Em algumas centenas de tentativas, secret completo vazado.
Timing-safe comparison (hash_equals, hmac.compare_digest, crypto.timingSafeEqual):
- Compara todos os bytes, mesmo após mismatch.
- Tempo constante independente de quantos bytes batem.
- Hacker não consegue inferir nada via timing.
Replay attacks
Mesmo com HMAC válido, request capturado pode ser reenviado:
- Hacker captura webhook legítimo (man-in-the-middle, log de servidor, etc).
- Reenvia 100x pro seu endpoint.
- Você processa 100x — pode causar dano (ex: notificação Slack 100x).
Mitigação 1 — Idempotência
Use X-Syncro-Delivery como dedup key:
$deliveryId = $_SERVER['HTTP_X_SYNCRO_DELIVERY'];
if (cache_has("processed:$deliveryId")) {
return response->json(['received' => true]);
}
process_webhook($payload);
cache_put("processed:$deliveryId", true, 86400); // 24h
Mitigação 2 — Timestamp validation
Sistema inclui emitted_at ISO no payload:
{
"event": "lead.created",
"emitted_at": "2026-04-30T14:30:00Z",
"data": {...}
}
Rejeite eventos antigos:
$emittedAt = new DateTime($payload['emitted_at']);
$now = new DateTime;
$diff = $now->getTimestamp - $emittedAt->getTimestamp;
if (abs($diff) > 300) { // 5 min
return response->json(['error' => 'Event too old']);
}
💡 Dica: tolere clock skew (±5min) entre Syncro e seu servidor.
Não-validar — riscos
Se você não valida HMAC:
- ✅ Funciona normalmente em produção.
- ❌ Qualquer um descobre URL → manda request fake.
- ❌ Sem proteção contra spoofing.
- ❌ Auditoria/SOC2/LGPD vão reprovar.
Sempre valide em produção.
Testar localmente
Pra desenvolvimento local, pegue webhook real do Syncro:
- Configure subscription apontando pra serviço como webhook.site.
- Trigger evento (ex: crie lead).
- Veja request completo em webhook.site (headers + body).
- Copie o body raw.
- Calcule HMAC localmente:
echo -n '<body raw colado>' | openssl dgst -sha256 -hmac '<seu secret>'
- Compare com
X-Syncro-Signaturerecebido.
Erros comuns
"Signature inválida sempre"
- Body sendo modificado antes de validar (ex: middleware que adiciona char). Use raw body.
- Secret errado.
- Encoding diferente (UTF-8 vs UTF-16).
- HMAC algoritmo diferente (SHA-512 ao invés de SHA-256).
"Funciona às vezes, falha outras"
- Json reformatting: você está parseando e re-serializando body. Use raw.
- Whitespace: body com
\nextra. Use exato.
"Header X-Syncro-Signature não chega"
- Você está usando middleware que strip headers.
- Verifique config de proxy (nginx, cloudflare).
"Validação demora muito"
HMAC é muito rápido (~microssegundos). Se demorar segundos, problema é outro (load do servidor, network).
"Como sei o secret?"
Salvo no momento da criação da subscription. Se perdeu, delete e recrie subscription pra ganhar novo.
Boas práticas
1. Sempre raw body
Não parseie antes. HMAC roda em bytes exatos.
2. Use bibliotecas de timing-safe
hash_equals, hmac.compare_digest, crypto.timingSafeEqual. Nunca ===.
3. Secret forte e único
Sistema gera 40 chars random. Se quiser fornecer próprio, use mínimo 32 chars aleatórios.
4. Não logue secret
Logs podem vazar. Mantenha em vault/env vars.
5. Rotacione secret
Se suspeitar vazamento:
- Crie subscription nova com secret novo.
- Atualize seu sistema.
- Delete subscription antiga.
Próximos passos
- Pra criar subscription, veja Webhooks de saída.
- Pra ver toda API, veja Endpoints disponíveis.