Skip to content

Integração Asaas — Guia Completo para o Frontend

Gateway de pagamento brasileiro (cartão de crédito + PIX) via Asaas API v3.


Arquitetura

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

    App->>API: POST /asaas/subscribe (card data)
    API->>Asaas: POST /subscriptions
    Asaas-->>API: subscription + first charge
    API->>DB: save subscription doc
    API-->>App: subscription + status

    Note over Asaas: Asaas gera cobranças automáticas
    Asaas->>API: POST /webhooks/asaas (PAYMENT_RECEIVED)
    API->>DB: update status → active

Endpoints Disponíveis

Billing (Customer — autenticado)

Método Rota Descrição
POST /api/v1/asaas/subscribe Criar assinatura com cartão de crédito
POST /api/v1/asaas/subscribe/pix Criar assinatura com PIX
GET /api/v1/asaas/subscription Status da assinatura atual
PUT /api/v1/asaas/subscription/credit-card Atualizar cartão de crédito
DELETE /api/v1/asaas/subscription Cancelar assinatura

Webhook (público)

Método Rota Descrição
POST /webhooks/asaas Receiver de eventos Asaas

Admin (autenticado, admin only)

Método Rota Descrição
GET /api/v1/admin/asaas/subscriptions Listar assinaturas Asaas
GET /api/v1/admin/asaas/subscriptions/stats Estatísticas (MRR, contagens)
POST /api/v1/admin/asaas/subscriptions/{id}/cancel Forçar cancelamento
GET /api/v1/admin/asaas/subscriptions/{id}/payments Histórico de pagamentos
POST /api/v1/admin/asaas/subscriptions/{id}/refund Estornar pagamento
GET /api/v1/admin/asaas/subscriptions/{id}/refunds Histórico de estornos

Diferenças Importantes vs Stripe

Aspecto Stripe Asaas
SDK no frontend Sim (stripe_js, flutter_stripe) Não — envio direto dos dados do cartão
Valores Centavos (ex: 4990 = R$49,90) Reais (ex: 49.90 = R$49,90)
PIX PaymentIntent + QR code Assinatura com billingType: PIX + QR automático
Autenticação API Bearer sk_xxx Header access_token: xxx
Cancelamento cancel_at_period_end Deleta a subscription (imediato)
Cartão PaymentMethod + attach Dados crus no body (creditCard + creditCardHolderInfo)

Dados de cartão enviados diretamente

Diferente do Stripe, o Asaas não usa tokenização no frontend. Os dados do cartão (número, CVV) são enviados diretamente no body da request para a FDPlay API, que os repassa ao Asaas. Isso simplifica a integração mas exige HTTPS obrigatório.


Gateway Padrão (Qual gateway exibir?)

O admin define qual gateway o frontend deve apresentar por padrão. O frontend consulta no init do app:

Request

GET /api/v1/config/payment-gateway

Response

{
  "default_payment_gateway": "asaas"
}

Flutter/Dart

/// 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'
}

Admin — Alterar gateway padrão

PUT /api/v1/admin/config/payment-gateway
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "gateway": "asaas"
}

Valores aceitos: "asaas", "stripe".

Gateway-agnostic

O middleware de acesso a vídeos não depende do gateway — só verifica status == 'active' na subscription. Todos os gateways estão sempre disponíveis; essa rota indica apenas a preferência configurada pelo admin.


Modo de Cobranca PIX (Periodo)

O admin configura o periodo de cobranca PIX: mensal, trimestral, semestral ou anual. O padrao e mensal.

O frontend deve consultar essa config antes de exibir a tela de pagamento PIX para mostrar o valor correto ao usuario.

Request (publico)

GET /api/v1/config/pix-billing-mode

Response

{
  "pix_billing_mode": "quarterly"
}
Valor Periodo Meses Valor (plano R$19,90/mes) Com cupom R$1,00
monthly Mensal 1 R$ 19,90 R$ 1,00
quarterly Trimestral 3 R$ 59,70 R$ 40,80 (2×19,90 + 1,00)
semiannually Semestral 6 R$ 119,40 R$ 100,50 (5×19,90 + 1,00)
yearly Anual 12 R$ 238,80 R$ 219,90 (11×19,90 + 1,00)

Flutter/Dart

