Verificar a assinatura
Qualquer pessoa pode enviar um POST para a sua URL. A assinatura prova que a chamada veio da
SeventyOne Pay e que o corpo não foi alterado. Sempre verifique antes de processar.
Como a assinatura é calculada
- Pegue o timestamp do header
x-seventyone-timestamp. - Monte o texto:
timestamp+.+ corpo bruto da requisição (os bytes exatos, sem reserializar o JSON). - Calcule HMAC-SHA256 desse texto usando o seu segredo (
whsec_…) como chave, em hexadecimal. - Compare com o valor do header
x-seventyone-signature, que tem o formatov1=<hex>, usando comparação em tempo constante. - Rejeite se o timestamp diferir do horário atual em mais de 300 segundos (proteção contra reenvio de chamadas capturadas).
Use o corpo bruto
Se o seu framework converte o corpo para objeto antes de você verificar, a reserialização muda os bytes e a assinatura não confere. Configure a rota para entregar o corpo cru.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SEGREDO = process.env.SEVENTYONE_WEBHOOK_SECRET; // whsec_...
const TOLERANCIA_SEGUNDOS = 300;
// Corpo cru apenas nesta rota
app.post("/webhooks/seventyone", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.header("x-seventyone-timestamp") ?? "";
const assinatura = req.header("x-seventyone-signature") ?? "";
const corpo = req.body.toString("utf8"); // string bruta
const agora = Math.floor(Date.now() / 1000);
if (!Number.isFinite(Number(timestamp)) || Math.abs(agora - Number(timestamp)) > TOLERANCIA_SEGUNDOS) {
return res.status(400).send("timestamp fora da janela");
}
const esperado = crypto.createHmac("sha256", SEGREDO).update(`${timestamp}.${corpo}`).digest("hex");
const recebido = assinatura.replace(/^v1=/, "");
const a = Buffer.from(esperado);
const b = Buffer.from(recebido);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("assinatura inválida");
}
const evento = JSON.parse(corpo);
// TODO: deduplicar por req.header("x-seventyone-event-id") e enfileirar o processamento
res.sendStatus(200);
});
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SEGREDO = os.environ["SEVENTYONE_WEBHOOK_SECRET"].encode()
TOLERANCIA_SEGUNDOS = 300
@app.post("/webhooks/seventyone")
def webhook():
timestamp = request.headers.get("x-seventyone-timestamp", "")
assinatura = request.headers.get("x-seventyone-signature", "")
corpo = request.get_data() # bytes crus
try:
ts = int(timestamp)
except ValueError:
abort(400)
if abs(time.time() - ts) > TOLERANCIA_SEGUNDOS:
abort(400)
esperado = hmac.new(SEGREDO, f"{timestamp}.".encode() + corpo, hashlib.sha256).hexdigest()
recebido = assinatura.removeprefix("v1=")
if not hmac.compare_digest(esperado, recebido):
abort(401)
evento = request.get_json()
# deduplicar por x-seventyone-event-id e enfileirar o processamento
return "", 200
PHP
<?php
$segredo = getenv('SEVENTYONE_WEBHOOK_SECRET'); // whsec_...
$tolerancia = 300;
$timestamp = $_SERVER['HTTP_X_SEVENTYONE_TIMESTAMP'] ?? '';
$assinatura = $_SERVER['HTTP_X_SEVENTYONE_SIGNATURE'] ?? '';
$corpo = file_get_contents('php://input'); // bytes crus
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerancia) {
http_response_code(400);
exit('timestamp fora da janela');
}
$esperado = hash_hmac('sha256', $timestamp . '.' . $corpo, $segredo);
$recebido = preg_replace('/^v1=/', '', $assinatura);
if (!hash_equals($esperado, $recebido)) {
http_response_code(401);
exit('assinatura inválida');
}
$evento = json_decode($corpo, true);
// deduplicar por X-SeventyOne-Event-Id e enfileirar o processamento
http_response_code(200);
Rotação do segredo
Você pode gerar um novo segredo no painel. Depois da rotação, as próximas entregas passam a ser assinadas com o novo valor; atualize a variável de ambiente do seu servidor ao mesmo tempo.
Solução de problemas
| Sintoma | Causa provável |
|---|---|
| A assinatura nunca confere | Corpo reserializado, ou segredo errado/de outro webhook. |
| Falha só às vezes | Relógio do servidor atrasado (janela de 300 s). |
| Funciona local e falha em produção | Proxy ou middleware alterando o corpo antes da verificação. |