No NestJS, a API Pix se organiza em três peças: um service que fala com a API usando fetch, um controller que cria a cobrança e devolve o QR Code e outro que recebe o webhook de pagamento. O detalhe que mais quebra integrações é a assinatura do webhook, que exige o corpo cru: por isso a aplicação sobe com rawBody: true. O código segue a documentação da API, usa NestJS 10 ou superior com Express, Node.js 18 ou superior e a base https://app.xflowpayments.com/api/v1. A versão sem framework 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. npm install @nestjs/configeConfigModule.forRoot()noAppModule.XFLOW_PUBLIC_KEY,XFLOW_SECRET_KEYeXFLOW_WEBHOOK_SECRETno.envou nas variáveis do servidor.
Ligar o corpo cru
// main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);
}
bootstrap();Com a opção, o Nest guarda o Buffer original em req.rawBody e continua convertendo o JSON em req.body. Nenhuma outra rota muda. Com o FastifyAdapter, a opção é a mesma.
Um service para a API
A autenticação é HTTP Basic: usuário é a chave pública, senha é o segredo. Valores vão em centavos, como inteiros.
// xflow/xflow.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
const BASE = "https://app.xflowpayments.com/api/v1";
export class XflowErro extends Error {
constructor(
readonly status: number,
readonly code: string | undefined,
readonly requestId: string | undefined,
readonly retryAfter: number | null,
) {
super(`${status} ${code} (${requestId})`);
}
}
@Injectable()
export class XflowService {
private readonly auth: string;
constructor(config: ConfigService) {
const credencial = `${config.getOrThrow("XFLOW_PUBLIC_KEY")}:${config.getOrThrow("XFLOW_SECRET_KEY")}`;
this.auth = "Basic " + Buffer.from(credencial).toString("base64");
}
async chamar<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
const headers: Record<string, string> = { Authorization: this.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),
signal: AbortSignal.timeout(30_000),
});
const texto = await res.text();
const json = texto ? JSON.parse(texto) : {};
if (!res.ok) {
throw new XflowErro(res.status, json.error, json.request_id, Number(res.headers.get("retry-after")) || null);
}
return json as T;
}
}Registre o XflowService nos providers de um XflowModule e exporte-o. Se o projeto já usa @nestjs/axios, o HttpService faz o mesmo com auth: { username, password } e firstValueFrom.
Criar a cobrança
// pedidos/pedidos.controller.ts
import { Controller, NotFoundException, Param, Post } from "@nestjs/common";
import { XflowService } from "../xflow/xflow.service";
import { PedidosService } from "./pedidos.service";
type Cobranca = { id: string; status: string; pix: { copyPaste: string; qrCodeBase64: string } };
@Controller("pedidos")
export class PedidosController {
constructor(
private readonly xflow: XflowService,
private readonly pedidos: PedidosService,
) {}
@Post(":id/pix")
async gerarPix(@Param("id") id: string) {
const pedido = await this.pedidos.buscar(id); // confira também se o pedido é do usuário logado
if (!pedido) throw new NotFoundException();
const cobranca = await this.xflow.chamar<Cobranca>(
"POST",
"/charges",
{
amountCents: pedido.totalCents, // inteiro: R$ 19,90 = 1990
customer: { name: pedido.nome, email: pedido.email, document: pedido.documento },
description: `Pedido #${pedido.id}`,
external_reference: `pedido-${pedido.id}`,
},
`pedido-${pedido.id}`, // Idempotency-Key
);
await this.pedidos.salvarCobranca(pedido.id, cobranca.id);
return { qrCode: cobranca.pix.qrCodeBase64, copiaECola: cobranca.pix.copyPaste };
}
}O valor vem do banco, nunca do corpo da requisição. A Idempotency-Key por pedido faz uma nova tentativa devolver a mesma cobrança em vez de gerar outro Pix. Reusar a chave com outro corpo dá 409 idempotency_conflict, como explica o guia de idempotência em API de pagamento. O external_reference (até 120 caracteres) volta em toda resposta e webhook.
Mostrar o QR Code e o copia e cola
qrCode é um PNG em data-URL: vai direto no src de uma <img>, no front-end que você usar. copiaECola é o código BR Code que o pagador cola no app do banco. Mostre num campo só de leitura, ao lado de um botão que chama navigator.clipboard.writeText(copiaECola), e 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 ou por POST /api/v1/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.
// webhooks/xflow-webhook.controller.ts
import { Controller, Headers, HttpCode, Post, RawBodyRequest, Req, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Request } from "express";
import { PedidosService } from "../pedidos/pedidos.service";
@Controller("webhooks")
export class XflowWebhookController {
constructor(
private readonly config: ConfigService,
private readonly pedidos: PedidosService,
) {}
@Post("xflow")
@HttpCode(200)
async receber(@Req() req: RawBodyRequest<Request>, @Headers("x-webhook-signature") assinatura?: string) {
if (!req.rawBody || !this.assinaturaValida(req.rawBody, assinatura ?? "")) {
throw new UnauthorizedException();
}
const { event, data } = req.body; // já convertido, e só lido depois da validação
if (event === "transaction.paid" && data.livemode) {
await this.pedidos.darBaixa(data.external_reference, data.pix.endToEnd); // ignora pedido já pago
}
}
private assinaturaValida(corpo: Buffer, assinatura: string) {
const segredo = this.config.getOrThrow<string>("XFLOW_WEBHOOK_SECRET");
const esperada = Buffer.from("sha256=" + createHmac("sha256", segredo).update(corpo).digest("hex"));
const recebida = Buffer.from(assinatura);
return esperada.length === recebida.length && timingSafeEqual(esperada, recebida);
}
}Os cuidados que são do Nest:
- Sem
rawBody: true,req.rawBodyéundefinede toda entrega cai no401. Converterreq.bodyde volta comJSON.stringifynão resolve, porque os bytes podem mudar. timingSafeEqualcompara em tempo constante e exige buffers do mesmo tamanho, por isso o tamanho é conferido antes.@HttpCode(200). Um@Postresponde201por padrão. Qualquer 2xx confirma a entrega, mas200deixa a intenção clara. Uma exceção vira500, e a xFlow reenvia até 3 vezes. Por issodarBaixaprecisa tolerar o mesmo evento duas vezes.- Guard global. Se o app exige login em todas as rotas, libere esta, por exemplo com o decorator
@Public()da receita de autenticação do Nest. livemode: falsemarca evento gerado com chave de teste.
Se a assinatura não bater, confira os valores no validador de assinatura de webhook. Os outros eventos estão em webhook Pix.
Testar no sandbox
Ponha as chaves de teste no .env, gere a cobrança pelo seu endpoint 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 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.
No teste e2e, a aplicação também precisa do rawBody: true:
// test/webhook.e2e-spec.ts (com as chaves de teste no .env)
import { Test } from "@nestjs/testing";
import { createHmac } from "node:crypto";
import request from "supertest"; // no Nest 10: import * as request from "supertest"
import { AppModule } from "../src/app.module";
it("aceita webhook assinado", async () => {
process.env.XFLOW_WEBHOOK_SECRET = "segredo-de-teste";
const modulo = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = modulo.createNestApplication({ rawBody: true });
await app.init();
const corpo = JSON.stringify({ event: "transaction.pending", data: { livemode: false } });
const assinatura = "sha256=" + createHmac("sha256", "segredo-de-teste").update(corpo).digest("hex");
await request(app.getHttpServer())
.post("/webhooks/xflow")
.set("Content-Type", "application/json")
.set("x-webhook-signature", assinatura)
.send(corpo)
.expect(200);
await app.close();
});Erros e limite de requisições
Um exception filter registra o request_id e devolve ao cliente uma mensagem simples:
// xflow/xflow-erro.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import type { Response } from "express";
import { XflowErro } from "./xflow.service";
@Catch(XflowErro)
export class XflowErroFilter implements ExceptionFilter {
private readonly log = new Logger("xFlow");
catch(erro: XflowErro, host: ArgumentsHost) {
this.log.warn(erro.message); // status, código e request_id
const res = host.switchToHttp().getResponse<Response>();
if (erro.retryAfter) res.setHeader("Retry-After", String(erro.retryAfter));
res.status(erro.status === 429 ? 503 : 502).json({ erro: "Não foi possível gerar o Pix agora." });
}
}Registre com app.useGlobalFilters(new XflowErroFilter()) ou @UseFilters no controller. O request_id localiza a chamada em Integrações > Logs da API, que guarda cada requisição autenticada por 30 dias. 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: o limite é por conta. Espere os segundos deretryAftere repita com a mesmaIdempotency-Key. Toda resposta autenticada trazx-ratelimit-remaining.502 charge_failed: falha ao gerar a cobrança no provedor. Repita com backoff.
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. - Confirme que o
main.tsdo build de produção temrawBody: true. - Cadastre a URL de produção do webhook, guarde o segredo e libere a rota no guard global.
- Mantenha o filtro de
livemodee a baixa idempotente. - Registre o
request_iddos erros e, se quiser, ative a allowlist de IP em Chaves de API.
Prefira o webhook ao polling de GET /api/v1/charges/{id}. Se o front-end é Next.js, veja API Pix no Next.js. A página Desenvolvedores reúne chaves, logs e guias.
Perguntas frequentes
Por que req.rawBody vem undefined no NestJS?
Porque a aplicação não foi criada com rawBody: true. Passe a opção em NestFactory.create(AppModule, { rawBody: true }) e, nos testes e2e, em createNestApplication({ rawBody: true }). Com ela, req.rawBody traz o Buffer original, e req.body continua com o JSON convertido.
Uso HttpService ou fetch para chamar a API Pix no NestJS?
Os dois funcionam. O fetch nativo do Node.js 18 ou superior dispensa dependência. O HttpService do @nestjs/axios devolve Observables, então você usa firstValueFrom, e a autenticação vai em auth: { username, password }.
Por que o webhook Pix é barrado antes de chegar ao controller?
Quase sempre por um guard global de autenticação, como o de JWT. A xFlow não manda o token do seu sistema, então libere a rota do webhook no guard. Quem protege essa rota é a assinatura do header x-webhook-signature.
Como testar a API Pix no NestJS sem pagar?
Com chaves de teste (pk_test_/sk_test_), crie a cobrança pelo seu endpoint e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook transaction.paid chega com livemode: false.