/// Consultar modo PIX antes de exibir tela de pagamento.
Future<String> getPixBillingMode() async {
  final resp = await http.get(
    Uri.parse('$baseUrl/api/v1/config/pix-billing-mode'),
  );
  final data = jsonDecode(resp.body);
  return data['pix_billing_mode']; // 'monthly', 'quarterly', 'semiannually', 'yearly'
}

/// Multiplicador de meses por modo PIX.
int getPixMultiplier(String mode) {
  switch (mode) {
    case 'quarterly': return 3;
    case 'semiannually': return 6;
    case 'yearly': return 12;
    default: return 1;
  }
}

/// Exibir valor correto na tela de pagamento PIX.
/// planAmount = valor mensal em centavos (ex: 1990 = R$19,90)
String getPixDisplayPrice(int planAmount, String pixMode) {
  final multiplier = getPixMultiplier(pixMode);
  final total = (planAmount * multiplier) / 100;
  final labels = {
    'monthly': 'mes', 'quarterly': 'trimestre',
    'semiannually': 'semestre', 'yearly': 'ano',
  };
  return 'R\$ ${total.toStringAsFixed(2)}/${labels[pixMode]}';
}

Fluxo recomendado no frontend

1. GET /config/pix-billing-mode → "monthly", "quarterly", "semiannually" ou "yearly"
2. GET /plans → obter valor mensal do plano
3. Calcular: valor_mensal × multiplicador (1, 3, 6 ou 12)
4. Exibir valor total na tela de pagamento
5. POST /asaas/subscribe/pix → backend calcula valor automaticamente
   (frontend NAO precisa enviar o valor — o backend le a config e ajusta)

O frontend NAO envia o valor

O backend calcula o valor automaticamente com base na config pix_billing_mode. O frontend so precisa exibir o valor correto ao usuario antes do pagamento.

Desconto com cupom

O desconto discount_first_month substitui o preco de 1 mes dentro do periodo: valor_final = ((meses - 1) × preco_mensal) + discount_first_month. Aplica-se a Asaas e Stripe PIX em todos os modos.

Admin — Alterar modo PIX

PUT /api/v1/admin/config/pix-billing-mode
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "mode": "quarterly"
}

Valores aceitos: "monthly", "quarterly", "semiannually", "yearly".

Afeta apenas novas assinaturas

Alterar essa config nao muda assinaturas existentes. Apenas novas assinaturas PIX usarao o modo atualizado.

Integrado a todos os gateways

A config pix_billing_mode e respeitada tanto pelo Asaas (POST /asaas/subscribe/pix) quanto pelo Stripe (POST /stripe/subscribe/pix). Uma unica config controla ambos.

Recuperacao de QR code PIX (abandono)

Se o customer abandonar o pagamento PIX antes de escanear o QR code, pode recupera-lo via:

GET /api/v1/customers/me/pix-qr
Authorization: Bearer <customer_token>

Response (Asaas):

{
  "subscription_id": "69ca10ee...",
  "status": "pending",
  "gateway": "asaas",
  "pix": {
    "gateway": "asaas",
    "payment_id": "pay_xxx",
    "qr_code": "00020126580014br.gov.bcb.pix...",
    "qr_code_image": "<base64 PNG>",
    "expiration_date": "2026-03-31T03:00:00Z"
  },
  "message": "PIX QR code retrieved. Scan to complete payment."
}

Regras:

  • Funciona para Asaas e Stripe PIX
  • Retorna 404 se nao ha subscription pending
  • Retorna 400 se a subscription nao e PIX
  • Retorna 410 (Stripe) se o QR code expirou
  • Asaas: subscriptions pending com mais de 24h sao auto-canceladas ao tentar novo subscribe

Cancelar PIX pendente

Se o customer quer trocar de meio de pagamento (ex: PIX → cartao), deve cancelar o PIX pendente via DELETE /api/v1/customers/me/subscription. O endpoint cancela no gateway (Asaas ou Stripe) e libera o customer para criar nova assinatura.


Fluxo 1: Assinatura com Cartão de Crédito

Diagrama

