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

  1. Acesse /integrations → aba API e Webhook → seção URLs de Webhook.
  2. Cadastre a URL pública do seu endpoint receptor. Você pode cadastrar até 3 URLs por conta (todas recebem todos os eventos, em paralelo).
  3. No momento da criação, o Sacador exibe uma única vez o signing_secret daquele endpoint, no formato whsec_…. Copie e armazene em local seguro (variável de ambiente, secret manager).
  4. 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

HeaderDescrição
X-Sacador-EventIdentificador do evento (ex.: billing.updated)
X-Sacador-TimestampEpoch 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-SignatureAssinatura no formato t=,v1=. O v1= permite versionarmos o esquema no futuro mantendo compatibilidade

Eventos disponíveis

EventoQuando disparaPayload
billing.updatedMudanç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

  1. 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).
  2. Extraia t e v1 do header X-Sacador-Signature.
  3. Compute HMAC-SHA256(secret, ".") em hex.
  4. Compare com v1 em tempo constante.
  5. 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.
  6. 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 OK vazio) 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.