Skip to content

Guia do Frontend Flutter — Cadastro e Assinatura

Público-alvo: Desenvolvedores Flutter/Dart

Objetivo: Implementar o fluxo completo de cadastro freemium e assinatura de customers.


🎯 Visão Geral do Fluxo

O sistema usa modelo freemium: customer se cadastra gratuitamente e assina depois.

flowchart TD
    A[Usuario acessa pagina de cadastro] --> B{Preenche email/CPF}
    B --> C[GET /customers/check-availability]
    C --> D{Disponivel?}
    D -->|Nao| E[Exibe erro em tempo real]
    E --> B
    D -->|Sim| F[Preenche demais campos]
    F --> G["POST /customers/signup (sem verification_code)"]
    G --> H{Sucesso?}
    H -->|Sim| I[Exibe tela de verificacao de email]
    H -->|Nao| J[Exibe erro 409/400]
    I --> K[Usuario digita codigo de 6 digitos]
    K --> L["POST /customers/signup (com verification_code)"]
    L --> M{Codigo valido?}
    M -->|Sim| N[Recebe JWT token]
    M -->|Nao| O{Tentativas restantes?}
    O -->|Sim| K
    O -->|Nao| P[Reenviar codigo]
    P --> I
    N --> Q[Redireciona para Dashboard]
    Q --> R{Quer assinar agora?}
    R -->|Nao| S[Usa app gratuitamente]
    R -->|Sim| T[Vai para pagina de planos]
    T --> U[Escolhe plano e preenche pagamento]
    U --> V[POST /asaas/subscribe ou /stripe/subscribe]
    V --> W{Pagamento aprovado?}
    W -->|Sim| X[Acesso liberado a videos]
    W -->|Nao| Y[Exibe erro de pagamento]
    S --> Z{Depois decide assinar?}
    Z -->|Sim| T

    I --> AA{Nao recebeu codigo?}
    AA -->|Sim| AB[POST /auth/resend-verification]
    AB --> I

Sincronização Automática no Login

A partir de 2026-02-05, o fluxo de login (POST /token) sincroniza automaticamente o status de assinatura do customer com o gateway ativo (Stripe ou Asaas).

O que muda para o frontend:

  • Após login bem-sucedido, chamar GET /customers/me/subscription retorna o status real (já reconciliado)
  • Não é necessário implementar polling ou verificação manual de status após login
  • O backend detecta automaticamente o gateway (subscription.gateway) e sincroniza com a API correta
  • Se o gateway estiver fora, o login funciona normalmente (sync é best-effort)

Fluxo recomendado pós-login:

POST /token → JWT recebido → GET /customers/me/subscription → Verificar status
    → "active": liberar acesso a vídeos
    → "suspended"/"cancelled"/"expired": redirecionar para página de assinatura
    → 404 (sem assinatura): redirecionar para página de planos

Gateways de Pagamento

O FDPlay suporta Stripe e Asaas como gateways de pagamento em rotas isoladas:

Gateway Rota de Subscribe Documentação
Stripe (Cartao) POST /stripe/subscribe Stripe Integration
Stripe (PIX) POST /stripe/subscribe/pix Stripe Integration
Asaas (Cartão) POST /asaas/subscribe Asaas Integration
Asaas (PIX) POST /asaas/subscribe/pix Asaas Integration

O middleware de acesso a vídeos é gateway-agnostic — só verifica status da assinatura.

Qual gateway usar? Consulte a preferência do admin em runtime:

final resp = await http.get(Uri.parse('$baseUrl/api/v1/config/payment-gateway'));
final gateway = jsonDecode(resp.body)['default_payment_gateway']; // 'asaas' ou 'stripe'
Todos os gateways estão sempre disponíveis — essa rota indica apenas a preferência configurada pelo admin.


🧪 Dados de Teste (Sandbox)

Ambiente Sandbox

Todos os dados abaixo só funcionam no ambiente sandbox. Em produção, use dados reais.

Planos Disponíveis

Obtendo Planos Atualizados

Os planos disponíveis devem ser obtidos via GET /api/v1/plans (endpoint público). Os IDs de gateway são preenchidos automaticamente após sincronização.

Campo Exemplo Descrição
plan_id plan-basic ID interno usado no payload de subscribe
name Plano Básico Nome de exibição
amount 2990 R$ 29,90 (valor em centavos)
interval_unit MONTH Frequência de cobrança
trial_days 7 Dias de teste grátis (se trial_enabled=true)
tier 1 Nível de acesso (1=básico, 2=plus, 3=premium)

Cartões de Teste (Sandbox)

✅ Cartões que APROVAM

Bandeira Número CVV Validade Uso
Visa 4111111111111111 123 12/2030 Recomendado para testes
Visa 4539620659922097 123 12/2030 Alternativo
Mastercard 5425233430109903 123 12/2030 Teste Mastercard
Mastercard 5500000000000004 123 12/2030 Alternativo
Elo 6362970000457013 123 12/2030 Teste Elo

❌ Cartões que RECUSAM (para testar erros)

Número Erro Retornado
4000000000000002 Cartão recusado
4000000000000069 Cartão expirado
4000000000000127 CVV inválido

CPFs Válidos para Teste

CPF Observação
12345678909 Recomendado
11144477735 Alternativo
98765432100 Alternativo

Formato

Sempre envie CPF sem pontos e traços (apenas números).


Configuracao de Ambiente

NUNCA hardcode a URL da API no codigo

A baseUrl deve ser configuravel por ambiente. Use variaveis de ambiente ou arquivo de configuracao.

class AppConfig {
  /// URL base da API — configurar por ambiente
  ///
  /// Producao: https://fdplay-api.infraifd.com/api/v1
  /// Local:    http://localhost:8000/api/v1
  static String get baseUrl {
    // Usar --dart-define ou .env
    return const String.fromEnvironment(
      'API_BASE_URL',
      defaultValue: 'http://localhost:8000/api/v1',
    );
  }
}

Execucao com ambiente definido:

# Desenvolvimento (local)
flutter run --dart-define=API_BASE_URL=http://localhost:8000/api/v1

# Producao
flutter run --dart-define=API_BASE_URL=https://fdplay-api.infraifd.com/api/v1

Nos exemplos de codigo abaixo, baseUrl aparece como constante para simplicidade. Em codigo real, use AppConfig.baseUrl.


Etapa 1: Cadastro de Customer (Signup com Verificacao de Email)

Mudanca de Fluxo (2026-02-09)

O cadastro agora exige verificacao de email antes de liberar o JWT. O mesmo endpoint POST /customers/signup opera em dois passos:

  1. Sem verification_code: Cria customer + envia codigo de 6 digitos por email
  2. Com verification_code: Valida codigo + retorna JWT

O frontend NAO recebe JWT ate o email ser verificado.

Endpoint

POST /api/v1/customers/signup
Content-Type: application/json

Fluxo Sequencial

sequenceDiagram
    participant App as Flutter App
    participant API as FDPlay API
    participant Resend as Resend (Email)
    participant DB as MongoDB

    Note over App: Passo 1: Enviar dados de cadastro
    App->>API: POST /customers/signup<br/>[{username, email, password, ...}]
    API->>DB: Cria customer (email_verified=false)
    API->>Resend: Envia codigo 6 digitos
    Resend-->>App: Email com codigo
    API-->>App: {customer_id, email, message, expires_in_minutes}
    Note over App: SEM JWT neste passo

    Note over App: Passo 2: Verificar codigo
    App->>API: POST /customers/signup<br/>[{email, password, ..., verification_code: "123456"}]
    API->>DB: Valida hash do codigo
    API->>DB: email_verified=true, limpa campos temporarios
    API-->>App: {docs: [customer], info: {auth: {access_token}}}
    Note over App: JWT recebido — salvar e redirecionar

JSON do Payload — Explicacao Campo a Campo

Passo 1 — Enviar dados (sem verification_code)

[
  {
    "username": "cliente_teste_001",
    "email": "cliente.teste@example.com",
    "password": "senha_segura_123",
    "full_name": "Cliente Teste da Silva",
    "tax_id": "12345678909",
    "phone": "+5511999887766",
    "nationality": "BR"
  }
]

Passo 2 — Verificar codigo (com verification_code)

[
  {
    "username": "cliente_teste_001",
    "email": "cliente.teste@example.com",
    "password": "senha_segura_123",
    "full_name": "Cliente Teste da Silva",
    "tax_id": "12345678909",
    "phone": "+5511999887766",
    "nationality": "BR",
    "verification_code": "482916"
  }
]
Campo Tipo Obrigatorio Validacao Descricao
username String Sim 3-50 chars, unico, sem espacos Nome de usuario para login
email String Sim Email valido, unico Email do customer
password String Sim Minimo 8 caracteres Senha (sera hasheada no backend)
full_name String Sim 3-200 caracteres Nome completo para cobranca
tax_id String Nao 11 digitos (CPF) ou 14 (CNPJ) CPF/CNPJ. Opcional no cadastro, obrigatorio para pagamento
phone String Sim Formato +55XXXXXXXXXXX Telefone com codigo do pais
nationality String Nao 2-3 chars ISO 3166-1 Nacionalidade (ex: BR, US, PRT)
verification_code String Nao Exatamente 6 digitos (^\d{6}$) Codigo recebido por email. Omitir para solicitar envio. Incluir para verificar.
verify_email_now bool Nao true (default) ou false true = fluxo Modo A (2 passos, JWT so apos verificacao). false = Modo B (JWT imediato, verificar depois via POST /customers/me/verify-email).

Campos opcionais no cadastro

tax_id, nationality e address sao opcionais no signup. O tax_id so e exigido no momento do pagamento (subscribe). Isso permite cadastro de clientes estrangeiros sem CPF.

Modo B — Verificacao postergada (ADR-058)

Quando o usuario quer entrar imediatamente sem verificar email, envie verify_email_now: false no Passo 1. A resposta inclui auth.access_token + email_verification_pending: true. A verificacao pode ser feita depois com:

await http.post(
    Uri.parse('$baseUrl/customers/me/verify-email'),
    headers: {
        'Authorization': 'Bearer $accessToken',
        'Content-Type': 'application/json',
    },
    body: jsonEncode({'code': '482917'}),
);

Resposta: {message, email_verified: true, email_verified_at}. Se nao houver codigo ativo (expirou, nunca foi enviado), solicite reenvio via POST /auth/resend-verification.

Por que e um Array?

O endpoint aceita List<CustomerSignup> para consistencia com outros endpoints CRUD. Envie sempre um array com um unico objeto.

Regras de Verificacao

  • Expiracao: Codigo expira em 15 minutos
  • Tentativas: Maximo 5 tentativas por codigo. Apos esgotar, reenvie o codigo.
  • Rate limit reenvio: 1 reenvio por 60 segundos (enviar mesmo payload sem verification_code reenvia)
  • Reenvio alternativo: POST /auth/resend-verification com {"email": "..."}

Codigo Dart/Flutter — Cadastro com Verificacao de Email

import 'dart:convert';
import 'package:http/http.dart' as http;

/// Modelo para dados de cadastro
class CustomerSignupData {
  final String username;
  final String email;
  final String password;
  final String fullName;
  final String taxId;
  final String phone;
  final String? verificationCode;

  CustomerSignupData({
    required this.username,
    required this.email,
    required this.password,
    required this.fullName,
    required this.taxId,
    required this.phone,
    this.verificationCode,
  });

