Documentação Webhook
Receba notificações em tempo real quando eventos ocorrem no Bunto ERP via HTTP POST com assinatura HMAC-SHA256.
Receba notificações em tempo real quando eventos ocorrem no Bunto ERP. Webhooks enviam requisições HTTP POST automaticamente para a URL configurada, com payload assinado via HMAC-SHA256.
Os webhooks são disparados tanto para operações realizadas via painel do ERP (autenticação JWT) quanto via API externa (token bnt_), garantindo que qualquer alteração no sistema gere a notificação correspondente.
Visão Geral
| Propriedade | Valor |
|---|---|
| Método | POST (enviado pelo Bunto para sua URL) |
| Formato | JSON (application/json; charset=utf-8) |
| Assinatura | HMAC-SHA256 (header X-Bunto-Signature) |
| Retries | Backoff exponencial (30s, 60s, 120s, 240s...) |
| Timeout | Configurável por webhook (5-30 segundos) |
| Retenção de logs | 30 dias (LGPD) |
| Limite | 20 webhooks por empresa |
| HTTPS | Obrigatório em produção |
Como funciona: Quando um evento ocorre (ex: produto criado, venda finalizada), o Bunto envia um POST para cada webhook ativo que escuta aquele evento. Sua aplicação recebe o payload, verifica a assinatura e processa os dados.
Configuração
Criando um Webhook
- Acesse o painel do Bunto ERP
- Navegue até Integrações > Webhooks
- Clique em Novo Webhook
- Preencha:
- URL de destino: Endpoint HTTPS que receberá as notificações
- Descrição: Ex: "Notificações para sistema de logística"
- Eventos: Selecione quais eventos disparam este webhook
- Máx. tentativas: 1 a 10 (padrão: 5)
- Timeout: 5 a 30 segundos (padrão: 10)
- Ao salvar, o sistema gera um Secret — copie e armazene no seu servidor
Importante: O secret é necessário para verificar a assinatura das requisições (veja Verificação de Assinatura). Se perdê-lo, use a opção "Rotacionar Secret" para gerar um novo.
Eventos Disponíveis
| Evento | Descrição | Quando é disparado |
|---|---|---|
produto.criado | Produto criado | Novo produto cadastrado no sistema |
produto.atualizado | Produto atualizado | Dados do produto alterados (nome, preço, situação, etc.) |
produto.excluido | Produto excluído | Produto removido do sistema |
cliente.criado | Cliente criado | Novo cliente cadastrado |
cliente.atualizado | Cliente atualizado | Dados do cliente alterados |
cliente.excluido | Cliente excluído | Cliente removido do sistema |
venda.criada | Venda criada | Nova venda/pedido registrado |
venda.atualizada | Venda atualizada | Dados da venda alterados (status, observação, etc.) |
venda.cancelada | Venda cancelada | Venda cancelada no sistema |
estoque.atualizado | Estoque atualizado | Lançamento de entrada, saída ou balanço de estoque |
nfe.emitida | NF-e emitida | Nota fiscal eletrônica autorizada pela SEFAZ |
nfe.cancelada | NF-e cancelada | Nota fiscal eletrônica cancelada |
Além desses, existe o evento especial webhook.teste usado pelo botão "Testar" na interface.
Estrutura do Payload
Todos os eventos seguem a mesma estrutura base:
json{ "evento": "produto.criado", "timestamp": "2026-02-13T13:56:47.865088+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { ... }, "idempotency_key": "3_produto.criado_38951ae5a9d440a1" }
| Campo | Tipo | Descrição |
|---|---|---|
evento | string | Tipo do evento (ex: produto.criado) |
timestamp | string (ISO 8601) | Data/hora do evento com timezone |
webhook_id | integer | ID do webhook que recebeu o evento |
empresa_id | integer | ID da empresa no Bunto ERP |
dados | object | Dados específicos do evento (varia por tipo) |
idempotency_key | string | Chave única para evitar processamento duplicado |
Exemplos de Payload por Evento
produto.criado
Disparado quando um novo produto é cadastrado, seja pelo painel do ERP ou pela API.
json{ "evento": "produto.criado", "timestamp": "2026-02-13T13:56:47.865088+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 640, "nome": "Camiseta Básica Algodão - M Preto", "codigo": "CAM001-M-PT", "preco_venda": "49.90", "preco_custo": "22.50", "unidade": "UN", "situacao": "A", "ativo": true, "gtin": "7891234567890", "criado_em": "2026-02-13 13:56:47.845604+00:00" }, "idempotency_key": "3_produto.criado_38951ae5a9d440a1" }
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | ID do produto |
nome | string | Nome do produto |
codigo | string | Código/SKU do produto |
preco_venda | string | Preço de venda (decimal como string) |
preco_custo | string | Preço de custo (decimal como string) |
unidade | string | Unidade de medida (UN, KG, L, etc.) |
situacao | string | Situação: A (Ativo), I (Inativo), E (Excluído) |
ativo | boolean | Se o produto está ativo |
gtin | string/null | Código GTIN/EAN |
criado_em | string | Data de criação |
produto.atualizado
Disparado quando qualquer campo do produto é alterado.
json{ "evento": "produto.atualizado", "timestamp": "2026-02-13T13:56:49.194533+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 640, "nome": "Camiseta Básica Algodão - M Preto", "codigo": "CAM001-M-PT", "preco_venda": "59.90", "preco_custo": "22.50", "unidade": "UN", "situacao": "A", "ativo": true, "gtin": "7891234567890", "atualizado_em": "2026-02-13 13:56:49.185592+00:00" }, "idempotency_key": "3_produto.atualizado_7583cb094a794ae2" }
Nota: Quando um produto é excluído via soft-delete, o evento produto.atualizado é disparado com situacao: "E" e ativo: false.
produto.excluido
Disparado quando um produto é removido permanentemente do sistema.
json{ "evento": "produto.excluido", "timestamp": "2026-02-13T13:56:50.545291+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 640, "nome": "Camiseta Básica Algodão - M Preto", "codigo": "CAM001-M-PT" }, "idempotency_key": "3_produto.excluido_27892a96252748ac" }
cliente.criado
Disparado quando um novo cliente é cadastrado.
LGPD: Dados sensíveis (CPF/CNPJ, e-mail, telefone) são mascarados automaticamente no payload do webhook para conformidade com a Lei Geral de Proteção de Dados.
json{ "evento": "cliente.criado", "timestamp": "2026-02-13T13:56:09.453507+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 364, "nome": "Maria Oliveira Santos", "tipo_pessoa": 2, "cnpj_cpf": "**.456.789/0001-00", "email": "m***@empresa.com.br", "celular": "*****-5678", "cidade": "Rio de Janeiro", "uf": "RJ" }, "idempotency_key": "3_cliente.criado_317206bacfaa408d" }
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | ID do cliente |
nome | string | Nome/Razão social |
tipo_pessoa | integer | 1 = Pessoa Física, 2 = Pessoa Jurídica |
cnpj_cpf | string | CPF/CNPJ mascarado (LGPD) |
email | string | E-mail mascarado (LGPD) |
celular | string | Telefone mascarado (LGPD) |
cidade | string/null | Cidade |
uf | string/null | Estado (UF) |
cliente.atualizado
Disparado quando qualquer campo do cliente é alterado.
json{ "evento": "cliente.atualizado", "timestamp": "2026-02-13T13:56:11.884078+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 364, "nome": "Maria Oliveira Santos LTDA", "tipo_pessoa": 2, "cnpj_cpf": "**.456.789/0001-00", "email": "c***@empresa.com.br", "celular": "*****-5678", "cidade": "Rio de Janeiro", "uf": "RJ" }, "idempotency_key": "3_cliente.atualizado_18aad6d02a1f46ad" }
cliente.excluido
Disparado quando um cliente é removido do sistema.
json{ "evento": "cliente.excluido", "timestamp": "2026-02-13T13:56:14.219727+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 364, "nome": "Maria Oliveira Santos LTDA", "cnpj_cpf": "**.456.789/0001-00" }, "idempotency_key": "3_cliente.excluido_4e06734635234632" }
venda.criada
Disparado quando uma nova venda/pedido é registrado.
json{ "evento": "venda.criada", "timestamp": "2026-02-13T14:10:22.331456+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 892, "numero_pedido": "001245", "status": "em_aberto", "tipo_pedido": "venda", "total_venda": "299.70", "cliente_id": 364 }, "idempotency_key": "3_venda.criada_c3d4e5f6g7h8i9j0" }
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | ID da venda |
numero_pedido | string | Número do pedido |
status | string | Status atual da venda |
tipo_pedido | string | Tipo: venda, orcamento, pedido |
total_venda | string | Valor total (decimal como string) |
cliente_id | integer | ID do cliente |
venda.atualizada
Disparado quando qualquer campo da venda é alterado.
json{ "evento": "venda.atualizada", "timestamp": "2026-02-13T14:15:33.456789+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 892, "numero_pedido": "001245", "status": "finalizada", "tipo_pedido": "venda", "total_venda": "299.70", "cliente_id": 364, "status_anterior": "em_aberto" }, "idempotency_key": "3_venda.atualizada_d4e5f6g7h8i9j0k1" }
venda.cancelada
Disparado quando o status da venda muda para cancelada.
json{ "evento": "venda.cancelada", "timestamp": "2026-02-13T14:20:44.567890+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 892, "numero_pedido": "001245", "status": "cancelada", "tipo_pedido": "venda", "total_venda": "299.70", "cliente_id": 364, "status_anterior": "finalizada" }, "idempotency_key": "3_venda.cancelada_e5f6g7h8i9j0k1l2" }
estoque.atualizado
Disparado quando há qualquer movimentação de estoque (entrada, saída ou balanço).
json{ "evento": "estoque.atualizado", "timestamp": "2026-02-13T13:56:55.721908+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 2223, "produto_id": 266, "deposito_id": 4, "tipo": "E", "origem": "M", "quantidade": "5.000", "saldo_atual": "70.000", "data_lancamento": "2026-02-13 13:56:55.695372+00:00", "produto_nome": "Camiseta Básica Algodão - G Branco", "produto_codigo": "CAM001-G-BR", "deposito_nome": "Próspera" }, "idempotency_key": "3_estoque.atualizado_822c699af3b74d26" }
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | ID do lançamento |
produto_id | integer | ID do produto |
deposito_id | integer | ID do depósito |
tipo | string | E (Entrada), S (Saída), B (Balanço) |
origem | string | M (Manual), V (Venda), C (Compra), D (Devolução), N (Nota Fiscal) |
quantidade | string | Quantidade movimentada (decimal como string) |
saldo_atual | string | Saldo após o lançamento (decimal como string) |
data_lancamento | string | Data/hora do lançamento |
produto_nome | string | Nome do produto |
produto_codigo | string | Código/SKU do produto |
deposito_nome | string | Nome do depósito |
nfe.emitida
Disparado quando uma NF-e é autorizada pela SEFAZ.
json{ "evento": "nfe.emitida", "timestamp": "2026-02-13T15:30:45.123456+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 5001, "tipo": "NFE", "numero": "12345", "serie": "1", "chave_acesso": "35260213999999000199550010000123451000000001", "status": "AUTORIZADO", "valor_total": "299.70", "protocolo": "135260000012345", "cliente_id": 364, "criado_em": "2026-02-13 15:30:00.000000+00:00" }, "idempotency_key": "3_nfe.emitida_e5f6g7h8i9j0k1l2" }
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | ID do documento fiscal |
tipo | string | NFE (Nota Fiscal Eletrônica) ou NFCE (Consumidor) |
numero | string | Número da nota |
serie | string | Série da nota |
chave_acesso | string | Chave de acesso (44 dígitos) |
status | string | AUTORIZADO |
valor_total | string | Valor total da nota (decimal como string) |
protocolo | string | Protocolo de autorização da SEFAZ |
cliente_id | integer | ID do cliente/destinatário |
criado_em | string | Data de criação do documento |
nfe.cancelada
Disparado quando uma NF-e é cancelada.
json{ "evento": "nfe.cancelada", "timestamp": "2026-02-13T16:00:12.654321+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "id": 5001, "tipo": "NFE", "numero": "12345", "serie": "1", "chave_acesso": "35260213999999000199550010000123451000000001", "status": "CANCELADO", "valor_total": "299.70", "protocolo_cancelamento": "135260000099999" }, "idempotency_key": "3_nfe.cancelada_f6g7h8i9j0k1l2m3" }
webhook.teste
Evento especial disparado pelo botão "Testar" na interface ou pela API de teste.
json{ "evento": "webhook.teste", "timestamp": "2026-02-13T13:50:00.000000+00:00", "webhook_id": 3, "empresa_id": 1, "dados": { "mensagem": "Este é um evento de teste. Se você recebeu, seu webhook está funcionando!" }, "idempotency_key": "3_teste_a1b2c3d4e5f6g7h8" }
Headers da Requisição
Cada requisição de webhook inclui os seguintes headers:
| Header | Exemplo | Descrição |
|---|---|---|
Content-Type | application/json; charset=utf-8 | Formato do payload |
User-Agent | BuntoERP-Webhook/1.0 | Identificação do sistema |
X-Bunto-Signature | sha256=63fad96bb1c44e80... | Assinatura HMAC-SHA256 do payload |
X-Bunto-Evento | estoque.atualizado | Tipo do evento |
X-Bunto-Webhook-Id | 3 | ID do webhook |
X-Bunto-Timestamp | 2026-02-13T13:56:55.721908+00:00 | Timestamp do evento (ISO 8601 com timezone) |
X-Bunto-Idempotency-Key | 3_estoque.atualizado_822c699af3b74d26 | Chave de idempotência |
Exemplo real de headers recebidos:
codeuser-agent: BuntoERP-Webhook/1.0 content-type: application/json; charset=utf-8 x-bunto-signature: sha256=63fad96bb1c44e8027877ee8d8c99c68b8fe2a2de3f655888a0ead907ea783b6 x-bunto-evento: estoque.atualizado x-bunto-webhook-id: 3 x-bunto-timestamp: 2026-02-13T13:56:55.721908+00:00 x-bunto-idempotency-key: 3_estoque.atualizado_822c699af3b74d26
Além desses, headers customizados configurados no webhook também são incluídos.
Verificação de Assinatura (Segurança)
Sempre verifique a assinatura para garantir que a requisição veio do Bunto ERP e não foi alterada.
Como funciona
- O Bunto calcula
HMAC-SHA256(secret, payload_json)usando o secret do webhook - O resultado é enviado no header
X-Bunto-Signaturecom prefixosha256= - Sua aplicação recalcula o HMAC com o mesmo secret e compara com o header
- Use comparação constant-time para evitar timing attacks
Python
pythonimport hmac import hashlib import json from flask import Flask, request, abort app = Flask(__name__) WEBHOOK_SECRET = "whsec_seu_secret_aqui" def verificar_assinatura(payload_body: bytes, assinatura_header: str) -> bool: """Verifica assinatura HMAC-SHA256 do webhook Bunto.""" mac = hmac.new( WEBHOOK_SECRET.encode('utf-8'), payload_body, hashlib.sha256 ) assinatura_esperada = f"sha256={mac.hexdigest()}" return hmac.compare_digest(assinatura_esperada, assinatura_header) @app.route('/webhook', methods=['POST']) def receber_webhook(): assinatura = request.headers.get('X-Bunto-Signature', '') if not verificar_assinatura(request.data, assinatura): abort(401, 'Assinatura inválida') payload = request.json evento = payload['evento'] dados = payload['dados'] if evento == 'produto.criado': print(f"Novo produto: {dados['nome']} (ID: {dados['id']})") elif evento == 'estoque.atualizado': print(f"Estoque: {dados['produto_nome']} | {dados['tipo']} {dados['quantidade']}") elif evento == 'cliente.criado': print(f"Novo cliente: {dados['nome']} ({dados['cidade']}/{dados['uf']})") return {'status': 'ok'}, 200
JavaScript (Node.js)
javascriptconst crypto = require('crypto'); const express = require('express'); const app = express(); const WEBHOOK_SECRET = 'whsec_seu_secret_aqui'; app.use(express.raw({ type: 'application/json' })); function verificarAssinatura(payloadBody, assinaturaHeader) { const mac = crypto.createHmac('sha256', WEBHOOK_SECRET) .update(payloadBody) .digest('hex'); const assinaturaEsperada = `sha256=${mac}`; return crypto.timingSafeEqual( Buffer.from(assinaturaEsperada), Buffer.from(assinaturaHeader) ); } app.post('/webhook', (req, res) => { const assinatura = req.headers['x-bunto-signature'] || ''; if (!verificarAssinatura(req.body, assinatura)) { return res.status(401).json({ erro: 'Assinatura inválida' }); } const payload = JSON.parse(req.body); const { evento, dados } = payload; switch (evento) { case 'produto.criado': console.log(`Novo produto: ${dados.nome} (ID: ${dados.id})`); break; case 'estoque.atualizado': console.log(`Estoque: ${dados.produto_nome} | ${dados.tipo} ${dados.quantidade}`); break; case 'cliente.criado': console.log(`Novo cliente: ${dados.nome}`); break; } res.status(200).json({ status: 'ok' }); }); app.listen(3000);
PHP
php<?php $secret = 'whsec_seu_secret_aqui'; $payload = file_get_contents('php://input'); $assinatura = $_SERVER['HTTP_X_BUNTO_SIGNATURE'] ?? ''; $mac = 'sha256=' . hash_hmac('sha256', $payload, $secret); if (!hash_equals($mac, $assinatura)) { http_response_code(401); echo json_encode(['erro' => 'Assinatura inválida']); exit; } $dados = json_decode($payload, true); $evento = $dados['evento']; switch ($evento) { case 'produto.criado': $produto = $dados['dados']; error_log("Novo produto: {$produto['nome']} (ID: {$produto['id']})"); break; case 'estoque.atualizado': $estoque = $dados['dados']; error_log("Estoque: {$estoque['produto_nome']} | {$estoque['tipo']} {$estoque['quantidade']}"); break; case 'cliente.criado': $cliente = $dados['dados']; error_log("Novo cliente: {$cliente['nome']}"); break; } http_response_code(200); echo json_encode(['status' => 'ok']);
Java (Spring Boot)
javaimport javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.web.bind.annotation.*; import org.springframework.http.ResponseEntity; import java.util.Map; @RestController public class WebhookController { private static final String WEBHOOK_SECRET = "whsec_seu_secret_aqui"; private boolean verificarAssinatura(String payload, String assinaturaHeader) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes("UTF-8"), "HmacSHA256")); byte[] hash = mac.doFinal(payload.getBytes("UTF-8")); StringBuilder hex = new StringBuilder(); for (byte b : hash) { hex.append(String.format("%02x", b)); } String assinaturaEsperada = "sha256=" + hex.toString(); return assinaturaEsperada.equals(assinaturaHeader); } catch (Exception e) { return false; } } @PostMapping("/webhook") public ResponseEntity<Map<String, String>> receberWebhook( @RequestBody String payload, @RequestHeader("X-Bunto-Signature") String assinatura, @RequestHeader("X-Bunto-Evento") String evento) { if (!verificarAssinatura(payload, assinatura)) { return ResponseEntity.status(401).body(Map.of("erro", "Assinatura inválida")); } switch (evento) { case "produto.criado": System.out.println("Novo produto recebido via webhook"); break; case "estoque.atualizado": System.out.println("Estoque atualizado via webhook"); break; case "cliente.criado": System.out.println("Novo cliente recebido via webhook"); break; } return ResponseEntity.ok(Map.of("status", "ok")); } }
C# (.NET)
csharpusing System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("webhook")] public class WebhookController : ControllerBase { private const string WebhookSecret = "whsec_seu_secret_aqui"; private bool VerificarAssinatura(string payload, string assinaturaHeader) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); var assinaturaEsperada = "sha256=" + BitConverter.ToString(hash) .Replace("-", "").ToLower(); return assinaturaEsperada == assinaturaHeader; } [HttpPost] public IActionResult ReceberWebhook( [FromBody] dynamic payload, [FromHeader(Name = "X-Bunto-Signature")] string assinatura, [FromHeader(Name = "X-Bunto-Evento")] string evento) { string body; using (var reader = new StreamReader(Request.Body)) { body = reader.ReadToEnd(); } if (!VerificarAssinatura(body, assinatura)) { return Unauthorized(new { erro = "Assinatura inválida" }); } switch (evento) { case "produto.criado": Console.WriteLine("Novo produto recebido via webhook"); break; case "estoque.atualizado": Console.WriteLine("Estoque atualizado via webhook"); break; case "cliente.criado": Console.WriteLine("Novo cliente recebido via webhook"); break; } return Ok(new { status = "ok" }); } }
Go
gopackage main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" ) const webhookSecret = "whsec_seu_secret_aqui" func verificarAssinatura(payload []byte, assinaturaHeader string) bool { mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write(payload) assinaturaEsperada := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(assinaturaEsperada), []byte(assinaturaHeader)) } func webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Erro ao ler body", 400) return } assinatura := r.Header.Get("X-Bunto-Signature") if !verificarAssinatura(body, assinatura) { http.Error(w, `{"erro":"Assinatura inválida"}`, 401) return } var payload map[string]interface{} json.Unmarshal(body, &payload) evento := payload["evento"].(string) dados := payload["dados"].(map[string]interface{}) switch evento { case "produto.criado": fmt.Printf("Novo produto: %s (ID: %.0f)\n", dados["nome"], dados["id"]) case "estoque.atualizado": fmt.Printf("Estoque: %s | %s %s\n", dados["produto_nome"], dados["tipo"], dados["quantidade"]) case "cliente.criado": fmt.Printf("Novo cliente: %s\n", dados["nome"]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) w.Write([]byte(`{"status":"ok"}`)) } func main() { http.HandleFunc("/webhook", webhookHandler) fmt.Println("Servidor webhook rodando na porta 3000") http.ListenAndServe(":3000", nil) }
Política de Retries
Quando o webhook falha (timeout, erro de conexão ou resposta não-2xx), o Bunto re-tenta com backoff exponencial:
| Tentativa | Intervalo | Tempo acumulado |
|---|---|---|
| 1 | Imediata | 0s |
| 2 | 30 segundos | 30s |
| 3 | 1 minuto | 1m 30s |
| 4 | 2 minutos | 3m 30s |
| 5 | 4 minutos | 7m 30s |
| 6 | 8 minutos | 15m 30s |
| 7 | 16 minutos | 31m 30s |
| 8 | 32 minutos | 63m 30s |
| 9 | 64 minutos | ~2h 7m |
| 10 | 128 minutos | ~4h 15m |
- O número máximo de tentativas é configurável (1-10, padrão: 5)
- Após esgotar todas as tentativas, o evento é marcado como falha definitiva
- Cada tentativa gera um registro de log independente
O que conta como sucesso
| Código HTTP | Resultado |
|---|---|
| 2xx (200-299) | Sucesso - não faz retry |
| 3xx (redirect) | Falha - redirects bloqueados por segurança |
| 4xx (400-499) | Falha - faz retry (pode ser erro temporário no receptor) |
| 5xx (500-599) | Falha - faz retry |
| Timeout | Falha - faz retry |
| Erro de conexão | Falha - faz retry |
Idempotência
Cada entrega possui uma idempotency_key única, enviada no header X-Bunto-Idempotency-Key e no corpo do payload.
Use essa chave para evitar processamento duplicado. Mesmo com as proteções internas do Bunto, é possível que sua aplicação receba o mesmo evento mais de uma vez (ex: se sua aplicação respondeu 200 mas houve timeout no retorno).
Formato: {webhook_id}_{evento}_{hash_unico}
Exemplo: 3_estoque.atualizado_822c699af3b74d26
python# Exemplo: armazenar chaves processadas chaves_processadas = set() # Use Redis ou banco de dados em produção @app.route('/webhook', methods=['POST']) def receber_webhook(): payload = request.json chave = payload['idempotency_key'] if chave in chaves_processadas: return {'status': 'já processado'}, 200 # Processar evento... chaves_processadas.add(chave) return {'status': 'ok'}, 200
LGPD - Proteção de Dados
Em conformidade com a Lei Geral de Proteção de Dados (Lei 13.709/2018), os webhooks do Bunto mascaram automaticamente dados pessoais sensíveis nos payloads:
| Dado | Formato mascarado | Exemplo |
|---|---|---|
| CPF | ***.XXX.XXX-XX | ***.456.789-00 |
| CNPJ | **.XXX.XXX/XXXX-XX | **.456.789/0001-00 |
X***@dominio.com | m***@empresa.com.br | |
| Telefone | *****-XXXX | *****-5678 |
Nota: Para acessar dados completos do cliente, utilize a API de Clientes (GET /v1/clientes/{id}/) com autenticação via token bnt_.
Segurança
Validação de URL (SSRF)
O Bunto valida a URL de destino contra ataques SSRF:
- Bloqueados: IPs privados (10.x, 172.16-31.x, 192.168.x), localhost, metadata endpoints (169.254.169.254)
- Bloqueados: URLs com credenciais embutidas (user:pass@host)
- Obrigatório: HTTPS em produção (HTTP permitido apenas em desenvolvimento)
- Bloqueados: Redirects (requisições nunca seguem 301/302)
Secret
- Gerado automaticamente na criação do webhook
- Exibido apenas na criação e na rotação — armazene com segurança
- Pode ser rotacionado a qualquer momento (invalida o anterior imediatamente)
Boas Práticas
- Sempre verifique a assinatura antes de processar o payload
- Use HTTPS no seu endpoint (obrigatório em produção)
- Responda rápido (dentro do timeout configurado) - processe assincronamente
- Implemente idempotência usando a
idempotency_key - Rotacione o secret periodicamente ou se houver suspeita de comprometimento
- Monitore os logs de entrega na interface do Bunto
Limites e Quotas
| Recurso | Limite |
|---|---|
| Webhooks por empresa | 20 |
| Headers customizados por webhook | 10 |
| Tamanho da chave de header | 128 caracteres |
| Tamanho do valor de header | 1.024 caracteres |
| Tamanho da URL | 2.048 caracteres |
| Tamanho da descrição | 255 caracteres |
| Response body armazenado | 2 KB (truncado) |
| Retenção de logs | 30 dias |
| Máx. tentativas | 10 |
| Timeout | 5-30 segundos |
Troubleshooting
Webhook não está recebendo eventos
- Verifique se o webhook está ativo (campo
ativo: true) - Confirme que os eventos corretos estão selecionados
- Use o botão Testar para verificar conectividade
- Verifique os logs de entrega para identificar erros
Assinatura inválida
- Confirme que está usando o secret correto (não confunda com o prefixo)
- Verifique que está calculando o HMAC sobre o body raw (não o JSON parseado)
- Use comparação constant-time (
hmac.compare_digestem Python,crypto.timingSafeEqualem Node.js)
Timeout frequente
- Aumente o timeout do webhook (até 30s)
- Processe assincronamente: responda 200 imediatamente e processe em background
- Verifique a performance do seu servidor
Muitas falhas
- Verifique se seu servidor está acessível externamente
- Confirme que está respondendo com código 2xx (200-299)
- Não retorne redirects (3xx) - são bloqueados por segurança
FAQ
P: Os webhooks disparam para operações via API e via painel do ERP?
R: Sim. Qualquer alteração no sistema dispara o webhook, independente de ter sido feita pelo painel (JWT) ou pela API externa (token bnt_).
P: Posso usar HTTP em vez de HTTPS? R: Apenas em desenvolvimento. Em produção, HTTPS é obrigatório.
P: O que acontece se meu servidor estiver fora do ar? R: O Bunto re-tenta com backoff exponencial até o máximo de tentativas configurado. Após esgotar, o evento é registrado como falha definitiva.
P: Posso receber o mesmo evento mais de uma vez?
R: Sim, em casos raros. Use a idempotency_key para evitar processamento duplicado.
P: Como sei que a requisição veio do Bunto?
R: Verifique a assinatura HMAC-SHA256 no header X-Bunto-Signature usando seu secret.
P: Perdi o secret, como recuperar? R: O secret não pode ser recuperado. Use a opção "Rotacionar Secret" no painel para gerar um novo.
P: Por que os dados de clientes aparecem mascarados? R: Para conformidade com a LGPD. CPF/CNPJ, e-mail e telefone são mascarados automaticamente. Use a API de Clientes para obter dados completos.
P: Headers customizados podem sobrescrever headers do sistema?
R: Não. Headers reservados (Content-Type, Authorization, X-Bunto-*, Cookie, etc.) são protegidos.