DePix Pay

API

Documentação da API Pix

Crie cobranças Pix no seu backend, mostre o QR na sua página a partir do qr_copy_paste, e confirme o pagamento com webhook assinado. Este guia descreve só o que existe hoje no gateway.

Base https://api.pay.p2p.app.br Checkout https://pay.p2p.app.br Atualizado 2026-10-08

1. Fluxo recomendado (QR na sua página)

[navegador] --(1) "pagar com Pix"--> [seu backend] [seu backend] --(2) POST /v1/charges (Bearer dpk_)--> [DePix Pay] [DePix Pay] --(3) { id, status, qr_copy_paste, expiration }--> [seu backend] [seu backend] --(4) qr_copy_paste--> [navegador]: QR + botão "copiar" [comprador paga no app do banco] [DePix Pay] --(5) POST notify_url (assinado com whsec_)--> [seu backend] [navegador] --(6) polling no SEU backend--> página de obrigado
Caminho principal: QR in-page via qr_copy_paste. A página hosted (pay_url) é alternativa — veja a seção 8.

2. Autenticação

Cada loja recebe uma chave de API e um segredo de webhook:

CredencialUso
dpk_… Só no servidor. Header Authorization: Bearer dpk_… ou X-Api-Key: dpk_….
whsec_… Só no servidor, para verificar o POST em notify_url.

Uma chave amarra uma loja. Peça a chave no Telegram com /chaveapi: o link chega por e-mail e abre a chave uma vez só. Uma chave nova substitui a antiga na hora. O segredo do webhook vem junto com a primeira chave.

3. Criar cobrança

POST https://api.pay.p2p.app.br/v1/charges

Authorization: Bearer dpk_…
Content-Type: application/json
Idempotency-Key: <opcional, 1–200 caracteres sem espaço>

Body

CampoObrigatórioRegra
amount_in_cents sim Inteiro de 1 a 10.000.000 (R$ 0,01 a R$ 100.000,00). Também aceita string numérica inteira, como "1500". Decimal e texto que não é inteiro: 400 invalid_amount.
end_user_tax_number acima de R$ 10,00, se não houver euid CPF (11) ou CNPJ (14). Aceita máscara. Dígito verificador validado. Deve ser do titular da conta que vai pagar. Com euid, o QR sai sem este campo.
end_user_full_name recomendado Nome do pagador, até 200 caracteres.
notify_url recomendado URL https pública do seu webhook (host final, sem redirect).
reference recomendado Id do pedido na sua loja (até 200). Volta nas respostas e webhooks. Sem garantia de unicidade no gateway.
description não Até 200 caracteres.
link_ttl_secs não Inteiro de 1200 a 86400. Se o campo não vier, o padrão é 1200 (20 min) — o mesmo prazo usado para expirar a cobrança quando o cliente não mandou link_ttl_secs. String ("1800") ou decimal (1800.5) é ignorado e vale o padrão. Inteiro fora da faixa: 400 invalid_link_ttl.
return_url não Usado só pela página hosted (“Voltar à loja”). Irrelevante no fluxo in-page. Só o nome return_url.
euid não Até 64 caracteres. Alias end_user_euid. Acima disso: 400 invalid_euid. Com euid preenchido, o QR sai acima de R$ 10,00 sem CPF/CNPJ.
auto_redirect não Booleano JSON: true ou false. Se faltar, ou não for booleano, vale false.
store_id não A loja já vem na chave. Alias storeId. Se vier, tem de ser a mesma loja da chave. Outra loja: 403 forbidden.
tenant_id não O tenant já vem na chave. Alias tenantId. Se vier e não for o tenant da chave: 401 unauthorized. Formato inválido: 422 invalid_tenant_id.
callback_url não Alias de notify_url. Mesma regra de URL https. Se os dois vierem, vale notify_url.
external_ref não Alias de reference. Mesmo limite de 200. Se os dois vierem, vale reference. A resposta devolve reference.

Aliases camelCase que funcionam, e só estes: amountInCents, endUserTaxNumber, endUserFullName, tenantId, storeId. notifyUrl, returnUrl e linkTtlSecs são ignorados — mandar notifyUrl no lugar de notify_url não cadastra webhook. Campos de carteira/taxa (splitFee etc.) são proibidos e retornam 400. metadata livre: ainda não disponível (ignorado).

