Pular para o conteúdo
xFlow Payments

Blog

API Pix no Flutter

Para integrar a API Pix no Flutter, o app nunca chama a API com a chave secreta: ele pede a cobrança ao seu backend, que envia POST /api/v1/charges com Basic auth e devolve só o pix.copyPaste. O app desenha o QR Code com um pacote como o qr_flutter, oferece o botão de copiar e acompanha o status pelo seu backend, que recebe o webhook transaction.paid assinado.

Atualizado em · Equipe xFlow Payments · 8 min de leitura

No Flutter, a integração com a API Pix tem uma regra que vem antes do código: o app não guarda nem usa a chave secreta. Ele pede a cobrança ao seu backend, que chama a API e devolve só o código copia e cola. O app desenha o QR Code, oferece o botão de copiar e pergunta ao backend, que recebe o webhook, se o pedido foi pago. O código segue a documentação da API e usa Dart dos dois lados. Se o seu backend já existe em outra linguagem, as mesmas rotas estão em API Pix em Node.js e API Pix em Python.

Por que a chave secreta não pode ir no app

A API autentica com HTTP Basic: a chave pública (pk_live_...) como usuário e o segredo (sk_live_...) como senha. Tudo o que vai dentro do app chega ao aparelho de qualquer pessoa:

  • O binário pode ser aberto. APK e IPA são arquivos que se descompactam, e o Flutter Web é JavaScript. Valores de --dart-define e arquivos .env empacotados viram texto dentro do binário. A ofuscação do Flutter só renomeia símbolos: não esconde texto nem impede engenharia reversa.
  • O tráfego pode ser lido por quem controla o aparelho, com um proxy. O header Authorization em Basic é só base64, que se reverte sem chave.
  • O segredo abre a conta inteira. Com ele, alguém cria cobranças em nome da sua empresa, lista as cobranças com nome, e-mail e CPF ou CNPJ dos seus clientes, consulta o saldo e, se o saque por API estiver habilitado, pede saque.
  • Revogar quebra o app. A revogação no painel é imediata, e todas as cópias instaladas param até a próxima versão.

O desenho certo:

  1. O app chama o seu backend, com o login do usuário: POST /pedidos/{id}/pix.
  2. O backend lê o pedido no banco e chama POST /api/v1/charges com as chaves.
  3. O backend devolve ao app só o copia e cola.
  4. O pagador paga, e a xFlow envia o webhook transaction.paid ao backend.
  5. O app consulta GET /pedidos/{id} no backend até ver o pedido pago.

Assim, só o servidor fala com a API, e dá até para ativar a allowlist de IP em Chaves de API, que recusa com 401 chamadas de fora da lista. Mais sobre isso em segurança da API Pix.

O backend em Dart: cobrança e webhook

Um servidor com shelf, shelf_router, http e crypto, com as chaves em variáveis de ambiente e valores em centavos:

dart
// bin/servidor.dart
import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';

final env = Platform.environment;
final auth = 'Basic ${base64Encode(utf8.encode('${env['XFLOW_PUBLIC_KEY']}:${env['XFLOW_SECRET_KEY']}'))}';
final producao = env['XFLOW_SECRET_KEY']!.startsWith('sk_live_');

Future<Response> gerarPix(Request req, String id) async {
  final pedido = await buscarPedido(req, id); // seu código: confere o login e lê o pedido no banco
  final resp = await http.post(
    Uri.parse('https://app.xflowpayments.com/api/v1/charges'),
    headers: {'Authorization': auth, 'Content-Type': 'application/json', 'Idempotency-Key': 'pedido-$id'},
    body: jsonEncode({
      'amountCents': pedido.totalCents, // R$ 19,90 = 1990
      'customer': {'name': pedido.nome, 'email': pedido.email, 'document': pedido.documento},
      'description': 'Pedido #$id',
      'external_reference': 'pedido-$id',
    }),
  );
  final cobranca = jsonDecode(resp.body) as Map<String, dynamic>;
  if (resp.statusCode >= 400) {
    print('xFlow ${resp.statusCode} ${cobranca['error']} ${cobranca['request_id']}');
    return Response(502);
  }
  await salvarCobranca(id, cobranca['id'] as String);
  return Response.ok(jsonEncode({'copiaECola': cobranca['pix']['copyPaste']}),
      headers: {'content-type': 'application/json'});
}

