Webhooks
Configurar e gerenciar webhooks para notificações em tempo real
Webhooks
Receba notificações em tempo real sobre eventos importantes como alterações em reservas, pagamentos e atualizações de status.
Por que usar Webhooks?
Webhooks permitem que você seja notificado automaticamente quando eventos ocorrem, eliminando a necessidade de fazer polling na API. Isso resulta em melhor experiência do usuário e menor consumo de recursos.
Como Funciona
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Evento Ocorre │────▶│ API Reservei │────▶│ Seu Servidor │
│ (ex: bilhete │ │ envia POST │ │ processa e │
│ emitido) │ │ para sua URL │ │ responde 200 OK │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘- Um evento ocorre na plataforma (reserva criada, bilhete emitido, etc.)
- A API Reservei envia um POST para a URL configurada
- Seu servidor processa o evento e responde com
200 OK - Se falhar, tentamos novamente com backoff exponencial
GET /api/v1/webhooks
Lista todos os webhooks configurados para seu time.
Response
{
"success": true,
"data": [
{
"id": "wh_0000AbCdEf123456",
"url": "https://seu-app.com/api/webhooks/reservei",
"events": [
"booking.created",
"booking.confirmed",
"booking.ticketed",
"booking.cancelled"
],
"active": true,
"created_at": "2025-01-10T10:00:00Z",
"last_triggered_at": "2025-01-15T14:30:00Z",
"success_rate": 98.5,
"total_deliveries": 127,
"failed_deliveries": 2
}
]
}Campos
| Campo | Tipo | Descrição |
|---|---|---|
id | string | ID único do webhook |
url | string | URL que recebe as notificações |
events | array | Lista de eventos escutados |
active | boolean | Se o webhook está ativo |
secret | string | Chave para verificar assinaturas (só no momento da criação) |
success_rate | number | Taxa de sucesso das entregas (%) |
total_deliveries | number | Total de notificações enviadas |
failed_deliveries | number | Total de falhas |
POST /api/v1/webhooks
Cria um novo webhook.
Request
{
"url": "https://seu-app.com/api/webhooks/reservei",
"events": [
"booking.created",
"booking.confirmed",
"booking.ticketed",
"booking.cancelled",
"wallet.credit",
"wallet.debit"
]
}Parâmetros
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
url | string | Sim | URL que receberá as notificações (deve ser HTTPS) |
events | array | Sim | Lista de eventos para escutar |
Requisitos da URL
- Deve usar HTTPS (HTTP não é aceito por segurança)
- Deve ser acessível publicamente
- Deve responder em menos de 5 segundos
- Deve retornar status 2xx para confirmar recebimento
Response
{
"success": true,
"data": {
"id": "wh_0000AbCdEf123456",
"url": "https://seu-app.com/api/webhooks/reservei",
"events": [
"booking.created",
"booking.confirmed",
"booking.ticketed",
"booking.cancelled",
"wallet.credit",
"wallet.debit"
],
"active": true,
"secret": "whsec_AbCdEfGhIjKlMnOpQrStUvWxYz123456789"
}
}Guarde o Secret!
O secret é exibido apenas uma vez no momento da criação. Use-o para verificar a autenticidade das notificações recebidas.
PUT /api/v1/webhooks/:id
Atualiza um webhook existente.
Request
{
"url": "https://novo-endpoint.com/webhooks",
"events": ["booking.created", "booking.ticketed"],
"active": true
}Response
{
"success": true,
"data": {
"id": "wh_0000AbCdEf123456",
"url": "https://novo-endpoint.com/webhooks",
"events": ["booking.created", "booking.ticketed"],
"active": true,
"updated_at": "2025-01-15T16:00:00Z"
}
}DELETE /api/v1/webhooks/:id
Remove um webhook.
Response
{
"success": true,
"data": {
"id": "wh_0000AbCdEf123456",
"deleted": true
}
}Eventos Disponíveis
Eventos de Reserva
| Evento | Descrição | Quando é Disparado |
|---|---|---|
booking.created | Nova reserva criada | Após POST /api/v1/bookings com sucesso |
booking.confirmed | Pagamento confirmado | Após confirmação de pagamento |
booking.ticketed | Bilhetes emitidos | Quando companhia emite bilhetes |
booking.cancelled | Reserva cancelada | Após cancelamento |
booking.expired | Reserva expirou | Prazo de pagamento excedido |
booking.failed | Falha na reserva | Erro na emissão |
booking.updated | Reserva atualizada | Alteração de dados/status |
Eventos de Wallet
| Evento | Descrição | Quando é Disparado |
|---|---|---|
wallet.credit | Crédito adicionado | Recarga ou estorno confirmado |
wallet.debit | Débito realizado | Reserva confirmada |
wallet.recharge.pending | Recarga pendente | Recarga solicitada |
wallet.recharge.completed | Recarga completa | Pagamento confirmado |
wallet.recharge.expired | Recarga expirada | Prazo de pagamento excedido |
wallet.low_balance | Saldo baixo | Saldo < R$ 500,00 |
Eventos de Integração
| Evento | Descrição | Quando é Disparado |
|---|---|---|
integration.connected | Integração conectada | Nova integração ativada |
integration.disconnected | Integração desconectada | Integração removida |
api_key.created | Nova API key | Chave criada |
api_key.revoked | API key revogada | Chave desativada |
Formato do Payload
Todas as notificações seguem este formato:
{
"id": "evt_0000AbCdEf123456",
"type": "booking.ticketed",
"api_version": "2025-01-01",
"created_at": "2025-01-15T14:30:00Z",
"data": {
"object": {
"id": "ord_0000GhIjKl789012",
"booking_reference": "XJ59L2",
"status": "ticketed",
"total_amount": "7825.60",
"currency": "BRL",
"documents": [
{
"type": "electronic_ticket",
"unique_identifier": "001-1234567890",
"passenger_id": "pas_0000..."
}
],
"passengers": [...],
"slices": [...],
"created_at": "2025-01-15T10:30:00Z"
},
"previous_attributes": {
"status": "confirmed"
}
},
"team_id": "team_0000...",
"webhook_id": "wh_0000AbCdEf123456"
}Campos do Evento
| Campo | Tipo | Descrição |
|---|---|---|
id | string | ID único do evento |
type | string | Tipo do evento |
api_version | string | Versão da API |
created_at | string | Timestamp do evento |
data.object | object | Objeto atual (reserva, transação, etc.) |
data.previous_attributes | object | Atributos que mudaram (para eventos de update) |
team_id | string | ID do seu time |
webhook_id | string | ID do webhook que recebeu |
Verificando a Assinatura
Para garantir que a notificação veio da Reservei, verifique a assinatura no header X-Reservei-Signature.
Headers Enviados
| Header | Descrição |
|---|---|
X-Reservei-Signature | Assinatura HMAC-SHA256 do payload |
X-Reservei-Timestamp | Timestamp Unix do envio |
X-Reservei-Event-Id | ID do evento |
Content-Type | application/json |
Algoritmo de Verificação
- Concatene o timestamp +
.+ payload raw - Calcule HMAC-SHA256 usando seu secret
- Compare com a assinatura recebida
import crypto from 'crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.RESERVEI_WEBHOOK_SECRET;
// Importante: usar raw body para verificação
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-reservei-signature'];
const timestamp = req.headers['x-reservei-timestamp'];
const payload = req.body.toString();
// Verificar timestamp (previne replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(401).json({ error: 'Timestamp too old' });
}
// Calcular assinatura esperada
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Comparação segura
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Processar evento
const event = JSON.parse(payload);
console.log(`✅ Evento recebido: ${event.type}`);
switch (event.type) {
case 'booking.created':
// Processar nova reserva
handleBookingCreated(event.data.object);
break;
case 'booking.ticketed':
// Enviar bilhete para cliente
handleBookingTicketed(event.data.object);
break;
case 'wallet.low_balance':
// Alertar admin
handleLowBalance(event.data.object);
break;
}
// Responder rapidamente
res.status(200).json({ received: true });
});
function handleBookingCreated(booking) {
console.log(`Nova reserva: ${booking.booking_reference}`);
}
function handleBookingTicketed(booking) {
console.log(`Bilhete emitido: ${booking.documents[0].unique_identifier}`);
}
function handleLowBalance(wallet) {
console.log(`⚠️ Saldo baixo: R$ ${wallet.balance}`);
}import hmac
import hashlib
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['RESERVEI_WEBHOOK_SECRET']
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Reservei-Signature')
timestamp = request.headers.get('X-Reservei-Timestamp')
payload = request.get_data(as_text=True)
# Verificar timestamp
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return jsonify({'error': 'Timestamp too old'}), 401
# Calcular assinatura
signed_payload = f"{timestamp}.{payload}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Comparação segura
if not hmac.compare_digest(signature, expected_signature):
return jsonify({'error': 'Invalid signature'}), 401
# Processar evento
event = request.json
print(f"✅ Evento recebido: {event['type']}")
if event['type'] == 'booking.created':
handle_booking_created(event['data']['object'])
elif event['type'] == 'booking.ticketed':
handle_booking_ticketed(event['data']['object'])
return jsonify({'received': True}), 200
def handle_booking_created(booking):
print(f"Nova reserva: {booking['booking_reference']}")
def handle_booking_ticketed(booking):
print(f"Bilhete: {booking['documents'][0]['unique_identifier']}")<?php
$webhookSecret = getenv('RESERVEI_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_RESERVEI_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_RESERVEI_TIMESTAMP'] ?? '';
$payload = file_get_contents('php://input');
// Verificar timestamp
$now = time();
if (abs($now - intval($timestamp)) > 300) {
http_response_code(401);
echo json_encode(['error' => 'Timestamp too old']);
exit;
}
// Calcular assinatura
$signedPayload = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
// Comparação segura
if (!hash_equals($signature, $expectedSignature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Processar evento
$event = json_decode($payload, true);
error_log("✅ Evento recebido: " . $event['type']);
switch ($event['type']) {
case 'booking.created':
handleBookingCreated($event['data']['object']);
break;
case 'booking.ticketed':
handleBookingTicketed($event['data']['object']);
break;
}
http_response_code(200);
echo json_encode(['received' => true]);
function handleBookingCreated($booking) {
error_log("Nova reserva: " . $booking['booking_reference']);
}
function handleBookingTicketed($booking) {
error_log("Bilhete: " . $booking['documents'][0]['unique_identifier']);
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = os.Getenv("RESERVEI_WEBHOOK_SECRET")
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Reservei-Signature")
timestamp := r.Header.Get("X-Reservei-Timestamp")
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Verificar timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
now := time.Now().Unix()
if math.Abs(float64(now-ts)) > 300 {
http.Error(w, "Timestamp too old", http.StatusUnauthorized)
return
}
// Calcular assinatura
signedPayload := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Comparação segura
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Processar evento
var event map[string]interface{}
json.Unmarshal(payload, &event)
eventType := event["type"].(string)
println("✅ Evento recebido:", eventType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}Política de Retentativas
Se sua URL não responder com 2xx, tentaremos novamente com backoff exponencial:
| Tentativa | Intervalo | Horário Exemplo |
|---|---|---|
| 1ª | Imediato | 10:00:00 |
| 2ª | 5 minutos | 10:05:00 |
| 3ª | 30 minutos | 10:35:00 |
| 4ª | 2 horas | 12:35:00 |
| 5ª | 12 horas | 00:35:00 |
| 6ª | 24 horas | 00:35:00 (+1 dia) |
Após 6 tentativas falhas consecutivas, o webhook é desativado automaticamente. Você receberá um email de alerta e precisará reativar manualmente no painel.
Boas Práticas
✅ Faça
- Responda rapidamente - Retorne
200 OKem até 5 segundos - Processe assincronamente - Use filas (Redis, SQS, RabbitMQ) para tarefas demoradas
- Implemente idempotência - O mesmo evento pode ser enviado mais de uma vez
- Verifique a assinatura - Sempre valide que a requisição veio da Reservei
- Use HTTPS - Obrigatório para receber webhooks
- Monitore entregas - Acompanhe a taxa de sucesso no painel
- Trate erros graciosamente - Log erros mas retorne 200 se possível
❌ Não Faça
- Processar no handler - Não execute lógica pesada síncrona
- Ignorar duplicados - Mesmo evento pode vir mais de uma vez
- Confiar cegamente - Sempre verifique a assinatura
- Usar HTTP - Apenas HTTPS é aceito
- Timeout longo - Não demore mais que 5 segundos para responder
- Ignorar alertas - Falhas consecutivas desativam o webhook
Exemplo de Processamento Assíncrono
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhook-events');
app.post('/webhook', async (req, res) => {
// Verificar assinatura...
// Enfileirar para processamento assíncrono
await webhookQueue.add('process-event', {
event: req.body
});
// Responder imediatamente
res.status(200).json({ received: true });
});
// Worker processa em background
const worker = new Worker('webhook-events', async (job) => {
const { event } = job.data;
switch (event.type) {
case 'booking.ticketed':
await sendTicketToCustomer(event.data.object);
await updateCRM(event.data.object);
await notifySlack(event.data.object);
break;
}
});Testando Webhooks
Ambiente de Desenvolvimento
Use ferramentas como ngrok ou localtunnel para expor localhost:
# Instalar ngrok
npm install -g ngrok
# Expor porta 3000
ngrok http 3000
# Use a URL gerada (ex: https://abc123.ngrok.io/webhook)Teste Manual via cURL
Simule um evento localmente:
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Reservei-Signature: test" \
-H "X-Reservei-Timestamp: $(date +%s)" \
-d '{
"id": "evt_test123",
"type": "booking.created",
"data": {
"object": {
"id": "ord_test",
"booking_reference": "TEST01"
}
}
}'Erros Comuns
| Código | Erro | Causa | Solução |
|---|---|---|---|
400 | Invalid URL | URL malformada ou HTTP | Use HTTPS válido |
400 | Invalid events | Eventos não reconhecidos | Verifique lista de eventos |
404 | Webhook not found | ID inválido | Verifique o ID |
409 | URL already registered | URL já existe | Use URL diferente |
422 | URL not reachable | Não conseguimos acessar | Verifique firewall/DNS |