sequenceDiagram
    participant User as Usuário
    participant App as Flutter App
    participant API as FDPlay API
    participant Asaas as Asaas API

    User->>App: Preenche dados do cartão
    App->>API: POST /api/v1/asaas/subscribe
    API->>Asaas: POST /customers (se necessário)
    Asaas-->>API: cus_xxx

    alt Com desconto (promo_code)
        API->>Asaas: POST /payments (R$5,50 avulso, externalReference=sub_oid)
        Asaas-->>API: pay_xxx CONFIRMED
        API->>Asaas: POST /subscriptions (R$19,90, nextDueDate=+30d)
        Asaas-->>API: sub_xxx
        Note over Asaas: Webhook PAYMENT_CONFIRMED (pay avulso)
        Asaas->>API: POST /webhooks/asaas (externalReference=sub_oid)
        API->>API: Fallback: find sub by _id → ativa
    else Sem desconto
        API->>Asaas: POST /subscriptions (R$19,90, nextDueDate=hoje)
        Asaas-->>API: sub_xxx + cobra imediatamente
        Note over Asaas: Webhook PAYMENT_CONFIRMED (subscription)
        Asaas->>API: POST /webhooks/asaas (payment.subscription=sub_xxx)
        API->>API: find sub by asaas_subscription_id → ativa
    end

    API->>API: Salva subscription no MongoDB
    API-->>App: { subscription, asaas_subscription_id, payment_status }
    App->>App: Polling status até active
    App->>User: Confirmação

Desconto primeiro mes com cartao de credito

O Asaas nao permite alterar o valor de subscriptions credit card apos a primeira fatura paga. Por isso, o desconto e aplicado via pagamento avulso (create_payment) separado da subscription. A subscription ja nasce com o valor cheio (R$19,90) e nextDueDate +30 dias. O pagamento avulso envia externalReference=subscription._id para que o webhook possa ativar a subscription correta.

Request

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

{
  "plan_id": "plan-basic",
  "promo_code": "DESCONTO10",
  "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"
  }
}

Response (201)

{
  "docs": [
    {
      "_id": "6650a1b2c3d4e5f6a7b8c9d0",
      "customer_id": "6650a1b2c3d4e5f6a7b8c9d1",
      "plan_id": "plan-basic",
      "gateway": "asaas",
      "asaas_subscription_id": "sub_abc123",
      "asaas_customer_id": "cus_xyz789",
      "status": "active",
      "payment_method": "credit_card",
      "started_at": "2026-03-14T12:00:00Z",
      "next_billing_date": "2026-04-14T00:00:00Z"
    }
  ],
  "info": {
    "message": "Assinatura criada com sucesso.",
    "asaas_subscription_id": "sub_abc123",
    "payment_status": "ACTIVE"
  },
  "links": [],
  "msg": "ok",
  "pagination": {
    "current_page": 0,
    "qty_docs_page": 1,
    "qty_of_pages": 1,
    "qty_total_docs": 1
  }
}

Flutter/Dart — Exemplo Completo

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

class AsaasService {
  final String baseUrl;
  final String token;

  AsaasService({required this.baseUrl, required this.token});

  Map<String, String> get _headers => {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer $token',
  };

  /// Criar assinatura com cartão de crédito
  Future<Map<String, dynamic>> subscribeCreditCard({
    required String planId,
    required String holderName,
    required String cardNumber,
    required String expiryMonth,
    required String expiryYear,
    required String ccv,
    required String name,
    required String email,
    required String cpf,
    required String postalCode,
    required String addressNumber,
    required String phone,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/asaas/subscribe'),
      headers: _headers,
      body: jsonEncode({
        'plan_id': planId,
        'credit_card': {
          'holderName': holderName,
          'number': cardNumber,
          'expiryMonth': expiryMonth,
          'expiryYear': expiryYear,
          'ccv': ccv,
        },
        'credit_card_holder_info': {
          'name': name,
          'email': email,
          'cpfCnpj': cpf,
          'postalCode': postalCode,
          'addressNumber': addressNumber,
          'phone': phone,
        },
      }),
    );

    if (response.statusCode == 201) {
      return jsonDecode(response.body);
    }
    throw Exception('Erro ao criar assinatura: ${response.body}');
  }
}

Widget de Formulário (Flutter)

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

class AsaasCreditCardForm extends StatefulWidget {
  final Function(Map<String, String> card, Map<String, String> holder) onSubmit;

  const AsaasCreditCardForm({super.key, required this.onSubmit});

  @override
  State<AsaasCreditCardForm> createState() => _AsaasCreditCardFormState();
}

class _AsaasCreditCardFormState extends State<AsaasCreditCardForm> {
  final _formKey = GlobalKey<FormState>();

