No Next.js com App Router, a API Pix se integra em três peças: uma função só do servidor que fala com a API, uma Server Action que cria a cobrança e devolve o QR Code ao componente, e um Route Handler que recebe o webhook de pagamento. Tudo com o fetch nativo, sem pacote para a API. O código segue a documentação da API e usa a base https://app.xflowpayments.com/api/v1. A base de Node.js está em API Pix em Node.js, e a visão geral, 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. XFLOW_PUBLIC_KEY,XFLOW_SECRET_KEYeXFLOW_WEBHOOK_SECRETno.env.localou nas variáveis do servidor.- O pacote
server-only(npm install server-only).
Importante: nunca use o prefixo
NEXT_PUBLIC_nessas variáveis. O Next.js embute variáveis com esse prefixo no JavaScript enviado ao navegador, e o segredo ficaria público.
Um cliente que só roda no servidor
A autenticação é HTTP Basic: usuário é a chave pública, senha é o segredo. Valores vão em centavos, como inteiros. O import "server-only" faz o build falhar se algum componente do navegador importar este arquivo:
// lib/xflow.ts
import "server-only";
const BASE = "https://app.xflowpayments.com/api/v1";
const AUTH =
"Basic " +
Buffer.from(`${process.env.XFLOW_PUBLIC_KEY}:${process.env.XFLOW_SECRET_KEY}`).toString("base64");
export class XflowErro extends Error {
status: number;
code: string;
retryAfter: number | null;
constructor(status: number, corpo: { error?: string; message?: string; request_id?: string }, retryAfter: number | null) {
super(`${status} ${corpo.error}: ${corpo.message} (${corpo.request_id})`);
this.status = status;
this.code = corpo.error ?? "";
this.retryAfter = retryAfter;
}
}
type Opcoes = { method?: "GET" | "POST"; body?: unknown; idempotencyKey?: string };
export async function xflow<T>(path: string, { method = "GET", body, idempotencyKey }: Opcoes = {}): Promise<T> {
const headers: Record<string, string> = { Authorization: AUTH };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const texto = await res.text();
const json = texto ? JSON.parse(texto) : {};
if (!res.ok) {
throw new XflowErro(res.status, json, Number(res.headers.get("retry-after")) || null);
}
return json as T;
}Toda resposta traz um request_id (req_...), e a mensagem do XflowErro já o inclui. Com ele você acha a chamada em Integrações > Logs da API, que guarda cada requisição autenticada por 30 dias.
Criar a cobrança com uma Server Action
// app/pedidos/[id]/actions.ts
"use server";
import { xflow } from "@/lib/xflow";
import { buscarPedido } from "@/lib/pedidos"; // sua camada de dados
type Cobranca = { id: string; status: string; pix: { copyPaste: string; qrCodeBase64: string } };
export async function gerarPix(pedidoId: string) {
// Toda Server Action é um endpoint público: valide e confira se o pedido é do usuário logado.
const pedido = await buscarPedido(pedidoId);
if (!pedido) throw new Error("Pedido não encontrado");
const cobranca = await xflow<Cobranca>("/charges", {
method: "POST",
idempotencyKey: `pedido-${pedido.id}`,
body: {
amountCents: pedido.totalCents, // o valor vem do banco, nunca do navegador
customer: { name: pedido.nome, email: pedido.email, document: pedido.documento },
description: `Pedido #${pedido.id}`,
external_reference: `pedido-${pedido.id}`,
},
});
return { qrCode: cobranca.pix.qrCodeBase64, copiaECola: cobranca.pix.copyPaste };
}Três decisões importam aqui:
- A action recebe só o id do pedido. Qualquer pessoa pode enviar o mesmo POST que o seu formulário envia, então valor, cliente e dono do pedido saem do banco.
Idempotency-Keypor pedido. Um clique duplo ou uma nova tentativa devolve a mesma cobrança em vez de gerar outro Pix. A mesma chave com outro corpo dá409 idempotency_conflict. Mais em idempotência em API de pagamento.- O retorno é enxuto. O que a action devolve vai para o navegador, então mande só o que a tela usa.
Se quem cria a cobrança é um app ou outro sistema, use o mesmo xflow() dentro de um Route Handler com export async function POST. As regras são as mesmas.
Mostrar o QR Code e o copia e cola
pix.qrCodeBase64 é um PNG em data-URL, então um <img> comum basta:
// app/pedidos/[id]/pagar-com-pix.tsx
"use client";
import { useState } from "react";
import { gerarPix } from "./actions";
type Pix = Awaited<ReturnType<typeof gerarPix>>;
export function PagarComPix({ pedidoId }: { pedidoId: string }) {
const [pix, setPix] = useState<Pix | null>(null);
if (!pix) {
return <button onClick={async () => setPix(await gerarPix(pedidoId))}>Pagar com Pix</button>;
}
return (
<div>
<img src={pix.qrCode} alt="QR Code Pix" width={240} height={240} />
<textarea readOnly value={pix.copiaECola} />
<button onClick={() => navigator.clipboard.writeText(pix.copiaECola)}>Copiar código Pix</button>
</div>
);
}pix.copyPaste é o código BR Code que o pagador cola no app do banco: repasse sem alterar. O botão de copiar ajuda quem está pagando pelo celular.
Receber o webhook num Route Handler
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.
// app/api/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { darBaixaNoPedido } from "@/lib/pedidos"; // sua camada de dados
function assinaturaValida(corpo: string, header: string | null) {
const segredo = process.env.XFLOW_WEBHOOK_SECRET ?? "";
const esperado = Buffer.from("sha256=" + createHmac("sha256", segredo).update(corpo).digest("hex"));
const recebido = Buffer.from(header ?? "");
return esperado.length === recebido.length && timingSafeEqual(esperado, recebido);
}
export async function POST(request: Request) {
const corpo = await request.text(); // corpo cru: não chame request.json() antes de validar
if (!assinaturaValida(corpo, request.headers.get("x-webhook-signature"))) {
return new Response(null, { status: 401 });
}
const { event, data } = JSON.parse(corpo);
try {
if (event === "transaction.paid" && data.livemode) {
await darBaixaNoPedido(data.external_reference, data.pix.endToEnd);
}
return new Response(null, { status: 200 });
} catch {
return new Response(null, { status: 500 }); // sem 2xx, a entrega é reenviada
}
}O que cada detalhe evita:
request.text()em vez derequest.json(). O HMAC precisa do texto exato. Converter e serializar de novo muda os bytes.timingSafeEqualcompara em tempo constante. Como ele exige buffers do mesmo tamanho, o tamanho é conferido antes.- Runtime Node.js. O exemplo usa
node:crypto. Esse já é o runtime padrão do Route Handler, então não troque paraedge. proxy.ts(o antigomiddleware.ts). Se ele exige login, libere/api/webhook. Quem protege a rota é a assinatura.- Responda 2xx. Sem isso, a xFlow reenvia até 3 vezes. A baixa precisa ignorar pedido que já está pago, e
livemode: falsemarca evento de teste.
Os outros eventos, como estorno e saque, estão em webhook Pix.
Testar no sandbox
Ponha as chaves de teste no .env.local. Os endpoints e as respostas são os mesmos, com livemode: false. Nenhum provedor Pix é chamado, e o pix.copyPaste de teste é um marcador que não pode ser pago. Gere a cobrança pela sua página e simule o pagamento com o id dela:
curl -X POST https://app.xflowpayments.com/api/v1/test/charges/{id}/pay \
-u "pk_test_...:sk_test_..."A cobrança vira paid, a taxa é calculada com as taxas reais da sua conta e o webhook transaction.paid chega com livemode: false. Para recebê-lo no localhost, exponha o servidor com um túnel HTTPS e cadastre essa URL. Com chave de produção, a simulação responde 403 test_mode_only. O roteiro completo está em sandbox da API Pix.
Erros e limite de requisições
Dentro da Server Action, capture o XflowErro, registre a mensagem com o request_id no servidor e devolva ao usuário um texto simples. Os códigos mais comuns:
401 unauthorized: chave inválida ou revogada, ou IP fora da allowlist.400 validation: corpo inválido ou valor fora do mínimo ou do máximo da conta.403 account_inactive: conta em análise ou bloqueada. Siga com a chave de teste.429 rate_limited: espere os segundos deerro.retryAftere repita com a mesmaIdempotency-Key. O limite é por conta, e toda resposta autenticada trazx-ratelimit-remaining.
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 ambiente de produção, semNEXT_PUBLIC_. - Cadastre a URL de produção do webhook e guarde o segredo do endpoint.
- Confirme que o
proxy.tsnão bloqueia/api/webhook. - Mantenha o filtro de
livemodee a baixa idempotente. - Se quiser, ative a allowlist de IP em Chaves de API. Fora da lista, a resposta é
401.
Prefira o webhook ao polling de GET /api/v1/charges/{id}. Arquivos prontos em Node.js, PHP e Python estão em exemplos de código da API Pix, e a página Desenvolvedores reúne chaves, logs e guias.
Perguntas frequentes
Posso chamar a API Pix direto de um componente no navegador?
Não. A chamada leva o segredo sk_live_, que não pode chegar ao navegador. Crie a cobrança numa Server Action ou num Route Handler e devolva ao componente só o QR Code e o copia e cola. E não use o prefixo NEXT_PUBLIC_ nas variáveis da xFlow.
Por que a assinatura do webhook não confere no Next.js?
Porque o corpo foi lido com request.json() e serializado de novo. O HMAC-SHA256 do header x-webhook-signature é calculado sobre o corpo cru. Leia com await request.text(), valide e só depois faça JSON.parse.
Server Action ou Route Handler para criar a cobrança Pix?
Server Action é o caminho mais curto quando a própria página do Next.js gera o Pix. Route Handler serve quando outro cliente, como um app de celular, chama o seu backend. Nos dois casos o valor vem do banco de dados, nunca do navegador.
Como testar o webhook Pix do Next.js rodando em localhost?
A URL do webhook precisa ser HTTPS e pública. Exponha o servidor local com um túnel HTTPS, cadastre essa URL usando a chave de teste e simule o pagamento com POST /api/v1/test/charges/{id}/pay. O evento chega com livemode: false.