Skip to content

API de Webhooks

Sistema de webhooks para integração com Stripe e Asaas, permitindo atualizações automáticas de status de assinaturas e pagamentos.

Gateways e Endpoints

Gateway Endpoint Autenticação
Stripe POST /webhooks/stripe Stripe Signature (Stripe-Signature)
Asaas POST /webhooks/asaas Token de acesso Asaas

Todos os webhooks são logados na collection webhook_logs com o campo gateway para diferenciar.


📊 Logs de Webhooks

Todos os eventos recebidos são automaticamente salvos na coleção webhook_logs para auditoria.

Modelo WebhookLog

class WebhookLog(DocBase):
    """
    Log de eventos de webhook dos gateways (Stripe, Asaas).

    Armazena histórico completo para auditoria e troubleshooting.
    """
    event_id: str                   # ID do evento
    event_type: str               # Tipo do evento
    gateway: str                     # 'stripe' ou 'asaas'
    payload: dict[str, Any]       # Payload completo recebido
    signature: str                 # Assinatura do gateway
    signature_valid: bool           # Resultado da validação
    processed: bool               # Se evento foi processado com sucesso
    result: dict[str, Any]         # Resultado do processamento
    received_at: datetime           # Timestamp de recebimento
    processed_at: datetime | None   # Timestamp de processamento

Exemplo de Log Salvo

{
  "_id": "507f1f77bcf86cd799439011",
  "event_id": "EVT_ABC123DEF456",
  "event_type": "SUBSCRIPTION.UPDATED",
  "payload": {
    "id": "EVT_ABC123DEF456",
    "type": "SUBSCRIPTION.UPDATED",
    "created_at": "2026-01-08T14:30:00Z",
    "data": {
      "id": "SUBS_XYZ789",
      "status": "ACTIVE"
    }
  },
  "signature": "sha256=abc123...",
  "signature_valid": true,
  "processed": true,
  "result": {
    "status": "processed",
    "subscription_id": "507f1f77bcf86cd799439012",
    "new_status": "active"
  },
  "received_at": "2026-01-08T14:30:00.123Z",
  "processed_at": "2026-01-08T14:30:00.456Z"
}

Endpoint de Consulta

Ver documentação completa em API de Webhook Logs (próxima seção).

# Buscar logs de eventos de uma subscription específica
GET /api/v1/webhook-logs?query={"payload.data.id":"SUBS_XYZ789"}

# Buscar logs com falha de validação de assinatura
GET /api/v1/webhook-logs?query={"signature_valid":false}

# Buscar logs não processados
GET /api/v1/webhook-logs?query={"processed":false}

# Estatísticas de webhooks
GET /api/v1/webhook-logs/stats

🔧 Configuração

1. Configurar Webhook Secret

Em .secrets/credentials.toml:

# Stripe
[development.stripe]
secret_key = "rk_live_xxx"

[production.stripe]
secret_key = "rk_live_xxx"

# Asaas
[development.asaas]
access_token = "your-asaas-sandbox-token"

[production.asaas]
access_token = "your-asaas-production-token"

Secrets DEVEM ser diferentes por ambiente

O access_token deve ser diferente para Sandbox e Produção.

  • Secrets de produção NUNCA devem estar em código/repositórios

Controle de Ambiente

A API usa a variável de ambiente FDPLAY_ENV para escolher qual seção usar: - FDPLAY_ENV=development → usa [development] (sandbox) - FDPLAY_ENV=production → usa [production] (live)

2. Configurar URLs nos Painéis dos Gateways

Stripe:

  1. Acesse Stripe Dashboard → Developers → Webhooks
  2. URL: https://fdplay-api.infraifd.com/webhooks/stripe

Asaas:

  1. Acesse o painel Asaas → Integrações → Webhooks
  2. URL: https://fdplay-api.infraifd.com/webhooks/asaas

3. Testar Webhooks

# Stripe CLI
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger customer.subscription.updated

# Verificar logs
GET /api/v1/webhook-logs

🎯 Fluxo Completo: Assinatura → Webhook → Banco de Dados

Cenário 1: Sem desconto (fluxo padrão)

