May 15, 2026

PDF Merge API Guide: Combine Multiple PDFs in One API Call

PDF mergePDF APIcombine PDFNode.jsPython

Bundle invoices into a monthly report. Assemble a multi-document contract package. Archive a batch of event certificates into a single downloadable PDF. PDF merging is a common requirement across web apps, back-office automation, and SaaS platforms.

The FUNBREW PDF merge API (POST /api/pdf/merge) combines 2–50 PDF files into one with a single API call and returns a timed download URL. No local library (PyPDF2, iText, etc.) required — just HTTP.

This guide covers the full API specification, working code examples in cURL, Node.js, Python, and PHP, best practices for high-volume merging, and common failure modes.

For the full PDF generation pipeline (HTML to PDF, batch processing, S3 archival), see the PDF Batch Processing Guide. For certificate automation, see the PDF Certificate Automation Guide.

API Specification

FUNBREW PDF provides two endpoints for merging PDFs.

Endpoint 1: Merge server-side files

POST /api/pdf/merge
Content-Type: application/json
Authorization: Bearer {API key}

Request body

Parameter Type Required Description
filenames string[] Yes Array of filenames to merge (2–50). These are files previously stored on the FUNBREW PDF server.
expiration_hours integer No Lifetime of the merged file in hours (0–168, default 24)
max_downloads integer No Maximum downloads before expiry (0–100, default 10)
watermark string No Watermark text to apply (max 255 characters)

Success response

{
  "success": true,
  "data": {
    "filename": "merged-abc123.pdf",
    "download_url": "https://pdf.funbrew.cloud/api/pdf/download/merged-abc123.pdf",
    "file_size": 204800,
    "expires_at": "2026-05-16T10:00:00Z"
  }
}

Endpoint 2: Upload and merge local files

POST /api/pdf/merge-upload
Content-Type: multipart/form-data
Authorization: Bearer {API key}

Request parameters

Parameter Type Required Description
files[] file No PDF files to upload (max 50 MB each, up to 50 files)
filenames[] string[] No Existing server filenames to include alongside uploaded files
expiration_hours integer No Lifetime in hours (0–168, default 24)
max_downloads integer No Maximum downloads (0–100, default 10)
watermark string No Watermark text

The combined count of files[] and filenames[] must be at least 2.

Error responses

HTTP Status Cause
401 Unauthorized Invalid or missing API key
403 Forbidden pdf.feature:merge not enabled on your plan
422 Unprocessable Entity File not found, expired, or encrypted

Code Examples

cURL (merge server-side files)

Generate PDFs first, then merge them using the returned filenames.

# Step 1: Generate the first PDF (capture the filename)
RESPONSE=$(curl -s -X POST https://pdf.funbrew.cloud/api/pdf/generate \
  -H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #001</h1><p>May 2026</p>",
    "options": { "format": "A4", "responseFormat": "url" }
  }')
FILE1=$(echo $RESPONSE | jq -r '.data.filename')

# Step 2: Generate the second PDF
RESPONSE=$(curl -s -X POST https://pdf.funbrew.cloud/api/pdf/generate \
  -H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #002</h1><p>May 2026</p>",
    "options": { "format": "A4", "responseFormat": "url" }
  }')
FILE2=$(echo $RESPONSE | jq -r '.data.filename')

# Step 3: Merge both PDFs
curl -s -X POST https://pdf.funbrew.cloud/api/pdf/merge \
  -H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"filenames\": [\"$FILE1\", \"$FILE2\"],
    \"expiration_hours\": 48,
    \"max_downloads\": 5
  }" | jq .

cURL (upload and merge local files)

curl -s -X POST https://pdf.funbrew.cloud/api/pdf/merge-upload \
  -H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
  -F "files[]=@invoice-001.pdf" \
  -F "files[]=@invoice-002.pdf" \
  -F "files[]=@invoice-003.pdf" \
  -F "expiration_hours=72" \
  -F "max_downloads=10" | jq .

Node.js (merge server-side files)

