cobranca.mjs
/**
* Node, sem dependência nenhuma (fetch e node:crypto já vêm no Node 20+).
*
* P2D_KEY='p2d_test_...' node cobranca.mjs
*
* Cobre: criar, idempotência, consultar, e a validação de assinatura do
* webhook — que é o pedaço que você vai copiar para o seu servidor.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
const CHAVE = process.env.P2D_KEY;
const BASE = process.env.P2D_URL ?? 'https://api.pix2depix.com';
if (!CHAVE) throw new Error('defina P2D_KEY com sua chave de API');
async function chamar(caminho, opcoes = {}) {
const res = await fetch(`${BASE}${caminho}`, {
...opcoes,
headers: {
authorization: `Bearer ${CHAVE}`,
'content-type': 'application/json',
...opcoes.headers,
},
body: opcoes.corpo ? JSON.stringify(opcoes.corpo) : undefined,
});
const dados = await res.json();
if (!res.ok) {
// O `code` é o que não muda — escreva seu if em cima dele, não da mensagem.
// O `requestId` é o que o suporte usa para achar a requisição.
const { code, message, requestId } = dados.error;
throw new Error(`[${res.status} ${code}] ${message} (requestId: ${requestId})`);
}
return { dados, status: res.status };
}
// ── 1. Conta e taxa vigente ────────────────────────────────────────────────
const { dados: conta } = await chamar('/v1/account');
console.log('ambiente:', conta.environment, '· taxa:', conta.plan.feeBps / 100 + '%');
// ── 2. Criar a cobrança ────────────────────────────────────────────────────
const pedido = `pedido-${Date.now()}`;
const corpo = {
amountInCents: 25000,
payerTaxNumber: '529.982.247-25',
payerName: 'Maria Silva',
externalId: pedido,
metadata: { origem: 'exemplo' },
};
const { dados: cobranca, status } = await chamar('/v1/charges', {
method: 'POST',
corpo,
// Retry seguro de rede: se a resposta se perder, repetir com a mesma chave
// devolve a cobrança original em vez de criar uma segunda.
headers: { 'idempotency-key': crypto.randomUUID() },
});
console.log(`cobrança ${cobranca.id} criada (HTTP ${status})`);
console.log('copia-e-cola:', cobranca.pix.qrCode);
console.log('líquido:', (cobranca.amount.netInCents / 100).toFixed(2));
// ── 3. A mesma requisição de novo não duplica ──────────────────────────────
const repetida = await chamar('/v1/charges', { method: 'POST', corpo });
console.log(
`repetição: HTTP ${repetida.status}, mesma cobrança:`,
repetida.dados.id === cobranca.id,
);
// ── 4. Sandbox: mover a cobrança sem Pix ───────────────────────────────────
if (conta.environment === 'test') {
for (const alvo of ['paid_pending_settlement', 'settled']) {
const { dados } = await chamar(`/v1/test/charges/${cobranca.id}/advance`, {
method: 'POST',
corpo: { status: alvo },
});
console.log('→', dados.status, dados.settlement.estimatedAt ?? '');
}
}
// ── 5. A confirmação que decide se o produto sai ───────────────────────────
const { dados: atual } = await chamar(`/v1/charges/${cobranca.id}`);
console.log(
atual.status === 'settled'
? 'DePix liquidado — pode liberar o produto.'
: `ainda em ${atual.status} — não libere.`,
);
// ── 6. Validação da assinatura do webhook ──────────────────────────────────
// Este é o pedaço que vai para o seu servidor.
export function assinaturaValida({ segredo, corpoBruto, cabecalhos }) {
const ts = Number(cabecalhos['x-p2d-timestamp']);
// Fora da janela de 5 minutos é entrega antiga sendo reenviada por terceiro.
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
// O corpo **bruto**, como chegou. Reserializar o JSON muda a assinatura.
const esperado =
'v1=' +
createHmac('sha256', segredo).update(`${ts}.${corpoBruto}`).digest('hex');
const a = Buffer.from(cabecalhos['x-p2d-signature'] ?? '');
const b = Buffer.from(esperado);
// Comparação em tempo constante: `===` vaza o segredo pelo tempo de resposta.
return a.length === b.length && timingSafeEqual(a, b);
}
// Prova de que a função concorda com o algoritmo documentado.
{
const segredo = 'whsec_exemplo';
const corpoBruto = '{"id":"evt_1","type":"charge.settled"}';
const ts = Math.floor(Date.now() / 1000);
const assinatura =
'v1=' + createHmac('sha256', segredo).update(`${ts}.${corpoBruto}`).digest('hex');
const cabecalhos = { 'x-p2d-timestamp': String(ts), 'x-p2d-signature': assinatura };
if (!assinaturaValida({ segredo, corpoBruto, cabecalhos })) {
throw new Error('a assinatura correta deveria validar');
}
if (assinaturaValida({ segredo, corpoBruto: corpoBruto + ' ', cabecalhos })) {
throw new Error('corpo adulterado deveria falhar');
}
console.log('validação de assinatura: ok');
}