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).
A URL precisa resolver para um endereço público:
localhost, IPs privados (10.x,192.168.x,172.16-31.x), link-local (169.254.x) e faixas reservadas são recusados no cadastro e reconferidos a cada entrega. - 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>
X-Sacador-Delivery: 5b0e6c8a-3f1d-4c2b-9a7e-2d4f6a8b1c3e
{"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 |
X-Sacador-Delivery | Identificador único da entrega (um UUID; trate como texto opaco). Repete-se em todas as retentativas da mesma entrega. Use-o para descartar duplicatas, ver Comportamento de entrega |
Eventos disponíveis
| Evento | Quando dispara | Payload |
|---|---|---|
billing.updated | O PagSeguro notifica o Sacador sobre uma cobrança paga pelo checkout do gateway | {"billing":" |
sale.updated | Uma venda do PDV foi concluída, recebeu pagamento de fiado pela tela, teve um pagamento estornado ou teve uma devolução | {"sale":" |
receivable_settlement.completed | Uma baixa por repasse terminou | {"receivable_settlement":" |
store_credit.created | Um movimento novo entrou no extrato de créditos do cliente: crédito pela API, consumo numa venda, estorno ou devolução em créditos, ajuste manual | {"store_credit": |
Os payloads trazem só identificadores. Para os eventos do PDV, busque o estado atual em GET/v1/sales/:code ou GET/v1/store_credits/:id.
Exemplos de payload dos eventos do PDV:
{"sale":"1042","contact":101}
{"store_credit":318,"contact":101}
- No
sale.updated,contacténullquando a venda não tem cliente identificado. O campo existe para você ignorar vendas de contatos que não lhe interessam sem chamar a API. - Uma baixa por repasse gera um
receivable_settlement.completedao terminar, e nenhumsale.updatedpelas vendas que quitou. Os códigos delas vêm em GET/v1/receivable_settlements/:reference. - Os eventos do PDV só saem depois que a operação foi gravada. Se ela falhar, nenhum evento é enviado.
Cobertura desta versão. Nas cobranças, o
billing.updatedsai somente no retorno do gateway PagSeguro. Pagamento de boleto bancário ou Pix registrado no banco (BB, AFPAY), pagamento via Mercado Pago, baixa manual (POST/v1/billings/:id/pay_off) e cancelamento não disparam webhook hoje. Enquanto isso, não construa a confirmação de pagamento só em cima do webhook: faça uma reconciliação periódica com GET/v1/billings?status=paid(ou pelo parstart_created_at/end_created_at).No PDV,
sale.updated,store_credit.createdereceivable_settlement.completedcobrem todas as operações listadas na tabela. Ainda assim, uma entrega pode se perder depois de esgotadas as retentativas: mantenha uma reconciliação periódica com GET/v1/sales(por período) e GET/v1/store_credits, e trate o webhook como um atalho, não como a fonte da verdade.
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: cada URL cadastrada recebe o evento em uma entrega independente. Falha em uma URL não afeta as outras.
- Retry automático: falhas de entrega (timeout, conexão recusada,
4xx,5xx) são reagendadas pela fila — a entrega original mais até 5 retentativas, com intervalo crescente. Esgotadas as tentativas, o evento é descartado; por isso mantenha a reconciliação periódica via API. - 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. - Sem redirecionamento: o Sacador não segue
3xx. Cadastre a URL final. - Conta cancelada ou encerrada: nenhum webhook é enviado enquanto a conta estiver nesse estado.
- Idempotência: projete seu handler para ser idempotente. Com retry automático, a mesma entrega chega mais de uma vez sempre que a sua resposta demora ou falha. Use o
X-Sacador-Deliverycomo chave de deduplicação: ele é gerado uma vez por entrega e se repete, idêntico, em todas as retentativas. Cada URL cadastrada recebe umX-Sacador-Deliverypróprio para o mesmo evento. Guarde os ids já processados e responda2xxsem reprocessar quando um deles voltar.
Resposta esperada
Qualquer status 2xx é considerado sucesso. Status 3xx, 4xx e 5xx são tratados como falha e a entrega é reagendada — 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.