const API_KEY  = process.env.FUNBREW_PDF_API_KEY;
const BASE_URL = "https://pdf.funbrew.cloud";

/**
 * Generate a PDF and return the server-side filename.
 */
async function generatePdf(html, options = {}) {
  const response = await fetch(`${BASE_URL}/api/pdf/generate`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format: "A4", ...options, responseFormat: "url" },
    }),
  });

  if (!response.ok) {
    throw new Error(`PDF generation failed: HTTP ${response.status}`);
  }

  const { data } = await response.json();
  return data.filename;
}

/**
 * Merge server-side PDF files by filename.
 */
async function mergePdfs(filenames, options = {}) {
  const response = await fetch(`${BASE_URL}/api/pdf/merge`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      filenames,
      expiration_hours: options.expirationHours ?? 24,
      max_downloads:    options.maxDownloads ?? 10,
      ...(options.watermark ? { watermark: options.watermark } : {}),
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`PDF merge failed: HTTP ${response.status} — ${body}`);
  }

  return response.json();
}

// Example: generate 3 invoices and merge them
async function generateAndMergeInvoices(invoices) {
  console.log(`Generating ${invoices.length} invoices...`);

  const filenames = await Promise.all(
    invoices.map((inv) =>
      generatePdf(`
        <div style="font-family: sans-serif; padding: 40px;">
          <h1>Invoice ${inv.number}</h1>
          <p>Client: ${inv.client}</p>
          <p>Amount: $${inv.amount.toLocaleString()}</p>
          <p>Date: ${inv.date}</p>
        </div>
      `)
    )
  );

  console.log(`Generated ${filenames.length} files. Merging...`);

  const result = await mergePdfs(filenames, {
    expirationHours: 48,
    maxDownloads: 5,
  });

  console.log("Merge complete:", result.data.download_url);
  return result.data;
}

// Run
generateAndMergeInvoices([
  { number: "#001", client: "Acme Corp",    amount: 15_000, date: "May 15, 2026" },
  { number: "#002", client: "Beta Ltd",     amount: 28_000, date: "May 15, 2026" },
  { number: "#003", client: "Gamma GmbH",   amount: 9_500,  date: "May 15, 2026" },
]).then(console.log).catch(console.error);

Node.js (upload local files)

const FormData = require("form-data");
const fs       = require("fs");
const path     = require("path");
const fetch    = require("node-fetch"); // node-fetch v2

async function mergeLocalPdfs(filePaths, options = {}) {
  const form = new FormData();

  for (const filePath of filePaths) {
    form.append("files[]", fs.createReadStream(filePath), {
      filename:    path.basename(filePath),
      contentType: "application/pdf",
    });
  }

  if (options.expirationHours !== undefined) {
    form.append("expiration_hours", String(options.expirationHours));
  }
  if (options.maxDownloads !== undefined) {
    form.append("max_downloads", String(options.maxDownloads));
  }
  if (options.watermark) {
    form.append("watermark", options.watermark);
  }

  const response = await fetch("https://pdf.funbrew.cloud/api/pdf/merge-upload", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
      ...form.getHeaders(),
    },
    body: form,
  });

  if (!response.ok) {
    throw new Error(`Merge upload failed: HTTP ${response.status}`);
  }

  return response.json();
}

// Example
mergeLocalPdfs(
  ["reports/january.pdf", "reports/february.pdf", "reports/march.pdf"],
  { expirationHours: 72, maxDownloads: 3, watermark: "CONFIDENTIAL" }
).then((result) => {
  console.log("Download URL:", result.data.download_url);
  console.log("File size:",   result.data.file_size, "bytes");
});

Python (merge server-side files)

import os
import httpx
from typing import Optional

API_KEY  = os.environ["FUNBREW_PDF_API_KEY"]
BASE_URL = "https://pdf.funbrew.cloud"


