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¶
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¶
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¶
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 |
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).
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)