Webhooks
O Sacador envia notificações POST para o seu sistema sempre que ocorre um evento relevante (ex.: atualização de status de cobrança). Cada requisição é assinada com HMAC-SHA256 para que você possa verificar que ela realmente partiu do Sacador e não foi adulterada em trânsito.
Configuração
- Acesse /integrations → aba API e Webhook → seção URLs de Webhook.
- Cadastre a URL pública do seu endpoint receptor. Você pode cadastrar até 3 URLs por conta (todas recebem todos os eventos, em paralelo).
- No momento da criação, o Sacador exibe uma única vez o
signing_secretdaquele endpoint, no formatowhsec_…. Copie e armazene em local seguro (variável de ambiente, secret manager). - Use esse secret no seu sistema receptor para validar a assinatura de cada requisição recebida.
Se você perder o secret, clique em Novo secret na linha do endpoint para gerar outro. O secret antigo deixa de funcionar imediatamente — atualize seu sistema receptor para usar o novo antes de fazer a rotação em produção.
Anatomia da requisição
POST /seu/endpoint HTTP/1.1
Host: seu-sistema.com.br
Content-Type: application/json
X-Sacador-Event: billing.updated
X-Sacador-Timestamp: 1762828800
X-Sacador-Signature: t=1762828800,v1=4f8b1a2c…<64 hex chars total>
{"billing":"ABC123"}
Headers
| Header | Descrição |
|---|---|
X-Sacador-Event | Identificador do evento (ex.: billing.updated) |
X-Sacador-Timestamp | Epoch em segundos do momento em que o webhook foi assinado/enviado. Faz parte da entrada do HMAC — qualquer alteração invalida a assinatura |
X-Sacador-Signature | Assinatura no formato t=. O v1= permite versionarmos o esquema no futuro mantendo compatibilidade |
Eventos disponíveis
| Evento | Quando dispara | Payload |
|---|---|---|
billing.updated | Mudança de status de uma cobrança (paga, cancelada, etc.) | {"billing":" |
Como validar a assinatura
A fórmula é:
signed_payload = "<X-Sacador-Timestamp>.<corpo bruto da requisição>"
expected_v1 = HMAC-SHA256(signing_secret, signed_payload) // hex lowercase
Compare expected_v1 com o valor v1= extraído de X-Sacador-Signature usando uma comparação resistente a timing attacks (ex.: OpenSSL.fixed_length_secure_compare em Ruby, hmac.compare_digest em Python, crypto.timingSafeEqual em Node, hash_equals em PHP). Nunca use == direto — isso expõe um canal lateral por tempo de comparação.
Passos obrigatórios
- Leia o corpo bruto da requisição (a string JSON original, não uma re-serialização do objeto parseado — espaços e ordem de chaves importam).
- Extraia
tev1do headerX-Sacador-Signature. - Compute
HMAC-SHA256(secret, "em hex..") - Compare com
v1em tempo constante. - Valide a janela de tempo: rejeite se
|now - t| > 300(5 minutos). Isso protege contra replay attacks caso uma requisição legítima seja capturada e reenviada depois. - Só então processe o payload.
Se qualquer passo falhar, responda com 401 e descarte a requisição.
Exemplos por linguagem
Ruby (Rails)
require 'openssl'
class WebhooksController < ApplicationController
TOLERANCE = 5 * 60 # segundos
def sacador
raw_body = request.raw_post
signature_header = request.headers['X-Sacador-Signature'].to_s
timestamp = request.headers['X-Sacador-Timestamp'].to_i
parts = signature_header.split(',').to_h { |p| p.split('=', 2) }
received_v1 = parts['v1'].to_s
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['SACADOR_WEBHOOK_SECRET'], "#{timestamp}.#{raw_body}")
return head :unauthorized unless OpenSSL.fixed_length_secure_compare(expected, received_v1)
return head :unauthorized if (Time.now.to_i - timestamp).abs > TOLERANCE
payload = JSON.parse(raw_body)
SacadorWebhookProcessor.call(event: request.headers['X-Sacador-Event'], payload: payload)
head :ok
end
end
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const TOLERANCE = 5 * 60; // segundos
const SECRET = process.env.SACADOR_WEBHOOK_SECRET;
// IMPORTANTE: usar express.raw para ter acesso ao corpo bruto
app.post(
'/webhooks/sacador',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const timestamp = parseInt(req.header('X-Sacador-Timestamp'), 10);
const signatureHeader = req.header('X-Sacador-Signature') || '';
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2))
);
const receivedV1 = Buffer.from(parts.v1 || '', 'utf8');
const expected = Buffer.from(
crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex'),
'utf8'
);
if (
receivedV1.length !== expected.length ||
!crypto.timingSafeEqual(receivedV1, expected)
) {
return res.status(401).end();
}
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE) {
return res.status(401).end();
}
const payload = JSON.parse(rawBody);
// ... processe o payload
res.status(200).end();
}
);
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
TOLERANCE = 5 * 60 # segundos
SECRET = os.environ['SACADOR_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/sacador')
def sacador():
raw_body = request.get_data() # bytes brutos, sem parse
timestamp = int(request.headers.get('X-Sacador-Timestamp', '0'))
signature_header = request.headers.get('X-Sacador-Signature', '')
parts = dict(p.split('=', 1) for p in signature_header.split(','))
received_v1 = parts.get('v1', '')
signed = f"{timestamp}.{raw_body.decode()}".encode()
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received_v1):
abort(401)
if abs(int(time.time()) - timestamp) > TOLERANCE:
abort(401)
payload = request.get_json(force=True)
# ... processe o payload
return '', 200
PHP
<?php
const TOLERANCE = 300; // segundos
$secret = getenv('SACADOR_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_SACADOR_TIMESTAMP'] ?? 0);
$header = $_SERVER['HTTP_X_SACADOR_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$receivedV1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $receivedV1)) {
http_response_code(401);
exit;
}
if (abs(time() - $timestamp) > TOLERANCE) {
http_response_code(401);
exit;
}
$payload = json_decode($rawBody, true);
// ... processe o payload
http_response_code(200);
Comportamento de entrega
- Paralelo: se você tiver múltiplas URLs cadastradas, todas recebem o mesmo evento ao mesmo tempo. Falha em uma URL não afeta as outras.
- Sem retry automático: nesta versão, falhas de entrega (timeout, 5xx) não são reagendadas. Se sua infra estiver indisponível, eventos podem ser perdidos. Implemente sua própria reconciliação periódica via API se precisar de garantias mais fortes.
- Timeout: o Sacador aguarda no máximo 10 segundos pela resposta. Responda rápido (
200 OKvazio) e processe o payload de forma assíncrona se for trabalhoso. - Idempotência: projete seu handler para ser idempotente — em caso de retry manual ou disparo duplicado, o mesmo evento pode chegar mais de uma vez.
Resposta esperada
Qualquer status 2xx é considerado sucesso e a entrega é descartada do log de tentativas. Status 3xx, 4xx e 5xx são tratados como falha (mas não reagendados — ver seção anterior).
Não retorne corpo: o Sacador ignora.
Cabeçalho de teste / debug
Para validar sua implementação, basta criar um endpoint de webhook apontando para um serviço como webhook.site ou ngrok, gerar uma cobrança no Sacador e mudá-la de status. Use o signing_secret exibido na criação para reproduzir o cálculo do HMAC e confirmar que bate com o v1= recebido.