  // Card fields
  final _holderName = TextEditingController();
  final _cardNumber = TextEditingController();
  final _expiryMonth = TextEditingController();
  final _expiryYear = TextEditingController();
  final _ccv = TextEditingController();

  // Holder info fields
  final _name = TextEditingController();
  final _email = TextEditingController();
  final _cpf = TextEditingController();
  final _postalCode = TextEditingController();
  final _addressNumber = TextEditingController();
  final _phone = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          // ---- Dados do Cartão ----
          const Text('Dados do Cartão', style: TextStyle(fontWeight: FontWeight.bold)),
          const SizedBox(height: 8),
          TextFormField(
            controller: _holderName,
            decoration: const InputDecoration(labelText: 'Nome no Cartão'),
            textCapitalization: TextCapitalization.characters,
            validator: (v) => v == null || v.isEmpty ? 'Obrigatório' : null,
          ),
          TextFormField(
            controller: _cardNumber,
            decoration: const InputDecoration(labelText: 'Número do Cartão'),
            keyboardType: TextInputType.number,
            inputFormatters: [FilteringTextInputFormatter.digitsOnly],
            validator: (v) => v == null || v.length < 13 ? 'Número inválido' : null,
          ),
          Row(
            children: [
              Expanded(
                child: TextFormField(
                  controller: _expiryMonth,
                  decoration: const InputDecoration(labelText: 'Mês (MM)'),
                  keyboardType: TextInputType.number,
                  inputFormatters: [
                    FilteringTextInputFormatter.digitsOnly,
                    LengthLimitingTextInputFormatter(2),
                  ],
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: TextFormField(
                  controller: _expiryYear,
                  decoration: const InputDecoration(labelText: 'Ano (AAAA)'),
                  keyboardType: TextInputType.number,
                  inputFormatters: [
                    FilteringTextInputFormatter.digitsOnly,
                    LengthLimitingTextInputFormatter(4),
                  ],
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: TextFormField(
                  controller: _ccv,
                  decoration: const InputDecoration(labelText: 'CVV'),
                  keyboardType: TextInputType.number,
                  obscureText: true,
                  inputFormatters: [
                    FilteringTextInputFormatter.digitsOnly,
                    LengthLimitingTextInputFormatter(4),
                  ],
                ),
              ),
            ],
          ),

          const SizedBox(height: 24),

          // ---- Dados do Titular ----
          const Text('Dados do Titular', style: TextStyle(fontWeight: FontWeight.bold)),
          const SizedBox(height: 8),
          TextFormField(
            controller: _name,
            decoration: const InputDecoration(labelText: 'Nome Completo'),
          ),
          TextFormField(
            controller: _email,
            decoration: const InputDecoration(labelText: 'Email'),
            keyboardType: TextInputType.emailAddress,
          ),
          TextFormField(
            controller: _cpf,
            decoration: const InputDecoration(labelText: 'CPF'),
            keyboardType: TextInputType.number,
            inputFormatters: [FilteringTextInputFormatter.digitsOnly],
          ),
          Row(
            children: [
              Expanded(
                child: TextFormField(
                  controller: _postalCode,
                  decoration: const InputDecoration(labelText: 'CEP'),
                  keyboardType: TextInputType.number,
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: TextFormField(
                  controller: _addressNumber,
                  decoration: const InputDecoration(labelText: 'Número'),
                ),
              ),
            ],
          ),
          TextFormField(
            controller: _phone,
            decoration: const InputDecoration(labelText: 'Telefone'),
            keyboardType: TextInputType.phone,
          ),

          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                widget.onSubmit(
                  {
                    'holderName': _holderName.text,
                    'number': _cardNumber.text,
                    'expiryMonth': _expiryMonth.text,
                    'expiryYear': _expiryYear.text,
                    'ccv': _ccv.text,
                  },
                  {
                    'name': _name.text,
                    'email': _email.text,
                    'cpfCnpj': _cpf.text,
                    'postalCode': _postalCode.text,
                    'addressNumber': _addressNumber.text,
                    'phone': _phone.text,
                  },
                );
              }
            },
            child: const Text('Assinar'),
          ),
        ],
      ),
    );
  }
}

Fluxo 2: Assinatura com PIX

Diagrama