Exemplo: body do POST

{
  "amount_in_cents": 19700,
  "end_user_tax_number": "12345678909",
  "end_user_full_name": "Maria Silva",
  "euid": "pagador-42",
  "notify_url": "https://www.sua-loja.com/api/webhooks/depix",
  "reference": "pedido-123",
  "description": "Camiseta",
  "link_ttl_secs": 1200,
  "return_url": "https://www.sua-loja.com/pedido/123",
  "auto_redirect": false,
  "store_id": "10001",
  "tenant_id": "p2papp"
}

amount_in_cents também pode ir como "19700". Os campos opcionais podem ser omitidos. Não mande notifyUrl nem linkTtlSecs.

Acima de R$ 10,00 sem CPF/CNPJ e sem euid não sai QR. A resposta vem 201 com status: "creating_deposit" e sem qr_copy_paste. Não há endpoint de API para completar o CPF depois — peça nome + CPF/CNPJ antes de criar a cobrança, ou mande euid (a página hosted consegue coletar o CPF depois; veja §8).

Resposta de sucesso (201) com QR

{
  "id": "chg_…",
  "status": "awaiting_payment",
  "tenant_id": "p2papp",
  "pay_url": "https://pay.p2p.app.br/pay/…",
  "qr_copy_paste": "00020126…6304ABCD",
  "amount_in_cents": 19700,
  "reference": "pedido-123",
  "description": "Camiseta",
  "expiration": "2026-10-06T14:38:39-03:00"
}

Resposta 201 creating_deposit (sem QR)

Acima de R$ 10,00 sem CPF/CNPJ e sem euid. Não há qr_copy_paste nem issuance_error.

{
  "id": "chg_…",
  "status": "creating_deposit",
  "tenant_id": "p2papp",
  "pay_url": "https://pay.p2p.app.br/pay/…",
  "amount_in_cents": 19700,
  "reference": "pedido-123",
  "description": "Camiseta",
  "expiration": null
}

Exemplo: corpo de erro

Limite de emissão. retry_after é segundos. O header Retry-After repete esse número quando ele existe.

{
  "error": "deposit_quota",
  "message": "deposit quota reached; retry later",
  "retry_after": 37,
  "charge": {
    "id": "chg_…",
    "status": "creating_deposit",
    "tenant_id": "p2papp",
    "pay_url": "https://pay.p2p.app.br/pay/…",
    "amount_in_cents": 19700,
    "reference": "pedido-123",
    "description": "Camiseta",
    "expiration": null,
    "issuance_error": "rate_limited"
  }
}

Validação é só error e message, sem charge: {"error":"invalid_amount","message":"amount_in_cents must be an integer between 1 and 10000000"}. GET sem a cobrança: {"error":"not_found"}.

Erros