sequenceDiagram
    participant Customer
    participant Frontend
    participant API
    participant Gateway as Gateway (Stripe/Asaas)
    participant DB

    Customer->>Frontend: Preenche dados de assinatura
    Frontend->>API: POST /api/v1/asaas/subscribe
    API->>Gateway: POST /subscriptions (R$19,90, cobra hoje)
    Gateway-->>API: sub_xxx + PENDING
    API->>DB: Salvar subscription (status=pending)
    API-->>Frontend: 201 Created
    Frontend->>Frontend: Polling status...

    Note over Gateway: Pagamento processado

    Gateway->>API: POST /webhooks/asaas (PAYMENT_CONFIRMED)
    Note right of API: payment.subscription = "sub_xxx"
    API->>DB: find({asaas_subscription_id: "sub_xxx"})
    API->>DB: status → active
    API-->>Gateway: 200 OK

    Frontend->>API: GET /subscription → status=active
    Frontend-->>Customer: Acesso liberado!

Cenário 2: Com desconto primeiro mes (Asaas credit card)

sequenceDiagram
    participant Customer
    participant Frontend
    participant API
    participant Gateway as Asaas API
    participant DB

    Customer->>Frontend: Preenche cartao + promo_code
    Frontend->>API: POST /api/v1/asaas/subscribe
    API->>DB: Gera subscription _id (sub_oid)
    API->>Gateway: POST /payments (R$5,50 avulso, externalReference=sub_oid)
    Gateway-->>API: pay_xxx CONFIRMED
    API->>Gateway: POST /subscriptions (R$19,90, nextDueDate=+30d)
    Gateway-->>API: sub_xxx
    API->>DB: Salvar subscription (status=pending, _id=sub_oid)
    API-->>Frontend: 201 Created
    Frontend->>Frontend: Polling status...

    Note over Gateway: Webhook do pagamento avulso

    Gateway->>API: POST /webhooks/asaas (PAYMENT_CONFIRMED)
    Note right of API: payment.subscription = null
    Note right of API: payment.externalReference = "sub_oid"
    API->>DB: find({_id: ObjectId(externalReference)}) ← fallback
    API->>DB: status → active
    API-->>Gateway: 200 OK

    Frontend->>API: GET /subscription → status=active
    Frontend-->>Customer: Acesso liberado!

Lookup de subscription no webhook

O webhook Asaas localiza a subscription por dois caminhos:

  1. Normal: payment.subscription → busca por asaas_subscription_id
  2. Fallback (pagamento avulso com desconto): payment.externalReference → busca por _id

O fallback so e usado quando payment.subscription e null e externalReference e um ObjectId valido (24 caracteres).


⚠️ Idempotência

Os gateways reenviam webhooks se não receberem resposta 200 OK em tempo hábil.

Estratégia de Idempotência

# Webhook handler já é idempotente:
# 1. Busca subscription por gateway_subscription_id (único)
# 2. Atualiza campos com valores do webhook (mesmos valores = sem efeito colateral)
# 3. Eventos duplicados resultam nas mesmas operações de update

# Exemplo: Receber 2x o mesmo evento
# - 1ª vez: update status = 'active' → OK
# - 2ª vez: update status = 'active' (já é 'active') → Sem mudança real

Logs de Webhooks Duplicados

Webhooks duplicados são salvos como logs separados:

// Log 1 (primeira entrega)
{
  "event_id": "EVT_ABC123",
  "received_at": "2026-01-08T14:30:00.123Z",
  "processed": true
}

// Log 2 (reenvio por timeout)
{
  "event_id": "EVT_ABC123",  // Mesmo event_id
  "received_at": "2026-01-08T14:30:05.789Z",
  "processed": true
}

Identificar duplicatas:

GET /api/v1/webhook-logs?query={"event_id":"EVT_ABC123"}
# Retorna múltiplos logs com mesmo event_id

🚨 Tratamento de Erros

Erro 401: Assinatura Inválida

{
  "detail": "Invalid webhook signature (HMAC-SHA256 verification failed)"
}

Causas: - Webhook secret incorreto em .secrets/credentials.toml - Payload modificado em trânsito (MitM attack) - Header de assinatura ausente ou malformado

Solução: 1. Verificar webhook_secret no config 2. Verificar que secret é o mesmo configurado no painel do gateway 3. Se persistir, regenerar secret no painel e atualizar config


Erro 404: Subscription Não Encontrada