def generate_pdf(html: str, **options) -> str:
    """Generate a PDF and return the server-side filename."""
    response = httpx.post(
        f"{BASE_URL}/api/pdf/generate",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={
            "html": html,
            "options": {"format": "A4", **options, "responseFormat": "url"},
        },
        timeout=120,
    )
    response.raise_for_status()
    return response.json()["data"]["filename"]


def merge_pdfs(
    filenames: list[str],
    expiration_hours: int = 24,
    max_downloads: int = 10,
    watermark: Optional[str] = None,
) -> dict:
    """Merge server-side PDF files."""
    payload: dict = {
        "filenames":        filenames,
        "expiration_hours": expiration_hours,
        "max_downloads":    max_downloads,
    }
    if watermark:
        payload["watermark"] = watermark

    response = httpx.post(
        f"{BASE_URL}/api/pdf/merge",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json=payload,
        timeout=300,
    )
    response.raise_for_status()
    return response.json()


# Example: generate monthly reports and merge them
def bundle_monthly_reports(month: str, departments: list[dict]) -> str:
    """Generate per-department reports and bundle into a single PDF."""
    filenames = []

    for dept in departments:
        html = f"""
        <html>
        <body style="font-family: sans-serif; padding: 40px;">
          <h1>{dept['name']} Monthly Report</h1>
          <p>Period: {month}</p>
          <p>Revenue: ${dept['revenue']:,}</p>
          <p>Target achievement: {dept['achievement_rate']:.1%}</p>
        </body>
        </html>
        """
        filename = generate_pdf(html)
        filenames.append(filename)
        print(f"Generated: {dept['name']} → {filename}")

    print(f"Merging {len(filenames)} files...")
    result = merge_pdfs(filenames, expiration_hours=72, max_downloads=50)
    print(f"Merge complete: {result['data']['download_url']}")
    return result["data"]["download_url"]


# Run
departments = [
    {"name": "Sales",      "revenue": 125_000, "achievement_rate": 1.08},
    {"name": "Marketing",  "revenue": 52_000,  "achievement_rate": 0.95},
    {"name": "Engineering","revenue": 80_000,  "achievement_rate": 1.15},
]
download_url = bundle_monthly_reports("April 2026", departments)

Python (upload local files)

import os
import httpx


def merge_local_pdfs(
    file_paths: list[str],
    expiration_hours: int = 24,
    max_downloads: int = 10,
    watermark: str | None = None,
) -> dict:
    """Upload local PDF files and merge them."""
    files = [
        ("files[]", (os.path.basename(p), open(p, "rb"), "application/pdf"))
        for p in file_paths
    ]
    data: dict[str, str] = {
        "expiration_hours": str(expiration_hours),
        "max_downloads":    str(max_downloads),
    }
    if watermark:
        data["watermark"] = watermark

    response = httpx.post(
        "https://pdf.funbrew.cloud/api/pdf/merge-upload",
        headers={"Authorization": f"Bearer {os.environ['FUNBREW_PDF_API_KEY']}"},
        files=files,
        data=data,
        timeout=300,
    )
    response.raise_for_status()
    return response.json()


# Example
result = merge_local_pdfs(
    ["reports/q1.pdf", "reports/q2.pdf", "reports/q3.pdf"],
    expiration_hours=168,   # 7 days
    max_downloads=3,
    watermark="DRAFT",
)
print("Download URL:", result["data"]["download_url"])
print("File size:",   result["data"]["file_size"], "bytes")

PHP

<?php

class FunbrewPdfMerger
{
    private string $apiKey;
    private string $baseUrl = 'https://pdf.funbrew.cloud';

    public function __construct(string $apiKey)
    {
        $this->apiKey = $apiKey;
    }