Future<Response> webhook(Request req) async {
  final corpo = await req.read().fold<List<int>>([], (bytes, parte) => bytes..addAll(parte));
  final hmac = Hmac(sha256, utf8.encode(env['XFLOW_WEBHOOK_SECRET']!));
  final esperada = 'sha256=${hmac.convert(corpo)}';
  if (!iguais(esperada, req.headers['x-webhook-signature'] ?? '')) return Response(401);

  final evento = jsonDecode(utf8.decode(corpo)) as Map<String, dynamic>;
  final data = evento['data'] as Map<String, dynamic>;
  if (evento['event'] == 'transaction.paid' && data['livemode'] == producao) {
    await darBaixa(data['external_reference'] as String); // ignora pedido já pago
  }
  return Response.ok('');
}

// Tempo constante: percorre tudo, sem parar no primeiro byte diferente.
bool iguais(String a, String b) {
  final x = utf8.encode(a), y = utf8.encode(b);
  if (x.length != y.length) return false;
  var diferenca = 0;
  for (var i = 0; i < x.length; i++) {
    diferenca |= x[i] ^ y[i];
  }
  return diferenca == 0;
}

void main() async {
  final app = Router()
    ..post('/pedidos/<id>/pix', gerarPix)
    ..get('/pedidos/<id>', statusDoPedido) // seu código: {"status": "aguardando" | "pago" | "expirado"}
    ..post('/webhooks/xflow', webhook);
  await io.serve(app.call, InternetAddress.anyIPv4, 8080);
}

O que cada parte resolve:

  • O valor vem do banco. O app manda só o id do pedido, porque um valor vindo do app pode ser trocado.
  • Idempotency-Key por pedido. Se o app repetir o pedido, a API devolve a mesma cobrança em vez de gerar outro Pix. Veja idempotência em API de pagamento.
  • A assinatura é validada sobre os bytes crus, antes do jsonDecode. O header x-webhook-signature traz sha256= mais o HMAC-SHA256 do corpo com o segredo do endpoint. A comparação em tempo constante não revela onde os valores diferem.
  • livemode igual ao ambiente da chave. Em produção, eventos de teste são descartados. Com chave de teste, o sandbox funciona de ponta a ponta.
  • Responda 2xx. Sem isso, a xFlow reenvia até 3 vezes, então a baixa precisa ignorar pedido já pago.

Cadastre a URL do webhook em Integrações > Webhooks. Ela precisa ser https:// e pública. Os outros eventos estão em webhook Pix.

O app: QR Code, botão de copiar e status

Com http e qr_flutter no pubspec.yaml, a tela pede o Pix, desenha o QR Code a partir do copia e cola e consulta o status no seu backend a cada 5 segundos:

dart
// lib/tela_pix.dart
import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:qr_flutter/qr_flutter.dart';

const backend = 'https://api.seusistema.com.br'; // o SEU backend, nunca a API da xFlow

class TelaPix extends StatefulWidget {
  const TelaPix({super.key, required this.pedidoId, required this.token});
  final String pedidoId;
  final String token; // sessão do usuário no seu backend

  @override
  State<TelaPix> createState() => _TelaPixState();
}

class _TelaPixState extends State<TelaPix> {
  String? copiaECola;
  String status = 'aguardando';
  Timer? consulta;

  Map<String, String> get headers => {'Authorization': 'Bearer ${widget.token}'};

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

  Future<void> gerarPix() async {
    final resp = await http.post(Uri.parse('$backend/pedidos/${widget.pedidoId}/pix'), headers: headers);
    if (!mounted) return;
    if (resp.statusCode != 200) {
      setState(() => status = 'erro');
      return;
    }
    setState(() => copiaECola = jsonDecode(resp.body)['copiaECola'] as String);
    consulta = Timer.periodic(const Duration(seconds: 5), (_) => atualizarStatus());
  }

  Future<void> atualizarStatus() async {
    final resp = await http.get(Uri.parse('$backend/pedidos/${widget.pedidoId}'), headers: headers);
    if (!mounted || resp.statusCode != 200) return;
    final novo = jsonDecode(resp.body)['status'] as String;
    if (novo != 'aguardando') consulta?.cancel();
    setState(() => status = novo);
  }