  Map<String, dynamic> toJson() {
    final json = <String, dynamic>{
      'username': username,
      'email': email,
      'password': password,
      'full_name': fullName,
      'tax_id': taxId.replaceAll(RegExp(r'\D'), ''),
      'phone': phone.startsWith('+') ? phone : '+55$phone',
    };
    if (verificationCode != null) {
      json['verification_code'] = verificationCode;
    }
    return json;
  }
}

/// Resposta do Passo 1 (codigo enviado, sem JWT)
class SignupCodeSentResponse {
  final String customerId;
  final String email;
  final String message;
  final int expiresInMinutes;

  SignupCodeSentResponse({
    required this.customerId,
    required this.email,
    required this.message,
    required this.expiresInMinutes,
  });

  factory SignupCodeSentResponse.fromJson(Map<String, dynamic> json) {
    return SignupCodeSentResponse(
      customerId: json['customer_id'],
      email: json['email'],
      message: json['message'],
      expiresInMinutes: json['expires_in_minutes'],
    );
  }
}

/// Resposta do Passo 2 (email verificado, com JWT)
class SignupVerifiedResponse {
  final String accessToken;
  final String tokenType;
  final String customerId;
  final String message;

  SignupVerifiedResponse({
    required this.accessToken,
    required this.tokenType,
    required this.customerId,
    required this.message,
  });

  factory SignupVerifiedResponse.fromJson(Map<String, dynamic> json) {
    final info = json['info'] as Map<String, dynamic>;
    final auth = info['auth'] as Map<String, dynamic>;
    final docs = json['docs'] as List;

    return SignupVerifiedResponse(
      accessToken: auth['access_token'],
      tokenType: auth['token_type'],
      customerId: docs.isNotEmpty ? docs[0]['_id'] : '',
      message: info['message'],
    );
  }
}

/// Service de autenticacao
class AuthService {
  static String get baseUrl => AppConfig.baseUrl;

  /// Passo 1: Envia dados de cadastro e solicita codigo de verificacao.
  ///
  /// Retorna [SignupCodeSentResponse] com customer_id e tempo de expiracao.
  /// Tambem funciona como reenvio se o email ja tem cadastro pendente.
  Future<SignupCodeSentResponse> signupSendCode(CustomerSignupData data) async {
    final url = Uri.parse('$baseUrl/customers/signup');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode([data.toJson()]),
    );

    final body = jsonDecode(response.body);

    if (response.statusCode == 200) {
      return SignupCodeSentResponse.fromJson(body);
    }

    _throwApiError(body, response.statusCode);
    throw Exception('Erro desconhecido');
  }

  /// Passo 2: Envia codigo de verificacao e recebe JWT.
  ///
  /// [data] deve conter o mesmo payload do Passo 1 + [verificationCode].
  /// Retorna [SignupVerifiedResponse] com access_token JWT.
  Future<SignupVerifiedResponse> signupVerifyCode(CustomerSignupData data) async {
    assert(data.verificationCode != null, 'verification_code obrigatorio no Passo 2');

    final url = Uri.parse('$baseUrl/customers/signup');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode([data.toJson()]),
    );

    final body = jsonDecode(response.body);

    if (response.statusCode == 200) {
      return SignupVerifiedResponse.fromJson(body);
    }

    _throwApiError(body, response.statusCode);
    throw Exception('Erro desconhecido');
  }

  /// Reenviar codigo de verificacao via endpoint dedicado.
  ///
  /// Alternativa ao reenvio pelo signup. Rate limited (1/minuto).
  Future<void> resendVerificationCode(String email) async {
    final url = Uri.parse('$baseUrl/auth/resend-verification');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email}),
    );

    if (response.statusCode == 429) {
      final body = jsonDecode(response.body);
      throw Exception(body['detail'] ?? 'Aguarde para reenviar o codigo.');
    }
  }

  void _throwApiError(Map<String, dynamic> body, int statusCode) {
    final detail = body['detail'];
    if (detail is String) {
      throw Exception(detail);
    } else if (detail is List) {
      final errors = detail.map((e) => e['msg']).join(', ');
      throw Exception(errors);
    }
    throw Exception('Erro $statusCode');
  }
}

Exemplo de Uso no Widget — Cadastro com Verificacao

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class SignupScreen extends StatefulWidget {
  @override
  _SignupScreenState createState() => _SignupScreenState();
}

class _SignupScreenState extends State<SignupScreen> {
  final _formKey = GlobalKey<FormState>();
  final _authService = AuthService();

  bool _loading = false;
  String? _error;
  bool _codeSent = false; // Controla se estamos no Passo 2

  // Controllers — dados de cadastro
  final _usernameController = TextEditingController();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _fullNameController = TextEditingController();
  final _taxIdController = TextEditingController();
  final _phoneController = TextEditingController();

  // Controller — codigo de verificacao
  final _codeController = TextEditingController();

  CustomerSignupData _buildSignupData({String? code}) {
    return CustomerSignupData(
      username: _usernameController.text.trim(),
      email: _emailController.text.trim(),
      password: _passwordController.text,
      fullName: _fullNameController.text.trim(),
      taxId: _taxIdController.text,
      phone: _phoneController.text,
      verificationCode: code,
    );
  }

  /// Passo 1: Enviar dados e solicitar codigo
  Future<void> _handleSendCode() async {
    if (!_formKey.currentState!.validate()) return;

    setState(() { _loading = true; _error = null; });

    try {
      final data = _buildSignupData();
      await _authService.signupSendCode(data);
      setState(() => _codeSent = true);
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  /// Passo 2: Verificar codigo e receber JWT
  Future<void> _handleVerifyCode() async {
    final code = _codeController.text.trim();
    if (code.length != 6) {
      setState(() => _error = 'Digite o codigo de 6 digitos.');
      return;
    }

    setState(() { _loading = true; _error = null; });

    try {
      final data = _buildSignupData(code: code);
      final response = await _authService.signupVerifyCode(data);

      // Salva token
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('access_token', response.accessToken);
      await prefs.setString('user_id', response.customerId);

      // Navega para dashboard
      Navigator.pushReplacementNamed(context, '/dashboard');
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  /// Reenviar codigo
  Future<void> _handleResendCode() async {
    setState(() { _loading = true; _error = null; });
    try {
      await _authService.resendVerificationCode(_emailController.text.trim());
      setState(() => _error = null);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Codigo reenviado!'), backgroundColor: Colors.green),
      );
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(_codeSent ? 'Verificar Email' : 'Criar Conta')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: _codeSent ? _buildVerificationForm() : _buildSignupForm(),
      ),
    );
  }

  /// Formulario de cadastro (Passo 1)
  Widget _buildSignupForm() {
    return Form(
      key: _formKey,
      child: ListView(
        children: [
          if (_error != null)
            Container(
              padding: EdgeInsets.all(12),
              color: Colors.red.shade100,
              child: Text(_error!, style: TextStyle(color: Colors.red)),
            ),
          SizedBox(height: 16),
          TextFormField(
            controller: _usernameController,
            decoration: InputDecoration(labelText: 'Nome de usuario'),
            validator: (v) => v!.length < 3 ? 'Minimo 3 caracteres' : null,
          ),
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: 'Email'),
            keyboardType: TextInputType.emailAddress,
            validator: (v) => !v!.contains('@') ? 'Email invalido' : null,
          ),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(labelText: 'Senha'),
            obscureText: true,
            validator: (v) => v!.length < 8 ? 'Minimo 8 caracteres' : null,
          ),
          TextFormField(
            controller: _fullNameController,
            decoration: InputDecoration(labelText: 'Nome completo'),
            validator: (v) => v!.length < 3 ? 'Nome muito curto' : null,
          ),
          TextFormField(
            controller: _taxIdController,
            decoration: InputDecoration(labelText: 'CPF (apenas numeros)'),
            keyboardType: TextInputType.number,
            validator: (v) {
              final digits = v!.replaceAll(RegExp(r'\D'), '');
              return digits.length != 11 ? 'CPF deve ter 11 digitos' : null;
            },
          ),
          TextFormField(
            controller: _phoneController,
            decoration: InputDecoration(labelText: 'Telefone (11999887766)'),
            keyboardType: TextInputType.phone,
            validator: (v) => v!.length < 10 ? 'Telefone invalido' : null,
          ),
          SizedBox(height: 24),
          ElevatedButton(
            onPressed: _loading ? null : _handleSendCode,
            child: _loading
                ? CircularProgressIndicator(color: Colors.white)
                : Text('Criar Conta'),
          ),
        ],
      ),
    );
  }

  /// Tela de verificacao de codigo (Passo 2)
  Widget _buildVerificationForm() {
    return ListView(
      children: [
        Icon(Icons.email_outlined, size: 64, color: Colors.blue),
        SizedBox(height: 16),
        Text(
          'Verifique seu email',
          style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
          textAlign: TextAlign.center,
        ),
        SizedBox(height: 8),
        Text(
          'Enviamos um codigo de 6 digitos para\n${_emailController.text}',
          textAlign: TextAlign.center,
          style: TextStyle(color: Colors.grey[600]),
        ),
        SizedBox(height: 24),
        if (_error != null)
          Container(
            padding: EdgeInsets.all(12),
            margin: EdgeInsets.only(bottom: 16),
            color: Colors.red.shade100,
            child: Text(_error!, style: TextStyle(color: Colors.red)),
          ),
        TextFormField(
          controller: _codeController,
          decoration: InputDecoration(
            labelText: 'Codigo de verificacao',
            hintText: '000000',
            counterText: '',
          ),
          keyboardType: TextInputType.number,
          maxLength: 6,
          textAlign: TextAlign.center,
          style: TextStyle(fontSize: 24, letterSpacing: 8),
        ),
        SizedBox(height: 24),
        ElevatedButton(
          onPressed: _loading ? null : _handleVerifyCode,
          child: _loading
              ? CircularProgressIndicator(color: Colors.white)
              : Text('Verificar'),
        ),
        SizedBox(height: 16),
        TextButton(
          onPressed: _loading ? null : _handleResendCode,
          child: Text('Reenviar codigo'),
        ),
        TextButton(
          onPressed: () => setState(() { _codeSent = false; _error = null; }),
          child: Text('Voltar e editar dados'),
        ),
      ],
    );
  }
}

Respostas do Endpoint — Exemplos

Passo 1 — Codigo enviado (200 OK)

{
  "customer_id": "678c1a2b3d4e5f6789012345",
  "email": "cliente.teste@example.com",
  "message": "Codigo de verificacao enviado para o email.",
  "expires_in_minutes": 15
}
Campo Descricao
customer_id ID do customer criado (MongoDB ObjectId)
email Email para onde o codigo foi enviado
message Mensagem de confirmacao
expires_in_minutes Tempo de vida do codigo (15 min)

Sem JWT neste passo

O Passo 1 NAO retorna token JWT. O customer e criado com email_verified=false e nao pode acessar endpoints protegidos ate completar o Passo 2.

Passo 2 — Email verificado (200 OK)

{
  "docs": [
    {
      "_id": "678c1a2b3d4e5f6789012345",
      "username": "cliente_teste_001",
      "email": "cliente.teste@example.com",
      "user_type": "customer",
      "full_name": "Cliente Teste da Silva",
      "tax_id": "12345678909",
      "phone": "+5511999887766",
      "email_verified": true,
      "current_subscription_id": null,
      "created_at": "2026-02-09T15:30:00Z",
      "updated_at": "2026-02-09T15:30:45Z"
    }
  ],
  "info": {
    "auth": {
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "token_type": "bearer"
    },
    "message": "Email verificado com sucesso.",
    "next_step": "/api/v1/customers/me/subscribe"
  },
  "total_docs": 1,
  "qty_docs_page": 1,
  "current_page": 0
}
Campo Descricao
docs[0]._id ID do customer verificado
docs[0].email_verified true — email confirmado
info.auth.access_token Token JWT — salvar para requests autenticadas
info.next_step Proximo endpoint sugerido (assinatura)