    /**
     * Merge server-side PDF files.
     *
     * @param string[]    $filenames
     * @param int         $expirationHours
     * @param int         $maxDownloads
     * @param string|null $watermark
     */
    public function merge(
        array $filenames,
        int $expirationHours = 24,
        int $maxDownloads = 10,
        ?string $watermark = null
    ): array {
        $payload = [
            'filenames'        => $filenames,
            'expiration_hours' => $expirationHours,
            'max_downloads'    => $maxDownloads,
        ];
        if ($watermark !== null) {
            $payload['watermark'] = $watermark;
        }

        $ch = curl_init("{$this->baseUrl}/api/pdf/merge");
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload),
            CURLOPT_HTTPHEADER     => [
                "Authorization: Bearer {$this->apiKey}",
                'Content-Type: application/json',
            ],
            CURLOPT_TIMEOUT        => 300,
        ]);

        $body   = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($status !== 200) {
            throw new \RuntimeException("PDF merge failed: HTTP {$status} — {$body}");
        }

        return json_decode($body, true);
    }

    /**
     * Upload local PDF files and merge them.
     *
     * @param string[] $filePaths Local file paths
     */
    public function mergeUpload(
        array $filePaths,
        int $expirationHours = 24,
        int $maxDownloads = 10,
        ?string $watermark = null
    ): array {
        $postFields = [
            'expiration_hours' => $expirationHours,
            'max_downloads'    => $maxDownloads,
        ];
        if ($watermark !== null) {
            $postFields['watermark'] = $watermark;
        }

        foreach ($filePaths as $i => $path) {
            $postFields["files[{$i}]"] = new \CURLFile($path, 'application/pdf', basename($path));
        }

        $ch = curl_init("{$this->baseUrl}/api/pdf/merge-upload");
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $postFields,
            CURLOPT_HTTPHEADER     => ["Authorization: Bearer {$this->apiKey}"],
            CURLOPT_TIMEOUT        => 300,
        ]);

        $body   = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($status !== 200) {
            throw new \RuntimeException("PDF merge upload failed: HTTP {$status} — {$body}");
        }

        return json_decode($body, true);
    }
}

// Usage (Laravel)
$merger = new FunbrewPdfMerger(env('FUNBREW_PDF_API_KEY'));

$result = $merger->merge(
    filenames:       ['invoice-001.pdf', 'invoice-002.pdf', 'invoice-003.pdf'],
    expirationHours: 48,
    maxDownloads:    5
);

echo "Download URL: " . $result['data']['download_url'] . "\n";
echo "File size: "    . $result['data']['file_size']    . " bytes\n";

Use Case Patterns

Invoice bundle (end-of-month dispatch)

Generate all invoices for a billing period and merge them per client for a single-attachment email.

import asyncio
import aiohttp
import httpx

async def generate_invoice_async(
    session: aiohttp.ClientSession,
    invoice: dict,
    api_key: str,
) -> str:
    html = f"""
    <html>
    <body style="font-family: sans-serif; padding: 40px;">
      <h1>Invoice #{invoice['number']}</h1>
      <table>
        <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>
        {''.join(f"<tr><td>{i['name']}</td><td>{i['qty']}</td><td>${i['price']:,}</td></tr>" for i in invoice['items'])}
      </table>
      <p><strong>Total: ${invoice['total']:,}</strong></p>
    </body>
    </html>
    """
    async with session.post(
        "https://pdf.funbrew.cloud/api/pdf/generate",
        headers={"Authorization": f"Bearer {api_key}"},
        json={"html": html, "options": {"format": "A4", "responseFormat": "url"}},
        timeout=aiohttp.ClientTimeout(total=120),
    ) as resp:
        resp.raise_for_status()
        return (await resp.json())["data"]["filename"]


async def bundle_client_invoices(invoices: list[dict], api_key: str) -> str:
    """Generate all invoices and merge into one PDF."""
    async with aiohttp.ClientSession() as session:
        sem = asyncio.Semaphore(5)

        async def limited(inv):
            async with sem:
                return await generate_invoice_async(session, inv, api_key)

        filenames = await asyncio.gather(*[limited(inv) for inv in invoices])

    result = httpx.post(
        "https://pdf.funbrew.cloud/api/pdf/merge",
        headers={"Authorization": f"Bearer {api_key}"},
        json={"filenames": list(filenames), "expiration_hours": 72},
        timeout=300,
    )
    result.raise_for_status()
    return result.json()["data"]["download_url"]

Contract package assembly

Merge a main contract, memorandum, and appendices into a single document set.

async function createContractPackage(contractData) {
  const API_KEY = process.env.FUNBREW_PDF_API_KEY;

  const documents = [
    { html: buildMainContractHtml(contractData) },
    { html: buildMemorandumHtml(contractData) },
    { html: buildAppendixHtml(contractData) },
  ];

  const filenames = await Promise.all(
    documents.map(async (doc) => {
      const res = await fetch("https://pdf.funbrew.cloud/api/pdf/generate", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          html: doc.html,
          options: { format: "A4", responseFormat: "url" },
        }),
      });
      return (await res.json()).data.filename;
    })
  );

  // Merge into a single PDF (7-day expiry, 2 downloads — one for each party)
  const mergeRes = await fetch("https://pdf.funbrew.cloud/api/pdf/merge", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      filenames,
      expiration_hours: 168,
      max_downloads:    2,
      watermark:        "DRAFT",
    }),
  });

  return (await mergeRes.json()).data.download_url;
}

Quarterly report bundle

Merge monthly report PDFs into a quarterly summary on a scheduled basis.

from datetime import date

def merge_quarterly_reports(year: int, quarter: int, api_key: str) -> str:
    start_month  = (quarter - 1) * 3 + 1
    months       = [start_month, start_month + 1, start_month + 2]

    filenames = []
    for month in months:
        report_date = date(year, month, 1)
        filename    = get_monthly_report_filename(report_date)  # from DB or S3
        if filename:
            filenames.append(filename)

    if len(filenames) < 2:
        raise ValueError(f"Not enough reports to merge: {len(filenames)}")

    result = httpx.post(
        "https://pdf.funbrew.cloud/api/pdf/merge",
        headers={"Authorization": f"Bearer {api_key}"},
        json={
            "filenames":        filenames,
            "expiration_hours": 168,
            "max_downloads":    50,
        },
        timeout=300,
    )
    result.raise_for_status()
    return result.json()["data"]["download_url"]

Best Practices for Bulk Merging

Two-stage merge for more than 50 files

The API limit is 50 files per request. For larger batches, split into groups and merge in two stages.

def merge_large_batch(filenames: list[str], api_key: str, batch_size: int = 40) -> str:
    """Handle batches larger than 50 files with a two-stage merge."""
    if len(filenames) <= batch_size:
        result = merge_pdfs(filenames, api_key=api_key)
        return result["data"]["filename"]

    # Stage 1: merge each batch
    batches               = [filenames[i:i+batch_size] for i in range(0, len(filenames), batch_size)]
    intermediate_filenames = []

    for i, batch in enumerate(batches):
        print(f"Merging batch {i+1}/{len(batches)} ({len(batch)} files)...")
        result = merge_pdfs(batch, api_key=api_key, expiration_hours=2)
        intermediate_filenames.append(result["data"]["filename"])

    # Stage 2: merge intermediates into the final PDF
    print(f"Final merge: {len(intermediate_filenames)} intermediate files...")
    final = merge_pdfs(intermediate_filenames, api_key=api_key)
    return final["data"]["download_url"]

Parallel generation, serial merge

Parallelize the PDF generation phase and keep the merge phase serial to maximize throughput.

async def generate_and_merge_pipeline(html_list: list[str], api_key: str) -> str:
    """Generate PDFs in parallel, then merge sequentially."""
    # Parallel generation
    filenames = await generate_all_async(html_list, api_key, concurrency=5)
    print(f"Generated {len(filenames)} PDFs")

    # Two-stage merge (handles >50 files automatically)
    return merge_large_batch(filenames, api_key)

Control page order explicitly

The order of filenames (or files[]) in the request determines the page order in the merged PDF. Sort before merging.

# Sort by invoice number before merging
items = [
    {"filename": "invoice-003.pdf", "invoice_no": 3},
    {"filename": "invoice-001.pdf", "invoice_no": 1},
    {"filename": "invoice-002.pdf", "invoice_no": 2},
]
sorted_filenames = [
    item["filename"]
    for item in sorted(items, key=lambda x: x["invoice_no"])
]
# → ["invoice-001.pdf", "invoice-002.pdf", "invoice-003.pdf"]

Choose expiration and download limits by use case

Use case expiration_hours max_downloads Rationale
Contract set (pre-signature) 168 (7 days) 2 One download each party
Internal report distribution 720 (30 days) 100 Full team access
Temporary review copy 4 3 Discard after review
Long-term archive 0 (permanent) 0 (unlimited) Persistent storage

Common Failures and Fixes

File not found (422)

{
  "success": false,
  "message": "File not found or not owned by your company: invoice-001.pdf"
}

Root causes:

  1. Different API key (company) used for generation vs. merge: Files are scoped to a company. The API key used to generate must belong to the same company as the one used to merge.
  2. File expired: If expiration_hours passed since generation, the file is gone. Generate with a longer expiry, or merge immediately after generation.
  3. Typo in filename: Use the exact data.filename value returned by the generate endpoint (including extension).

Encrypted PDF error

Encrypted PDFs cannot be parsed by the underlying PDF library.

Error: This PDF document probably uses a compression technique which is not supported by the free parser shipped with FPDI.

Fix: Remove the password before uploading. With qpdf:

qpdf --password="your-password" --decrypt encrypted.pdf decrypted.pdf

PDF version incompatibility

PDF 2.0 files may fail to parse. PDFs generated by Chromium are typically PDF 1.7 or lower, but files created by third-party tools may be newer. Check the version:

with open("document.pdf", "rb") as f:
    header = f.read(8).decode("ascii", errors="ignore")
    print(header)   # e.g. "%PDF-2.0"

If you see PDF 2.0, convert with Ghostscript:

gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -dCompatibilityLevel=1.7 \
   -sOutputFile=converted.pdf input.pdf

File size limit exceeded

Each file uploaded via merge-upload must be 50 MB or smaller. Compress large PDFs before uploading:

# Compress with Ghostscript
gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite \
   -dCompatibilityLevel=1.7 \
   -dPDFSETTINGS=/ebook \
   -sOutputFile=compressed.pdf input.pdf
# /screen (smallest) /ebook (medium) /printer (large) /prepress (maximum quality)

Request timeout

Merging many files or large files can take time. Set the HTTP client timeout to at least 300 seconds.

# httpx
response = httpx.post(url, ..., timeout=300)

# requests
response = requests.post(url, ..., timeout=300)

Related APIs

API Endpoint Purpose
PDF generation POST /api/pdf/generate Render HTML to PDF
PDF merge POST /api/pdf/merge Combine multiple PDFs
File download GET /api/pdf/download/{filename} Download a stored file

For the full API reference, see the API Documentation. For high-volume batch generation pipelines, see the PDF Batch Processing Guide. For certificate automation (bulk issuance, S3 archival, email delivery), see the PDF Certificate Automation Guide. For event-specific bulk certificate pipelines, see the Bulk Certificate Generator for Events Guide.


Summary

Key points for the FUNBREW PDF merge API:

  1. Two endpoints: Use POST /api/pdf/merge for server-side files and POST /api/pdf/merge-upload for local file uploads
  2. 50-file limit: For larger batches, use a two-stage merge (split → intermediate merge → final merge)
  3. Expiration and download limits: Set expiration_hours and max_downloads to match your use case
  4. Page order: The array order in filenames / files[] directly determines the page order in the merged PDF — sort explicitly
  5. Common failures: Watch for encrypted PDFs, expired source files, PDF version mismatches, and size limit overruns
  6. Parallel generation + serial merge: Parallelize the generation phase with a semaphore; keep the merge phase serial

Use the FUNBREW PDF Playground to test PDF generation before adding the merge step. Full API reference at API Documentation.

Powered by FUNBREW PDF