HTTP / errorSignificadoAção
201 + creating_deposit sem QR Faltou CPF/CNPJ e euid (valor > R$ 10) Nova cobrança com CPF ou euid
409 deposit_verifying / deposit_in_progress Pix pode já ter sido criado Não crie outra. Faça poll no GET até QR ou failed
409 idempotency_in_progress Essa Idempotency-Key ainda está em processamento. Sem charge Espere e repita a mesma chave
409 deposit_closed A cobrança não está mais gerando Pix Leia o GET. Não tente emitir de novo nesta cobrança
422 identification_required Emissor exigiu identificação Nova cobrança com CPF/CNPJ
422 deposit_failed Emissor recusou com HTTP 422 Corrija o que o message indicar, ou fale com a DePix
422 mvp_store_unreadable, missing_store, missing_store_wallet, missing_store_euid, missing_tenant_wallet, missing_split_fee, invalid_split_fee, split_address_collision, eulen_account_unavailable, invalid_tenant_id Loja, carteira, taxa ou conta do emissor não dá para emitir Falar com a DePix. Sem charge quando a falha é antes de gravar
429 deposit_quota Cota de emissão da DePix. Corpo traz retry_after (número). Header Retry-After sempre vem Espere e crie nova cobrança com outra Idempotency-Key
429 eulen_rate_limited Emissor limitou a emissão. retry_after no corpo e o header Retry-After só vêm quando o emissor mandou Retry-After. Senão o corpo traz "retry_after": null e o header não vem Igual ao deposit_quota
429 rate_limited Muitas chamadas na API (60/min por chave, POST e GET juntos). Corpo: error, message (too many requests), retry_after. Header Retry-After sempre vem. Sem charge Espere retry_after segundos
503 temporarily_unavailable Emissor indisponível. Retry-After e retry_after só quando o emissor mandou o header. Senão "retry_after": null e sem header Igual ao 429 de emissão
503 rate_limit_unavailable Contador de limite indisponível. Sem retry_after e sem header Retry-After Tente de novo mais tarde
502 deposit_failed / deposit_address_unknown Falha final do emissor. deposit_address_unknown é o messageCode DEPOSIT_ADDRESS_NETWORK_UNKNOWN. Se o emissor respondeu 422, o HTTP desta API também é 422 (não 502) Não repita o mesmo Pix. Nova cobrança, ou fale com a DePix
400 invalid_amount, invalid_tax_number, invalid_name, invalid_euid, invalid_url, invalid_field, invalid_link_ttl, invalid_json, invalid_idempotency_key, split_not_allowed Corrigir entrada
401 unauthorized Chave errada/revogada, ou tenant_id do body não é o da chave —
403 forbidden store_id (body ou query) é outra loja Omita store_id ou mande o da chave
403 store_key_required A chave não é de loja (chave de operador em criar ou em GET /v1/store) Use a chave dpk_ da loja
403 store_not_active / partner_disabled Loja não liberada Falar com a DePix
404 not_found GET /v1/charges/{id} sem essa cobrança, ou cobrança de outra loja Confira o id
413 payload_too_large Body acima de 65536 bytes Enxugue o JSON

O header Retry-After não vem sempre. Em deposit_quota e em rate_limited ele vem. Em eulen_rate_limited e em 503 temporarily_unavailable ele só vem quando o emissor mandou um. Em rate_limit_unavailable não vem.

Quando a cobrança chegou a ser gravada, 409/422/429/503 de emissão trazem charge (com id). idempotency_in_progress, rate_limited, payload_too_large e a validação 400 não trazem.

Idempotency-Key: repetir a mesma chave devolve a mesma resposta da primeira vez (mesmo status e corpo, inclusive um 429), sem prazo e sem comparar o body. Use uma chave por tentativa. O status atual sempre se lê no GET.

Exemplo: Next.js (App Router)