sequenceDiagram
    participant User as Usuário
    participant App as Flutter App
    participant API as FDPlay API
    participant Asaas as Asaas API

    User->>App: Seleciona PIX
    App->>API: POST /api/v1/asaas/subscribe/pix
    API->>Asaas: POST /subscriptions (billingType=PIX)
    Asaas-->>API: sub_xxx + first payment (pay_xxx)
    API->>Asaas: GET /payments/{pay_xxx}/pixQrCode
    Asaas-->>API: { payload, encodedImage, expirationDate }
    API-->>App: { subscription, pix: { qr_code, qr_code_image } }
    App->>User: Exibe QR code

    User->>User: Paga via app do banco
    Asaas->>API: POST /webhooks/asaas (PAYMENT_RECEIVED)
    API->>API: Ativa subscription no MongoDB

Request

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

{
  "plan_id": "plan-annual",
  "promo_code": "DESCONTO10"
}

CPF obrigatório

O campo tax_id (CPF) do customer deve estar preenchido para pagamentos PIX. Se não estiver, a API retorna erro 400.

Response (201)

{
  "docs": [
    {
      "_id": "6650a1b2c3d4e5f6a7b8c9d0",
      "customer_id": "6650a1b2c3d4e5f6a7b8c9d1",
      "plan_id": "plan-annual",
      "gateway": "asaas",
      "asaas_subscription_id": "sub_def456",
      "status": "pending",
      "payment_method": "pix"
    }
  ],
  "info": {
    "message": "Assinatura PIX criada. Escaneie o QR code para pagar.",
    "asaas_subscription_id": "sub_def456",
    "payment_status": "PENDING",
    "pix": {
      "payment_id": "pay_ghi789",
      "qr_code": "00020126580014br.gov.bcb.pix0136...",
      "qr_code_image": "iVBORw0KGgoAAAANSUhEUgAA...",
      "expiration_date": "2026-03-15T23:59:59Z"
    }
  },
  "links": [],
  "msg": "ok",
  "pagination": {
    "current_page": 0,
    "qty_docs_page": 1,
    "qty_of_pages": 1,
    "qty_total_docs": 1
  }
}

Flutter/Dart — Exibir QR Code PIX

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class PixPaymentWidget extends StatelessWidget {
  final String qrCode;        // payload copia-e-cola
  final String qrCodeImage;   // base64 PNG

  const PixPaymentWidget({
    super.key,
    required this.qrCode,
    required this.qrCodeImage,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text(
          'Escaneie o QR Code para pagar',
          style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
        ),
        const SizedBox(height: 16),

        // QR Code image (base64)
        if (qrCodeImage.isNotEmpty)
          Image.memory(
            base64Decode(qrCodeImage),
            width: 250,
            height: 250,
          ),

        const SizedBox(height: 16),

        // Copia-e-cola
        Container(
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: Colors.grey[100],
            borderRadius: BorderRadius.circular(8),
          ),
          child: SelectableText(
            qrCode,
            style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
          ),
        ),

        const SizedBox(height: 8),
        TextButton.icon(
          onPressed: () {
            Clipboard.setData(ClipboardData(text: qrCode));
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Código PIX copiado!')),
            );
          },
          icon: const Icon(Icons.copy),
          label: const Text('Copiar código PIX'),
        ),
      ],
    );
  }
}

Flutter/Dart — Criar assinatura PIX

/// Criar assinatura PIX
Future<Map<String, dynamic>> subscribePix({
  required String planId,
}) async {
  final response = await http.post(
    Uri.parse('$baseUrl/api/v1/asaas/subscribe/pix'),
    headers: _headers,
    body: jsonEncode({'plan_id': planId}),
  );

  if (response.statusCode == 201) {
    return jsonDecode(response.body);
  }
  throw Exception('Erro ao criar assinatura PIX: ${response.body}');
}

Confirmar Pagamento (PIX e Cartao)

Apos criar a assinatura, o frontend precisa saber se o pagamento foi confirmado.

IMPORTANTE — Rota correta para consultar status

A rota de consulta e GET /api/v1/asaas/subscription. NAO existe endpoint /api/v1/asaas/pix/status/{payment_id} — essa rota retorna 404.

Comportamento por metodo de pagamento:

Metodo Response do subscribe Precisa polling?
Cartao status: active (cobranca sincrona) NAO — ja esta ativo
PIX status: pending (aguardando pagamento) SIM — polling ate active

Flutter/Dart — Verificar status apos subscribe

