No Django, a API Pix se integra com a biblioteca requests e três peças: as credenciais no settings.py, lidas de variáveis de ambiente, uma view que cria a cobrança e entrega o QR Code ao template e uma view que recebe o webhook de pagamento. O código segue a documentação da API, usa Django 4.2 ou superior e a base https://app.xflowpayments.com/api/v1. A versão em Python sem framework, com urllib e conciliação, está em API Pix em Python. A visão geral fica no guia da API Pix.
O que você precisa
- Uma chave gerada no painel, em Desenvolvedores > Chaves de API. A chave pública (
pk_live_...) fica sempre visível. O segredo (sk_live_...) aparece uma única vez. - Para testar, uma chave do ambiente Teste (
pk_test_...esk_test_...), que pode ser criada antes da aprovação do cadastro. pip install requests.- As três variáveis expostas no
settings.py:
# settings.py
import os
XFLOW_PUBLIC_KEY = os.environ["XFLOW_PUBLIC_KEY"]
XFLOW_SECRET_KEY = os.environ["XFLOW_SECRET_KEY"]
XFLOW_WEBHOOK_SECRET = os.environ["XFLOW_WEBHOOK_SECRET"]Com os.environ[...], o servidor nem sobe se faltar uma variável. É melhor descobrir isso no deploy do que no primeiro Pix.
Um cliente para a API
A autenticação é HTTP Basic: usuário é a chave pública, senha é o segredo. Valores vão em centavos, como inteiros. As chaves são lidas do settings dentro da função, e não na importação, para que o override_settings funcione nos testes:
# pagamentos/xflow.py
import requests
from django.conf import settings
BASE = "https://app.xflowpayments.com/api/v1"
class XflowErro(Exception):
def __init__(self, status, corpo, retry_after=None):
self.status = status
self.code = corpo.get("error")
self.request_id = corpo.get("request_id")
self.retry_after = retry_after
super().__init__(f"{status} {self.code} ({self.request_id})")
def xflow(method, path, json=None, idempotency_key=None):
resp = requests.request(
method,
BASE + path,
auth=(settings.XFLOW_PUBLIC_KEY, settings.XFLOW_SECRET_KEY),
json=json,
headers={"Idempotency-Key": idempotency_key} if idempotency_key else {},
timeout=30,
)
corpo = resp.json() if resp.content else {}
if resp.status_code >= 400:
raise XflowErro(resp.status_code, corpo, resp.headers.get("retry-after"))
return corpoCriar a cobrança numa view
# pagamentos/views.py
import logging
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_POST
from .models import Pedido
from .xflow import XflowErro, xflow
log = logging.getLogger(__name__)
@login_required
@require_POST
def gerar_pix(request, pedido_id):
pedido = get_object_or_404(Pedido, pk=pedido_id, cliente=request.user)
try:
cobranca = xflow(
"POST",
"/charges",
json={
"amountCents": pedido.total_centavos, # inteiro: R$ 19,90 = 1990
"customer": {
"name": pedido.cliente_nome,
"email": pedido.cliente_email,
"document": pedido.cliente_documento, # CPF ou CNPJ, só dígitos
},
"description": f"Pedido #{pedido.pk}",
"external_reference": pedido.referencia, # ex.: "pedido-1234"
},
idempotency_key=pedido.referencia,
)
except XflowErro as erro:
log.warning("xFlow recusou a cobrança: %s", erro) # traz o request_id
return render(request, "pagamentos/erro.html", status=502)
pedido.xflow_charge_id = cobranca["id"]
pedido.save(update_fields=["xflow_charge_id"])
return render(request, "pagamentos/pix.html", {"pix": cobranca["pix"]})Ajuste os nomes dos campos ao seu model. O que importa:
- O valor sai do banco. A view recebe só o id do pedido e confere se ele é do usuário logado.
Idempotency-Keypor pedido. Um clique duplo devolve a mesma cobrança em vez de gerar outro Pix. A mesma chave com outro corpo dá409 idempotency_conflict. Detalhes em idempotência em API de pagamento.external_reference(até 120 caracteres) liga a cobrança ao pedido e volta em toda resposta e webhook. Valor fora do mínimo ou do máximo da conta retorna400 validation.
Mostrar o QR Code no template
pix.qrCodeBase64 é um PNG em data-URL, pronto para o src. O autoescape do Django não altera esse texto:
<!-- pagamentos/templates/pagamentos/pix.html -->
<img src="{{ pix.qrCodeBase64 }}" alt="QR Code Pix" width="240" height="240">
<textarea id="pix" readonly>{{ pix.copyPaste }}</textarea>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('pix').value)">
Copiar código Pix
</button>pix.copyPaste é o código BR Code que o pagador cola no app do banco: repasse sem alterar. Os dois campos vêm na criação e em GET /api/v1/charges/{id}, mas não na listagem.
Receber o webhook
Cadastre a URL em Integrações > Webhooks. Ela precisa ser https:// e pública. Cada entrega é um POST JSON com os headers x-webhook-event e x-webhook-signature: sha256=..., o HMAC-SHA256 do corpo cru com o segredo do endpoint.
# pagamentos/webhooks.py
import hashlib
import hmac
import json
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import Pedido
def assinatura_valida(corpo: bytes, recebida: str) -> bool:
segredo = settings.XFLOW_WEBHOOK_SECRET.encode()
esperada = "sha256=" + hmac.new(segredo, corpo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada.encode(), recebida.encode())
@csrf_exempt
@require_POST
def webhook_xflow(request):
corpo = request.body # bytes crus, antes de qualquer parse
if not assinatura_valida(corpo, request.headers.get("x-webhook-signature", "")):
return HttpResponse(status=401)
evento = json.loads(corpo)
data = evento["data"]
if evento["event"] == "transaction.paid" and data["livemode"]:
# update condicional: o mesmo evento pode chegar duas vezes
Pedido.objects.filter(referencia=data["external_reference"], status="aguardando").update(
status="pago", end_to_end=data["pix"]["endToEnd"]
)
return HttpResponse(status=200)# urls.py
from django.urls import path
from pagamentos import views, webhooks
urlpatterns = [
path("pedidos/<int:pedido_id>/pix/", views.gerar_pix),
path("webhooks/xflow/", webhooks.webhook_xflow),
]Os cuidados que são do Django:
@csrf_exemptsó nesta view. Sem ele, oCsrfViewMiddlewareresponde403. A proteção da rota passa a ser a assinatura.request.body, e nãorequest.POST, que fica vazio com JSON. No Django REST Framework, leiarequest.bodyantes de tocar emrequest.data: depois que o DRF consome o corpo, o acesso arequest.bodygeraRawPostDataException.- Cadastre a URL com a barra final, igual ao
urls.py. Sem ela, oAPPEND_SLASHresponde com redirecionamento (ou erro, emDEBUG) e o evento não chega à view. hmac.compare_digestcompara em tempo constante. Não use==para assinatura.- Responda 2xx. Uma exceção vira
500, e a xFlow reenvia até 3 vezes. Por isso oupdatesó muda pedido que ainda está aguardando.livemode: falsemarca evento de teste.
Se a assinatura não bater, compare os valores no validador de assinatura de webhook. Os outros eventos estão em webhook Pix.
Testar no sandbox
Ponha as chaves de teste nas variáveis e rode no python manage.py shell:
from pagamentos.xflow import xflow
teste = xflow("POST", "/charges", json={
"amountCents": 1990,
"customer": {"name": "Cliente", "email": "c@exemplo.com", "document": "00000000000"},
})
xflow("POST", f"/test/charges/{teste['id']}/pay")
paga = xflow("GET", f"/charges/{teste['id']}")
print(paga["status"], paga["livemode"]) # paid FalseA cobrança vira paid, a taxa sai das taxas reais da sua conta e o webhook chega com livemode: false. O pix.copyPaste de teste não pode ser pago, e a simulação com chave de produção responde 403 test_mode_only. Roteiro completo em sandbox da API Pix.
Para a view do webhook, um teste com o client que exige CSRF prova que o @csrf_exempt está no lugar:
# pagamentos/tests.py
import hashlib
import hmac
import json
from django.test import Client, TestCase, override_settings
@override_settings(XFLOW_WEBHOOK_SECRET="segredo-de-teste")
class WebhookXflowTest(TestCase):
corpo = json.dumps({"event": "transaction.pending", "data": {"livemode": False}})
def enviar(self, assinatura):
return Client(enforce_csrf_checks=True).post(
"/webhooks/xflow/", self.corpo, content_type="application/json",
HTTP_X_WEBHOOK_SIGNATURE=assinatura,
)
def test_assinatura_certa(self):
sig = hmac.new(b"segredo-de-teste", self.corpo.encode(), hashlib.sha256).hexdigest()
self.assertEqual(self.enviar("sha256=" + sig).status_code, 200)
def test_assinatura_errada(self):
self.assertEqual(self.enviar("sha256=errada").status_code, 401)Erros e limite de requisições
A view acima já captura o XflowErro e registra o request_id, que localiza a chamada em Integrações > Logs da API por 30 dias. Os códigos mais comuns:
401 unauthorized: chave inválida ou revogada, ou IP fora da allowlist.403 account_inactive: conta em análise ou bloqueada. Siga com a chave de teste.429 rate_limited: o limite é por conta. Numa view, não segure o worker comtime.sleep: mostre uma mensagem e deixe o cliente tentar de novo. A mesmaIdempotency-Keygarante que a nova tentativa não gera outro Pix. Em tarefas de fundo, espere os segundos deerro.retry_after.502 charge_failed: falha ao gerar a cobrança no provedor. Repita com backoff.
Toda resposta autenticada traz x-ratelimit-limit, x-ratelimit-remaining e x-ratelimit-reset. A tabela completa está em erros da API Pix.
Antes de ir para produção
- Troque as chaves de teste por
pk_live_/sk_live_só nas variáveis do servidor de produção. - Cadastre a URL de produção do webhook, com a barra final, e guarde o segredo do endpoint.
- Confirme que só a view do webhook tem
@csrf_exempt. - Mantenha o filtro de
livemodee oupdatecondicional. - Registre o
request_idde toda resposta com erro e, se quiser, ative a allowlist de IP em Chaves de API.
Prefira o webhook ao polling de GET /api/v1/charges/{id}. A versão assíncrona deste fluxo está em API Pix no FastAPI, e a página Desenvolvedores reúne chaves, logs e guias.
Perguntas frequentes
Por que o webhook Pix responde 403 no Django?
Porque a view passa pelo CsrfViewMiddleware, que recusa POST sem token de CSRF, e a xFlow não envia esse token. Marque só a view do webhook com @csrf_exempt. Quem protege a rota é a assinatura HMAC do header x-webhook-signature.
Posso usar request.POST ou request.data para validar o webhook?
Não. Com corpo JSON, request.POST fica vazio, e o request.data do Django REST Framework já vem convertido. O HMAC é calculado sobre os bytes exatos recebidos: valide com request.body e só depois faça json.loads.
Onde guardar as chaves da API Pix no Django?
Em variáveis de ambiente, lidas no settings.py com os.environ. O segredo sk_live_ e o segredo do webhook não vão para o repositório, para templates nem para o JavaScript enviado ao navegador.
Como testar a integração Pix no Django sem pagar?
Com chaves de teste (pk_test_/sk_test_), crie a cobrança no python manage.py shell e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook transaction.paid chega com livemode: false.