Erros possiveis

Status detail Causa Acao do frontend
400 Codigo expirado. Reenvie o codigo. Codigo passou de 15 min Exibir botao "Reenviar codigo"
400 Codigo incorreto. N tentativa(s) restante(s). Codigo errado Exibir tentativas restantes
404 Nenhum cadastro pendente encontrado para este email. Email nao tem cadastro pendente Redirecionar para signup
409 Email already exists Email ja verificado Sugerir login
409 Username already exists Username em uso Pedir outro username
429 Aguarde N segundos para reenviar o codigo. Rate limit reenvio (60s) Exibir timer
429 Maximo de tentativas atingido. Reenvie o codigo. 5 tentativas erradas Reenviar codigo automaticamente

Endpoint de Reenvio de Codigo

Alternativa dedicada para reenviar o codigo sem precisar repetir todos os dados de cadastro:

POST /api/v1/auth/resend-verification
Content-Type: application/json
{
  "email": "cliente.teste@example.com"
}

Resposta (200 OK):

{
  "message": "Se o email estiver cadastrado e pendente, o codigo sera reenviado.",
  "expires_in_minutes": 15
}

Resposta Generica (Anti-Enumeracao)

A resposta e identica independente de o email existir ou nao. Isso previne que atacantes descubram quais emails estao cadastrados.


💳 Etapa 2: Assinatura (Subscribe)

O FDPlay suporta Stripe e Asaas como gateways de pagamento. Use os guias específicos para cada gateway:

Gateway Tipo Guia
Stripe (Cartão) POST /stripe/subscribe Stripe Integration
Stripe (PIX) POST /stripe/subscribe/pix Stripe Integration
Asaas (Cartão) POST /asaas/subscribe Asaas Integration
Asaas (PIX) POST /asaas/subscribe/pix Asaas Integration

Qual gateway usar?

Consulte a preferência configurada pelo admin:

final resp = await http.get(Uri.parse('$baseUrl/api/v1/config/payment-gateway'));
final gateway = jsonDecode(resp.body)['default_payment_gateway']; // 'asaas' ou 'stripe'

Modelo de Endereço

/// Modelo para endereço
class AddressData {
  final String street;
  final String number;
  final String complement;
  final String locality;
  final String city;
  final String regionCode;
  final String postalCode;

  AddressData({
    required this.street,
    required this.number,
    this.complement = '',
    required this.locality,
    required this.city,
    required this.regionCode,
    required this.postalCode,
  });

  Map<String, dynamic> toJson() => {
    'street': street,
    'number': number,
    'complement': complement,
    'locality': locality,
    'city': city,
    'region_code': regionCode.toUpperCase(),
    'country': 'BRA',
    'postal_code': postalCode.replaceAll(RegExp(r'\D'), ''),
  };
}

Resposta de Sucesso — Assinatura

{
  "subscription": {
    "_id": "678c2d3e4f5a6b7890123456",
    "customer_id": "678c1a2b3d4e5f6789012345",
    "plan_id": "plan-basic",
    "status": "active",
    "payment_method": "credit_card",
    "started_at": "2026-01-18T18:20:15Z",
    "next_billing_date": "2026-02-18T00:00:00Z",
    "expires_at": null,
    "payment_failures": 0
  },
  "message": "Subscription created successfully. You can now access premium content."
}

Exemplo de Uso — Tela de Pagamento

class PaymentScreen extends StatefulWidget {
  final String planId;
  final String planName;
  final int planAmount; // Em centavos

  PaymentScreen({
    required this.planId,
    required this.planName,
    required this.planAmount,
  });

  @override
  _PaymentScreenState createState() => _PaymentScreenState();
}

class _PaymentScreenState extends State<PaymentScreen> {
  final _formKey = GlobalKey<FormState>();
  final _subscriptionService = SubscriptionService();

  bool _loading = false;
  String? _error;

  // Controllers - Cartão
  final _cardNumberController = TextEditingController();
  final _expMonthController = TextEditingController();
  final _expYearController = TextEditingController();
  final _cvvController = TextEditingController();
  final _holderNameController = TextEditingController();

  // Controllers - Endereço
  final _streetController = TextEditingController();
  final _numberController = TextEditingController();
  final _complementController = TextEditingController();
  final _localityController = TextEditingController();
  final _cityController = TextEditingController();
  final _regionCodeController = TextEditingController();
  final _postalCodeController = TextEditingController();

  String get _formattedPrice => 
      'R\$ ${(widget.planAmount / 100).toStringAsFixed(2)}';

  Future<void> _handlePayment() async {
    if (!_formKey.currentState!.validate()) return;

    setState(() {
      _loading = true;
      _error = null;
    });

    try {
      final prefs = await SharedPreferences.getInstance();
      final token = prefs.getString('access_token');

      if (token == null) {
        throw Exception('Sessão expirada. Faça login novamente.');
      }

      final address = AddressData(
        street: _streetController.text,
        number: _numberController.text,
        complement: _complementController.text,
        locality: _localityController.text,
        city: _cityController.text,
        regionCode: _regionCodeController.text,
        postalCode: _postalCodeController.text,
      );

      // Chama o service que criptografa e envia
      final subscription = await _subscriptionService.subscribe(
        token: token,
        planId: widget.planId,
        cardNumber: _cardNumberController.text,
        cardHolder: _holderNameController.text,
        cardCvv: _cvvController.text,
        cardExpMonth: _expMonthController.text,
        cardExpYear: _expYearController.text,
        address: address,
      );

      // Sucesso!
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Assinatura criada! Status: ${subscription.status}'),
          backgroundColor: Colors.green,
        ),
      );