/// Chamar logo apos POST /asaas/subscribe ou /asaas/subscribe/pix.
/// Se status == 'active' → pronto. Se 'pending' → iniciar polling.
Future<void> handleSubscribeResponse(Map<String, dynamic> response) async {
  final status = response['subscription']?['status'];

  if (status == 'active') {
    // Cartao: pagamento confirmado imediatamente
    navigateToSuccessScreen();
    return;
  }

  if (status == 'pending') {
    // PIX: exibir QR code e iniciar polling
    showPixQrCode(response['pix']);
    final confirmed = await waitForPixConfirmation();
    if (confirmed) {
      navigateToSuccessScreen();
    } else {
      showError('QR code expirado. Tente novamente.');
    }
  }
}

Flutter/Dart — Polling de Confirmacao PIX

Apos exibir o QR code, o frontend faz polling em GET /api/v1/asaas/subscription para detectar quando o webhook PAYMENT_RECEIVED atualizou o status para active.

/// Polling para confirmacao do pagamento PIX.
/// Chamar apos exibir o QR code.
/// Retorna true quando o pagamento foi confirmado.
Future<bool> waitForPixConfirmation({
  Duration interval = const Duration(seconds: 5),
  Duration timeout = const Duration(minutes: 30),
}) async {
  final deadline = DateTime.now().add(timeout);

  while (DateTime.now().isBefore(deadline)) {
    try {
      final response = await http.get(
        Uri.parse('$baseUrl/api/v1/asaas/subscription'),
        headers: _headers,
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        final status = data['subscription']?['status'];
        if (status == 'active') {
          return true; // Pagamento confirmado!
        }
      }
    } catch (_) {
      // Ignora erros de rede, tenta novamente
    }

    await Future.delayed(interval);
  }

  return false; // Timeout — pagamento nao confirmado
}

Fluxo completo recomendado

1. POST /api/v1/asaas/subscribe/pix  → { status: "pending", pix: { qr_code, qr_code_image } }
2. Exibir QR code (Image.memory(base64Decode(qr_code_image)))
3. Exibir botao "Copiar codigo PIX" (qr_code = payload copia-e-cola)
4. Iniciar polling: GET /api/v1/asaas/subscription a cada 5 segundos
5. Quando subscription.status == "active" → pagamento confirmado → tela de sucesso
6. Se 30 minutos sem confirmacao → exibir "QR code expirado, tente novamente"

Response real do polling (exemplo)

{
  "subscription": {
    "status": "active",
    "payment_method": "pix",
    "gateway": "asaas",
    "asaas_subscription_id": "sub_u7evhowmgl4h4qfe",
    "started_at": "2026-03-20T22:20:26.531000+00:00",
    "next_billing_date": "2026-04-20T00:00:00+00:00",
    "last_payment_at": "2026-03-20T22:21:08.091000+00:00"
  }
}

Como funciona por tras

O polling consulta o MongoDB local. O webhook do Asaas (PAYMENT_RECEIVED) e o que atualiza o status de pending para active. Se o webhook falhar, o status permanece pending. O login sync tambem verifica o status remoto como fallback.


Consultar Status da Assinatura

Request

GET /api/v1/asaas/subscription
Authorization: Bearer <token>

Response (200)

{
  "subscription": {
    "_id": "6650a1b2c3d4e5f6a7b8c9d0",
    "customer_id": "6650a1b2c3d4e5f6a7b8c9d1",
    "plan_id": "plan-basic",
    "gateway": "asaas",
    "asaas_subscription_id": "sub_abc123",
    "asaas_customer_id": "cus_xyz789",
    "status": "active",
    "payment_method": "credit_card",
    "started_at": "2026-03-14T12:00:00Z",
    "next_billing_date": "2026-04-14T00:00:00Z",
    "payment_failures": 0,
    "total_refunded": 0,
    "has_chargeback": false
  }
}

Flutter/Dart

/// Consultar status da assinatura
Future<Map<String, dynamic>> getSubscription() async {
  final response = await http.get(
    Uri.parse('$baseUrl/api/v1/asaas/subscription'),
    headers: _headers,
  );

  if (response.statusCode == 200) {
    return jsonDecode(response.body);
  }
  throw Exception('Erro ao consultar assinatura: ${response.body}');
}

Atualizar Cartão de Crédito

Request

PUT /api/v1/asaas/subscription/credit-card
Authorization: Bearer <token>
Content-Type: application/json

