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 — QR manual (modo manual) ou delega p/ Pix Automático (modo recurring) |
POST |
/api/v1/asaas/subscribe/pix-automatico |
Criar assinatura com Pix Automático (débito automático recorrente) |
GET |
/api/v1/config/pix-subscription-mode |
Modo PIX da plataforma (manual | recurring) — público |
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.
PIX manual vs Pix Automático — qual usar?
Há dois modelos de PIX recorrente, com experiências de usuário diferentes:
subscribe/pix (QR manual) |
subscribe/pix-automatico (débito automático) |
|
|---|---|---|
| Renovação | Cliente paga um novo QR a cada ciclo | Automática após autorizar 1× |
| Ação por ciclo | Escanear/pagar QR | Nenhuma |
| Inadimplência | Alta (depende do cliente lembrar) | Baixa |
| Autorização | A cada cobrança | Uma vez, no app do banco |
Para assinatura "de verdade" (sem fricção mensal), use pix-automatico. Ver Fluxo 3.
Gate de plataforma — PIX pode ser só recorrente (como o cartão)
Há um modo de plataforma pix_subscription_mode (manual | recurring, default manual), consultável em GET /api/v1/config/pix-subscription-mode:
recurring→POST /asaas/subscribe/pixpassa a criar Pix Automático (recorrente) automaticamente. Ou seja, ao escolher PIX o cliente tem apenas a opção recorrente, igual ao cartão — o app não precisa mudar nada. A resposta passa a trazerasaas_pix_auth_id(ver Fluxo 3).manual→ comportamento legado abaixo (Fluxo 2): QR por ciclo.
O front pode ler esse modo para ajustar o texto da tela (ex.: "você está autorizando a recorrência"). O switch é administrativo (PUT /api/v1/admin/config/pix-subscription-mode).
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¶
Response¶
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)¶
Response¶
| 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:
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¶
QR manual — ativo apenas quando pix_subscription_mode = manual
Este é o fluxo de QR manual por ciclo. Quando a plataforma está em recurring, o mesmo endpoint POST /asaas/subscribe/pix passa a se comportar como o Fluxo 3 (Pix Automático) — sem mudança no app. Ver o gate acima.
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.
Fluxo 3: Assinatura com Pix Automático (débito automático)¶
Modelo de débito automático recorrente (padrão Pix Automático do Banco Central). O cliente autoriza uma única vez no app do banco; a partir daí o Asaas gera e cobra cada ciclo automaticamente — sem QR novo a cada mês.
Quando usar
Use este fluxo para assinatura recorrente real (mensal/anual) em que você não quer depender do cliente pagar um QR a cada ciclo. Para PIX avulso/manual, use o Fluxo 2.
Diagrama¶
sequenceDiagram
participant User as Usuário
participant App as Flutter App
participant API as FDPlay API
participant Asaas as Asaas API
participant Bank as App do Banco
User->>App: Seleciona Pix Automático
App->>API: POST /api/v1/asaas/subscribe/pix-automatico
API->>API: Persiste subscription stub (status=pending)
API->>Asaas: POST /pix/automatic/authorizations (QR imediato + consentimento)
Asaas-->>API: { id (auth), qrCode: { payload, encodedImage } }
API-->>App: { subscription, pix: { qr_code, qr_code_image } }
App->>User: Exibe QR code (1ª cobrança + autorização da recorrência)
User->>Bank: Paga e AUTORIZA a recorrência
Asaas->>API: webhook AUTHORIZATION_ACTIVATED + PAYMENT_RECEIVED
API->>API: Ativa subscription + libera acesso
Note over Asaas,API: Próximos ciclos — automático
Asaas->>Asaas: Gera cobrança recorrente
Asaas->>API: webhook PAYMENT_RECEIVED
API->>API: Atualiza last_payment_at (sem ação do usuário)
Request¶
POST /api/v1/asaas/subscribe/pix-automatico
Authorization: Bearer <token>
Content-Type: application/json
{
"plan_id": "plan-basic",
"promo_code": "DESCONTO10"
}
CPF obrigatório
O tax_id (CPF) do customer deve estar preenchido. Sem ele, a API retorna 400.
Response (201)¶
O front exibe o QR igual ao Fluxo 2, mas o texto deve deixar claro que o cliente está autorizando a recorrência (não só pagando uma vez).
{
"docs": [
{
"_id": "6650a1b2c3d4e5f6a7b8c9d0",
"customer_id": "6650a1b2c3d4e5f6a7b8c9d1",
"plan_id": "plan-basic",
"gateway": "asaas",
"asaas_pix_auth_id": "auth_abc123",
"status": "pending",
"payment_method": "pix"
}
],
"info": {
"message": "Autorização Pix Automático criada. Escaneie o QR code e confirme a recorrência no app do seu banco.",
"asaas_pix_auth_id": "auth_abc123",
"authorization_status": "PENDING_FIRST_PAYMENT",
"pix": {
"authorization_id": "auth_abc123",
"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 }
}
Diferença de campo vs PIX manual
No Pix Automático o identificador é asaas_pix_auth_id (id da autorização), não asaas_subscription_id. O bloco pix traz authorization_id em vez de payment_id.
Flutter/Dart — Criar assinatura Pix Automático¶
/// Cria a assinatura via Pix Automático e retorna os dados do QR.
/// Reutilize o mesmo PixPaymentWidget do Fluxo 2 para exibir o QR.
Future<Map<String, dynamic>> subscribePixAutomatico({
required String planId,
String? promoCode,
}) async {
final resp = await http.post(
Uri.parse('$baseUrl/api/v1/asaas/subscribe/pix-automatico'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'plan_id': planId,
if (promoCode != null) 'promo_code': promoCode,
}),
);
if (resp.statusCode != 201) {
throw Exception('Falha ao criar Pix Automático: ${resp.body}');
}
final data = jsonDecode(resp.body);
return data['info']['pix']; // { authorization_id, qr_code, qr_code_image, expiration_date }
}
UX — deixe claro que é recorrência
No app do banco o cliente verá um pedido de autorização de débito automático (não um PIX comum). Na tela do QR, informe: "Ao pagar, você autoriza a cobrança automática do plano a cada ciclo. Pode cancelar a qualquer momento."
Ativação e ciclo de vida (via webhook)¶
O status sai de pending quando o cliente autoriza no banco. O polling de status é o mesmo do Fluxo 2 (GET /api/v1/asaas/subscription).
| Evento Asaas | Efeito na assinatura |
|---|---|
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_ACTIVATED |
pending → active + libera acesso |
PAYMENT_RECEIVED (recorrente) |
Atualiza last_payment_at (renovação automática) |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_CANCELLED / _REFUSED |
→ cancelled + remove acesso |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_EXPIRED |
→ expired + remove acesso |
Cancelamento¶
Usa o mesmo endpoint do Fluxo 2 (DELETE /api/v1/asaas/subscription). O backend detecta asaas_pix_auth_id e revoga a autorização no Asaas (em vez de deletar uma subscription), interrompendo as cobranças futuras.
Consultar Status da Assinatura¶
Request¶
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)¶
Cancelar Assinatura¶
Request¶
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 |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_ACTIVATED |
Ativa subscription (Pix Automático) + libera acesso |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_CANCELLED |
Cancela subscription + remove acesso |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_REFUSED |
Cancela subscription (QR expirou sem pagar) |
PIX_AUTOMATIC_RECURRING_AUTHORIZATION_EXPIRED |
Expira subscription + remove acesso |
Eventos de autorização não têm payment
Os eventos PIX_AUTOMATIC_RECURRING_AUTHORIZATION_* carregam o objeto pixAutomaticAuthorization (não payment). As cobranças recorrentes continuam vindo como PAYMENT_RECEIVED/PAYMENT_OVERDUE normais.
Lookup da Subscription¶
O webhook localiza a subscription no MongoDB por três caminhos:
| Cenário | Campo no payload | Lookup |
|---|---|---|
| Pagamento de subscription (normal) | payment.subscription = "sub_xxx" |
find_one({asaas_subscription_id: "sub_xxx"}) |
| Cobrança/autorização Pix Automático | payment.pixAutomaticAuthorization ou pixAutomaticAuthorization.id |
find_one({asaas_pix_auth_id: "auth_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).
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:
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¶
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-gatewaypara decidir qual gateway exibir - [ ] Autenticação: Obter token JWT via
POST /api/v1/token - [ ] Consultar planos:
GET /api/v1/plans— filtrar poris_active: trueepublish_tocontendo"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_idantes 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) epix.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)