      // Navega para vídeos
      Navigator.pushReplacementNamed(context, '/videos');

    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Assinar ${widget.planName}')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: ListView(
            children: [
              // Preço
              Card(
                child: Padding(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    children: [
                      Text(widget.planName, style: TextStyle(fontSize: 20)),
                      SizedBox(height: 8),
                      Text(_formattedPrice + '/mês', 
                           style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
                    ],
                  ),
                ),
              ),

              SizedBox(height: 16),

              if (_error != null)
                Container(
                  padding: EdgeInsets.all(12),
                  color: Colors.red.shade100,
                  child: Text(_error!, style: TextStyle(color: Colors.red)),
                ),

              // Dados do Cartão
              Text('Dados do Cartão', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              SizedBox(height: 8),

              TextFormField(
                controller: _cardNumberController,
                decoration: InputDecoration(labelText: 'Número do cartão'),
                keyboardType: TextInputType.number,
                maxLength: 19,
                validator: (v) {
                  final digits = v!.replaceAll(RegExp(r'\D'), '');
                  return digits.length < 15 ? 'Número inválido' : null;
                },
              ),

              Row(
                children: [
                  Expanded(
                    child: TextFormField(
                      controller: _expMonthController,
                      decoration: InputDecoration(labelText: 'Mês'),
                      keyboardType: TextInputType.number,
                      maxLength: 2,
                      validator: (v) {
                        final month = int.tryParse(v!) ?? 0;
                        return month < 1 || month > 12 ? 'Inválido' : null;
                      },
                    ),
                  ),
                  SizedBox(width: 16),
                  Expanded(
                    child: TextFormField(
                      controller: _expYearController,
                      decoration: InputDecoration(labelText: 'Ano'),
                      keyboardType: TextInputType.number,
                      maxLength: 4,
                      validator: (v) {
                        final year = int.tryParse(v!) ?? 0;
                        return year < 2026 ? 'Inválido' : null;
                      },
                    ),
                  ),
                  SizedBox(width: 16),
                  Expanded(
                    child: TextFormField(
                      controller: _cvvController,
                      decoration: InputDecoration(labelText: 'CVV'),
                      keyboardType: TextInputType.number,
                      maxLength: 4,
                      obscureText: true,
                      validator: (v) => v!.length < 3 ? 'Inválido' : null,
                    ),
                  ),
                ],
              ),

              TextFormField(
                controller: _holderNameController,
                decoration: InputDecoration(labelText: 'Nome no cartão'),
                textCapitalization: TextCapitalization.characters,
                validator: (v) => v!.length < 3 ? 'Nome inválido' : null,
              ),

              SizedBox(height: 24),

              // Endereço
              Text('Endereço de Cobrança', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              SizedBox(height: 8),

              TextFormField(
                controller: _postalCodeController,
                decoration: InputDecoration(labelText: 'CEP'),
                keyboardType: TextInputType.number,
                maxLength: 9,
                validator: (v) {
                  final digits = v!.replaceAll(RegExp(r'\D'), '');
                  return digits.length != 8 ? 'CEP deve ter 8 dígitos' : null;
                },
              ),

              TextFormField(
                controller: _streetController,
                decoration: InputDecoration(labelText: 'Rua/Avenida'),
                validator: (v) => v!.length < 3 ? 'Endereço inválido' : null,
              ),

              Row(
                children: [
                  Expanded(
                    flex: 1,
                    child: TextFormField(
                      controller: _numberController,
                      decoration: InputDecoration(labelText: 'Número'),
                      validator: (v) => v!.isEmpty ? 'Obrigatório' : null,
                    ),
                  ),
                  SizedBox(width: 16),
                  Expanded(
                    flex: 2,
                    child: TextFormField(
                      controller: _complementController,
                      decoration: InputDecoration(labelText: 'Complemento'),
                    ),
                  ),
                ],
              ),

              TextFormField(
                controller: _localityController,
                decoration: InputDecoration(labelText: 'Bairro'),
                validator: (v) => v!.length < 3 ? 'Bairro inválido' : null,
              ),

              Row(
                children: [
                  Expanded(
                    flex: 3,
                    child: TextFormField(
                      controller: _cityController,
                      decoration: InputDecoration(labelText: 'Cidade'),
                      validator: (v) => v!.length < 3 ? 'Cidade inválida' : null,
                    ),
                  ),
                  SizedBox(width: 16),
                  Expanded(
                    flex: 1,
                    child: TextFormField(
                      controller: _regionCodeController,
                      decoration: InputDecoration(labelText: 'UF'),
                      textCapitalization: TextCapitalization.characters,
                      maxLength: 2,
                      validator: (v) => v!.length != 2 ? 'UF inválida' : null,
                    ),
                  ),
                ],
              ),

              SizedBox(height: 32),

              ElevatedButton(
                onPressed: _loading ? null : _handlePayment,
                style: ElevatedButton.styleFrom(
                  padding: EdgeInsets.symmetric(vertical: 16),
                ),
                child: _loading
                    ? CircularProgressIndicator(color: Colors.white)
                    : Text('Assinar por $_formattedPrice/mês'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Resposta de Sucesso — Assinatura

{
  "subscription": {
    "_id": "678c2d3e4f5a6b7890123456",
    "customer_id": "678c1a2b3d4e5f6789012345",
    "plan_id": "plan-basic",
    "status": "active",
    "payment_method": "credit_card",
    "started_at": "2026-01-18T18:20:15Z",
    "next_billing_date": "2026-02-18T00:00:00Z",
    "expires_at": null,
    "payment_failures": 0
  },
  "message": "Subscription created successfully. You can now access premium content."
}
Campo Descrição
status trial = período de teste, active = pagando
next_billing_date Próxima cobrança (após trial)

💰 Métodos de Pagamento Disponíveis

O sistema suporta diferentes métodos de pagamento:

Método Mensal Trimestral Semestral Anual Pagamento Único
Cartão de Crédito ✅ Automático - - -
PIX
Boleto ✅ Via API - - -

PIX para Assinaturas

O periodo do PIX e configurado pelo admin via pix_billing_mode: monthly (1m), quarterly (3m), semiannually (6m) ou yearly (12m). O frontend consulta GET /api/v1/config/pix-billing-mode para exibir o valor correto.


🟢 PIX — Assinatura por Periodo

PIX para assinaturas funciona como pagamento unico do periodo configurado. O backend cria uma order PIX via Asaas e gera um QR code. Quando o cliente paga, um webhook ativa a assinatura automaticamente.

Fluxo PIX Anual

sequenceDiagram
    participant App as Flutter App
    participant API as FDPlay API
    participant Asaas
    participant DB as MongoDB

    App->>API: POST /asaas/subscribe/pix<br/>{plan_id}
    API->>Asaas: POST /pix/qrCodes (QR code PIX)
    Asaas-->>API: {id, encodedImage, payload}
    API->>DB: Subscription (status=pending)
    API-->>App: {subscription, pix: {qr_codes}}
    Note over App: Exibe QR Code para cliente
    App->>App: Cliente escaneia com app do banco
    Note over Asaas: Pagamento confirmado
    Asaas->>API: POST /webhooks/asaas<br/>event: PAYMENT_RECEIVED
    API->>DB: Subscription status=active<br/>expires_at = now + 1 ano
    Note over App: GET /videos → 200 OK

Endpoint (Rota Preferida — Asaas)

POST /api/v1/asaas/subscribe/pix
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Payload PIX — Campos

{
  "plan_id": "plan-basic"
}
Campo Tipo Obrigatório Descrição
plan_id String Slug do plano (ex: plan-basic)
promo_code String Nao Codigo promocional (opcional)

Rota Legacy

A rota POST /customers/me/subscribe e a rota legacy e retorna HTTP 501 (Not Implemented). Use as rotas especificas por gateway:

  • Asaas PIX: POST /asaas/subscribe/pix
  • Asaas Cartao: POST /asaas/subscribe
  • Stripe Cartao: POST /stripe/subscribe
  • Stripe PIX: POST /stripe/subscribe/pix

Resposta de Sucesso — PIX

{
  "subscription": {
    "_id": "507f1f77bcf86cd799439012",
    "customer_id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "status": "pending",
    "payment_method": "pix",
    "started_at": "2026-02-03T15:00:00Z",
    "next_billing_date": null,
    "expires_at": null
  },
  "pix": {
    "order_id": "ORDE_A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
    "qr_codes": [
      {
        "id": "QRCO_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        "amount": {
          "value": 23880
        },
        "text": "00020126580014br.gov.bcb.pix0136...",
        "links": [
          {
            "rel": "QRCODE.PNG",
            "href": "https://sandbox.asaas.com/qrcodes/QRCO_.../png",
            "media": "image/png",
            "type": "GET"
          }
        ]
      }
    ],
    "expiration_date": "2026-02-03T15:30:00-03:00"
  },
  "message": "PIX subscription created. Scan QR code to pay. Subscription activates automatically after payment confirmation."
}
Campo Descrição
subscription.status "pending" — aguardando pagamento PIX
subscription.next_billing_date null — PIX não tem recorrência
subscription.expires_at null — definido como now + 1 ano após pagamento
pix.qr_codes[0].text Codigo "copia e cola" para pagamento
pix.qr_codes[0].links[0].href URL da imagem PNG do QR Code
pix.expiration_date QR Code expira em 30 minutos

Erros Possíveis

Codigo Mensagem Causa
422 amount is required for PIX Campo amount ausente no payload
400 Customer already has an active subscription Ja tem assinatura ativa
404 Plan not found: plan-xxx plan_id invalido
500 PIX order creation failed Erro ao criar order PIX no gateway

Codigo Dart/Flutter — Assinatura PIX Anual

/// Service para assinatura PIX anual
class PixSubscriptionService {
  static String get baseUrl => AppConfig.baseUrl;

  /// Cria assinatura via PIX (Asaas)
  ///
  /// [planId] — Slug do plano (ex: "plan-basic")
  Future<PixSubscriptionResponse> subscribePix({
    required String token,
    required String planId,
    String? promoCode,
  }) async {
    final payload = <String, dynamic>{
      'plan_id': planId,
    };

    if (promoCode != null) {
      payload['promo_code'] = promoCode;
    }

    final response = await http.post(
      Uri.parse('$baseUrl/asaas/subscribe/pix'),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $token',
      },
      body: jsonEncode(payload),
    );

    final body = jsonDecode(response.body);

    if (response.statusCode == 201) {
      return PixSubscriptionResponse.fromJson(body);
    } else {
      final detail = body['detail'] ?? 'Erro ao criar assinatura PIX';
      throw Exception(detail is String ? detail : jsonEncode(detail));
    }
  }
}

/// Resposta da assinatura PIX
class PixSubscriptionResponse {
  final String subscriptionId;
  final String orderId;
  final String status;
  final String qrCodeText;
  final String qrCodeImageUrl;
  final DateTime expiresAt;
  final int amount;

  PixSubscriptionResponse({
    required this.subscriptionId,
    required this.orderId,
    required this.status,
    required this.qrCodeText,
    required this.qrCodeImageUrl,
    required this.expiresAt,
    required this.amount,
  });

  factory PixSubscriptionResponse.fromJson(Map<String, dynamic> json) {
    final sub = json['subscription'];
    final pix = json['pix'];
    final qrCodes = pix['qr_codes'] as List;
    final qrCode = qrCodes.isNotEmpty ? qrCodes[0] : {};
    final links = (qrCode['links'] as List?) ?? [];
    final imageLink = links.firstWhere(
      (l) => l['media'] == 'image/png',
      orElse: () => {'href': ''},
    );

    return PixSubscriptionResponse(
      subscriptionId: sub['_id'],
      orderId: pix['order_id'],
      status: sub['status'],
      qrCodeText: qrCode['text'] ?? '',
      qrCodeImageUrl: imageLink['href'] ?? '',
      expiresAt: DateTime.parse(pix['expiration_date']),
      amount: qrCode['amount']?['value'] ?? 0,
    );
  }

  String get formattedAmount =>
      'R\$ ${(amount / 100).toStringAsFixed(2)}';

  bool get isExpired => DateTime.now().isAfter(expiresAt);
}

Tela de Pagamento PIX Anual (Widget)

class PixAnnualPaymentScreen extends StatefulWidget {
  final String planId;
  final String planName;
  final int annualAmount; // Em centavos

  const PixAnnualPaymentScreen({
    Key? key,
    required this.planId,
    required this.planName,
    required this.annualAmount,
  }) : super(key: key);

  @override
  _PixAnnualPaymentScreenState createState() => _PixAnnualPaymentScreenState();
}

class _PixAnnualPaymentScreenState extends State<PixAnnualPaymentScreen> {
  final _pixService = PixSubscriptionService();

  bool _loading = true;
  String? _error;
  PixSubscriptionResponse? _pixResponse;

  @override
  void initState() {
    super.initState();
    _createPixSubscription();
  }

  Future<void> _createPixSubscription() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final token = prefs.getString('access_token');

      if (token == null) {
        throw Exception('Sessão expirada. Faça login novamente.');
      }

      final response = await _pixService.subscribePix(
        token: token,
        planId: widget.planId,
      );

      setState(() {
        _pixResponse = response;
        _loading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString().replaceFirst('Exception: ', '');
        _loading = false;
      });
    }
  }

  void _copyPixCode() {
    if (_pixResponse != null) {
      Clipboard.setData(ClipboardData(text: _pixResponse!.qrCodeText));
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Código PIX copiado!'), backgroundColor: Colors.green),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('PIX — ${widget.planName} Anual')),
      body: _loading
          ? Center(child: CircularProgressIndicator())
          : _error != null
              ? Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.error_outline, size: 64, color: Colors.red),
                      SizedBox(height: 16),
                      Text(_error!, textAlign: TextAlign.center),
                      SizedBox(height: 16),
                      ElevatedButton(
                        onPressed: () {
                          setState(() { _loading = true; _error = null; });
                          _createPixSubscription();
                        },
                        child: Text('Tentar Novamente'),
                      ),
                    ],
                  ),
                )
              : SingleChildScrollView(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    children: [
                      // Info do plano
                      Card(
                        child: Padding(
                          padding: EdgeInsets.all(16),
                          child: Column(children: [
                            Text(widget.planName, style: TextStyle(fontSize: 18)),
                            SizedBox(height: 4),
                            Text('Plano Anual', style: TextStyle(color: Colors.grey)),
                            SizedBox(height: 8),
                            Text(
                              _pixResponse!.formattedAmount,
                              style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.green),
                            ),
                            Text('pagamento único (12 meses)', style: TextStyle(color: Colors.grey)),
                          ]),
                        ),
                      ),

                      SizedBox(height: 24),
                      Text('Escaneie o QR Code', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                      SizedBox(height: 16),

                      // QR Code image
                      Container(
                        padding: EdgeInsets.all(16),
                        decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.circular(8),
                          border: Border.all(color: Colors.grey.shade300),
                        ),
                        child: _pixResponse!.qrCodeImageUrl.isNotEmpty
                            ? Image.network(_pixResponse!.qrCodeImageUrl, width: 250, height: 250)
                            : Icon(Icons.qr_code_2, size: 250, color: Colors.grey),
                      ),

                      SizedBox(height: 24),
                      Text('Ou copie o código PIX:', style: TextStyle(fontSize: 16)),
                      SizedBox(height: 8),

                      // Copia e cola
                      Container(
                        padding: EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: Colors.grey.shade100,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Row(children: [
                          Expanded(
                            child: Text(
                              _pixResponse!.qrCodeText,
                              style: TextStyle(fontSize: 12, fontFamily: 'monospace'),
                              maxLines: 3,
                              overflow: TextOverflow.ellipsis,
                            ),
                          ),
                          IconButton(icon: Icon(Icons.copy), onPressed: _copyPixCode),
                        ]),
                      ),

                      SizedBox(height: 16),
                      ElevatedButton.icon(
                        onPressed: _copyPixCode,
                        icon: Icon(Icons.copy),
                        label: Text('Copiar Código PIX'),
                      ),

                      SizedBox(height: 24),
                      Row(mainAxisAlignment: MainAxisAlignment.center, children: [
                        Icon(Icons.timer, size: 16, color: Colors.orange),
                        SizedBox(width: 8),
                        Text(
                          'QR Code expira em 30 minutos',
                          style: TextStyle(color: Colors.orange),
                        ),
                      ]),

                      SizedBox(height: 16),

                      // Status info
                      Card(
                        color: Colors.blue.shade50,
                        child: Padding(
                          padding: EdgeInsets.all(16),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text('Após o pagamento:', style: TextStyle(fontWeight: FontWeight.bold)),
                              SizedBox(height: 8),
                              Text('1. O pagamento é confirmado automaticamente via webhook'),
                              Text('2. Sua assinatura é ativada (status: active)'),
                              Text('3. Acesso a vídeos é liberado por 12 meses'),
                              Text('4. Você pode verificar com GET /customers/me/subscription'),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
    );
  }
}

Verificar Status da Assinatura PIX (Polling)

Após exibir o QR code, use polling para detectar quando o pagamento foi confirmado:

/// Verifica se a assinatura PIX foi ativada.
/// O backend resolve automaticamente subscriptions Stripe pendentes:
/// se o pagamento falhou, retorna status='cancelled' ou 404.
Future<void> pollSubscriptionStatus(String token) async {
  Timer.periodic(Duration(seconds: 5), (timer) async {
    try {
      final response = await http.get(
        Uri.parse('$baseUrl/customers/me/subscription'),
        headers: {'Authorization': 'Bearer $token'},
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        final status = data['subscription']['status'];

        if (status == 'active') {
          timer.cancel();
          Navigator.pushReplacementNamed(context, '/videos');
        } else if (status == 'cancelled') {
          timer.cancel();
          // Pagamento falhou — exibir erro e permitir retry
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Pagamento não confirmado. Tente novamente.')),
          );
        }
      } else if (response.statusCode == 404) {
        timer.cancel();
        // Subscription removida (pagamento falhou) — permitir retry
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Pagamento expirado. Tente novamente.')),
        );
      }
    } catch (_) {
      // Ignorar erros de polling, tentar novamente
    }
  });
}

