Integration guide · PHP

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.

Updated 2026-08-07 · 8 min read · by the FaturaPDF team

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-curl and ext-json (both standard).
  • A key from the RapidAPI listing — free tier, no card.
  • Store it in the environment (getenv('RAPIDAPI_KEY')) or in .env outside 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:

phppayload.php
<?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.',
];
Encode with JSON_UNESCAPED_UNICODE

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.

FieldRequiredWhat it does
tipono"fatura" (invoice, default) or "recibo" (receipt). Changes the whole layout, not just a label.
numeronoYour document number, free text — e.g. "0001/2026".
datanoISO YYYY-MM-DD or DD/MM/YYYY. Omitted = today. Always printed as DD/MM/YYYY.
vencimentonoDue date, same formats. Only makes sense on fatura.
emitente.nomeyesIssuer legal name.
emitente.documentoyesIssuer CPF or CNPJ. Check digits are verified — a wrong one returns 400, it is not silently printed.
destinatario.nomeyesCustomer name.
destinatario.documentonoCustomer CPF/CNPJ. Optional, but validated when present.
itens[].descricaoyesLine description, up to 200 chars.
itens[].quantidadenoDefaults to 1.
itens[].valor_unitarioyesUnit price as a number. Do not send totals — the API computes subtotal and total in integer cents.
descontonoFlat discount subtracted from the subtotal.
forma_pagamentonoFree text, e.g. "PIX", "Boleto".
observacoesnoNotes block at the bottom, up to 2000 chars.
pix_copia_colanoA PIX BR Code string you already generated; the API renders it as a scannable QR on the PDF.
mostrar_valor_por_extensonotrue prints the total spelled out in Portuguese. Automatic on receipts.

Minimal working example (raw cURL)

phpinvoice.php
<?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";
CURLOPT_FAILONERROR must stay off

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.

The one mistake everybody makes

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

StatuserrorCause & fix
200Body is raw PDF bytes, Content-Type: application/pdf.
400invalid_jsonBody was not parseable JSON. Usually a serialization bug on your side.
400invalid_paramsThe useful one. message lists every problem at once, semicolon-separated — including which CPF/CNPJ failed its check digit.
401unauthorizedThe request did not arrive through the RapidAPI gateway. Check X-RapidAPI-Key and X-RapidAPI-Host.
405method_not_allowedYou sent GET. Both endpoints are POST-only.
413payload_too_largeBody over ~200 KB, or more than 200 line items.
429Returned by the RapidAPI gateway when your plan quota is exhausted, before the API is reached.
500 / 504render_failed / render_timeoutRendering failed or exceeded the limit. Safe to retry.
phphandle-errors.php
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.

phpdownload.php
<?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
Never write ?> 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

bashshell
# 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 PDF

The same thing with Guzzle

phpGuzzleInvoiceClient.php
<?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

phpTaxId.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

Related guides