Generate a Brazilian invoice PDF in PHP
Raw cURL and Guzzle, both correct — including the output-buffering mistake that inserts a stray newline into your PDF and makes it unopenable.
PHP is still where an enormous amount of Brazilian billing software lives, and it is also the
language where "download a binary file" goes wrong most often — not because cURL is hard, but because
a single blank line outside a <?php tag ends up inside the response body.
Before you write any of this, it is worth knowing which document you are actually producing: a fatura and a recibo are private commercial documents that software can generate freely, while an NF-e or NFS-e cannot be produced without a digital certificate. The document types are explained here.
This guide gives you a working client in raw cURL (no dependencies, works on any shared host) and in Guzzle, then covers the PHP-specific ways a valid PDF gets mangled on its way to the user.
Requirements
- PHP 7.4+ with
ext-curlandext-json(both standard). - A key from the RapidAPI listing — free tier, no card.
- Store it in the environment (
getenv('RAPIDAPI_KEY')) or in.envoutside the web root. Never in a file that Apache can serve as plain text if PHP fails to execute.
The request body
Every request is the same JSON document. The field names are in Portuguese because they
mirror the vocabulary printed on the PDF — emitente is the issuer, destinatario
is the customer, itens are the line items:
<?php
$payload = [
'tipo' => 'fatura', // or 'recibo'
'numero' => '0001/2026',
'data' => '2026-08-07',
'vencimento' => '2026-08-22',
'emitente' => [
'nome' => 'Atlas Soluções Digitais LTDA',
'documento' => '11.222.333/0001-81', // check digits are verified
'endereco' => 'Av. Paulista, 1000 - São Paulo/SP',
],
'destinatario' => [
'nome' => 'Comércio Silva & Filhos ME',
'documento' => '22.333.444/0001-81',
],
'itens' => [
['descricao' => 'Consultoria de implementação', 'quantidade' => 20, 'valor_unitario' => 250],
['descricao' => 'Licença mensal', 'quantidade' => 1, 'valor_unitario' => 349.90],
],
'desconto' => 100,
'forma_pagamento' => 'PIX',
'observacoes' => 'Pagamento em até 10 dias.',
];Not required — escaped \u00e7 decodes to the same character server-side — but it keeps your
logs and debugging readable, and it makes the payload smaller. What is required: your PHP source
files must actually be UTF-8. A file saved as ISO-8859-1 produces
json_encode(): Malformed UTF-8 and json_encode returns false, which then
posts an empty body and yields a confusing 400.
| Field | Required | What it does |
|---|---|---|
tipo | no | "fatura" (invoice, default) or "recibo" (receipt). Changes the whole layout, not just a label. |
numero | no | Your document number, free text — e.g. "0001/2026". |
data | no | ISO YYYY-MM-DD or DD/MM/YYYY. Omitted = today. Always printed as DD/MM/YYYY. |
vencimento | no | Due date, same formats. Only makes sense on fatura. |
emitente.nome | yes | Issuer legal name. |
emitente.documento | yes | Issuer CPF or CNPJ. Check digits are verified — a wrong one returns 400, it is not silently printed. |
destinatario.nome | yes | Customer name. |
destinatario.documento | no | Customer CPF/CNPJ. Optional, but validated when present. |
itens[].descricao | yes | Line description, up to 200 chars. |
itens[].quantidade | no | Defaults to 1. |
itens[].valor_unitario | yes | Unit price as a number. Do not send totals — the API computes subtotal and total in integer cents. |
desconto | no | Flat discount subtracted from the subtotal. |
forma_pagamento | no | Free text, e.g. "PIX", "Boleto". |
observacoes | no | Notes block at the bottom, up to 2000 chars. |
pix_copia_cola | no | A PIX BR Code string you already generated; the API renders it as a scannable QR on the PDF. |
mostrar_valor_por_extenso | no | true prints the total spelled out in Portuguese. Automatic on receipts. |
Minimal working example (raw cURL)
<?php
declare(strict_types=1);
const RAPIDAPI_HOST = 'brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com';
class InvoiceApiException extends RuntimeException
{
public function __construct(public readonly int $status, public readonly string $code, string $message)
{
parent::__construct("[{$status}] {$code}: {$message}");
}
}
/**
* @return string raw PDF bytes
* @throws InvoiceApiException
*/
function generateInvoice(array $payload, int $timeout = 15): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new InvoiceApiException(0, 'encode_failed', json_last_error_msg());
}
$ch = curl_init('https://' . RAPIDAPI_HOST . '/invoice');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_RETURNTRANSFER => true, // return the body instead of echoing it
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-RapidAPI-Key: ' . getenv('RAPIDAPI_KEY'),
'X-RapidAPI-Host: ' . RAPIDAPI_HOST,
],
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FAILONERROR => false, // keep the body on 4xx so we can read the message
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new InvoiceApiException(0, 'network_error', $curlErr);
}
if ($status !== 200) {
$detail = json_decode($body, true) ?: [];
throw new InvoiceApiException(
$status,
$detail['error'] ?? 'http_error',
$detail['message'] ?? substr($body, 0, 300)
);
}
return $body; // binary-safe: PHP strings are byte arrays
}
$pdf = generateInvoice($payload);
file_put_contents(__DIR__ . '/fatura.pdf', $pdf);
echo 'wrote fatura.pdf (' . strlen($pdf) . " bytes)\n";With it on, cURL returns false on any 4xx and discards the response body — throwing away
the invalid_params message that tells you exactly which field is wrong. Read the status
yourself with CURLINFO_HTTP_CODE instead.
A successful response is binary, an error response is JSON. If you decode the
response as text before checking the status code, a valid PDF becomes an unopenable file — and the real
error message gets thrown away. Always branch on the status code (or on Content-Type) first,
then read bytes or JSON accordingly. Every example below does this.
Reading the errors
| Status | error | Cause & fix |
|---|---|---|
| 200 | — | Body is raw PDF bytes, Content-Type: application/pdf. |
| 400 | invalid_json | Body was not parseable JSON. Usually a serialization bug on your side. |
| 400 | invalid_params | The useful one. message lists every problem at once, semicolon-separated — including which CPF/CNPJ failed its check digit. |
| 401 | unauthorized | The request did not arrive through the RapidAPI gateway. Check X-RapidAPI-Key and X-RapidAPI-Host. |
| 405 | method_not_allowed | You sent GET. Both endpoints are POST-only. |
| 413 | payload_too_large | Body over ~200 KB, or more than 200 line items. |
| 429 | — | Returned by the RapidAPI gateway when your plan quota is exhausted, before the API is reached. |
| 500 / 504 | render_failed / render_timeout | Rendering failed or exceeded the limit. Safe to retry. |
try {
$pdf = generateInvoice($payload);
} catch (InvoiceApiException $e) {
if ($e->code === 'invalid_params') {
// One message, every problem, separated by "; "
foreach (explode('; ', $e->getMessage()) as $problem) {
error_log('invoice field error: ' . $problem);
}
http_response_code(422);
exit(json_encode(['errors' => explode('; ', $e->getMessage())]));
}
if ($e->status === 429) {
error_log('RapidAPI quota exhausted');
}
throw $e;
}Streaming the PDF to the browser
This is where PHP bites. The rules: send no output before the headers, clear any buffer, set an
accurate Content-Length, and exit immediately after.
<?php
$pdf = generateInvoice($payload);
// Discard anything already buffered (a stray echo, a BOM, a warning).
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="fatura-0001-2026.pdf"');
header('Content-Length: ' . strlen($pdf));
header('Cache-Control: private, max-age=0, must-revalidate');
header('X-Content-Type-Options: nosniff');
echo $pdf;
exit; // nothing after this - not even a closing PHP tag?> at the end of a PHP file
Any whitespace or newline after the closing tag is emitted as part of the response body. On an HTML page nobody notices; prepended to a PDF it shifts every byte offset and the file will not open. PSR-12 requires omitting the closing tag in pure-PHP files precisely because of this class of bug. Same reason to watch for a UTF-8 BOM saved by an editor — three invisible bytes, one corrupted PDF.
Diagnosing a "corrupted" PDF in one command
# A healthy file starts with %PDF- at byte 0.
head -c 20 fatura.pdf | xxd | head -2
# 00000000: 2550 4446 2d31 2e37 ... -> "%PDF-1.7" OK
# 00000000: 0a25 5044 462d 312e ... -> leading 0x0a = stray newline BEFORE the PDFThe same thing with Guzzle
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
function invoiceClient(): Client
{
$stack = HandlerStack::create();
// Retry transient failures only - never a 400.
$stack->push(Middleware::retry(
function (int $retries, RequestInterface $req, $response = null, $err = null): bool {
if ($retries >= 3) return false;
if ($err !== null) return true; // connection/timeout
return in_array($response?->getStatusCode(), [500, 502, 503, 504], true);
},
fn (int $retries): int => 500 * (2 ** $retries) // 500ms, 1s, 2s
));
return new Client([
'base_uri' => 'https://' . RAPIDAPI_HOST,
'handler' => $stack,
'timeout' => 15,
'headers' => [
'X-RapidAPI-Key' => getenv('RAPIDAPI_KEY'),
'X-RapidAPI-Host' => RAPIDAPI_HOST,
],
'http_errors' => false, // same reasoning as CURLOPT_FAILONERROR
]);
}
function generateInvoiceGuzzle(array $payload): string
{
$res = invoiceClient()->post('/invoice', ['json' => $payload]);
$body = (string) $res->getBody();
if ($res->getStatusCode() !== 200) {
$detail = json_decode($body, true) ?: [];
throw new InvoiceApiException(
$res->getStatusCode(),
$detail['error'] ?? 'http_error',
$detail['message'] ?? substr($body, 0, 300)
);
}
return $body;
}Guzzle's 'json' => option encodes with JSON_UNESCAPED_SLASHES and sets the
content type for you. Casting getBody() to string is binary-safe — PHP strings are byte
arrays, not encoded text, which is the one place PHP is friendlier than Python here.
Validating CPF/CNPJ in PHP
<?php
function onlyDigits(?string $value): string
{
return preg_replace('/\D/', '', (string) $value);
}
function checkDigit(array $nums, array $weights): int
{
$sum = 0;
foreach ($weights as $i => $w) {
$sum += $nums[$i] * $w;
}
$rest = $sum % 11;
return $rest < 2 ? 0 : 11 - $rest; // NOT 11 - $rest when $rest is 0 or 1
}
function isValidCpf(?string $value): bool
{
$d = onlyDigits($value);
if (strlen($d) !== 11) return false;
if (preg_match('/^(\d)\1{10}$/', $d)) return false; // 111.111.111-11
$n = array_map('intval', str_split($d));
return checkDigit($n, range(10, 2)) === $n[9]
&& checkDigit($n, range(11, 2)) === $n[10];
}
function isValidCnpj(?string $value): bool
{
$d = onlyDigits($value);
if (strlen($d) !== 14) return false;
if (preg_match('/^(\d)\1{13}$/', $d)) return false;
$n = array_map('intval', str_split($d));
$w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
$w2 = array_merge([6], $w1);
return checkDigit($n, $w1) === $n[12] && checkDigit($n, $w2) === $n[13];
}
function isValidTaxId(?string $value): bool
{
return strlen(onlyDigits($value)) === 11 ? isValidCpf($value) : isValidCnpj($value);
}range(10, 2) produces [10, 9, 8, 7, 6, 5, 4, 3, 2] — nine descending weights for the
first CPF check digit — and checkDigit stops at count($weights), so the extra array
elements are ignored. Full derivation in
the check-digit guide.
PHP-specific traps
1. Output before headers
Cannot modify header information - headers already sent by ... means something echoed
first. The message names the file and line. Common culprits: a BOM, a closing ?>, or a
var_dump left in a bootstrap file.
2. Deprecation warnings inside the body
On PHP 8.x with display_errors=On in production, a deprecation notice is printed into the
response body — including the body of your PDF download. Turn display_errors off and log
instead. This is the second most common cause of "the API returned a corrupt file" that turns out not
to be the API at all.
3. max_execution_time on shared hosts
Batch-generating 200 invoices in one request will hit the 30-second default. Push generation into a queue worker (Symfony Messenger, Laravel queues) rather than raising the limit.
4. Old CURLOPT_BINARYTRANSFER
Deprecated since PHP 5.1.3 and removed in PHP 8 — it has had no effect for years.
CURLOPT_RETURNTRANSFER already returns raw bytes. If a tutorial tells you to set it, the
tutorial is old enough to be wrong about other things too.
Using Laravel?
The Http facade, a dedicated service class, queued generation and Blade download responses are
covered separately in the Laravel guide.
Frequently asked questions
My PDF downloads but will not open. What is wrong?
Almost certainly stray output before the PDF bytes. Run head -c 20 file.pdf | xxd — a healthy file starts with %PDF- at byte 0. If you see other bytes first, look for a closing ?> tag with trailing whitespace, a UTF-8 BOM, or a PHP warning being printed into the body.
Does this work on shared hosting?
Yes. The raw cURL version has no Composer dependency and needs only ext-curl, which is enabled on essentially every shared host. Watch max_execution_time if you generate documents in bulk.
Should I use cURL or Guzzle?
Guzzle if the project already has it — you get retry middleware, PSR-7 and connection pooling for free. Raw cURL if you are adding a single feature to a legacy codebase and do not want a Composer dependency. Both are shown above and behave identically.
Can I generate the PDF and email it with PHPMailer?
Yes. You hold the bytes in a string, so $mail->addStringAttachment($pdf, 'fatura.pdf', 'base64', 'application/pdf') works directly — no temp file needed.
Generate these PDFs from your own code
FaturaPDF turns a JSON payload into a ready-to-send Brazilian invoice or receipt PDF — validated CPF/CNPJ, R$ formatting, amount in words, optional PIX QR. Free tier: 20 documents/month, no credit card.
Get an API key on RapidAPI → Or try the free browser generator