Para usar a API Pix em PHP, você precisa só da extensão cURL: uma chamada cria a cobrança, a resposta traz o QR Code e o copia e cola, e um script recebe o webhook que confirma o pagamento. O código abaixo segue a documentação da API, roda em PHP 8 e usa a base https://app.xflowpayments.com/api/v1. Para a visão geral da integração, veja o guia da API Pix.
Antes de começar
- Gere uma chave 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, crie também uma chave do ambiente Teste (
pk_test_...esk_test_...). Dá para criar antes da aprovação do cadastro. - Guarde as credenciais em variáveis de ambiente:
XFLOW_PUBLIC_KEY,XFLOW_SECRET_KEYeXFLOW_WEBHOOK_SECRET.
O segredo nunca vai para o navegador nem para o repositório. Se vazar, revogue no painel: a revogação é imediata.
Uma função para chamar a API
A autenticação é HTTP Basic, com a chave pública como usuário e o segredo como senha. O cURL monta o header sozinho com CURLOPT_USERPWD. Os valores vão em centavos, como inteiros.
<?php
// xflow.php
const XFLOW_BASE = 'https://app.xflowpayments.com/api/v1';
function xflow(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$headers = ['Accept: application/json'];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init(XFLOW_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => getenv('XFLOW_PUBLIC_KEY') . ':' . getenv('XFLOW_SECRET_KEY'),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('Falha de rede: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($raw, true) ?? [];
if ($status >= 400) {
throw new RuntimeException(sprintf(
'%d %s: %s (%s)',
$status,
$json['error'] ?? 'erro',
$json['message'] ?? '',
$json['request_id'] ?? 'sem request_id'
), $status);
}
return $json;
}Toda resposta traz um request_id (req_...), no corpo e no header x-request-id. Grave esse valor no log quando der erro. Ele localiza a chamada em Integrações > Logs da API, que guarda status, duração e código de erro de cada requisição autenticada por 30 dias. Os códigos possíveis estão em erros da API Pix.
Criar a cobrança Pix
<?php
require __DIR__ . '/xflow.php';
$pedidoId = '1234';
$cobranca = xflow('POST', '/charges', [
'amountCents' => 1990, // R$ 19,90
'customer' => [
'name' => 'Cliente Exemplo',
'email' => 'cliente@exemplo.com',
'document' => '00000000000', // CPF ou CNPJ, só dígitos
],
'description' => "Pedido #{$pedidoId}",
'external_reference' => "pedido-{$pedidoId}",
], "pedido-{$pedidoId}");
echo $cobranca['id'] . ' ' . $cobranca['status']; // clx... pendingO último argumento é a Idempotency-Key. Use uma por pedido: se a requisição cair e você repetir, a API devolve a mesma cobrança em vez de criar outro Pix. Reusar a chave com outro corpo dá 409 idempotency_conflict. O tema completo está em idempotência em API de pagamento.
Grave o id da cobrança junto do pedido. É com ele que você consulta o status em GET /api/v1/charges/{id} e, se precisar devolver o dinheiro, pede o estorno em POST /api/v1/charges/{id}/refund, como mostra o guia de estorno Pix por API.
O external_reference (até 120 caracteres) é o id do pedido no seu sistema. Ele volta em toda resposta e webhook da cobrança e pode ser usado como filtro na listagem. Se o valor estiver fora do mínimo ou do máximo da sua conta, a resposta é 400 validation.
Exibir o QR Code e o copia e cola
<img src="<?= htmlspecialchars($cobranca['pix']['qrCodeBase64']) ?>" alt="QR Code Pix" width="240" height="240">
<textarea readonly><?= htmlspecialchars($cobranca['pix']['copyPaste']) ?></textarea>pix.qrCodeBase64 é um PNG em data-URL, pronto para o src da imagem. pix.copyPaste é o payload BR Code que o pagador cola no app do banco. Não altere esse texto. Os dois campos vêm na criação e em GET /api/v1/charges/{id}, mas não na listagem. Veja também Pix copia e cola por API.
Receber o webhook
Cadastre a URL em Integrações > 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.
<?php
// webhook.php
$raw = file_get_contents('php://input');
$assinatura = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$esperado = 'sha256=' . hash_hmac('sha256', $raw, getenv('XFLOW_WEBHOOK_SECRET'));
if (!hash_equals($esperado, $assinatura)) {
http_response_code(401);
exit;
}
$evento = json_decode($raw, true);
$data = $evento['data'];
try {
if ($evento['event'] === 'transaction.paid' && $data['livemode'] === true) {
darBaixaNoPedido($data['external_reference'], $data['pix']['endToEnd']); // sua função
}
http_response_code(200);
} catch (Throwable $e) {
http_response_code(500); // sem 2xx, a entrega é reenviada
}O que esse script faz e por quê:
- Valida antes de decodificar. A assinatura é calculada sobre o texto exato recebido. Se você decodificar e codificar o JSON de novo, o HMAC muda. No Laravel, leia o corpo com
$request->getContent(). - Compara com
hash_equals, que leva o mesmo tempo para qualquer entrada. - Responde 2xx para confirmar. Sem isso, a xFlow reenvia até 3 vezes. Por isso
darBaixaNoPedidoprecisa ignorar pedido que já está pago. - Ignora
livemode: false, que marca eventos gerados com chave de teste.
No data do webhook, o valor vem em amount, e transaction.paid traz também net e fee. payer e pix.endToEnd vêm do PSP na confirmação e servem para conciliar com o extrato. Os outros eventos estão em webhook Pix.
Testar no sandbox
Com a chave de teste nas variáveis, o código é o mesmo. As respostas trazem livemode: false, nenhum provedor Pix é chamado e o pix.copyPaste é um marcador que não pode ser pago. Para simular o pagamento:
<?php
require __DIR__ . '/xflow.php';
$teste = xflow('POST', '/charges', [
'amountCents' => 1990,
'customer' => ['name' => 'Cliente', 'email' => 'c@exemplo.com', 'document' => '00000000000'],
]);
xflow('POST', "/test/charges/{$teste['id']}/pay");
$paga = xflow('GET', "/charges/{$teste['id']}");
echo $paga['status']; // paidA cobrança vira paid, a taxa sai das taxas reais da sua conta e o webhook transaction.paid chega com livemode: false. Com chave de produção, a simulação responde 403 test_mode_only. Saques não funcionam em teste e retornam 403 test_mode_unsupported. Mais detalhes em sandbox da API Pix.
Conciliar as cobranças pagas
Para fechar o dia, liste as cobranças pagas de um período. A listagem usa paginação por cursor: limit vai de 1 a 100 e, enquanto has_more for true, você repete a chamada com starting_after igual ao id do último item.
<?php
require __DIR__ . '/xflow.php';
function cobrancasPagas(string $de, string $ate): Generator
{
$cursor = null;
do {
$query = http_build_query(array_filter([
'status' => 'paid', 'from' => $de, 'to' => $ate, 'limit' => 100, 'starting_after' => $cursor,
]));
$pagina = xflow('GET', '/charges?' . $query);
yield from $pagina['data'];
$cursor = end($pagina['data'])['id'] ?? null;
} while ($pagina['has_more'] && $cursor !== null);
}
foreach (cobrancasPagas('2026-10-01', '2026-10-31') as $c) {
echo $c['external_reference'] . ' ' . $c['amountCents'] . PHP_EOL;
}Cada item traz payer e pix.endToEnd, os campos que a documentação indica para bater o recebimento com o extrato bancário.
Antes de ir para produção
- Troque as chaves de teste pelas de produção (
pk_live_/sk_live_) só no servidor de produção. - Cadastre a URL de produção do webhook e guarde o segredo do endpoint.
- Mantenha o filtro que ignora
livemode: false. - Grave o
request_idde toda resposta com erro. - Se quiser, ative a allowlist de IP em Chaves de API. Requisições de fora da lista recebem
401.
Erros que aparecem primeiro
| HTTP | Código | O que fazer |
|---|---|---|
| 401 | unauthorized |
Confira as chaves. Se usa allowlist de IP, confira se o IP do servidor está na lista. |
| 400 | validation |
Corrija o corpo. O campo details aponta o problema. |
| 403 | account_inactive |
A conta está em análise ou bloqueada. Use a chave de teste enquanto isso. |
| 409 | idempotency_conflict |
Use uma Idempotency-Key nova para um pedido novo. |
| 429 | rate_limited |
Espere os segundos do header retry-after e tente de novo com backoff. |
Prefira o webhook para saber que a cobrança foi paga. O polling de GET /api/v1/charges/{id} até o status virar paid fica como alternativa. O mesmo fluxo em outras linguagens está em API Pix em Node.js e API Pix em Python.
Perguntas frequentes
Preciso de biblioteca para usar a API Pix em PHP?
Não. A extensão cURL do PHP basta. A API da xFlow é REST com JSON e autenticação HTTP Basic: usuário é a chave pública pk_live_ e senha é o segredo sk_live_.
Como ler o corpo cru do webhook em PHP?
Com file_get_contents('php://input'). Calcule o HMAC-SHA256 desse texto com o segredo do endpoint, prefixe com sha256= e compare com o header x-webhook-signature usando hash_equals. No Laravel, o corpo cru vem de $request->getContent().
Como testar a API Pix em PHP sem pagar de verdade?
Use uma chave de teste (pk_test_/sk_test_), crie a cobrança normalmente e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook transaction.paid chega com livemode: false.
Por que recebo 409 idempotency_conflict?
Porque a Idempotency-Key enviada já foi usada com um corpo diferente. A mesma chave só serve para repetir o mesmo pedido. Para uma cobrança nova, gere uma chave nova.