Causas: - Webhook recebido antes da subscription ser salva no DB (race condition) - Subscription deletada manualmente do banco de dados - ID da subscription incorreto no payload

Solução: 1. Verificar ordem de operações (salvar subscription antes de ativar webhook) 2. Verificar logs de criação de subscription 3. O gateway reenviará webhook automaticamente (retry)


📈 Monitoramento

Métricas Importantes

  1. Taxa de Sucesso de Webhooks:

    GET /api/v1/webhook-logs/stats
    # Returns: success_rate, total_received, processed, failed
    

  2. Webhooks com Assinatura Inválida:

    GET /api/v1/webhook-logs?query={"signature_valid":false}
    # Deve ser 0 em produção (indica possível ataque)
    

  3. Webhooks Não Processados:

    GET /api/v1/webhook-logs?query={"processed":false}
    # Investigar falhas de processamento
    

  4. Latência de Processamento:

    # Calcular diferença entre received_at e processed_at
    latency = webhook_log.processed_at - webhook_log.received_at
    # Deve ser < 1 segundo
    

Alertas Recomendados

  • ⚠️ Taxa de sucesso < 95% em 1 hora
  • 🚨 Mais de 3 webhooks com assinatura inválida em 1 hora (possível ataque)
  • ⚠️ Latência média > 5 segundos
  • 🚨 Webhooks não processados > 10 em 1 hora

🔍 Troubleshooting

Webhook Não Está Sendo Recebido

  1. Verificar URL configurada no gateway:
  2. Deve ser HTTPS (HTTP não é aceito)
  3. Deve ser acessível publicamente (sem VPN/firewall bloqueando)

  4. Testar conectividade (Stripe CLI):

    stripe listen --forward-to localhost:8000/webhooks/stripe
    stripe trigger customer.subscription.updated
    

  5. Verificar logs do servidor:

    # Logs de requisições recebidas
    tail -f /var/log/fdplay-api/access.log | grep webhooks
    

Webhook Recebido Mas Não Processado

  1. Verificar logs de webhook:

    GET /api/v1/webhook-logs?query={"event_id":"EVT_ABC123"}
    # Check result.status and result.error
    

  2. Verificar assinatura:

    # Se signature_valid = false, verificar webhook_secret
    GET /api/v1/webhook-logs?query={"signature_valid":false}
    

  3. Verificar exceções:

    # result.status = 'error'
    # result.error = "Subscription SUBS_XYZ789 not found in database"
    



Stripe Webhooks

POST /webhooks/stripe

Receber e processar eventos do Stripe Billing.

URL Configurada no Stripe Dashboard:

https://fdplay-api.infraifd.com/webhooks/stripe

Sem prefixo /api/v1

O endpoint Stripe usa /webhooks/stripe (sem /api/v1), seguindo o mesmo padrão dos webhooks Asaas.


🔐 Segurança: Stripe Signature

Todos os webhooks Stripe são autenticados via assinatura Stripe-Signature.

Stripe-Signature: t=1614556828,v1=abc123def456...,v0=...

Validação de Assinatura:

import stripe

def construct_webhook_event(
    payload: bytes,       # Corpo raw da requisição
    sig_header: str,      # Header Stripe-Signature
    webhook_secret: str   # whsec_xxx do Stripe Dashboard
) -> stripe.Event:
    """
    Verificar assinatura e construir evento Stripe.
    Usa stripe.Webhook.construct_event() (SDK oficial).
    """
    return stripe.Webhook.construct_event(
        payload, sig_header, webhook_secret
    )

Fluxo de Validação:

sequenceDiagram
    participant Stripe
    participant API
    participant SDK as Stripe SDK
    participant Handler
    participant DB

    Stripe->>API: POST /webhooks/stripe<br/>Stripe-Signature: t=...,v1=...
    API->>SDK: construct_webhook_event()
    SDK->>SDK: Verify HMAC-SHA256

    alt Signature Valid
        SDK-->>API: stripe.Event
        API->>API: Check duplicate (event_id)
        API->>Handler: Process event
        Handler->>DB: Update subscription
        Handler->>DB: Save webhook_log (gateway='stripe')
        Handler-->>API: Result
        API-->>Stripe: 200 OK
    else Signature Invalid
        SDK-->>API: SignatureVerificationError
        API-->>Stripe: 401 Unauthorized
    end

📡 Eventos Stripe Processados