  @override
  void dispose() {
    consulta?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final codigo = copiaECola;
    const textos = {'pago': 'Pagamento confirmado', 'expirado': 'O Pix expirou. Gere um novo.'};
    if (status != 'aguardando') return Center(child: Text(textos[status] ?? 'Não foi possível gerar o Pix.'));
    if (codigo == null) return const Center(child: CircularProgressIndicator());
    return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
      QrImageView(data: codigo, size: 240),
      SelectableText(codigo, maxLines: 3),
      ElevatedButton.icon(
        icon: const Icon(Icons.copy),
        label: const Text('Copiar código Pix'),
        onPressed: () => Clipboard.setData(ClipboardData(text: codigo)),
      ),
    ]);
  }
}

Detalhes da tela:

  • O QR sai do copia e cola. O QrImageView desenha o mesmo código BR Code que o pagador colaria no app do banco, então não altere o texto. Se preferir não usar pacote, o backend pode repassar o pix.qrCodeBase64, um PNG em data-URL: decodifique a parte depois da vírgula com base64Decode e mostre com Image.memory.
  • O botão de copiar é o caminho principal no celular: quem paga no próprio aparelho não escaneia a tela.
  • A consulta vai ao seu backend, não à API. O limite de requisições da xFlow é por conta, somando todos os aparelhos, e ler o seu banco não gasta esse limite.
  • Pix expirado. Não há webhook de expiração. Se o pedido segue aguardando por muito tempo, o backend confere com GET /api/v1/charges/{id}: o status expired pede uma cobrança nova, com outra Idempotency-Key. Veja cobrança Pix expirada.

Testar no sandbox

Ponha as chaves de teste (pk_test_... e sk_test_...) no backend. Elas podem ser criadas antes da aprovação do cadastro. O app mostra o QR normalmente, mas o pix.copyPaste de teste não pode ser pago. Para simular o pagamento, use o id da cobrança salvo pelo backend:

bash
curl -X POST https://app.xflowpayments.com/api/v1/test/charges/{id}/pay \
  -u "pk_test_...:sk_test_..."

A cobrança vira paid, o webhook chega ao backend com livemode: false e a próxima consulta do app troca a tela. Com chave de produção, a simulação responde 403 test_mode_only. Para receber o webhook no backend rodando na sua máquina, veja como testar webhook localmente. Roteiro completo em sandbox da API Pix.

Erros e limite de requisições

O backend registra o request_id de cada erro, que localiza a chamada em Integrações > Logs da API, e o app mostra uma mensagem simples. Os códigos mais comuns:

  • 401 unauthorized: chave inválida ou revogada, ou IP fora da allowlist.
  • 429 rate_limited: espere os segundos do header retry-after e repita com a mesma Idempotency-Key. Toda resposta autenticada traz x-ratelimit-remaining.
  • 502 charge_failed: falha ao gerar a cobrança no provedor. Repita com backoff.

A lista completa está em erros da API Pix.

Antes de ir para produção

  1. Procure sk_ no código e na configuração de build do app. Não pode aparecer.
  2. Troque as chaves de teste por pk_live_/sk_live_ só nas variáveis do backend.
  3. Cadastre a URL de produção do webhook e guarde o segredo do endpoint.
  4. Confirme que o backend exige login e lê o valor do banco.
  5. Pare a consulta de status quando a tela fecha e trate o Pix expirado.

Para conferir uma assinatura que não bate, use o validador de assinatura de webhook. A visão completa da integração está no guia da API Pix.

Perguntas frequentes

Posso chamar a API Pix direto do app Flutter?

Não. A chamada exige o segredo sk_live_, e tudo o que vai no app pode ser extraído do APK, do IPA ou do JavaScript do Flutter Web, inclusive valores de --dart-define. Com o segredo, qualquer pessoa cria cobranças em nome da sua empresa e lê os dados dos seus clientes. O app fala com o seu backend, e só o backend fala com a API.

Qual pacote usar para mostrar o QR Code Pix no Flutter?

O qr_flutter desenha o QR Code a partir do texto do copia e cola, com o widget QrImageView. Sem pacote, dá para decodificar o pix.qrCodeBase64 da API, que é um PNG em data-URL, e exibir com Image.memory.

Como o app Flutter sabe que o Pix foi pago?

O seu backend recebe o webhook transaction.paid, valida a assinatura e marca o pedido como pago. O app consulta o status do pedido no seu backend a cada poucos segundos, ou recebe um aviso por push, e troca de tela quando o pedido vira pago.

O QR Code do sandbox funciona no app do banco?

Não. Com chave de teste, o pix.copyPaste é um marcador que não pode ser pago. Para testar a tela inteira, simule o pagamento com POST /api/v1/test/charges/{id}/pay. O webhook chega ao backend com livemode: false.

Continue lendo