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:
- Acesse Stripe Dashboard → Developers → Webhooks
- URL:
https://fdplay-api.infraifd.com/webhooks/stripe
Asaas:
- Acesse o painel Asaas → Integrações → Webhooks
- 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:
- Normal:
payment.subscription→ busca porasaas_subscription_id - 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¶
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¶
-
Taxa de Sucesso de Webhooks:
-
Webhooks com Assinatura Inválida:
-
Webhooks Não Processados:
-
Latência de Processamento:
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¶
- Verificar URL configurada no gateway:
- Deve ser HTTPS (HTTP não é aceito)
-
Deve ser acessível publicamente (sem VPN/firewall bloqueando)
-
Testar conectividade (Stripe CLI):
-
Verificar logs do servidor:
Webhook Recebido Mas Não Processado¶
-
Verificar logs de webhook:
-
Verificar assinatura:
-
Verificar exceções:
Stripe Webhooks¶
POST /webhooks/stripe¶
Receber e processar eventos do Stripe Billing.
URL Configurada no Stripe Dashboard:
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.
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:
- Buscar subscription por
stripe_subscription_id - Incrementar
payment_failures - 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:
- Buscar subscription por
stripe_payment_intent_id = pi_xxx - Se nao encontrada:
skipped(pode ser PI de card subscription — safe to skip) - Se
status != 'pending':skipped(idempotencia) - 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:
- Buscar subscription por
stripe_payment_intent_id = pi_xxx - Se
status != 'pending':skipped(idempotencia) - Cancelar:
status='cancelled', limparcurrent_subscription_iddo 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¶
- Acesse Stripe Dashboard → Developers → Webhooks
- Clique em Add endpoint
- URL:
https://fdplay-api.infraifd.com/webhooks/stripe - Selecione os eventos:
customer.subscription.updatedcustomer.subscription.createdcustomer.subscription.deletedinvoice.paidinvoice.payment_failedcharge.refundedcharge.dispute.createdpayment_intent.succeededpayment_intent.payment_failed- (Opcional) Copie o Signing secret (
whsec_xxx) e configure comowebhook_secretnocredentials.tomlpara 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¶
- Implementar retry automático para webhooks com falha de processamento
- Dashboard de monitoramento de webhooks em tempo real
- Notificações proativas para admins em caso de falhas críticas
- Webhook replay - reprocessar eventos históricos manualmente
- Rate limiting - proteger contra spam de webhooks maliciosos
- Reembolso parcial via Orders API (PIX) — já suportado pelo
cancel_order_charge