WebhooksAPISegurançaBest Practices

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

14 de janeiro de 2025
8 min read
Webhooks: O Guia Definitivo para Developers

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:

  1. Sempre valide signature
  2. Use idempotência
  3. Responda 200 rápido
  4. Processe async com queue
  5. Monitore falhas

Docs completas: docs.pulse.infinitum.com/webhooks

Testar agora: Dashboard > Webhooks