Webhook vs Polling

O backend recebe a confirmação de pagamento via webhook automaticamente. O polling no frontend serve apenas para atualizar a UI quando o status mudar de pending para active.

Resolução automática de status pendente (Stripe)

Para subscriptions Stripe com status: pending, o endpoint GET /customers/me/subscription consulta o Stripe API em tempo real. Se o pagamento falhou (incomplete, canceled), o backend atualiza para cancelled e limpa current_subscription_id automaticamente. O polling nunca retorna pending indefinidamente para pagamentos Stripe recusados.

Recuperar QR Code PIX (Abandono)

Se o customer fechar o app antes de pagar, pode recuperar o QR code na proxima visita:

/// Recuperar QR code PIX para subscription pending.
/// Retorna null se nao ha subscription pending.
Future<Map<String, dynamic>?> recoverPixQr(String token) async {
  final resp = await http.get(
    Uri.parse('$baseUrl/api/v1/customers/me/pix-qr'),
    headers: {'Authorization': 'Bearer $token'},
  );
  if (resp.statusCode == 200) return jsonDecode(resp.body);
  if (resp.statusCode == 404) return null; // sem pending
  if (resp.statusCode == 410) return null; // QR expirado (Stripe, 30min)
  throw Exception('Erro: ${resp.body}');
}

Fluxo recomendado no app

Ao abrir a tela de assinatura, verificar primeiro se ha subscription pending via GET /customers/me/pix-qr. Se retornar QR code, exibi-lo diretamente. Se retornar 404/410, exibir fluxo normal de subscribe.

Limpeza automatica (Asaas)

Subscriptions pending com mais de 24h sao auto-canceladas quando o customer tenta criar nova assinatura PIX. O frontend nao precisa gerenciar expiracao manualmente.

Cancelar PIX Pendente (trocar meio de pagamento)

Se o customer gerou um PIX mas quer pagar com cartao, cancele o PIX pendente primeiro:

/// Cancelar subscription pendente (PIX ou qualquer gateway).
/// Depois o customer pode criar nova subscription com outro metodo.
Future<void> cancelPendingSubscription(String token) async {
  final resp = await http.delete(
    Uri.parse('$baseUrl/api/v1/customers/me/subscription'),
    headers: {'Authorization': 'Bearer $token'},
  );
  if (resp.statusCode != 200) {
    throw Exception('Erro ao cancelar: ${resp.body}');
  }
}

Fluxo completo: PIX → Cartao

  1. GET /customers/me/pix-qr → exibir QR code + botao "Cancelar e usar cartao"
  2. Ao clicar: DELETE /customers/me/subscription → cancela PIX no gateway
  3. Redirecionar para fluxo de cartao (POST /asaas/subscribe ou POST /stripe/subscribe)

Trocar Cartao de Credito (Renew)

PUT /api/v1/customers/me/subscription/renew

Cancela a assinatura atual e cria uma nova com o mesmo plano e novo cartao. O gateway e detectado automaticamente a partir da assinatura ativa.

Payload — Asaas

{
  "credit_card": {
    "holderName": "NOME NO CARTAO",
    "number": "5162306219378829",
    "expiryMonth": "05",
    "expiryYear": "2028",
    "ccv": "318"
  },
  "credit_card_holder_info": {
    "name": "Nome Completo",
    "email": "email@example.com",
    "cpfCnpj": "24971563792",
    "postalCode": "89223005",
    "addressNumber": "277",
    "phone": "47998781877"
  }
}

Payload — Stripe

{
  "payment_method_id": "pm_1234567890abcdef"
}

Resposta (200)

{
  "subscription": {
    "id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "gateway": "asaas",
    "status": "pending",
    "payment_method": "credit_card",
    "started_at": "2026-04-03T12:00:00+00:00"
  },
  "message": "Assinatura renovada com sucesso. Novo cartao configurado."
}

Erros

Status Detalhe
400 Campos obrigatorios faltando para o gateway
400 Subscription com status invalido para renew
404 Customer ou subscription nao encontrada

Compra de Ingressos (Ticket Store)

Para compra avulsa de ingressos para eventos, consulte a secao Ticket Store abaixo.


🔍 Verificação de Disponibilidade (Opcional)

Antes do cadastro, verifique se email/CPF estão disponíveis.

Endpoint

GET /api/v1/customers/check-availability?email=X&tax_id=Y

Código Dart

class AvailabilityResult {
  final bool? emailAvailable;
  final bool? taxIdAvailable;

  AvailabilityResult({this.emailAvailable, this.taxIdAvailable});

  factory AvailabilityResult.fromJson(Map<String, dynamic> json) {
    return AvailabilityResult(
      emailAvailable: json['email_available'],
      taxIdAvailable: json['tax_id_available'],
    );
  }
}

Future<AvailabilityResult> checkAvailability({
  String? email,
  String? taxId,
}) async {
  final params = <String, String>{};
  if (email != null) params['email'] = email;
  if (taxId != null) params['tax_id'] = taxId.replaceAll(RegExp(r'\D'), '');

  final url = Uri.parse('$baseUrl/customers/check-availability')
      .replace(queryParameters: params);

  final response = await http.get(url);
  return AvailabilityResult.fromJson(jsonDecode(response.body));
}

// Uso com debounce
Timer? _debounce;

void _onEmailChanged(String value) {
  _debounce?.cancel();
  _debounce = Timer(Duration(milliseconds: 500), () async {
    if (value.contains('@')) {
      final result = await checkAvailability(email: value);
      if (result.emailAvailable == false) {
        setState(() => _emailError = 'Email já cadastrado');
      } else {
        setState(() => _emailError = null);
      }
    }
  });
}

🔐 Autenticação — Header Authorization

Todas as requests após login devem incluir o token JWT:

final headers = {
  'Content-Type': 'application/json',
  'Authorization': 'Bearer $token',
};

Verificar Expiração do Token

bool isTokenExpired(String token) {
  try {
    final parts = token.split('.');
    if (parts.length != 3) return true;

    final payload = jsonDecode(
      utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
    );

    final exp = payload['exp'] as int;
    return DateTime.now().millisecondsSinceEpoch > exp * 1000;
  } catch (_) {
    return true;
  }
}

🔑 Recuperação de Senha (Forgot Password)

Fluxo para customers que esqueceram a senha. Totalmente público (sem autenticação).

Fluxo Visual

flowchart TD
    A[Tela de Login] --> B[Clica em 'Esqueci minha senha']
    B --> C[Tela Forgot Password]
    C --> D[Digita email]
    D --> E[POST /auth/forgot-password]
    E --> F[Navega para tela de código]
    F --> G[Usuário abre email]
    G --> H[Copia código de 6 dígitos]
    H --> I[Digita código + nova senha + confirmação]
    I --> J[POST /auth/reset-password]
    J --> K{Sucesso?}
    K -->|Sim| L[Redireciona ao Login]
    K -->|Código expirado| M[Exibe erro + botão para reenviar]
    K -->|Código inválido| N[Exibe erro + tentativas restantes]
    K -->|Tentativas esgotadas| M

Endpoints Envolvidos

Endpoint Método Auth Descrição
/auth/forgot-password POST ❌ Não Solicita envio de código de 6 dígitos por email
/auth/reset-password POST ❌ Não Redefine senha com email + código + nova senha

Parâmetros do Código

  • Código de 6 dígitos numéricos
  • Expira em 15 minutos
  • Máximo 5 tentativas por código
  • Reenvio a cada 60 segundos (mesmo endpoint forgot-password)

Código Dart/Flutter — Recuperação de Senha

import 'dart:convert';
import 'package:http/http.dart' as http;

class PasswordResetService {
  static String get baseUrl => AppConfig.baseUrl;

  /// Solicita reset de senha (envia email com código de 6 dígitos).
  ///
  /// Sempre retorna sucesso (anti-enumeração de emails).
  /// Rate limit: 1 reenvio a cada 60 segundos (429 se antes).
  Future<String> forgotPassword(String email) async {
    final response = await http.post(
      Uri.parse('$baseUrl/auth/forgot-password'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email}),
    );

    if (response.statusCode == 429) {
      final body = jsonDecode(response.body);
      throw Exception(body['detail'] ?? 'Aguarde para reenviar o código.');
    }

    final body = jsonDecode(response.body);
    return body['message'];
  }

  /// Redefine a senha usando o código de 6 dígitos recebido via email.
  ///
  /// Lança [Exception] se o código for inválido, expirado
  /// ou se as tentativas foram esgotadas.
  Future<String> resetPassword({
    required String email,
    required String code,
    required String newPassword,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/auth/reset-password'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'email': email,
        'code': code,
        'new_password': newPassword,
      }),
    );

    final body = jsonDecode(response.body);

    if (response.statusCode == 200) {
      return body['message'];
    }

    if (response.statusCode == 429) {
      throw Exception(body['detail'] ?? 'Tentativas esgotadas. Solicite um novo código.');
    }

    throw Exception(body['detail'] ?? 'Erro ao redefinir senha');
  }
}

Widget — Tela Esqueci Minha Senha

class ForgotPasswordScreen extends StatefulWidget {
  @override
  _ForgotPasswordScreenState createState() => _ForgotPasswordScreenState();
}

class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
  final _emailController = TextEditingController();
  final _service = PasswordResetService();
  bool _loading = false;
  String? _error;

  Future<void> _handleSubmit() async {
    if (_emailController.text.isEmpty || !_emailController.text.contains('@')) {
      setState(() => _error = 'Digite um email válido');
      return;
    }

    setState(() { _loading = true; _error = null; });

    try {
      await _service.forgotPassword(_emailController.text.trim());
      // Navega para tela de código, passando o email
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (_) => ResetPasswordScreen(
            email: _emailController.text.trim(),
          ),
        ),
      );
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Esqueci Minha Senha')),
      body: Padding(
        padding: EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Digite seu email para receber o código de redefinição de senha.',
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 24),
            TextFormField(
              controller: _emailController,
              decoration: InputDecoration(labelText: 'Email'),
              keyboardType: TextInputType.emailAddress,
            ),
            if (_error != null) ...[
              SizedBox(height: 8),
              Text(_error!, style: TextStyle(color: Colors.red)),
            ],
            SizedBox(height: 24),
            ElevatedButton(
              onPressed: _loading ? null : _handleSubmit,
              child: _loading
                  ? CircularProgressIndicator(color: Colors.white)
                  : Text('Enviar Código'),
            ),
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: Text('Voltar ao Login'),
            ),
          ],
        ),
      ),
    );
  }
}

Widget — Tela Redefinir Senha (Código)

class ResetPasswordScreen extends StatefulWidget {
  final String email; // Email informado na tela anterior

  const ResetPasswordScreen({Key? key, required this.email}) : super(key: key);