Evento Ação Detalhes
customer.subscription.updated Mapear status, atualizar next_billing_date Status Stripe → MongoDB (ver tabela abaixo)
customer.subscription.created Mesmo que updated Processado pelo mesmo handler
customer.subscription.deleted status='cancelled', limpar current_subscription_id Acesso revogado
invoice.paid last_payment_at=now, payment_failures=0 Se status era pending/incomplete, ativa
invoice.payment_failed Incrementar payment_failures Suspende após 3 falhas consecutivas
charge.refunded $inc: {total_refunded: amount} Via customer → subscription lookup
charge.dispute.created Auto-suspensão + has_chargeback=True Bloqueia acesso do customer
payment_intent.succeeded Ativar PIX subscription pendente status='active', expires_at=now+1year
payment_intent.payment_failed Cancelar PIX subscription pendente Limpa current_subscription_id

Mapeamento de Status Stripe → MongoDB

Status Stripe Status MongoDB Ação
active active payment_failures=0
trialing active Período de teste (acesso liberado)
past_due suspended payment_failures++
unpaid suspended payment_failures++
canceled cancelled Limpar current_subscription_id
incomplete pending Aguardando 3D Secure
incomplete_expired expired 3DS expirou
paused suspended payment_failures++

Exemplo de Payload — customer.subscription.updated

{
  "id": "evt_1RChxWG6Qr6aGiCIexample",
  "type": "customer.subscription.updated",
  "data": {
    "object": {
      "id": "sub_1RChxWG6Qr6aGiCI",
      "status": "active",
      "current_period_end": 1711540800,
      "customer": "cus_1RChxWG6Qr6aGiCI",
      "items": {
        "data": [
          {
            "price": {
              "id": "price_1RChxWG6Qr6aGiCI",
              "unit_amount": 2990,
              "currency": "brl"
            }
          }
        ]
      }
    }
  }
}

Response (200 OK):

{
  "received": true,
  "event_id": "evt_1RChxWG6Qr6aGiCIexample",
  "event_type": "customer.subscription.updated",
  "result": {
    "status": "processed",
    "subscription_id": "507f1f77bcf86cd799439011",
    "new_status": "active"
  }
}

Exemplo de Payload — invoice.payment_failed

{
  "id": "evt_invoicefail123",
  "type": "invoice.payment_failed",
  "data": {
    "object": {
      "id": "in_1RChxW123",
      "subscription": "sub_1RChxWG6Qr6aGiCI",
      "customer": "cus_1RChxWG6Qr6aGiCI",
      "amount_due": 2990,
      "currency": "brl",
      "status": "open"
    }
  }
}

Processamento:

  1. Buscar subscription por stripe_subscription_id
  2. Incrementar payment_failures
  3. Se payment_failures >= 3: suspender assinatura (status='suspended')

Exemplo de Payload — charge.dispute.created

{
  "id": "evt_dispute123",
  "type": "charge.dispute.created",
  "data": {
    "object": {
      "id": "dp_1RChxW123",
      "charge": "ch_1RChxW123",
      "customer": "cus_1RChxWG6Qr6aGiCI",
      "amount": 2990,
      "reason": "fraudulent",
      "status": "needs_response"
    }
  }
}

Processamento (auto-suspensão):

Campo Atualização
status 'suspended'
has_chargeback True
last_chargeback_at Timestamp atual
chargeback_count Incrementado +1
current_subscription_id (user) None (acesso bloqueado)

Auto-suspensão por Chargeback (Stripe)

Chargebacks no Stripe causam suspensão automática da subscription e bloqueio do acesso do customer. Reativação requer ação manual do admin.


Exemplo de Payload — payment_intent.succeeded (PIX)

{
  "id": "evt_pixsuccess123",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_3RChxWG6Qr6aGiCI",
      "status": "succeeded",
      "amount": 23880,
      "currency": "brl",
      "payment_method_types": ["pix"],
      "customer": "cus_1RChxWG6Qr6aGiCI",
      "metadata": {
        "plan_id": "plan-basic",
        "customer_id": "507f1f77bcf86cd799439012"
      }
    }
  }
}

Processamento:

  1. Buscar subscription por stripe_payment_intent_id = pi_xxx
  2. Se nao encontrada: skipped (pode ser PI de card subscription — safe to skip)
  3. Se status != 'pending': skipped (idempotencia)
  4. Ativar: status='active', expires_at=now+1year, last_payment_at=now