// app/api/pix/charge/route.ts
export const runtime = "nodejs";
const BASE = "https://api.pay.p2p.app.br";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await loadOrder(orderId); // valor, nome, CPF — no servidor
  if (!order || order.status === "paid") {
    return Response.json({ error: "invalid_order" }, { status: 400 });
  }

  const res = await fetch(`${BASE}/v1/charges`, {
    method: "POST",
    cache: "no-store",
    headers: {
      Authorization: `Bearer ${process.env.DEPIX_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `${order.id}-pix-${order.pixAttempt}`,
    },
    body: JSON.stringify({
      amount_in_cents: order.amountInCents,
      end_user_full_name: order.buyerName,
      end_user_tax_number: order.buyerTaxNumber,
      reference: order.id,
      description: String(order.productName || "").slice(0, 200),
      notify_url: "https://www.sua-loja.com/api/webhooks/depix",
    }),
  });
  const data = await res.json().catch(() => ({}));
  if (data?.id) await saveChargeOnOrder(order.id, data.id, data.status);

  if (res.status === 201 && data.qr_copy_paste) {
    return Response.json({
      status: "awaiting_payment",
      qr: data.qr_copy_paste,
      expiration: data.expiration,
    });
  }
  if (res.status === 201 && data.status === "creating_deposit") {
    return Response.json({ status: "error", error: "tax_number_required" }, { status: 422 });
  }
  if (res.status === 409) return Response.json({ status: "verifying" }, { status: 202 });
  if (res.status === 429 || res.status === 503) {
    return Response.json({
      status: "retry",
      retryAfter: Number(res.headers.get("Retry-After") ?? 30),
    }, { status: 503 });
  }
  return Response.json({ status: "error", error: data.error ?? "unknown" }, { status: 502 });
}

Desenhar o QR no front

import QRCode from "qrcode"; // npm i qrcode

const dataUrl = await QRCode.toDataURL(qr, {
  margin: 1,
  width: 280,
  errorCorrectionLevel: "M",
});
// <img src={dataUrl} alt="QR Code Pix" />
// botão copiar: navigator.clipboard.writeText(qr)

4. Consultar status e a loja

GET https://api.pay.p2p.app.br/v1/charges/{id} — mesmo auth. Mesmo formato da criação, com pay_url quando o link ainda pode ser aberto. qr_copy_paste só vem enquanto awaiting_payment. issuance_error só vem enquanto não há QR. Depois do pagamento podem vir também payer_name_masked, payer_tax_masked, pix_message, late, dispute, bank_review, review_reason. Cobrança inexistente ou de outra loja: 404 {"error":"not_found"}.

Exemplo: GET depois do pagamento

{
  "id": "chg_…",
  "status": "paid",
  "tenant_id": "p2papp",
  "pay_url": "https://pay.p2p.app.br/pay/…",
  "amount_in_cents": 19700,
  "reference": "pedido-123",
  "description": "Camiseta",
  "expiration": "2026-10-06T14:38:39-03:00",
  "payer_name_masked": "Maria S.",
  "payer_tax_masked": "***.***.***-09",
  "pix_message": "pedido 42"
}

late, dispute (open ou refunded), bank_review e review_reason só entram quando existem. Não há qr_copy_paste depois de awaiting_payment.

GET /v1/charges lista as 50 mais recentes da loja. Paginação e busca por reference: ainda não disponíveis. Cada item é a cobrança mais created_at, sem pay_url e sem os campos do pagador. qr_copy_paste só no item awaiting_payment.

Exemplo: lista

{
  "charges": [
    {
      "id": "chg_…",
      "status": "paid",
      "tenant_id": "p2papp",
      "amount_in_cents": 19700,
      "reference": "pedido-123",
      "description": "Camiseta",
      "expiration": "2026-10-06T14:38:39-03:00",
      "created_at": "2026-10-06T17:18:39.000Z"
    }
  ]
}

GET /v1/store

GET https://api.pay.p2p.app.br/v1/store — chave da loja (dpk_). Devolve a loja da chave, sem segredo. Chave que não é de loja: 403 store_key_required. Loja inativa: 403 store_not_active.

{
  "store_id": "10001",
  "tenant_id": "p2papp",
  "status": "active",
  "destination_address": "lq1qq…",
  "merchant_euid": "EU01STORE",
  "split": {
    "address": "lq1qq…",
    "fee": "2.50%"
  }
}

store_id é o id da loja na chave. split é null quando o endereço ou a taxa do parceiro não está disponível.

Limite: 60 requisições por minuto por chave, somando POST e GET. O navegador faz polling no seu backend (atualizado pelo webhook), nunca direto na DePix.

5. Estados

statusNa telaLiberar?
creating_depositgerando Pix…não
awaiting_paymentQR + copia-e-colanão
heldrecebido e parado (under_bank_review, under_review, pending_pix2fa ou delayed)não
paidobrigadosim (uma vez)
expiredexpirou — gerar outronão
failed / canceledtente de novonão
refundedestornadorevogar
reviewem verificaçãonão

6. Valores, prazos e limites

7. Webhook (confirmação)

POST no seu notify_url, body JSON igual ao GET (sem pay_url), mais bank_tx_id e blockchain_tx_id quando existirem.

Exemplo: webhook charge.paid

{
  "id": "chg_…",
  "status": "paid",
  "tenant_id": "p2papp",
  "amount_in_cents": 19700,
  "reference": "pedido-123",
  "description": "Camiseta",
  "expiration": "2026-10-06T14:38:39-03:00",
  "bank_tx_id": "E123456789",
  "blockchain_tx_id": "abc123txid",
  "payer_name_masked": "Maria S.",
  "payer_tax_masked": "***.***.***-09",
  "pix_message": "pedido 42"
}

bank_tx_id e blockchain_tx_id só entram quando o emissor os mandou. O mesmo vale para os campos do pagador, late, dispute, bank_review e review_reason. Campo ausente não vem como null — a exceção é expiration.

Content-Type: application/json
X-DePix-Event: charge.paid
X-DePix-Signature: sha256=<hex>
webhook-id: msg_…
webhook-timestamp: 1759689600
webhook-signature: v1,<base64>

O tipo do evento vem só no header X-DePix-Event (não está no body).

Eventos

EventoQuando
charge.paidpago → liberar
charge.heldsó under_bank_review. under_review, pending_pix2fa e delayed ficam held e não mandam este evento
charge.disputedMED aberto
charge.refundedestornado → revogar
charge.expiredPix expirou sem pagamento
charge.failedfalhou (ex.: recusado pelo emissor)
charge.reviewprecisa de verificação manual

Não existe charge.canceled. canceled não manda webhook.

Assinatura (como o gateway calcula hoje)

Vetor de teste:

secret            = whsec_test_vector
webhook-id        = msg_test
webhook-timestamp = 1700000000
body              = {"id":"chg_test","status":"paid"}
X-DePix-Signature = sha256=128e4cf4b6f6928089d4c2c2b2e9c7abd25b48a728c4ade4ae0f24aa6659b00f
webhook-signature = v1,MWwyPUIfY/+8WVxw36RnDrqTv8IJuGvC8D8DjN14gNs=

Entregas

Exemplo: verificar no Node / Next.js

// app/api/webhooks/depix/route.ts
import crypto from "node:crypto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const TOLERANCE_SEC = 300;

function safeEqual(a: string, b: string) {
  const x = Buffer.from(a, "utf8");
  const y = Buffer.from(b, "utf8");
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verify(rawBody: string, h: Headers, secret: string): boolean {
  const legacy = h.get("x-depix-signature") ?? "";
  const id = h.get("webhook-id") ?? "";
  const ts = h.get("webhook-timestamp") ?? "";
  const sig = h.get("webhook-signature") ?? "";

  const expectedLegacy =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  if (!safeEqual(legacy, expectedLegacy)) return false;

  const t = Number(ts);
  if (!id || !Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) {
    return false;
  }
  const expectedStd =
    "v1," +
    crypto
      .createHmac("sha256", secret)
      .update(`${id}.${ts}.${rawBody}`, "utf8")
      .digest("base64");
  return safeEqual(sig, expectedStd);
}

export async function POST(req: Request) {
  const raw = await req.text(); // body CRU — não use req.json() antes
  const secrets = [
    process.env.DEPIX_WEBHOOK_SECRET,
    process.env.DEPIX_WEBHOOK_SECRET_PREVIOUS,
  ].filter(Boolean) as string[];
  if (!secrets.some((s) => verify(raw, req.headers, s))) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = req.headers.get("x-depix-event") ?? "";
  const body = JSON.parse(raw) as {
    id: string;
    status: string;
    amount_in_cents: number;
    reference?: string;
  };

  // Idempotência: UNIQUE (charge_id, event). Se já existe, 200.
  const firstTime = await recordWebhookOnce(body.id, event, req.headers.get("webhook-id"));
  if (!firstTime) return new Response("ok", { status: 200 });

  if (event === "charge.paid") {
    // Reconsulte GET /v1/charges/:id e confira valor/pedido antes de liberar
    await markOrderPaid(body.id);
  } else if (event === "charge.refunded") {
    await markOrderRefunded(body.id);
  }
  return new Response("ok", { status: 200 });
}

Responda rápido (< 10 s). E-mail e outros efeitos vão para fila/job.

8. Página hosted (alternativa)

O campo pay_url aponta para https://pay.p2p.app.br/pay/{token}. O comprador vê o QR (ou o formulário de CPF quando a cobrança foi criada sem identificação acima de R$ 10). Use quando preferir redirect em vez de embutir o QR.

9. Ainda não disponível

Não implemente contando com: