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). 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.
  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>
X-Sacador-Delivery: 5b0e6c8a-3f1d-4c2b-9a7e-2d4f6a8b1c3e

{"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
X-Sacador-DeliveryIdentificador ú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

EventoQuando disparaPayload
billing.updatedO PagSeguro notifica o Sacador sobre uma cobrança paga pelo checkout do gateway{"billing":""}
sale.updatedUma venda do PDV foi concluída, recebeu pagamento de fiado pela tela, teve um pagamento estornado ou teve uma devolução{"sale":"","contact":}
receivable_settlement.completedUma baixa por repasse terminou{"receivable_settlement":""}
store_credit.createdUm 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":,"contact":}

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 é null quando 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.completed ao terminar, e nenhum sale.updated pelas 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.updated sai 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 par start_created_at/end_created_at).

No PDV, sale.updated, store_credit.created e receivable_settlement.completed cobrem 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

  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: 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 OK vazio) 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-Delivery como chave de deduplicação: ele é gerado uma vez por entrega e se repete, idêntico, em todas as retentativas. Cada URL cadastrada recebe um X-Sacador-Delivery próprio para o mesmo evento. Guarde os ids já processados e responda 2xx sem 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.