cobranca.php
<?php
/**
* PHP, só com o que vem na instalação padrão (curl e hash).
*
* P2D_KEY='p2d_test_...' php cobranca.php
*
* 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.
*
* No fim do arquivo há um recebedor de webhook pronto, com as notas de
* WooCommerce e Laravel.
*/
declare(strict_types=1);
final class ErroDaApi extends RuntimeException
{
public function __construct(
public readonly int $status,
/** O `code` é o que não muda — escreva seu if em cima dele. */
public readonly string $code,
/** Mande ao suporte: é por ele que a requisição é achada. */
public readonly string $requestId,
string $mensagem,
) {
parent::__construct("[{$status} {$code}] {$mensagem} (requestId: {$requestId})");
}
}
final class Pix2DePix
{
public function __construct(
private readonly string $chave,
private readonly string $base = 'https://api.pix2depix.com',
) {
}
/** @param array<string,mixed>|null $corpo */
public function chamar(string $caminho, ?array $corpo = null, array $cabecalhos = []): array
{
$ch = curl_init($this->base . $caminho);
$headers = array_merge([
'authorization: Bearer ' . $this->chave,
'content-type: application/json',
], $cabecalhos);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $corpo === null ? 'GET' : 'POST',
CURLOPT_POSTFIELDS => $corpo === null ? null : json_encode($corpo, JSON_UNESCAPED_UNICODE),
]);
$resposta = curl_exec($ch);
if ($resposta === false) {
throw new RuntimeException('falha de rede: ' . curl_error($ch));
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$dados = json_decode((string) $resposta, true, flags: JSON_THROW_ON_ERROR);
if ($status >= 400) {
throw new ErroDaApi(
$status,
$dados['error']['code'],
$dados['error']['requestId'],
$dados['error']['message'],
);
}
return [$dados, $status];
}
}
/**
* Validação da assinatura do webhook. Este é o pedaço que vai para o seu
* servidor.
*
* @param string $corpoBruto o corpo **como chegou** — nunca json_encode(json_decode(...))
* @param array<string,string> $cabecalhos em minúsculas
*/
function assinaturaValida(string $segredo, string $corpoBruto, array $cabecalhos): bool
{
$ts = (int) ($cabecalhos['x-p2d-timestamp'] ?? 0);
// Fora da janela de 5 minutos é entrega antiga sendo reenviada por terceiro.
if ($ts === 0 || abs(time() - $ts) > 300) {
return false;
}
$esperado = 'v1=' . hash_hmac('sha256', $ts . '.' . $corpoBruto, $segredo);
// hash_equals: `===` em string vaza o segredo pelo tempo de resposta.
return hash_equals($esperado, $cabecalhos['x-p2d-signature'] ?? '');
}
// ── Recebedor de webhook, para copiar ───────────────────────────────────────
//
// $corpoBruto = file_get_contents('php://input'); // NUNCA $_POST
// $cabecalhos = array_change_key_case(getallheaders(), CASE_LOWER);
//
// if (!assinaturaValida(getenv('P2D_WEBHOOK_SECRET'), $corpoBruto, $cabecalhos)) {
// http_response_code(401);
// exit;
// }
//
// $evento = json_decode($corpoBruto, true);
//
// // A entrega é at-least-once: deduplique pelo id antes de processar.
// if (jaProcessamos($evento['id'])) { http_response_code(200); exit; }
// registraEvento($evento['id']);
//
// // Enfileire e responda rápido — o timeout é de 10 segundos.
// enfileira($evento);
// http_response_code(200);
//
// Laravel: use $request->getContent(), não $request->all(), e exclua a
// rota do VerifyCsrfToken.
// WooCommerce: register_rest_route(...) e $request->get_body().
// ── Exemplo executável ──────────────────────────────────────────────────────
$chave = getenv('P2D_KEY');
if ($chave === false || $chave === '') {
fwrite(STDERR, "defina P2D_KEY com sua chave de API\n");
exit(1);
}
$api = new Pix2DePix($chave, getenv('P2D_URL') ?: 'https://api.pix2depix.com');
// 1. Conta e taxa vigente.
[$conta] = $api->chamar('/v1/account');
printf("ambiente: %s · taxa: %s%%\n", $conta['environment'], $conta['plan']['feeBps'] / 100);
// 2. Criar a cobrança. O externalId é o número do pedido no seu sistema.
$pedido = 'pedido-' . time();
$corpo = [
'amountInCents' => 25000,
'payerTaxNumber' => '529.982.247-25',
'payerName' => 'Maria Silva',
'externalId' => $pedido,
'metadata' => ['origem' => 'exemplo'],
];
// Retry seguro de rede: repetir com a mesma chave devolve a cobrança original.
$idempotencia = 'idempotency-key: ' . bin2hex(random_bytes(16));
[$cobranca, $status] = $api->chamar('/v1/charges', $corpo, [$idempotencia]);
printf("cobrança %s criada (HTTP %d)\n", $cobranca['id'], $status);
printf("copia-e-cola: %s\n", $cobranca['pix']['qrCode']);
printf("líquido: %.2f\n", $cobranca['amount']['netInCents'] / 100);
// 3. A mesma requisição de novo não duplica.
[$repetida, $status] = $api->chamar('/v1/charges', $corpo);
printf("repetição: HTTP %d, mesma cobrança: %s\n", $status, var_export($repetida['id'] === $cobranca['id'], true));
// 4. Sandbox: mover a cobrança sem Pix.
if ($conta['environment'] === 'test') {
foreach (['paid_pending_settlement', 'settled'] as $alvo) {
[$movida] = $api->chamar("/v1/test/charges/{$cobranca['id']}/advance", ['status' => $alvo]);
printf("→ %s %s\n", $movida['status'], $movida['settlement']['estimatedAt'] ?? '');
}
}
// 5. A confirmação que decide se o produto sai.
[$atual] = $api->chamar("/v1/charges/{$cobranca['id']}");
echo $atual['status'] === 'settled'
? "DePix liquidado — pode liberar o produto.\n"
: "ainda em {$atual['status']} — não libere.\n";
// 6. Prova de que a validação concorda com o algoritmo documentado.
$segredo = 'whsec_exemplo';
$corpoBruto = '{"id":"evt_1","type":"charge.settled"}';
$ts = time();
$cabecalhos = [
'x-p2d-timestamp' => (string) $ts,
'x-p2d-signature' => 'v1=' . hash_hmac('sha256', $ts . '.' . $corpoBruto, $segredo),
];
// `if` e não `assert()`: com zend.assertions=-1 os asserts viram nada, e um
// teste que some é um teste que passa sem testar.
if (!assinaturaValida($segredo, $corpoBruto, $cabecalhos)) {
fwrite(STDERR, "a assinatura correta deveria validar\n");
exit(1);
}
if (assinaturaValida($segredo, $corpoBruto . ' ', $cabecalhos)) {
fwrite(STDERR, "corpo adulterado deveria falhar\n");
exit(1);
}
echo "validação de assinatura: ok\n";