Você integra a API Pix em Node.js em três passos: criar a cobrança, mostrar o QR Code ao pagador e confirmar o pagamento pelo webhook. Tudo com o fetch nativo do Node.js 18 ou superior, sem pacote extra para falar com a API. O código abaixo segue a documentação da API, usa a base https://app.xflowpayments.com/api/v1 e funciona igual com chave de produção e de teste. Se você ainda está avaliando a integração como um todo, comece pelo guia da API Pix.
O que você precisa
- Uma chave de API, 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_...). Ela pode ser criada antes da aprovação do cadastro. - Node.js 18 ou superior, que já traz
fetch. - Três variáveis de ambiente:
XFLOW_PUBLIC_KEY,XFLOW_SECRET_KEYe, para o webhook,XFLOW_WEBHOOK_SECRET.
O segredo fica só no servidor. Nunca o envie ao navegador nem o grave no repositório.
Um cliente HTTP pequeno
A autenticação é HTTP Basic: usuário é a chave pública, senha é o segredo. Valores monetários vão em centavos, como inteiros. Toda resposta traz um request_id (req_...) no corpo e no header x-request-id. Esta função concentra autenticação, JSON e erros:
// xflow.mjs
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 async function xflow(path, { method = "GET", body, idempotencyKey } = {}) {
const headers = { Authorization: AUTH };
if (body) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE + path, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const texto = await res.text();
const json = texto ? JSON.parse(texto) : {};
if (!res.ok) {
const erro = new Error(`${res.status} ${json.error}: ${json.message} (${json.request_id})`);
erro.status = res.status;
erro.code = json.error;
erro.retryAfter = Number(res.headers.get("retry-after")) || null;
throw erro;
}
return json;
}Grave o request_id dos erros no seu log. Com ele você encontra a chamada em Integrações > Logs da API, onde cada requisição autenticada aparece com status, duração e código de erro por 30 dias. A lista completa de códigos está em erros da API Pix.
Criar a cobrança Pix
import { xflow } from "./xflow.mjs";
const pedido = { id: "1234", totalCents: 1990 };
const cobranca = await xflow("/charges", {
method: "POST",
idempotencyKey: `pedido-${pedido.id}`,
body: {
amountCents: pedido.totalCents,
customer: {
name: "Cliente Exemplo",
email: "cliente@exemplo.com",
document: "00000000000", // CPF ou CNPJ, só dígitos
},
description: `Pedido #${pedido.id}`,
external_reference: `pedido-${pedido.id}`,
},
});
console.log(cobranca.id, cobranca.status); // "clx...", "pending"Três detalhes evitam retrabalho:
Idempotency-Keyúnica por pedido. Se a rede cair e você repetir a chamada, a API devolve a mesma cobrança em vez de gerar outro Pix. Reusar a chave com um corpo diferente retorna409 idempotency_conflict. Mais sobre isso em idempotência em API de pagamento.external_reference(até 120 caracteres) é o id do pedido no seu sistema. Ele volta em toda resposta e webhook da cobrança e serve de filtro emGET /api/v1/charges?external_reference=pedido-1234.- Limites de valor. Cada conta tem valor mínimo e máximo por cobrança. Fora da faixa, a resposta é
400 validatione nada é criado.
Mostrar o QR Code e o copia e cola
A resposta 201 já traz o QR pronto. Você não precisa gerar imagem no seu servidor:
const html = `
<img src="${cobranca.pix.qrCodeBase64}" alt="QR Code Pix" width="240" height="240">
<textarea readonly>${cobranca.pix.copyPaste}</textarea>
`;pix.qrCodeBase64 é um PNG em data-URL, pronto para <img src>. pix.copyPaste é o código no padrão BR Code que o pagador cola no app do banco. Trate esse texto como opaco e repasse sem alterar. Os dois campos vêm no 201 e no GET /api/v1/charges/{id}, mas não na listagem. Para detalhes de exibição, veja Pix copia e cola por API e QR Code Pix dinâmico por API.
Receber o webhook com Express
Cadastre a URL em Integrações > Webhooks ou por POST /api/v1/webhooks. Ela precisa começar com https:// e ser pública. A cada evento, a xFlow envia um POST JSON com o header x-webhook-event e o header x-webhook-signature: sha256=..., que é o HMAC-SHA256 do corpo cru calculado com o segredo do endpoint.
Como a validação usa o corpo cru, a rota recebe o corpo com express.raw, e não com express.json:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SEGREDO = process.env.XFLOW_WEBHOOK_SECRET;
function assinaturaValida(raw, header) {
const esperado = "sha256=" + createHmac("sha256", SEGREDO).update(raw).digest("hex");
const a = Buffer.from(esperado);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post("/webhooks/xflow", express.raw({ type: "application/json" }), async (req, res) => {
if (!Buffer.isBuffer(req.body) || !assinaturaValida(req.body, req.get("x-webhook-signature"))) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
try {
if (event === "transaction.paid" && data.livemode) {
await darBaixaNoPedido(data.external_reference, data.pix.endToEnd);
}
res.sendStatus(200);
} catch {
res.sendStatus(500); // sem 2xx, a entrega é reenviada
}
});
app.listen(3000);darBaixaNoPedido é a sua função de negócio. Quatro cuidados:
- Responda 2xx para confirmar. Sem isso, a xFlow reenvia até 3 vezes. Como o mesmo evento pode chegar mais de uma vez, a baixa precisa ser idempotente: se o pedido já está pago, não faça nada.
- Filtre
livemode. Eventos gerados com chave de teste trazemlivemode: falsenodata. Não dê baixa em pedido real com eles. - No webhook, o valor vem em
amount, comnetefeeemtransaction.paid. Na cobrança, o campo éamountCents. payerepix.endToEndchegam do PSP na confirmação e servem para conciliar com o extrato. Veja o que é o End-to-end ID.
O guia completo de eventos está em webhook Pix.
A mesma rota com http nativo
Sem Express, o módulo node:http resolve. O cuidado é o mesmo: juntar o corpo em Buffer antes de validar.
import { createServer } from "node:http";
// assinaturaValida: a mesma função do exemplo com Express
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/xflow") {
return res.writeHead(404).end();
}
const partes = [];
req.on("data", (parte) => partes.push(parte));
req.on("end", async () => {
const raw = Buffer.concat(partes);
if (!assinaturaValida(raw, req.headers["x-webhook-signature"])) {
return res.writeHead(401).end();
}
const { event, data } = JSON.parse(raw.toString("utf8"));
try {
await tratarEvento(event, data); // sua função
res.writeHead(200).end();
} catch {
res.writeHead(500).end();
}
});
}).listen(3000);Testar no sandbox
Troque as variáveis para a chave de teste. Os endpoints e os formatos de resposta são os mesmos, e toda resposta traz livemode: false. Nenhum provedor Pix é chamado, então o pix.copyPaste de teste é um marcador que não pode ser pago. Para simular o pagamento:
const teste = await xflow("/charges", {
method: "POST",
body: {
amountCents: 1990,
customer: { name: "Cliente", email: "c@exemplo.com", document: "00000000000" },
},
});
await xflow(`/test/charges/${teste.id}/pay`, { method: "POST" });
const paga = await xflow(`/charges/${teste.id}`);
console.log(paga.status, paga.livemode); // "paid", falseA cobrança vira paid, a taxa é calculada com as taxas reais da sua conta e o webhook transaction.paid é disparado com livemode: false. A simulação só aceita chave de teste: com chave de produção, a resposta é 403 test_mode_only. Saques não rodam em teste (403 test_mode_unsupported). O passo a passo do ambiente está em sandbox da API Pix.
Erros e limite de requisições
401 unauthorized: chave ausente, inválida ou revogada, ou IP fora da allowlist configurada no painel.429 rate_limited: espere os segundos do headerretry-after(no exemplo,erro.retryAfter) e tente de novo com backoff. O limite é por conta, e toda resposta autenticada trazx-ratelimit-limit,x-ratelimit-remainingex-ratelimit-reset.502 charge_failed: falha ao gerar a cobrança no provedor. Guarde orequest_ide tente de novo com backoff.
Antes de ir para produção
- Troque
pk_test_/sk_test_porpk_live_/sk_live_só nas variáveis do servidor de produção. - Cadastre a URL de produção do webhook e guarde o segredo. Criado pela API, o
secretaparece uma única vez, na resposta201. - Mantenha o filtro de
livemodeativo no handler. - Registre o
request_idde toda resposta com erro. - Se quiser restringir a origem das chamadas, configure a allowlist de IP em Chaves de API. Fora da lista, a resposta é
401, mesmo com a credencial certa.
Prefira o webhook para saber que a cobrança foi paga. Se precisar de um plano B, faça polling de GET /api/v1/charges/{id} até o status virar paid, respeitando os headers de limite. O mesmo fluxo em outras linguagens está em API Pix em PHP e API Pix em Python.
Perguntas frequentes
Preciso de SDK para usar a API Pix em Node.js?
Não. A API da xFlow é REST com JSON, e o fetch nativo do Node.js 18 ou superior resolve. Você monta o header Authorization com Basic base64(pk_live_...:sk_live_...) e envia o corpo em JSON, com valores em centavos.
Por que a assinatura do webhook não bate quando uso Express?
Porque o express.json() transforma o corpo em objeto antes da validação, e a assinatura x-webhook-signature é calculada sobre o corpo cru. Use express.raw({ type: 'application/json' }) na rota do webhook e só faça JSON.parse depois de validar.
Como testar a integração Pix em Node.js sem pagar?
Crie uma chave de teste no painel (pk_test_/sk_test_), gere a cobrança com ela e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook transaction.paid chega com livemode: false.
O valor da cobrança vai em reais ou em centavos?
Em centavos, como número inteiro. R$ 19,90 vira amountCents: 1990.