{
  "credit_card": {
    "holderName": "NOVO NOME",
    "number": "4111111111111111",
    "expiryMonth": "12",
    "expiryYear": "2029",
    "ccv": "123"
  },
  "credit_card_holder_info": {
    "name": "Novo Nome",
    "email": "novo@email.com",
    "cpfCnpj": "24971563792",
    "postalCode": "89223005",
    "addressNumber": "277",
    "phone": "47998781877"
  }
}

Response (200)

{
  "message": "Credit card updated successfully.",
  "updated_at": "2026-03-14T15:30:00+00:00"
}

Cancelar Assinatura

Request

DELETE /api/v1/asaas/subscription
Authorization: Bearer <token>

Response (200)

{
  "message": "Asaas subscription cancelled successfully.",
  "cancelled_at": "2026-03-14T16:00:00+00:00"
}

Cancelamento imediato

Diferente do Stripe (cancel_at_period_end), o Asaas deleta a assinatura imediatamente. O acesso do customer é removido na hora.


Mapeamento de Status

Asaas Subscription Status → MongoDB

Asaas Status MongoDB Status
ACTIVE active
INACTIVE cancelled
EXPIRED expired

Asaas Payment Status → MongoDB

Asaas Payment Status MongoDB Status
RECEIVED active
CONFIRMED active
PENDING pending
AWAITING_RISK_ANALYSIS pending
OVERDUE suspended
REFUNDED cancelled
CHARGEBACK_REQUESTED suspended
DELETED cancelled

Ciclos de Cobrança

Ciclo Asaas Equivalente Local
WEEKLY 1 semana
BIWEEKLY 2 semanas
MONTHLY 1 mês
QUARTERLY 3 meses
SEMIANNUALLY 6 meses
YEARLY 1 ano

Webhooks

O Asaas envia eventos para POST /webhooks/asaas com o seguinte formato:

{
  "id": "evt_abc123",
  "event": "PAYMENT_RECEIVED",
  "payment": {
    "id": "pay_xyz789",
    "subscription": "sub_abc123",
    "status": "RECEIVED",
    "value": 49.90,
    "billingType": "CREDIT_CARD",
    "dueDate": "2026-03-14",
    "paymentDate": "2026-03-14"
  }
}

Eventos Processados

Evento Ação
PAYMENT_RECEIVED Ativa subscription (pending/suspended → active)
PAYMENT_CONFIRMED Ativa subscription (mesmo que RECEIVED)
PAYMENT_OVERDUE Incrementa payment_failures. Suspende se >= 3 falhas
PAYMENT_REFUNDED Incrementa total_refunded
PAYMENT_CHARGEBACK_REQUESTED Suspende subscription, bloqueia customer
PAYMENT_CHARGEBACK_DISPUTE Suspende subscription, bloqueia customer
PAYMENT_CREATED Apenas logado
PAYMENT_DELETED Apenas logado

Lookup da Subscription

O webhook localiza a subscription no MongoDB por dois caminhos:

Cenário Campo no payload Lookup
Pagamento de subscription (normal) payment.subscription = "sub_xxx" find_one({asaas_subscription_id: "sub_xxx"})
Pagamento avulso com desconto payment.subscription = null, payment.externalReference = "ObjectId" find_one({_id: ObjectId(externalReference)})

O fallback via externalReference e usado quando o primeiro mes e cobrado via create_payment avulso (desconto promo). O pagamento avulso nao tem subscription no payload, entao o webhook usa o externalReference (que contem o _id da subscription no MongoDB) para encontrar e ativar.

Deduplicação

Cada evento é registrado na collection webhook_logs com event_id único. Eventos duplicados são ignorados automaticamente.


Admin — Rotas Administrativas

Listar Assinaturas

GET /api/v1/admin/asaas/subscriptions?current_page=0&qty_docs_page=10
Authorization: Bearer <admin_token>

Filtra automaticamente por gateway='asaas'. Suporta paginação e query complexa.

Estatísticas de Assinaturas

Retorna contagens por status, MRR (Monthly Recurring Revenue) e valor médio das assinaturas Asaas ativas.

O MRR é calculado somando o amount de todos os planos com interval_unit == "MONTH" vinculados a assinaturas ativas. O average_subscription_value é o MRR dividido pelo número de assinaturas ativas (divisão inteira).

GET /api/v1/admin/asaas/subscriptions/stats
Authorization: Bearer <admin_token>

Response (200)

