No Laravel, a API Pix se integra com a Http facade, sem pacote extra: uma chamada cria a cobrança, o Blade mostra o QR Code e o copia e cola, e uma rota recebe o webhook que confirma o pagamento. O código abaixo segue a documentação da API, usa Laravel 11 ou superior e a base https://app.xflowpayments.com/api/v1. Se você usa PHP puro, o guia é o de API Pix em PHP. Para a visão geral, veja o guia da API Pix.
O que você precisa
- Uma chave gerada 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, uma chave do ambiente Teste (
pk_test_...esk_test_...), que pode ser criada antes da aprovação do cadastro. - As credenciais no
.enve expostas emconfig/services.php:
// config/services.php
'xflow' => [
'public_key' => env('XFLOW_PUBLIC_KEY'),
'secret_key' => env('XFLOW_SECRET_KEY'),
'webhook_secret' => env('XFLOW_WEBHOOK_SECRET'),
],No resto do código, leia com config('services.xflow...'), nunca com env(): depois de php artisan config:cache, o env() fora dos arquivos de configuração retorna null. O segredo não vai para o repositório nem para o front-end.
Um cliente com a Http facade
A autenticação é HTTP Basic, com a chave pública como usuário e o segredo como senha. Valores monetários vão em centavos, como inteiros. Uma classe pequena guarda a configuração:
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class Xflow
{
public static function http(): PendingRequest
{
return Http::baseUrl('https://app.xflowpayments.com/api/v1')
->withBasicAuth(config('services.xflow.public_key'), config('services.xflow.secret_key'))
->acceptJson()
->timeout(30);
}
}Como Xflow::http() devolve um PendingRequest novo a cada chamada, um header que você acrescenta numa cobrança, como a Idempotency-Key, não vaza para a próxima requisição.
Criar a cobrança Pix
use App\Services\Xflow;
$pedido = Pedido::findOrFail($pedidoId); // seu model
$cobranca = Xflow::http()
->withHeaders(['Idempotency-Key' => "pedido-{$pedido->id}"])
->post('/charges', [
'amountCents' => $pedido->total_centavos, // inteiro: R$ 19,90 = 1990
'customer' => [
'name' => $pedido->cliente_nome,
'email' => $pedido->cliente_email,
'document' => $pedido->cliente_documento, // CPF ou CNPJ, só dígitos
],
'description' => "Pedido #{$pedido->id}",
'external_reference' => "pedido-{$pedido->id}",
])
->throw()
->json();
$pedido->update(['xflow_charge_id' => $cobranca['id']]);O que cada parte resolve:
Idempotency-Keypor pedido. Se a requisição cair e você repetir, a API devolve a mesma cobrança em vez de gerar outro Pix. A mesma chave com outro corpo dá409 idempotency_conflict. Detalhes em idempotência em API de pagamento.throw()transforma qualquer 4xx ou 5xx emRequestException, tratada mais abaixo.external_reference(até 120 caracteres) liga a cobrança ao pedido. Ele volta em toda resposta e webhook e filtra a listagem. Valor fora do mínimo ou do máximo da conta retorna400 validation.
Guarde o id da cobrança. Com ele você consulta o status e, se precisar, pede o estorno Pix por API.
Mostrar o QR Code no Blade
pix.qrCodeBase64 é um PNG em data-URL, pronto para o src:
<img src="{{ $cobranca['pix']['qrCodeBase64'] }}" alt="QR Code Pix" width="240" height="240">
<textarea readonly>{{ $cobranca['pix']['copyPaste'] }}</textarea>O {{ }} do Blade escapa o HTML e não altera o data-URL. pix.copyPaste é o código BR Code que o pagador cola no app do banco: repasse sem mudar nada. Os dois campos vêm na criação e em GET /api/v1/charges/{id}, mas não na listagem.
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.
// routes/web.php
Route::post('/webhooks/xflow', XflowWebhookController::class);
// bootstrap/app.php: a xFlow não envia token de CSRF
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/xflow']);
})No Laravel 10, o mesmo caminho vai no $except de App\Http\Middleware\VerifyCsrfToken. Outra saída é registrar a rota em routes/api.php, que não passa pelo CSRF e ganha o prefixo /api.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessarPagamentoXflow;
use Illuminate\Http\Request;
class XflowWebhookController
{
public function __invoke(Request $request)
{
$raw = $request->getContent(); // corpo cru, antes de qualquer parse
$esperado = 'sha256=' . hash_hmac('sha256', $raw, config('services.xflow.webhook_secret'));
if (! hash_equals($esperado, (string) $request->header('x-webhook-signature'))) {
abort(401);
}
$evento = json_decode($raw, true);
if ($evento['event'] === 'transaction.paid' && $evento['data']['livemode'] === true) {
ProcessarPagamentoXflow::dispatch($evento['data']);
}
return response()->noContent(); // 204 confirma a entrega
}
}Os cuidados:
- Use
$request->getContent().$request->all()ou$request->json()devolvem o JSON já convertido, e recodificar muda os bytes do HMAC. hash_equalscompara em tempo constante. Não use===para assinatura.- Responda 2xx. Sem isso, a xFlow reenvia até 3 vezes.
- Ignore
livemode: false, que marca eventos gerados com chave de teste.
Fila opcional
O job deixa a resposta rápida e isola o trabalho pesado:
<?php
namespace App\Jobs;
use App\Models\Pedido;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessarPagamentoXflow implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(public array $data) {}
public function handle(): void
{
// Só muda pedido que ainda não está pago: o mesmo evento pode chegar duas vezes.
Pedido::where('referencia', $this->data['external_reference'])
->where('status', '!=', 'pago')
->update(['status' => 'pago', 'end_to_end' => $this->data['pix']['endToEnd']]);
}
}Ajuste os nomes das colunas ao seu banco. Repare na troca de responsabilidade: depois do 204, a xFlow considera o evento entregue. Se o job falhar, quem tenta de novo é a sua fila, até o $tries. Sem fila, chame o mesmo código direto no controller. Os outros eventos estão em webhook Pix.
Testar no sandbox
Troque o .env para a chave de teste e rode no php artisan tinker:
$teste = Xflow::http()->post('/charges', [
'amountCents' => 1990,
'customer' => ['name' => 'Cliente', 'email' => 'c@exemplo.com', 'document' => '00000000000'],
])->throw()->json();
Xflow::http()->post("/test/charges/{$teste['id']}/pay")->throw();
$paga = Xflow::http()->get("/charges/{$teste['id']}")->throw()->json();
dump($paga['status'], $paga['livemode']); // "paid", falseA 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 rodam em teste (403 test_mode_unsupported). Mais em sandbox da API Pix.
Nos testes automatizados, Http::fake() substitui a API, e a rota do webhook se testa com um corpo assinado por você:
it('aceita webhook assinado', function () {
config(['services.xflow.webhook_secret' => 'segredo-de-teste']);
$corpo = json_encode(['event' => 'transaction.pending', 'data' => ['livemode' => false]]);
$assinatura = 'sha256=' . hash_hmac('sha256', $corpo, 'segredo-de-teste');
$this->call('POST', '/webhooks/xflow', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => $assinatura,
], $corpo)->assertNoContent();
});Erros e limite de requisições
Com throw(), os erros chegam como RequestException, com a resposta em $e->response:
try {
$cobranca = Xflow::http()
->withHeaders(['Idempotency-Key' => "pedido-{$pedido->id}"])
->post('/charges', $dados)
->throw()
->json();
} catch (RequestException $e) {
Log::warning('xFlow recusou a chamada', [
'status' => $e->response->status(),
'erro' => $e->response->json('error'),
'request_id' => $e->response->json('request_id'),
]);
throw $e;
}O request_id localiza a chamada em Integrações > Logs da API, que guarda cada requisição autenticada por 30 dias. Os códigos que aparecem primeiro:
401 unauthorized: chave inválida ou revogada, ou IP fora da allowlist.403 account_inactive: conta em análise ou bloqueada. Siga com a chave de teste.429 rate_limited: espere os segundos de$e->response->header('Retry-After')e repita com a mesmaIdempotency-Key. O limite é por conta, e toda resposta autenticada trazx-ratelimit-remaining.
A lista completa está em erros da API Pix.
Antes de ir para produção
- Troque as chaves de teste por
pk_live_/sk_live_só no.envde produção e rodephp artisan config:cache. - Cadastre a URL de produção do webhook e guarde o segredo do endpoint.
- Confirme que a rota do webhook está fora do CSRF e que o worker da fila está rodando.
- Mantenha o filtro de
livemodee a baixa idempotente. - Se quiser, ative a allowlist de IP em Chaves de API. Fora da lista, a resposta é
401.
Prefira o webhook ao polling de GET /api/v1/charges/{id}. Chaves, logs e os outros guias estão na página Desenvolvedores.
Perguntas frequentes
Por que o webhook Pix retorna 419 no Laravel?
Porque a rota está em routes/web.php e passa pela verificação de CSRF, que recusa POST sem token. Exclua o caminho em bootstrap/app.php com validateCsrfTokens(except: ['webhooks/xflow']), no Laravel 11 ou superior, ou registre a rota em routes/api.php.
Preciso de pacote para usar a API Pix no Laravel?
Não. A Http facade, que já vem no Laravel, faz as chamadas com withBasicAuth. A API da xFlow é REST com JSON, valores em centavos e autenticação HTTP Basic com a chave pública e o segredo.
Devo processar o webhook Pix numa fila?
É opcional. Validar a assinatura na hora e mandar o trabalho para um job deixa a resposta rápida. Depois que você responde 2xx, a xFlow não reenvia o evento, então as novas tentativas passam a ser da sua fila, configuradas com $tries.
Como testar a API Pix no Laravel sem pagar?
Com chaves de teste (pk_test_/sk_test_), crie a cobrança e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook chega com livemode: false. Nos testes automatizados, use Http::fake para não chamar a API.