Pular para o conteúdo
xFlow Payments

Blog

API Pix no NestJS

Para integrar a API Pix no NestJS, crie um service que chama POST /api/v1/charges com fetch e Basic auth (pk_live_ como usuário, sk_live_ como senha), devolva o pix.qrCodeBase64 e o pix.copyPaste ao cliente e receba o webhook transaction.paid num controller, com rawBody: true no NestFactory.create, validando o HMAC-SHA256 de req.rawBody com crypto.timingSafeEqual. Teste antes com chaves pk_test_.

Atualizado em · Equipe xFlow Payments · 8 min de leitura

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_... e sk_test_...), que pode ser criada antes da aprovação do cadastro.
  • npm install @nestjs/config e ConfigModule.forRoot() no AppModule.
  • XFLOW_PUBLIC_KEY, XFLOW_SECRET_KEY e XFLOW_WEBHOOK_SECRET no .env ou nas variáveis do servidor.

Ligar o corpo cru

ts
// 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.

ts
// 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

ts
// 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.

ts
// 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 é undefined e toda entrega cai no 401. Converter req.body de volta com JSON.stringify não resolve, porque os bytes podem mudar.
  • timingSafeEqual compara em tempo constante e exige buffers do mesmo tamanho, por isso o tamanho é conferido antes.
  • @HttpCode(200). Um @Post responde 201 por padrão. Qualquer 2xx confirma a entrega, mas 200 deixa a intenção clara. Uma exceção vira 500, e a xFlow reenvia até 3 vezes. Por isso darBaixa precisa 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: false marca 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:

bash
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:

ts
// 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:

ts
// 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 de retryAfter 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 tabela completa está em erros da API Pix.

Antes de ir para produção

  1. Troque as chaves de teste por pk_live_/sk_live_ só nas variáveis do servidor de produção.
  2. Confirme que o main.ts do build de produção tem rawBody: true.
  3. Cadastre a URL de produção do webhook, guarde o segredo e libere a rota no guard global.
  4. Mantenha o filtro de livemode e a baixa idempotente.
  5. Registre o request_id dos 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.

Continue lendo

SoluçãoAPI PixA API Pix da xFlow Payments é uma API REST para criar cobranças Pix com QR Code e copia e cola, estornar, sacar e receber webhooks assinados. Uma única integração conecta a sua operação a 11 adquirentes, com roteamento por regras e retentativa automática.BlogAPI Pix em Node.jsPara integrar a API Pix em Node.js, chame POST /api/v1/charges com fetch e Basic auth (pk_live_ como usuário, sk_live_ como senha), mostre o pix.qrCodeBase64 e o pix.copyPaste da resposta e confirme o pagamento pelo webhook transaction.paid, validando a assinatura HMAC-SHA256 do corpo cru. Antes da produção, teste com chaves pk_test_ e simule o pagamento.BlogWebhook Pix: eventos, assinatura HMAC e boas práticasWebhook Pix é o aviso que a API envia para a sua URL quando algo acontece com uma cobrança ou um saque, como o pagamento confirmado. Na xFlow, cada aviso é um POST JSON assinado com HMAC-SHA256, reenviado até 3 vezes até você responder 2xx.BlogAPI Pix sandbox: como testar sem mover dinheiroO sandbox da API Pix da xFlow funciona com chaves pk_test_ e sk_test_: mesmos endpoints, mesmas respostas com livemode: false, nenhum dinheiro real e um endpoint para simular o pagamento que dispara o webhook transaction.paid.BlogAPI Pix no Next.jsPara integrar a API Pix no Next.js, crie a cobrança no servidor, numa Server Action ou num Route Handler, com POST /api/v1/charges e Basic auth (pk_live_ como usuário, sk_live_ como senha), mostre o pix.qrCodeBase64 e o pix.copyPaste no componente e confirme o pagamento em app/api/webhook/route.ts, validando o HMAC-SHA256 de await request.text(). O segredo nunca vai para o navegador.