Campo Tipo Descrição
gateway string Sempre "asaas"
total_subscriptions int Total de assinaturas (todos os status)
active int Assinaturas com status active
suspended int Assinaturas com status suspended
cancelled int Assinaturas com status cancelled
pending int Assinaturas com status pending
expired int Assinaturas com status expired
monthly_recurring_revenue int MRR em centavos (soma dos planos mensais ativos)
average_subscription_value int Valor medio por assinatura ativa em centavos
{
  "gateway": "asaas",
  "total_subscriptions": 42,
  "active": 35,
  "suspended": 3,
  "cancelled": 4,
  "pending": 0,
  "expired": 0,
  "monthly_recurring_revenue": 174650,
  "average_subscription_value": 4990
}

Flutter/Dart

/// Buscar estatísticas de assinaturas Asaas (admin only).
Future<Map<String, dynamic>> getAsaasStats() async {
  final response = await http.get(
    Uri.parse('$baseUrl/api/v1/admin/asaas/subscriptions/stats'),
    headers: _headers,
  );

  if (response.statusCode == 200) {
    return jsonDecode(response.body);
  }
  throw Exception('Erro ao buscar estatísticas: ${response.body}');
}

Histórico de Pagamentos

GET /api/v1/admin/asaas/subscriptions/{subscription_id}/payments
Authorization: Bearer <admin_token>

Retorna os pagamentos reais da API Asaas (não do MongoDB).

Estornar Pagamento

POST /api/v1/admin/asaas/subscriptions/{subscription_id}/refund
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "reason": "Cliente solicitou reembolso"
}

Se payment_id for omitido, o sistema detecta automaticamente o último pagamento pago (status RECEIVED ou CONFIRMED).

Para estorno parcial:

{
  "payment_id": "pay_abc123",
  "value": 25.00,
  "reason": "Estorno parcial"
}

Erros Comuns

Status Erro Solução
400 Customer already has an active subscription Cancele a assinatura atual primeiro
400 Plan is not active Verifique se o plano está ativo
400 Plan interval has no Asaas cycle equivalent Plano com intervalo não suportado
400 CPF (tax_id) is required for PIX Atualize o perfil com CPF antes de usar PIX
400 Current subscription is not managed by Asaas Customer tem assinatura de outro gateway
404 Customer not found Token inválido ou user_type != customer
404 No active subscription Customer sem assinatura ativa
500 Asaas credentials not configured API key não configurada no servidor
500 Asaas connection error Falha de comunicação com Asaas

Dados de Teste (Sandbox)

Cartão de Crédito (Sandbox)

{
  "holderName": "JOAO DA SILVA",
  "number": "5162306219378829",
  "expiryMonth": "05",
  "expiryYear": "2028",
  "ccv": "318"
}

CPF de Teste

  • 24971563792 (aprovado no sandbox)

Base URL Sandbox

https://api-sandbox.asaas.com/v3

Painel Sandbox

Acesse o painel de sandbox em https://sandbox.asaas.com para visualizar clientes, assinaturas e pagamentos criados durante testes.


Checklist de Integração

  • [ ] Gateway padrão: Consultar GET /api/v1/config/payment-gateway para decidir qual gateway exibir
  • [ ] Autenticação: Obter token JWT via POST /api/v1/token
  • [ ] Consultar planos: GET /api/v1/plans — filtrar por is_active: true e publish_to contendo "asaas"
  • [ ] Formulário de cartão: Campos holderName, number, expiryMonth, expiryYear, ccv
  • [ ] Dados do titular: Campos name, email, cpfCnpj, postalCode, addressNumber, phone
  • [ ] CPF obrigatório: Validar que customer tem tax_id antes de PIX
  • [ ] Criar assinatura cartão: POST /api/v1/asaas/subscribe
  • [ ] Criar assinatura PIX: POST /api/v1/asaas/subscribe/pix
  • [ ] Exibir QR code PIX: Usar pix.qr_code_image (base64) e pix.qr_code (copia-e-cola)
  • [ ] Consultar status: GET /api/v1/asaas/subscription
  • [ ] Atualizar cartão: PUT /api/v1/asaas/subscription/credit-card
  • [ ] Cancelar: DELETE /api/v1/asaas/subscription
  • [ ] Tratar erros: Exibir mensagens amigáveis para cada código HTTP
  • [ ] Polling de status (PIX): Consultar status periodicamente até active (ou usar push notification via webhook)