Webhooks: O Guia Definitivo para Developers
Tudo sobre webhooks: validação, retry, debugging, idempotência e as melhores práticas para implementar corretamente.
Equipe Tech Infi Pulse
Autor
Webhooks são simples conceitualmente, mas têm várias armadilhas. Este guia mostra como implementar corretamente para produção.
O que são Webhooks?
TL;DR: Notificações HTTP que a API envia quando algo acontece.
┌─────────────┐ ┌──────────────┐
│ Cliente │ │ API │
│ Paga PIX │──────────────────────▶│ Pulse │
└─────────────┘ └──────────────┘
│
│ Webhook POST
▼
┌──────────────┐
│ Seu Server │
│ /webhooks/* │
└──────────────┘
Por que não polling?
// ❌ Ruim: Polling a cada 5 segundos
setInterval(async () => {
const payment = await pulse.payments.retrieve(paymentId)
if (payment.status === 'completed') {
fulfillOrder()
}
}, 5000)
// ✅ Bom: Webhook em tempo real
app.post('/webhooks/pulse', (req, res) => {
const event = req.body
if (event.type === 'payment.completed') {
fulfillOrder()
}
})
Eventos Disponíveis
1. payment.created
Pagamento criado, aguardando PIX.
{
"type": "payment.created",
"data": {
"id": "pay_abc123",
"status": "pending",
"amount": 100,
"currency": "BRL",
"createdAt": "2025-01-14T10:00:00Z"
}
}
Use case: Log analytics, notificar usuário que link foi gerado.
2. payment.processing
PIX recebido, convertendo para USDT.
{
"type": "payment.processing",
"data": {
"id": "pay_abc123",
"status": "processing",
"pixConfirmedAt": "2025-01-14T10:05:32Z"
}
}
Use case: Atualizar UI do usuário ("Pagamento confirmado, processando...").
3. payment.completed
USDT depositado na wallet. Este é o importante.
{
"type": "payment.completed",
"data": {
"id": "pay_abc123",
"status": "completed",
"amount": 100,
"amountUSDT": 18.52,
"walletAddress": "0x742d35Cc...",
"transactionHash": "0xabcd1234...",
"completedAt": "2025-01-14T10:07:15Z"
}
}
Use case: Liberar produto, enviar email de confirmação, atualizar banco.
4. payment.failed
Erro na conversão ou expiração.
{
"type": "payment.failed",
"data": {
"id": "pay_abc123",
"status": "failed",
"failureReason": "expired",
"failedAt": "2025-01-14T12:00:00Z"
}
}
Use case: Notificar usuário, criar novo link se quiser.
Validação de Assinatura HMAC
Regra #1: Sempre valide a assinatura. Sempre.
Por que?
Sem validação, qualquer um pode enviar POST falso:
curl -X POST https://seu-app.com/webhooks/pulse \
-H "Content-Type: application/json" \
-d '{"type":"payment.completed","data":{"id":"pay_fake"}}'
Seu código processa como válido → libera produto de graça.
Como Validar (Node.js)
import crypto from 'crypto'
import express from 'express'
const app = express()
// IMPORTANTE: Use express.raw() para webhooks
app.post('/webhooks/pulse',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['pulse-signature'] as string
const secret = process.env.PULSE_WEBHOOK_SECRET // Pegar no dashboard
// Calcular HMAC do payload
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // Raw body, não parsed
.digest('hex')
// Comparar de forma segura
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
console.error('Invalid webhook signature')
return res.status(401).send('Invalid signature')
}
// Signature válida, pode processar
const event = JSON.parse(req.body.toString())
handleWebhook(event)
res.status(200).send('OK')
}
)
Como Validar (Python)
import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/pulse', methods=['POST'])
def webhook():
signature = request.headers.get('Pulse-Signature')
secret = os.environ['PULSE_WEBHOOK_SECRET']
# Calcular HMAC
expected = hmac.new(
secret.encode(),
request.data,
hashlib.sha256
).hexdigest()
# Comparar
if not hmac.compare_digest(signature, expected):
return 'Invalid signature', 401
event = request.json
handle_webhook(event)
return 'OK', 200
Idempotência: Processando Webhooks Apenas Uma Vez
O Problema
Webhooks têm retry automático. Você pode receber o mesmo evento 2-3 vezes.
[10:05:32] payment.completed (1ª tentativa)
└─ Seu server timeout (não responde 200)
[10:05:42] payment.completed (retry #1)
└─ Processado ✓ mas resposta lenta
[10:05:52] payment.completed (retry #2)
└─ Processado de novo! ❌ DUPLICADO
Resultado: Você libera produto 2x, envia 2 emails, etc.
Solução: Idempotency Key
const processedWebhooks = new Set() // Production: use Redis
async function handleWebhook(event: WebhookEvent) {
const idempotencyKey = event.id // ID único do evento
// Já processamos este evento?
if (processedWebhooks.has(idempotencyKey)) {
console.log(`Event ${idempotencyKey} already processed, skipping`)
return
}
// Marcar como processado ANTES de processar
processedWebhooks.add(idempotencyKey)
try {
// Processar evento
if (event.type === 'payment.completed') {
await fulfillOrder(event.data.metadata.orderId)
}
} catch (error) {
// Se der erro, remover da lista para retry
processedWebhooks.delete(idempotencyKey)
throw error
}
}
Com Redis (Produção)
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function handleWebhook(event: WebhookEvent) {
const key = `webhook:processed:${event.id}`
// Tentar adicionar no Redis com NX (only if not exists)
const wasNew = await redis.set(key, '1', 'EX', 86400, 'NX') // 24h TTL
if (!wasNew) {
console.log('Duplicate webhook, ignoring')
return
}
// Processar...
}
Com Database
async function handleWebhook(event: WebhookEvent) {
try {
// Insert com constraint UNIQUE no event.id
await db.webhooks.create({
eventId: event.id,
type: event.type,
processedAt: new Date()
})
} catch (error) {
if (error.code === '23505') { // PostgreSQL unique violation
console.log('Duplicate webhook')
return
}
throw error
}
// Processar...
}
Retry Strategy
Pulse faz retry automático assim:
Tentativa 1: Imediato
Tentativa 2: +10 segundos
Tentativa 3: +60 segundos
Se as 3 falharem: Webhook é marcado como failed no dashboard.
Como Garantir Recebimento
1. Responder 200 rapidamente
// ❌ Ruim: Processar tudo antes de responder
app.post('/webhooks/pulse', async (req, res) => {
const event = req.body
await longRunningTask(event) // 30 segundos
res.status(200).send('OK') // Timeout!
})
// ✅ Bom: Responder imediato, processar async
app.post('/webhooks/pulse', (req, res) => {
const event = req.body
// Validar signature
if (!validateSignature(req)) {
return res.status(401).send('Invalid')
}
// Adicionar na fila
queue.add('webhook', event)
// Responder imediato
res.status(200).send('OK')
})
// Processar em background worker
queue.process('webhook', async (job) => {
await handleWebhook(job.data)
})
2. Usar job queue (Bull/BullMQ)
import Queue from 'bull'
const webhookQueue = new Queue('webhooks', process.env.REDIS_URL)
app.post('/webhooks/pulse', (req, res) => {
webhookQueue.add({
event: req.body,
receivedAt: new Date()
})
res.status(200).send('OK')
})
webhookQueue.process(async (job) => {
const { event } = job.data
if (event.type === 'payment.completed') {
await fulfillOrder(event.data.metadata.orderId)
await sendEmail(event.data.metadata.userEmail)
}
})
Debugging Webhooks
1. Logs Estruturados
import winston from 'winston'
const logger = winston.createLogger({
format: winston.format.json(),
transports: [new winston.transports.Console()]
})
app.post('/webhooks/pulse', (req, res) => {
logger.info('Webhook received', {
type: req.body.type,
paymentId: req.body.data.id,
signature: req.headers['pulse-signature']
})
// ... processar ...
logger.info('Webhook processed successfully', {
type: req.body.type,
paymentId: req.body.data.id,
duration: Date.now() - startTime
})
})
2. Testar Localmente com ngrok
# Terminal 1: Seu server
npm run dev
# → localhost:3000
# Terminal 2: ngrok
ngrok http 3000
# → https://abc123.ngrok.io
# Dashboard Pulse > Webhooks
# URL: https://abc123.ngrok.io/webhooks/pulse
Agora pagamentos de teste disparam webhooks pro seu localhost!
3. Replay Webhooks no Dashboard
Dashboard > Webhooks > Failed Events
└─ Clicar "Retry"
Útil para testar código novo sem criar pagamentos novos.
4. Webhook Inspector
// Temporary endpoint para ver payload
app.post('/webhooks/inspect', express.json(), (req, res) => {
console.log('=== WEBHOOK RECEIVED ===')
console.log('Headers:', req.headers)
console.log('Body:', JSON.stringify(req.body, null, 2))
res.status(200).send('OK')
})
Monitoramento em Produção
Alertas se Webhooks Falharem
import Sentry from '@sentry/node'
app.post('/webhooks/pulse', async (req, res) => {
try {
await handleWebhook(req.body)
res.status(200).send('OK')
} catch (error) {
// Enviar pro Sentry/Datadog
Sentry.captureException(error, {
tags: {
webhookType: req.body.type,
paymentId: req.body.data.id
}
})
res.status(500).send('Internal Error')
}
})
Métricas
import { metrics } from './metrics'
app.post('/webhooks/pulse', (req, res) => {
const start = Date.now()
try {
handleWebhook(req.body)
metrics.increment('webhooks.processed', {
type: req.body.type,
status: 'success'
})
metrics.timing('webhooks.duration', Date.now() - start)
res.status(200).send('OK')
} catch (error) {
metrics.increment('webhooks.processed', {
type: req.body.type,
status: 'error'
})
throw error
}
})
Casos de Uso Avançados
1. Webhook Fanout
Disparar para múltiplos serviços internos:
async function handleWebhook(event: WebhookEvent) {
if (event.type === 'payment.completed') {
// Processar em paralelo
await Promise.all([
fulfillmentService.createOrder(event.data),
analyticsService.trackConversion(event.data),
emailService.sendConfirmation(event.data),
slackService.notifyTeam(event.data)
])
}
}
2. Conditional Processing
async function handleWebhook(event: WebhookEvent) {
const metadata = event.data.metadata
// Diferentes flows baseado em metadata
if (metadata.productType === 'digital') {
await deliverDigitalProduct(metadata.orderId)
} else if (metadata.productType === 'physical') {
await createShippingLabel(metadata.orderId)
} else if (metadata.subscriptionId) {
await extendSubscription(metadata.subscriptionId)
}
}
3. Transaction Rollback
async function handleWebhook(event: WebhookEvent) {
const db = await getDbConnection()
try {
await db.beginTransaction()
await db.orders.update(event.data.metadata.orderId, {
status: 'paid',
paidAt: event.data.completedAt
})
await db.inventory.decrement(event.data.metadata.productId)
await db.analytics.create({
event: 'purchase',
userId: event.data.metadata.userId,
amount: event.data.amount
})
await db.commit()
} catch (error) {
await db.rollback()
throw error
}
}
Checklist de Produção
- [ ] Signature validation implementada
- [ ] Idempotência com Redis/DB
- [ ] Job queue para processar async
- [ ] Responder 200 em < 3 segundos
- [ ] Error handling com retry
- [ ] Logs estruturados (JSON)
- [ ] Monitoramento (Sentry/Datadog)
- [ ] Alertas se taxa de erro > 5%
- [ ] Testes com ngrok antes de deploy
- [ ] Replay webhooks falhados no dashboard
Common Mistakes
❌ 1. Não validar signature
// NUNCA faça isso
app.post('/webhooks/pulse', (req, res) => {
handleWebhook(req.body) // Aceita qualquer POST
})
❌ 2. Processar síncrono
// Timeout se fulfillOrder demorar
app.post('/webhooks/pulse', async (req, res) => {
await fulfillOrder(req.body.data.metadata.orderId) // 30s
res.status(200).send('OK') // Request timeout!
})
❌ 3. Ignorar retries
// Processa 3x se houver retry
app.post('/webhooks/pulse', (req, res) => {
createOrder(req.body.data) // Sem idempotency check
})
❌ 4. Parse body antes de validar
// express.json() parseia antes de você validar signature
app.use(express.json())
app.post('/webhooks/pulse', (req, res) => {
validateSignature(req.body) // Body já foi parseado!
})
// Correto: express.raw() primeiro
app.post('/webhooks/pulse',
express.raw({ type: 'application/json' }),
(req, res) => {
validateSignature(req.body) // Raw buffer
const event = JSON.parse(req.body)
}
)
Conclusão
Webhooks parecem simples mas têm várias nuances. Siga estas práticas:
- Sempre valide signature
- Use idempotência
- Responda 200 rápido
- Processe async com queue
- Monitore falhas
Docs completas: docs.pulse.infinitum.com/webhooks
Testar agora: Dashboard > Webhooks