DePix Pay
API
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.
qr_copy_paste. Gere a imagem do QR no front (ex.: lib qrcode). Não precisa redirecionar para pay_url.dpk_ nunca vai para o navegador. A API não libera CORS para outros domínios: tudo passa pelo seu backend.status: "paid" (webhook com assinatura válida ou GET autenticado). Nunca só porque o QR apareceu.qr_copy_paste.
A página hosted (pay_url) é alternativa — veja a seção 8.
Cada loja recebe uma chave de API e um segredo de webhook:
| Credencial | Uso |
|---|---|
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.
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>
| Campo | Obrigatório | Regra |
|---|---|---|
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).
{
"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.
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).
{
"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"
}
qr_copy_paste para o QR e o botão “copiar”.expiration é o texto do emissor, sem conversão. Pode ser null, um instante em Z (2026-10-05T17:40:00Z) ou com fuso (2026-10-06T14:38:39-03:00). Quando vier null, o Pix vale link_ttl_secs, ou 1200 segundos se o cliente não mandou esse campo.reference e description só aparecem quando foram enviados. expiration sempre vem, mesmo null.issuance_error entra na cobrança (criar e GET) enquanto não há QR. Some quando o QR existe. Pode ser rate_limited, unavailable, identification_required, verifying, in_progress, failed, ou o mesmo código de um 422 que recusou o depósito.pay_url é a página hosted. Não monte essa URL na mão.qr_image_url (imagem pronta): ainda não disponível.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
}
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"}.
| HTTP / error | Significado | Açã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.
// 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 });
}
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)
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"}.
{
"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.
{
"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/storeGET 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.
| status | Na tela | Liberar? |
|---|---|---|
creating_deposit | gerando Pix… | não |
awaiting_payment | QR + copia-e-cola | não |
held | recebido e parado (under_bank_review, under_review, pending_pix2fa ou delayed) | não |
paid | obrigado | sim (uma vez) |
expired | expirou — gerar outro | não |
failed / canceled | tente de novo | não |
refunded | estornado | revogar |
review | em verificação | não |
held também é o status de under_review, pending_pix2fa e delayed. Esses três não mandam webhook. charge.held só sai em under_bank_review (aí o GET pode trazer bank_review: true).canceled também não manda webhook. Confirme pelo GET.paid não volta atrás, mas refunded pode chegar depois (MED/estorno Pix).expired/failed/canceled pode virar paid com late: true.expired para trocar a tela: quando o seu timer zerar, ofereça novo Pix e continue aceitando um paid eventual da anterior.euid.link_ttl_secs. O campo, se vier, é inteiro de 20 min a 24 h.deposit_quota. Avise a DePix antes de lançamentos grandes.GET /v1/store devolve a taxa vigente em split.fee.refunded aparece quando o banco do pagador estorna (MED).POST no seu notify_url, body JSON igual ao GET (sem pay_url), mais bank_tx_id e blockchain_tx_id quando existirem.
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).
| Evento | Quando |
|---|---|
charge.paid | pago → liberar |
charge.held | só under_bank_review. under_review, pending_pix2fa e delayed ficam held e não mandam este evento |
charge.disputed | MED aberto |
charge.refunded | estornado → revogar |
charge.expired | Pix expirou sem pagamento |
charge.failed | falhou (ex.: recusado pelo emissor) |
charge.review | precisa de verificação manual |
Não existe charge.canceled. canceled não manda webhook.
X-DePix-Signature = "sha256=" + hex(HMAC_SHA256(chave = "whsec_…" como texto, mensagem = body cru))webhook-signature = "v1," + base64(HMAC_SHA256(chave = "whsec_…" como texto, mensagem = webhook-id + "." + webhook-timestamp + "." + body cru))webhook-timestamp (unix, segundos) é renovado a cada tentativa. O webhook-id é o mesmo nas retentativas.standardwebhooks/svix: elas decodificam o secret em base64; aqui o secret é texto.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=
GET para pedidos aguardando Pix.// 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.
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.
id da cobrança. Não monte a URL.return_url no create habilita o link “Voltar à loja” após paid.GET — a página hosted não substitui isso.Não implemente contando com:
qr_image_url)metadata livre; busca por reference; paginação da lista