  @override
  _ResetPasswordScreenState createState() => _ResetPasswordScreenState();
}

class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
  final _codeController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmController = TextEditingController();
  final _service = PasswordResetService();
  bool _loading = false;
  bool _resending = false;
  bool _success = false;
  String? _error;

  Future<void> _handleSubmit() async {
    if (_codeController.text.length != 6) {
      setState(() => _error = 'Digite o código de 6 dígitos');
      return;
    }
    if (_passwordController.text.length < 8) {
      setState(() => _error = 'A senha deve ter no mínimo 8 caracteres');
      return;
    }
    if (_passwordController.text != _confirmController.text) {
      setState(() => _error = 'As senhas não coincidem');
      return;
    }

    setState(() { _loading = true; _error = null; });

    try {
      await _service.resetPassword(
        email: widget.email,
        code: _codeController.text,
        newPassword: _passwordController.text,
      );
      setState(() => _success = true);
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  Future<void> _handleResend() async {
    setState(() { _resending = true; _error = null; });

    try {
      await _service.forgotPassword(widget.email);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Novo código enviado!')),
      );
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _resending = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_success) {
      return Scaffold(
        body: Padding(
          padding: EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.check_circle, size: 64, color: Colors.green),
              SizedBox(height: 16),
              Text('Senha redefinida com sucesso!', style: TextStyle(fontSize: 18)),
              SizedBox(height: 8),
              Text('Faça login com sua nova senha.'),
              SizedBox(height: 24),
              ElevatedButton(
                onPressed: () => Navigator.pushReplacementNamed(context, '/login'),
                child: Text('Ir para Login'),
              ),
            ],
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(title: Text('Redefinir Senha')),
      body: Padding(
        padding: EdgeInsets.all(24),
        child: SingleChildScrollView(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                'Digite o código de 6 dígitos enviado para ${widget.email}',
                textAlign: TextAlign.center,
              ),
              SizedBox(height: 24),
              TextFormField(
                controller: _codeController,
                decoration: InputDecoration(labelText: 'Código de 6 dígitos'),
                keyboardType: TextInputType.number,
                maxLength: 6,
                textAlign: TextAlign.center,
                style: TextStyle(fontSize: 24, letterSpacing: 8),
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _passwordController,
                decoration: InputDecoration(labelText: 'Nova senha (mínimo 8 caracteres)'),
                obscureText: true,
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _confirmController,
                decoration: InputDecoration(labelText: 'Confirmar nova senha'),
                obscureText: true,
              ),
              if (_error != null) ...[
                SizedBox(height: 8),
                Text(_error!, style: TextStyle(color: Colors.red)),
              ],
              SizedBox(height: 24),
              ElevatedButton(
                onPressed: _loading ? null : _handleSubmit,
                child: _loading
                    ? CircularProgressIndicator(color: Colors.white)
                    : Text('Redefinir Senha'),
              ),
              SizedBox(height: 16),
              TextButton(
                onPressed: _resending ? null : _handleResend,
                child: _resending
                    ? SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
                    : Text('Reenviar código'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Documentação Completa

Para detalhes técnicos sobre os endpoints, validação de código e erros, veja API de Autenticação — Forgot Password.


🗑️ Exclusão de Conta (Self-Service)

Permite que o customer delete sua própria conta. A assinatura ativa (se houver) é cancelada automaticamente no gateway de pagamento.

Fluxo Visual

flowchart TD
    A[Tela de Configurações / Perfil] --> B[Clica em 'Excluir Conta']
    B --> C[Modal de Confirmação]
    C --> D{Tem assinatura ativa?}
    D -->|Sim| E[Aviso: assinatura será cancelada]
    D -->|Não| F[Aviso: ação irreversível]
    E --> G[Digita senha atual]
    F --> G
    G --> H[DELETE /customers/me]
    H --> I{Sucesso?}
    I -->|Sim| J[Limpar tokens locais]
    J --> K[Redirecionar ao Login]
    I -->|Senha incorreta| L[Exibir erro]

Endpoint

Endpoint Método Auth Body Descrição
/customers/me DELETE ✅ JWT {password} Deleta conta com cascade

Código Dart/Flutter — Exclusão de Conta

import 'dart:convert';
import 'package:http/http.dart' as http;

class AccountService {
  static String get baseUrl => AppConfig.baseUrl;

  /// Deleta a conta do customer autenticado.
  ///
  /// Requer confirmação de senha. Se houver assinatura ativa,
  /// ela é cancelada no gateway de pagamento automaticamente (cascade).
  /// Após sucesso, o token JWT torna-se inválido.
  Future<void> deleteAccount({
    required String token,
    required String password,
  }) async {
    final response = await http.delete(
      Uri.parse('$baseUrl/customers/me'),
      headers: {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
      },
      body: jsonEncode({'password': password}),
    );

    if (response.statusCode != 200) {
      final body = jsonDecode(response.body);
      final message = body['error']?['message'] ?? body['detail'] ?? 'Erro ao deletar conta';
      throw Exception(message);
    }
  }
}

Widget — Dialog de Confirmação

class DeleteAccountDialog extends StatefulWidget {
  final bool hasActiveSubscription;

  const DeleteAccountDialog({Key? key, this.hasActiveSubscription = false}) : super(key: key);

  @override
  _DeleteAccountDialogState createState() => _DeleteAccountDialogState();
}

class _DeleteAccountDialogState extends State<DeleteAccountDialog> {
  final _passwordController = TextEditingController();
  final _accountService = AccountService();
  bool _loading = false;
  String? _error;

  Future<void> _handleDelete() async {
    if (_passwordController.text.isEmpty) {
      setState(() => _error = 'Digite sua senha');
      return;
    }

    setState(() { _loading = true; _error = null; });

    try {
      final prefs = await SharedPreferences.getInstance();
      final token = prefs.getString('access_token');

      if (token == null) {
        throw Exception('Sessão expirada');
      }

      await _accountService.deleteAccount(
        token: token,
        password: _passwordController.text,
      );

      // Limpar dados locais
      await prefs.clear();

      // Redirecionar ao login
      Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false);
    } catch (e) {
      setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text('Excluir Conta'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'Esta ação é irreversível. Todos os seus dados serão excluídos permanentemente.',
            style: TextStyle(color: Colors.red),
          ),
          if (widget.hasActiveSubscription) ...[
            SizedBox(height: 12),
            Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.orange.shade50,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                children: [
                  Icon(Icons.warning, color: Colors.orange),
                  SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      'Sua assinatura ativa será cancelada automaticamente.',
                      style: TextStyle(color: Colors.orange.shade900),
                    ),
                  ),
                ],
              ),
            ),
          ],
          SizedBox(height: 16),
          Text('Digite sua senha para confirmar:'),
          SizedBox(height: 8),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: InputDecoration(
              labelText: 'Senha atual',
              border: OutlineInputBorder(),
            ),
          ),
          if (_error != null) ...[
            SizedBox(height: 8),
            Text(_error!, style: TextStyle(color: Colors.red, fontSize: 13)),
          ],
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text('Cancelar'),
        ),
        ElevatedButton(
          onPressed: _loading ? null : _handleDelete,
          style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
          child: _loading
              ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
              : Text('Excluir Conta', style: TextStyle(color: Colors.white)),
        ),
      ],
    );
  }
}

// Uso na tela de perfil/configurações:
showDialog(
  context: context,
  builder: (_) => DeleteAccountDialog(
    hasActiveSubscription: customer.currentSubscriptionId != null,
  ),
);

Documentação Completa

Para detalhes técnicos sobre o endpoint, cascade e erros, veja Customers API — Deletar Conta.


📊 Fluxo Completo de Teste — Script Dart

Obter plan_id

O plan_id deve ser obtido de GET /api/v1/plans. Use plan-basic, plan-plus ou plan-premium.

import 'dart:convert';
import 'package:http/http.dart' as http;

/// Script para testar fluxo completo (Stripe) — execute no console
Future<void> testarFluxoCompleto() async {
  final baseUrl = AppConfig.baseUrl;

  // Dados de teste com timestamp para evitar duplicação
  final timestamp = DateTime.now().millisecondsSinceEpoch;

  final customerData = {
    'username': 'teste_$timestamp',
    'email': 'teste_$timestamp@example.com',
    'password': 'senha_segura_123',
    'full_name': 'Cliente Teste da Silva',
    'tax_id': '12345678909',
    'phone': '+5511999887766',
  };

  print('=== ETAPA 1: CADASTRO ===\n');

  // 1. Cadastro
  final signupResponse = await http.post(
    Uri.parse('$baseUrl/customers/signup'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode([customerData]),
  );

  if (signupResponse.statusCode != 200) {
    print('Erro no cadastro: ${signupResponse.body}');
    return;
  }

  final signupData = jsonDecode(signupResponse.body);
  final token = signupData['info']['auth']['access_token'];

  print('Customer cadastrado!');
  print('   Username: teste_$timestamp');
  print('   Token: ${token.substring(0, 50)}...\n');

  // 2. Criar assinatura via Stripe
  // Para Stripe: use flutter_stripe para obter um payment_method_id
  // Para Asaas: consulte asaas-integration.md
  print('=== ETAPA 2: CRIAR ASSINATURA (STRIPE) ===\n');

  final subscribePayload = {
    'plan_id': 'plan-basic',        // Obtido de GET /api/v1/plans
    'payment_method_id': 'pm_card_visa',  // ID obtido via flutter_stripe
  };

  final subscribeResponse = await http.post(
    Uri.parse('$baseUrl/stripe/subscribe'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $token',
    },
    body: jsonEncode(subscribePayload),
  );

  if (subscribeResponse.statusCode != 200) {
    print('Erro na assinatura: ${subscribeResponse.body}');
    return;
  }

  final subscribeData = jsonDecode(subscribeResponse.body);
  final subscription = subscribeData['subscription'];

  print('Assinatura criada com sucesso!');
  print('   Status: ${subscription['status']}');
  print('   Proxima cobranca: ${subscription['next_billing_date']}');

  print('\n=== TESTE CONCLUIDO COM SUCESSO ===');
}

🐛 Tratamento de Erros

Erros Comuns e Soluções

Código Mensagem Causa Solução
409 "Username already exists" Username em uso Sugerir outro username
409 "Email already exists" Email já cadastrado Oferecer login
400 "String should have at least X characters" Validação falhou Verificar campo indicado
400 "Address is required" Falta endereço Exibir formulário de endereço
400 "Customer already has active subscription" Já tem assinatura Redirecionar para /subscription
401 "Could not validate credentials" Token inválido/expirado Fazer login novamente
404 "Plan not found" plan_id inválido Verificar plano existe
500 "Subscription creation failed" Erro no gateway de pagamento Verificar dados do cartão ou contatar suporte

Parser de Erros

String parseError(http.Response response) {
  try {
    final body = jsonDecode(response.body);
    final detail = body['detail'];

    if (detail is String) {
      return detail;
    } else if (detail is List) {
      // Erros de validação Pydantic
      return detail.map((e) {
        final field = (e['loc'] as List).last;
        final msg = e['msg'];
        return '$field: $msg';
      }).join('\n');
    }
  } catch (_) {}

  return 'Erro desconhecido (${response.statusCode})';
}

🖼️ Avatar e Histórico de Reprodução (Novidade 2026-02-15)

Novos endpoints unificados para Admin e Customer gerenciarem foto de perfil e progresso de vídeos.

Documentação Completa

Para detalhes técnicos, payloads, erros e widgets completos, veja Perfil — Avatar & Watch History.

Avatar — Resumo para o Frontend

O avatar é armazenado no GridFS (MongoDB). O fluxo é:

  1. Upload: POST /api/v1/me/avatar (multipart/form-data, max 5 MB, JPEG/PNG/WebP)
  2. Exibir: GET /api/v1/avatars/{avatar_id} (público, sem auth — usar em Image.network)
  3. Remover: DELETE /api/v1/me/avatar

O avatar_id está disponível em GET /customers/me (campo avatar_id). Ao fazer upload de um novo avatar, o antigo é substituído automaticamente.

// Upload de avatar
final uri = Uri.parse('$baseUrl/me/avatar');
final request = http.MultipartRequest('POST', uri)
  ..headers['Authorization'] = 'Bearer $token'
  ..files.add(await http.MultipartFile.fromPath('file', imageFile.path));

final response = await request.send();
final body = jsonDecode(await response.stream.bytesToString());
final avatarId = body['avatar_id']; // Usar em GET /avatars/{avatarId}

// Exibir avatar (público, sem auth)
Image.network('${AppConfig.baseUrl}/avatars/$avatarId')

Watch History — Resumo para o Frontend

O progresso de reprodução é salvo numa collection separada (watch_history), indexada por user_id + video_id.

Ação Endpoint Quando usar
Salvar progresso PUT /me/watch-history A cada 10-15s durante reprodução + ao pausar/sair
Recuperar progresso GET /me/watch-history?video_id=xxx Ao abrir o player de vídeo
"Continuar Assistindo" GET /me/watch-history Na home, listar vídeos com progresso
Limpar histórico DELETE /me/watch-history Nas configurações do app
// Salvar progresso (chamar a cada 10-15 segundos)
await http.put(
  Uri.parse('$baseUrl/me/watch-history'),
  headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
  body: jsonEncode({'video_id': videoId, 'timeline': positionInSeconds}),
);

// Recuperar progresso ao abrir player
final resp = await http.get(
  Uri.parse('$baseUrl/me/watch-history?video_id=$videoId'),
  headers: {'Authorization': 'Bearer $token'},
);
final history = jsonDecode(resp.body)['watch_history'];
if (history.isNotEmpty) {
  final savedPosition = history[0]['timeline']; // Seek para esta posição
}


💳 Stripe — Gateway de Pagamento

O FDPlay suporta Stripe e Asaas como gateways de pagamento, com rotas completamente separadas. O admin define qual é a preferência atual via PUT /admin/config/payment-gateway, e o frontend consulta via GET /config/payment-gateway. Ver Asaas Integration para o guia completo do Asaas.

Consultar Gateway Padrão (Flutter)

/// Chamar no init do app ou antes de exibir tela de pagamento.
Future<String> getDefaultGateway() async {
  final resp = await http.get(Uri.parse('$baseUrl/api/v1/config/payment-gateway'));
  final data = jsonDecode(resp.body);
  return data['default_payment_gateway']; // 'asaas' ou 'stripe'
}

Quando Usar Cada Gateway

Cenário Gateway Recomendado
Admin definiu preferência via config Seguir GET /config/payment-gateway
Cliente brasileiro, cartão ou PIX Asaas (integração mais simples, sem SDK)
Cliente brasileiro, quer pagar com boleto Asaas
Cliente internacional ou prefere cartão de crédito Stripe
Aplicativo precisa de Apple Pay / Google Pay Stripe
Troca de cartão sem cancelar assinatura Stripe ou Asaas (ambos in-place update)

Quick Start Stripe (Flutter)

// 1. Obter publishable key do backend (NUNCA hardcodar)
final configResp = await http.get(Uri.parse('$baseUrl/api/v1/stripe/config'));
final publishableKey = jsonDecode(configResp.body)['publishable_key'];
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();

// 2. Coletar cartão via CardField widget (PCI Level 1)
// 3. Criar PaymentMethod
final pm = await Stripe.instance.createPaymentMethod(
  params: PaymentMethodParams.card(paymentMethodData: PaymentMethodData()),
);

// 4. Criar assinatura
final resp = await http.post(
  Uri.parse('$baseUrl/api/v1/stripe/subscribe'),
  headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
  body: jsonEncode({'plan_id': 'plan-basic', 'payment_method_id': pm.id}),
);

// 5. Tratar resultado
final result = jsonDecode(resp.body);
final info = result['info'];
if (info['payment_failed'] == true) {
  // Cartao recusado — exibir info['message'] e permitir retry
} else if (info['client_secret'] != null) {
  await Stripe.instance.confirmPayment(paymentIntentClientSecret: info['client_secret']);
}

Quick Start Stripe PIX (Flutter)

// 1. Criar assinatura PIX (sem Stripe SDK — backend cria server-side)
final resp = await http.post(
  Uri.parse('$baseUrl/api/v1/stripe/subscribe/pix'),
  headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
  body: jsonEncode({'plan_id': 'plan-basic', 'amount': 23880}),
);

final result = jsonDecode(resp.body);

// 2. Exibir QR code para o cliente
final qrCode = result['pix']['qr_code'];           // Copia e cola
final qrImage = result['pix']['qr_code_image_url']; // Imagem PNG

// 3. Ativacao automatica via webhook (nada mais a fazer no frontend)

Documentação completa: Integração Stripe



Ticket Store

Endpoints para compra de ingressos para eventos. Todos os endpoints de compra requerem autenticacao JWT.

Listar Eventos

GET /api/v1/store/events
Authorization: Bearer <TOKEN_JWT>

Query params:

Parametro Tipo Default Descricao
qty_docs_page int 20 Itens por pagina (1-100)
current_page int 0 Pagina atual

Retorna apenas eventos ativos, com is_sale_box_office=true e data futura.

Resposta (200):

{
  "docs": [
    {
      "_id": "507f1f77bcf86cd799439011",
      "title": "Show ao Vivo",
      "event_date": "2026-06-15T20:00:00Z",
      "ticket_value": 50.0,
      "capacity": 500,
      "tickets_issued": 120,
      "available_tickets": 380,
      "image_id": "507f1f77bcf86cd799439012",
      "is_sale_box_office": true,
      "is_active": true
    }
  ],
  "current_page": 0,
  "qty_docs_page": 20
}

Detalhe do Evento

GET /api/v1/store/events/{event_id}
Authorization: Bearer <TOKEN_JWT>

Resposta (200): Mesmo formato de um item da listagem, com campo available_tickets calculado.

Comprar via Asaas Cartao

POST /api/v1/store/purchase/asaas/card
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "event_id": "507f1f77bcf86cd799439011",
  "quantity": 2,
  "credit_card": {
    "holderName": "NOME NO CARTAO",
    "number": "5162306219378829",
    "expiryMonth": "05",
    "expiryYear": "2028",
    "ccv": "318"
  },
  "credit_card_holder_info": {
    "name": "Nome Completo",
    "email": "email@example.com",
    "cpfCnpj": "24971563792",
    "postalCode": "89223005",
    "addressNumber": "277",
    "phone": "47998781877"
  },
  "promo_code": null
}
Campo Tipo Obrigatorio Descricao
event_id String Sim ObjectId do evento
quantity int Nao Quantidade de ingressos (1-10, default 1)
credit_card Object Sim Dados do cartao (holderName, number, expiryMonth, expiryYear, ccv)
credit_card_holder_info Object Sim Dados do titular (name, email, cpfCnpj, postalCode, addressNumber, phone)
promo_code String Nao Codigo promocional (opcional)

Resposta (200):

{
  "order": {
    "_id": "507f1f77bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "event_id": "507f1f77bcf86cd799439012",
    "quantity": 2,
    "total": 100.0,
    "gateway": "asaas",
    "payment_method": "credit_card",
    "payment_status": "CONFIRMED",
    "ticket_ids": ["tid1", "tid2"]
  },
  "payment": {
    "id": "pay_xxx",
    "status": "CONFIRMED",
    "billing_type": "CREDIT_CARD"
  }
}

Comprar via Asaas PIX

POST /api/v1/store/purchase/asaas/pix
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "event_id": "507f1f77bcf86cd799439011",
  "quantity": 1
}
Campo Tipo Obrigatorio Descricao
event_id String Sim ObjectId do evento
quantity int Nao Quantidade de ingressos (1-10, default 1)

Resposta (200):

{
  "order": {
    "_id": "507f1f77bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "event_id": "507f1f77bcf86cd799439012",
    "quantity": 1,
    "total": 50.0,
    "gateway": "asaas",
    "payment_method": "pix",
    "payment_status": "PENDING",
    "ticket_ids": ["tid1"]
  },
  "payment": {
    "id": "pay_xxx",
    "status": "PENDING",
    "billing_type": "PIX"
  },
  "pix": {
    "payload": "00020126580014br.gov.bcb.pix0136...",
    "encoded_image": "base64...",
    "expiration_date": "2026-06-15T20:30:00Z"
  }
}

Comprar via Stripe Cartao

POST /api/v1/store/purchase/stripe/card
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "event_id": "507f1f77bcf86cd799439011",
  "quantity": 2,
  "payment_method_id": "pm_1ABC2DEF3GHI"
}
Campo Tipo Obrigatorio Descricao
event_id String Sim ObjectId do evento
quantity int Nao Quantidade de ingressos (1-10, default 1)
payment_method_id String Sim Stripe PaymentMethod ID (pm_xxx) obtido via flutter_stripe

Resposta (200):

{
  "order": {
    "_id": "507f1f77bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "event_id": "507f1f77bcf86cd799439012",
    "quantity": 2,
    "total": 100.0,
    "gateway": "stripe",
    "payment_method": "credit_card",
    "payment_status": "succeeded",
    "ticket_ids": ["tid1", "tid2"]
  },
  "payment": {
    "id": "pi_xxx",
    "status": "succeeded",
    "client_secret": "pi_xxx_secret_xxx"
  }
}

3D Secure

Se payment.status for requires_action e payment.client_secret estiver presente, use Stripe.instance.confirmPayment(paymentIntentClientSecret: clientSecret) para completar a autenticacao 3D Secure.

Comprar via Stripe PIX

POST /api/v1/store/purchase/stripe/pix
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "event_id": "507f1f77bcf86cd799439011",
  "quantity": 1
}

Resposta (200):

{
  "order": {
    "_id": "507f1f77bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "event_id": "507f1f77bcf86cd799439012",
    "quantity": 1,
    "total": 50.0,
    "gateway": "stripe",
    "payment_method": "pix",
    "payment_status": "requires_action",
    "ticket_ids": ["tid1"]
  },
  "payment": {
    "id": "pi_xxx",
    "status": "requires_action",
    "client_secret": "pi_xxx_secret_xxx"
  },
  "pix": {
    "qr_code": "00020126580014br.gov.bcb.pix0136...",
    "image_url": "https://...",
    "expires_at": 1718486400
  }
}

Listar Meus Pedidos

GET /api/v1/me/ticket-orders
Authorization: Bearer <TOKEN_JWT>

Query params:

Parametro Tipo Default Descricao
qty_docs_page int 10 Itens por pagina (1-100)
current_page int 0 Pagina atual

Resposta (200):

{
  "docs": [
    {
      "_id": "507f1f77bcf86cd799439099",
      "customer_id": "507f1f77bcf86cd799439011",
      "event_id": "507f1f77bcf86cd799439012",
      "quantity": 2,
      "total": 100.0,
      "gateway": "asaas",
      "payment_method": "credit_card",
      "payment_status": "CONFIRMED",
      "status": "pending",
      "ticket_ids": ["tid1", "tid2"],
      "created_at": "2026-06-10T15:00:00Z"
    }
  ],
  "current_page": 0,
  "qty_docs_page": 10
}

Detalhe do Pedido

GET /api/v1/me/ticket-orders/{order_id}
Authorization: Bearer <TOKEN_JWT>

Resposta (200): Mesmo formato de um item da listagem de pedidos.

Obter QR Code do Ingresso

GET /api/v1/me/tickets/{ticket_id}/qr
Authorization: Bearer <TOKEN_JWT>

Gera um payload QR criptografado para o ingresso. O ingresso deve pertencer ao customer autenticado e ter status available.

Resposta (200):

{
  "ticket_id": "507f1f77bcf86cd799439050",
  "qr_payload": "encrypted_qr_token_string...",
  "status": "available"
}
Erro Status Descricao
Pagamento pendente (PIX) 400 Ticket payment is still pending.
Ingresso ja consumido 400 Ticket already consumed.
Ingresso expirado 400 Ticket expired.
Ingresso nao encontrado 404 Ticket not found or does not belong to you.

Nota sobre pending: Tickets comprados via Ticket Store com PIX sao criados imediatamente com status='pending' e so mudam para available apos o webhook de confirmacao do pagamento. O frontend deve: - Consultar GET /me/ticket-orders/{order_id} e aguardar payment_status ∈ {CONFIRMED, RECEIVED, succeeded} antes de chamar este endpoint. - Exibir visualmente tickets pending de forma distinta (ex: laranja), sem permitir emissao de QR. - Tickets pending nao sao consumiveis nem via POST /tickets/{id}/consume nem via QR admin (retornam 400).

Validar QR Code (Admin)

POST /api/v1/tickets/validate-qr
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "token": "encrypted_qr_token_string..."
}

Resposta (200):

{
  "valid": true,
  "ticket_id": "507f1f77bcf86cd799439050",
  "customer_id": "507f1f77bcf86cd799439011",
  "status": "available",
  "event": {
    "title": "Show ao Vivo",
    "event_date": "2026-06-15T20:00:00Z",
    "location": "Teatro Municipal"
  }
}

Consumir QR Code (Admin)

POST /api/v1/tickets/consume-qr
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "token": "encrypted_qr_token_string..."
}

Valida e consome o ingresso em uma unica operacao. O ingresso deve ter status available.

Resposta (200):

{
  "consumed": true,
  "ticket_id": "507f1f77bcf86cd799439050",
  "consumed_at": "2026-06-15T20:15:00Z"
}
Erro Status Descricao
QR invalido 400 Invalid or expired QR code.
Ja consumido 400 Ticket already consumed.
Pagamento pendente 400 Ticket payment is still pending.
Evento passado 400 Ticket expired (event date passed).

Perfil — Avatar e Historico de Reproducao

Upload de Avatar

POST /api/v1/me/avatar
Authorization: Bearer <TOKEN_JWT>
Content-Type: multipart/form-data
Campo Tipo Obrigatorio Descricao
file File Sim Imagem JPEG, PNG ou WebP (max 5 MB)

Resposta (200):

{
  "avatar_id": "507f1f77bcf86cd799439011",
  "message": "Avatar atualizado com sucesso."
}

Substituicao automatica

Se o usuario ja possui avatar, o antigo e removido do GridFS automaticamente.

Remover Avatar

DELETE /api/v1/me/avatar
Authorization: Bearer <TOKEN_JWT>

Resposta (200):

{
  "message": "Avatar removido com sucesso."
}
Erro Status Descricao
Sem avatar 404 No avatar found.

Obter Avatar (Publico)

GET /api/v1/avatars/{file_id}

Autenticacao: Nao requerida. Endpoint publico para uso em tags <img> ou Image.network.

Resposta: StreamingResponse com a imagem (JPEG, PNG ou WebP).

// Exemplo Flutter
Image.network('${AppConfig.baseUrl}/avatars/$avatarId')

Atualizar Historico de Reproducao

PUT /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "video_id": "507f1f77bcf86cd799439012",
  "timeline": 542.8
}
Campo Tipo Obrigatorio Descricao
video_id String Sim ObjectId do video
timeline float Sim Posicao de reproducao em segundos (>= 0)

Resposta (200):

{
  "message": "Progresso atualizado.",
  "video_id": "507f1f77bcf86cd799439012",
  "timeline": 542.8,
  "upserted": true
}

Quando chamar

Chamar a cada 10-15 segundos durante reproducao e ao pausar/sair do player.

Obter Historico de Reproducao

GET /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>

Query params (opcionais):

Parametro Tipo Descricao
video_id String Filtrar por video especifico

Resposta (200):

{
  "watch_history": [
    {
      "video_id": "507f1f77bcf86cd799439012",
      "timeline": 542.8,
      "updated_at": "2026-03-25T12:00:00+00:00"
    }
  ]
}

Limpar Historico de Reproducao

DELETE /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>

Query params (opcionais):

Parametro Tipo Descricao
video_id String Deletar apenas o historico de um video especifico. Se omitido, limpa todo o historico.

Resposta (200):

{
  "message": "Historico removido.",
  "deleted_count": 3
}

Fluxos de Autenticacao

Alterar Senha (Autenticado)

POST /api/v1/auth/change-password
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "current_password": "old_password_123",
  "new_password": "new_secure_password_456"
}
Campo Tipo Obrigatorio Descricao
current_password String Sim Senha atual
new_password String Sim Nova senha (minimo 8 caracteres)

Resposta (200):

{
  "message": "Password changed successfully",
  "password_reset_required": false
}
Erro Status Descricao
Senha incorreta 400 Current password is incorrect
Usuario nao encontrado 404 User not found

Esqueci Minha Senha (Publico)

POST /api/v1/auth/forgot-password
Content-Type: application/json

Autenticacao: Nao requerida.

Body:

{
  "email": "joao@example.com"
}

Resposta (200): Sempre retorna a mesma mensagem (anti-enumeracao de emails).

{
  "message": "Se o email estiver cadastrado, voce recebera as instrucoes."
}

Rate limit

Um codigo por 60 segundos por email. Se chamado antes do cooldown, retorna 429.

Redefinir Senha com Codigo (Publico)

POST /api/v1/auth/reset-password
Content-Type: application/json

Autenticacao: Nao requerida.

Body:

{
  "email": "joao@example.com",
  "code": "847291",
  "new_password": "new_secure_password_456"
}
Campo Tipo Obrigatorio Descricao
email String Sim Email da conta
code String Sim Codigo de 6 digitos recebido por email
new_password String Sim Nova senha (minimo 8 caracteres)

Resposta (200):

{
  "message": "Senha redefinida com sucesso."
}
Erro Status Descricao
Codigo expirado 400 Code expired. Request a new one.
Codigo incorreto 400 Codigo incorreto. N tentativa(s) restante(s).
Tentativas esgotadas 429 Maximum attempts reached. Request a new code.
Usuario nao encontrado 404 User not found.

Customer Self-Service

Obter Perfil

GET /api/v1/customers/me
Authorization: Bearer <TOKEN_JWT>

Resposta (200):

{
  "docs": [
    {
      "_id": "678c1a2b3d4e5f6789012345",
      "username": "cliente_teste_001",
      "email": "cliente.teste@example.com",
      "user_type": "customer",
      "full_name": "Cliente Teste da Silva",
      "tax_id": "12345678909",
      "phone": "+5511999887766",
      "nationality": "BR",
      "email_verified": true,
      "avatar_id": "507f1f77bcf86cd799439011",
      "current_subscription_id": "678c2d3e4f5a6b7890123456",
      "created_at": "2026-02-09T15:30:00Z",
      "updated_at": "2026-02-09T15:30:45Z"
    }
  ],
  "total_docs": 1,
  "qty_docs_page": 1,
  "current_page": 0
}

Atualizar Perfil

PUT /api/v1/customers/me
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body: Array com um objeto contendo os campos a atualizar (todos opcionais):

[
  {
    "_id": "678c1a2b3d4e5f6789012345",
    "full_name": "Joao da Silva Atualizado",
    "phone": "+5511987654321",
    "nationality": "BR",
    "address": {
      "street": "Av. Paulista",
      "number": "1000",
      "complement": "Apto 123",
      "locality": "Bela Vista",
      "city": "Sao Paulo",
      "region_code": "SP",
      "country": "BRA",
      "postal_code": "01310100"
    }
  }
]
Campo Tipo Descricao
full_name String Nome completo (3-200 chars)
phone String Telefone
tax_id String CPF/CNPJ
nationality String Codigo pais ISO 3166-1 (2-3 chars)
address Object Endereco de cobranca
my_list list[String] Lista pessoal do customer

Email nao pode ser alterado diretamente

Tentativas de alterar email via este endpoint retornam 400. Use o fluxo de troca de email (POST /customers/me/change-email).

Resposta (200): Mesmo formato da resposta de GET /customers/me com os dados atualizados.

Solicitar Troca de Email

POST /api/v1/customers/me/change-email
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "new_email": "novo_email@example.com"
}

Resposta (200):

{
  "message": "Codigo de verificacao enviado para o novo email.",
  "new_email": "novo_email@example.com",
  "expires_in_minutes": 15
}
Erro Status Descricao
Mesmo email 400 New email must be different from the current one.
Email em uso 409 This email is already in use.
Rate limit 429 Aguarde N segundos para reenviar o codigo.

Confirmar Troca de Email

POST /api/v1/customers/me/confirm-email
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "verification_code": "482917"
}
Campo Tipo Obrigatorio Descricao
verification_code String Sim Codigo de 6 digitos enviado ao novo email

Resposta (200): Retorna os dados atualizados do customer (mesmo formato de GET /customers/me), com info.message: "Email alterado com sucesso.".

Erro Status Descricao
Sem troca pendente 400 No pending email change found.
Codigo expirado 400 Code expired. Request a new verification code.
Codigo incorreto 400 Codigo incorreto. N tentativa(s) restante(s).
Email em uso (race condition) 409 This email is already in use.
Tentativas esgotadas 429 Maximum attempts reached. Request a new verification code.

Excluir Conta

DELETE /api/v1/customers/me
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Body:

{
  "password": "my_current_password"
}

Requer confirmacao de senha. Cancela assinatura ativa no gateway de pagamento automaticamente (cascade).

Resposta (200):

{
  "message": "Conta excluida com sucesso.",
  "details": {
    "customer_id": "678c1a2b3d4e5f6789012345",
    "subscription_cancelled": true,
    "gateway_response": null
  }
}
Erro Status Descricao
Senha incorreta 400 Incorrect password.
Customer nao encontrado 404 Customer not found.

Acao irreversivel

Apos exclusao, o token JWT torna-se invalido. Limpar dados locais e redirecionar ao login.

Listar Meus Codigos Promocionais

GET /api/v1/customers/me/promo-codes
Authorization: Bearer <TOKEN_JWT>

Retorna todos os codigos promocionais atribuidos ao customer, com status de resgate.

Resposta (200):

{
  "codes": [
    {
      "code": "PROMO10",
      "title": "Desconto primeiro mes",
      "subtitle": "Primeiro mes por R$ 5,50",
      "code_type": "promo",
      "redeemed": false,
      "discount_first_month": 5.50,
      "tickets": null,
      "event_date": null,
      "image_id": null
    },
    {
      "code": "TICKET-VIP",
      "title": "Ingresso VIP",
      "subtitle": "Ingresso para evento especial",
      "code_type": "ticket",
      "redeemed": true,
      "discount_first_month": null,
      "tickets": 2,
      "event_date": "2026-06-15T20:00:00+00:00",
      "image_id": "507f1f77bcf86cd799439012"
    }
  ]
}

Se o customer nao possui codigos, retorna {"codes": []}.


Suporte