Exemplo de Payload — payment_intent.payment_failed (PIX expirado)

{
  "id": "evt_pixfail123",
  "type": "payment_intent.payment_failed",
  "data": {
    "object": {
      "id": "pi_3RChxWG6Qr6aGiCI",
      "status": "requires_payment_method",
      "amount": 23880,
      "currency": "brl",
      "payment_method_types": ["pix"],
      "last_payment_error": {
        "message": "The payment method has expired."
      }
    }
  }
}

Processamento:

  1. Buscar subscription por stripe_payment_intent_id = pi_xxx
  2. Se status != 'pending': skipped (idempotencia)
  3. Cancelar: status='cancelled', limpar current_subscription_id do customer

🔧 Configuração do Webhook Stripe

1. Configurar Webhook Secret

Em .secrets/credentials.toml:

[production.stripe]
secret_key = "rk_live_xxx"  # Restricted key que cobre todas as operações

[development.stripe]
secret_key = "rk_live_xxx"  # Restricted key (mesma conta)

Configuração simplificada

O backend usa apenas secret_key (restricted key rk_live_) para todas as operações Stripe. A publishable_key é configurada diretamente no app Flutter. O webhook_secret é opcional — se não configurado, o webhook aceita payloads sem verificação de assinatura (com warning no log).

2. Configurar URL no Stripe Dashboard

  1. Acesse Stripe Dashboard → Developers → Webhooks
  2. Clique em Add endpoint
  3. URL: https://fdplay-api.infraifd.com/webhooks/stripe
  4. Selecione os eventos:
  5. customer.subscription.updated
  6. customer.subscription.created
  7. customer.subscription.deleted
  8. invoice.paid
  9. invoice.payment_failed
  10. charge.refunded
  11. charge.dispute.created
  12. payment_intent.succeeded
  13. payment_intent.payment_failed
  14. (Opcional) Copie o Signing secret (whsec_xxx) e configure como webhook_secret no credentials.toml para ativar verificação de assinatura

3. Testar Webhook

# Via Stripe CLI (recomendado para desenvolvimento)
stripe listen --forward-to localhost:8000/webhooks/stripe

# Trigger evento de teste
stripe trigger customer.subscription.updated

Idempotência (Stripe)

O webhook Stripe implementa deduplicação por event_id (índice unique no MongoDB):

# Verificar duplicata antes de processar
existing_log = await webhook_logs.find_one({'event_id': event_id})
if existing_log:
    return {'status': 'duplicate'}  # Ignorar reprocessamento

Stripe pode reenviar eventos por até 72 horas se não receber resposta 2xx.


Logs de Webhook (Stripe)

Os logs Stripe são salvos na mesma collection webhook_logs com gateway='stripe':

{
  "_id": "507f1f77bcf86cd799439099",
  "gateway": "stripe",
  "event_id": "evt_1RChxWG6Qr6aGiCIexample",
  "event_type": "customer.subscription.updated",
  "payload": { "...evento completo..." },
  "signature": "t=1614556828,v1=abc123...",
  "signature_valid": true,
  "processed": true,
  "result": {
    "status": "processed",
    "subscription_id": "507f1f77bcf86cd799439011",
    "new_status": "active"
  },
  "received_at": "2026-02-27T14:30:00.123Z",
  "processed_at": "2026-02-27T14:30:00.456Z"
}

Consultar logs Stripe:

# Buscar logs do gateway Stripe
GET /api/v1/webhook-logs?query={"gateway":"stripe"}

# Buscar logs de um evento específico
GET /api/v1/webhook-logs?query={"event_id":"evt_1RChxWG6Qr6aGiCIexample"}

# Buscar falhas Stripe
GET /api/v1/webhook-logs?query={"gateway":"stripe","processed":false}

🚀 Próximos Passos

  1. Implementar retry automático para webhooks com falha de processamento
  2. Dashboard de monitoramento de webhooks em tempo real
  3. Notificações proativas para admins em caso de falhas críticas
  4. Webhook replay - reprocessar eventos históricos manualmente
  5. Rate limiting - proteger contra spam de webhooks maliciosos
  6. Reembolso parcial via Orders API (PIX) — já suportado pelo cancel_order_charge