Skip to main content

PDF Tools: Merge and Split

Merge several PDFs into one, or split one PDF into several files. Pages are copied, not re-rendered, so sizes, rotation and content stay exactly as they were.

EndpointWhat it does
POST /pdf/mergeMerge up to 20 PDFs (4 MB in total) and get the result immediately
POST /pdf/splitSplit a PDF of up to 4 MB into up to 100 files
POST /pdf/inspectPage count and password protection, free
POST /pdf/upload-urlUpload a large PDF (up to 150 MB)
POST /pdf/jobsMerge or split uploaded PDFs as a background job

When to Use

Use the PDF tools when:

  • You already have PDFs and need to combine them, reorder them, or take pages out
  • You're assembling document packs — contract + appendix + invoice
  • You're splitting a statement or payslip run into one file per person
  • You need only some pages of a long document

Use something else when:

  • You need to create a PDF from HTML, Markdown, images or a URL → POST /quickjob
  • You need to convert many HTML or Markdown files → POST /bulkjob
Instant or background

Requests with up to 4 MB of PDFs run instantly with /pdf/merge and /pdf/split. Anything larger is uploaded with /pdf/upload-url and processed as a background job with /pdf/jobs.

Plan requirement

Merge and split must be enabled on your plan. GET /plans/{plan_id} returns pdf_tools_enabled; without it every /pdf route returns 403 PDF_TOOLS_NOT_ENABLED.

Quick Example

curl -X POST https://api.podpdf.com/pdf/merge \
-H "X-API-Key: your_api_key_here" \
-F "files=@contract.pdf" \
-F "files=@appendix.pdf" \
-o merged.pdf

Authentication

Send either an API key or a Cognito ID token:

X-API-Key: your_api_key_here
Authorization: Bearer <cognito_id_token>

Page Ranges

Every page selection uses one syntax: comma-separated parts, each a page (3), a range (1-3) or an open range (8-end). Pages are 1-based and used in the order written.

RangePages
33
1-31, 2, 3
8-end8 to the last page
1,3,5-71, 3, 5, 6, 7
5,15, then 1
2,22 twice

Rejected with 400 INVALID_PAGE_RANGE: page 0, negative or decimal numbers, empty parts (1,,2), a range that runs backwards (5-3) and more than 500 parts. A page past the end of the document returns 400 PAGE_OUT_OF_RANGE with details.total_pages.


POST /pdf/merge

Merge 2 to 20 PDFs, up to 4 MB in total, in the order given.

Request

Multipart (recommended)

FieldRequiredDescription
filesYesOne part per PDF, in merge order
page_rangesNoJSON array with one entry per file: a page range, or null for every page
optionsNoJSON object, see below
storeNotrue to store the result and return JSON with a download link
curl -X POST https://api.podpdf.com/pdf/merge \
-H "X-API-Key: your_api_key_here" \
-F "files=@contract.pdf" \
-F "files=@appendix.pdf" \
-F "files=@invoice.pdf" \
-F 'page_ranges=[null, "2-end", "1"]' \
-F 'options={"output_filename": "deal-pack", "title": "Deal pack"}' \
-o deal-pack.pdf

JSON with base64

{
"inputs": [
{ "pdf_base64": "JVBERi0xLjcK...", "filename": "contract.pdf" },
{ "pdf_base64": "JVBERi0xLjcK...", "filename": "appendix.pdf", "pages": "2-end" }
],
"options": { "output_filename": "deal-pack" },
"store": false
}

Base64 adds a third to the size, so the 4 MB limit applies to the decoded files.

OptionDescription
output_filenameName of the merged file, without .pdf (default merged)
titleDocument title stored in the PDF

Response

Merged PDF (200) — returned directly when the result is smaller than 4.4 MB and store is not set:

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="deal-pack.pdf"
X-PDF-Pages: 12
X-PDF-Output-Count: 1
X-PDF-Warnings:
X-Job-Id: 0f6c5a1e-4b1d-4c9e-a2f4-6d7e8f9a0b1c

Stored result (200 JSON) — with store: true, or when the merged PDF is too large to return directly:

{
"job_id": "0f6c5a1e-4b1d-4c9e-a2f4-6d7e8f9a0b1c",
"operation": "pdf_merge",
"status": "completed",
"input_pages": 12,
"output_count": 1,
"outputs": [
{
"index": 0,
"name": "deal-pack.pdf",
"pages": 12,
"page_range": "1-12",
"size_bytes": 348210,
"warnings": [],
"download_url": "https://...deal-pack.pdf?X-Amz-Signature=..."
}
],
"zip_url": null,
"download_url_expires_at": "2026-09-13T11:00:00.000Z",
"warnings": []
}

POST /pdf/split

Split one PDF of up to 4 MB and 500 pages into up to 100 files.

Request

Multipart (recommended)

FieldRequiredDescription
fileYesThe PDF
splitYesJSON object, see modes below
optionsNoJSON object: filename_pattern
storeNotrue to store a single-file result instead of returning it directly
curl -X POST https://api.podpdf.com/pdf/split \
-H "X-API-Key: your_api_key_here" \
-F "file=@statements.pdf" \
-F 'split={"mode": "every_n", "every_n": 2}' \
-F 'options={"filename_pattern": "statement-{index:03}"}'

JSON with base64

{
"input": { "pdf_base64": "JVBERi0xLjcK...", "filename": "statements.pdf" },
"split": { "mode": "ranges", "ranges": ["1-3", "4-9", "10-end"] }
}

Split modes

modeSettingsOutput
extractpages: a page rangeOne PDF with those pages
rangesranges: array of page rangesOne PDF per range (ranges may overlap)
every_nevery_n: whole number ≥ 1A new file every N pages; the last one may be shorter
each_pageOne PDF per page

File names

filename_pattern supports {basename} (input name without .pdf), {index}, {index:03} (zero-padded to 3 digits) and {pages} (the page range). The default is {basename}-{index:03}.pdf: scan.pdf becomes scan-001.pdf, scan-002.pdf, … Unsafe characters are replaced and duplicate names get a -2, -3 suffix.

Response

A split that produces one file returns the PDF directly, exactly like merge.

A split that produces several files stores them and returns JSON with a link per file and a ZIP of all of them:

{
"job_id": "7a1c0e52-9d3b-4b0f-8c11-2f6a9e7d4c3b",
"operation": "pdf_split",
"status": "completed",
"input_pages": 10,
"output_count": 3,
"outputs": [
{ "index": 0, "name": "statements-001.pdf", "pages": 3, "page_range": "1-3", "size_bytes": 91822, "warnings": [], "download_url": "https://..." },
{ "index": 1, "name": "statements-002.pdf", "pages": 6, "page_range": "4-9", "size_bytes": 170114, "warnings": [], "download_url": "https://..." },
{ "index": 2, "name": "statements-003.pdf", "pages": 1, "page_range": "10", "size_bytes": 30557, "warnings": [], "download_url": "https://..." }
],
"zip_url": "https://...podpdf-7a1c0e52-....zip?X-Amz-Signature=...",
"download_url_expires_at": "2026-09-13T11:00:00.000Z",
"warnings": []
}

Download links last one hour. The files stay available for 7 days through GET /jobs/{job_id}/files/{index}/download and GET /jobs/{job_id}/download.

Output size

Each output is a complete PDF, so shared content — a letterhead image, embedded fonts — is included in every file. Splitting a templated document page by page can add up to more than the original. Splits whose files would total more than 300 MB (1 GB for jobs) are refused up front with OUTPUT_TOO_LARGE.


POST /pdf/inspect

Check a PDF before merging or splitting it. Free, and not recorded as a job.

Send a PDF of up to 4 MB as multipart file (or JSON input.pdf_base64), or {"s3_key": "...", "filename": "..."} for a file already uploaded with /pdf/upload-url.

curl -X POST https://api.podpdf.com/pdf/inspect \
-H "X-API-Key: your_api_key_here" \
-F "file=@statements.pdf"
{
"filename": "statements.pdf",
"size_bytes": 1832200,
"page_count": 48,
"encrypted": false,
"has_forms": false,
"has_signatures": false,
"first_page": { "width": 595.28, "height": 841.89 },
"warnings": [],
"max_pages": 500
}

An encrypted file returns 200 with "encrypted": true and page_count: null. max_pages is the page limit for the path the file would take: 500 instant, 5000 for a job.


POST /pdf/upload-url

Get a signed URL to upload one PDF of up to 150 MB.

curl -X POST https://api.podpdf.com/pdf/upload-url \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"content_length_bytes": 48211934}'
{
"upload_url": "https://podpdf-prod-pdfs.s3.eu-central-1.amazonaws.com/pdf-uploads/...?X-Amz-Signature=...",
"s3_key": "pdf-uploads/01HZXUSERID/01J8K2Q9ZP6X7Y3M4N5V6W7A8B.pdf",
"expires_at": "2026-09-13T10:15:00.000Z",
"max_bytes": 157286400,
"required_headers": { "Content-Type": "application/pdf" }
}

Then upload the file:

curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @exhibits.pdf

content_length_bytes must be the exact file size, and the URL is valid for 15 minutes. Do not send your API key to the upload URL. Uploads are deleted once a job that uses them finishes, and after one day otherwise.


POST /pdf/jobs

Merge or split uploaded PDFs in the background.

FieldRequiredDescription
operationYesmerge or split
inputsYes[{ "s3_key", "filename", "pages" }] — 2 to 100 for merge, exactly 1 for split. pages (merge only) is a page range
splitFor splitSame object as /pdf/split
optionsNooutput_filename, title (merge) or filename_pattern (split)
curl -X POST https://api.podpdf.com/pdf/jobs \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"operation": "split",
"inputs": [{ "s3_key": "pdf-uploads/01HZXUSERID/01J8K2Q9ZP6X7Y3M4N5V6W7A8B.pdf", "filename": "payslips.pdf" }],
"split": { "mode": "each_page" },
"options": { "filename_pattern": "payslip-{index:03}" }
}'
{
"job_id": "5b2d8f40-1e7c-4a8b-9c3d-6e0f1a2b3c4d",
"status": "queued",
"job_type": "pdfop",
"operation": "split",
"message": "PDF job queued for processing"
}

Tracking the job

Poll GET /jobs/{job_id} until status is completed or failed, or subscribe a webhook to job.completed and job.failed. While the job runs, output_count and processed_count show progress.

When it's done:

  • GET /jobs/{job_id}/files lists every output file
  • GET /jobs/{job_id}/files/{index}/download returns a link to one file
  • GET /jobs/{job_id}/download returns the merged PDF, or a ZIP of all split files

A job either produces every file or fails as a whole; failed jobs have error_code and error_message, and /files contains one failed row naming the input. Up to 2 merge/split jobs can run at the same time per account.


Error Responses

{
"error": {
"code": "PDF_ENCRYPTED",
"message": "\"statement.pdf\" is password-protected or encrypted. Remove the protection and upload it again.",
"details": { "filename": "statement.pdf", "input_index": 1 }
}
}
StatusCodeMeaning
400MISSING_PDFSA merge needs at least 2 PDFs
400MISSING_PDFNo PDF in the request
400TOO_MANY_FILESMore than 20 files (100 for a job)
400PDF_INPUT_TOO_LARGEOver 4 MB instant, 150 MB per upload or 150 MB per job
400PDF_PAGE_LIMIT_EXCEEDEDMore than 500 pages processed (5000 for a job)
400INVALID_PAGE_RANGEA page range can't be read; details.token shows the part
400PAGE_OUT_OF_RANGEA page past the end; details.total_pages has the count
400INVALID_SPLIT_MODEsplit.mode missing or unknown
400OUTPUT_COUNT_EXCEEDEDThe split would make more than 100 files (500 for a job)
400OUTPUT_TOO_LARGEThe output files would be too large in total
400INVALID_PARAMETERA field has the wrong shape
403PDF_TOOLS_NOT_ENABLEDMerge and split aren't enabled on the plan
403INSUFFICIENT_CREDITSNot enough credits for the operation
403FORBIDDENThe s3_key doesn't belong to your account
404UPLOAD_NOT_FOUNDNothing was uploaded to that s3_key, or it expired
408PDFOP_TIMEOUTAn instant request took longer than 25 seconds; use a job
409PDF_JOB_ALREADY_ACTIVE2 merge/split jobs are already running
413The request is larger than the API accepts (about 6 MB); use an upload
422PDF_ENCRYPTEDA file is password-protected or encrypted
422INVALID_PDFA file is damaged, truncated or not a PDF
422PDF_NO_PAGESA file has no pages
422PDF_TOO_COMPLEXA file is too large or complex to process

input_index and filename in details identify which file caused the error.

Password-protected PDFs

Many PDFs are encrypted even though they open without a password — bank statements and exports from document systems often are. Their pages can't be copied without decrypting them, so they're refused with PDF_ENCRYPTED rather than producing blank pages. Remove the protection, then try again.

What Is and Isn't Kept

Result
Page content, size, rotationKept exactly
Links and annotations on a pageKept
Fillable form fieldsShown, no longer fillable — warning FORM_FIELDS_NOT_PRESERVED
BookmarksNot kept — warning BOOKMARKS_NOT_PRESERVED
Digital signaturesNo longer valid — warning SIGNATURE_INVALIDATED

Warnings are returned in X-PDF-Warnings (comma-separated), warnings in JSON responses, and on each row from GET /jobs/{job_id}/files. They never fail the request.

Complete Examples

cURL — split a large PDF with a job

API=https://api.podpdf.com
KEY=your_api_key_here
FILE=payslips.pdf

# 1. Upload URL
UPLOAD=$(curl -s -X POST $API/pdf/upload-url -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"content_length_bytes\": $(wc -c < $FILE)}")

# 2. Upload
curl -s -X PUT "$(echo $UPLOAD | jq -r .upload_url)" -H "Content-Type: application/pdf" --data-binary @$FILE

# 3. Submit
JOB=$(curl -s -X POST $API/pdf/jobs -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"operation\": \"split\", \"inputs\": [{\"s3_key\": \"$(echo $UPLOAD | jq -r .s3_key)\"}], \"split\": {\"mode\": \"each_page\"}}" | jq -r .job_id)

# 4. Wait
while STATUS=$(curl -s $API/jobs/$JOB -H "X-API-Key: $KEY" | jq -r .status); [ "$STATUS" = queued ] || [ "$STATUS" = processing ]; do
sleep 3
done
echo "Job $JOB finished: $STATUS"

# 5. Download everything as a ZIP
curl -s -o payslips.zip "$(curl -s $API/jobs/$JOB/download -H "X-API-Key: $KEY" | jq -r .download_url)"

JavaScript (Node 18+)

merge-split.js
const API = process.env.PODPDF_API_URL || 'https://api.podpdf.com';
const KEY = process.env.PODPDF_API_KEY;

/** Merge PDFs (Buffers) in order; pages is an optional page range per file */
async function mergePdfs(files) {
const form = new FormData();
for (const { buffer, name } of files) {
form.append('files', new Blob([buffer], { type: 'application/pdf' }), name);
}
form.append('page_ranges', JSON.stringify(files.map((file) => file.pages ?? null)));

const response = await fetch(`${API}/pdf/merge`, { method: 'POST', headers: { 'X-API-Key': KEY }, body: form });
if (!response.ok) throw new Error(JSON.stringify(await response.json()));
return { pdf: Buffer.from(await response.arrayBuffer()), pages: Number(response.headers.get('x-pdf-pages')) };
}

/** Split a small PDF; returns [{ name, pages, download_url }] */
async function splitPdf(buffer, split) {
const form = new FormData();
form.append('file', new Blob([buffer], { type: 'application/pdf' }), 'document.pdf');
form.append('split', JSON.stringify(split));
form.append('store', 'true');

const response = await fetch(`${API}/pdf/split`, { method: 'POST', headers: { 'X-API-Key': KEY }, body: form });
const body = await response.json();
if (!response.ok) throw new Error(JSON.stringify(body));
return body.outputs;
}

/** Split a large PDF with an upload and a background job */
async function splitLargePdf(buffer, split) {
const json = { 'X-API-Key': KEY, 'Content-Type': 'application/json' };
const upload = await (await fetch(`${API}/pdf/upload-url`, {
method: 'POST', headers: json, body: JSON.stringify({ content_length_bytes: buffer.length }),
})).json();
await fetch(upload.upload_url, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, body: buffer });

const { job_id: jobId } = await (await fetch(`${API}/pdf/jobs`, {
method: 'POST', headers: json, body: JSON.stringify({ operation: 'split', inputs: [{ s3_key: upload.s3_key }], split }),
})).json();

let job;
do {
await new Promise((resolve) => setTimeout(resolve, 3000));
job = await (await fetch(`${API}/jobs/${jobId}`, { headers: { 'X-API-Key': KEY } })).json();
} while (job.status === 'queued' || job.status === 'processing');
if (job.status !== 'completed') throw new Error(`${job.error_code}: ${job.error_message}`);

return (await (await fetch(`${API}/jobs/${jobId}/files`, { headers: { 'X-API-Key': KEY } })).json()).files;
}

Python

import json
import requests

API = 'https://api.podpdf.com'
HEADERS = {'X-API-Key': 'your_api_key_here'}

# Merge two files, the second from page 2
merged = requests.post(
f'{API}/pdf/merge',
headers=HEADERS,
files=[('files', open('contract.pdf', 'rb')), ('files', open('appendix.pdf', 'rb'))],
data={'page_ranges': json.dumps([None, '2-end'])},
)
merged.raise_for_status()
open('merged.pdf', 'wb').write(merged.content)

# Split into files of 10 pages each
split = requests.post(
f'{API}/pdf/split',
headers=HEADERS,
files={'file': open('report.pdf', 'rb')},
data={'split': json.dumps({'mode': 'every_n', 'every_n': 10})},
)
split.raise_for_status()
for output in split.json()['outputs']:
open(output['name'], 'wb').write(requests.get(output['download_url']).content)

Billing

Merge and split are billed once per operation, not per input or output file. The price is your plan's price_per_pdf_operation (a quarter of price_per_pdf when not set), multiplied by the number of pages processed:

Pages processedMultiplier
1–50×1
51–250×2
251–1000×4
1001 and more×8

Pages processed are the selected pages for a merge and the document's pages for a split. /pdf/inspect is free, and failed operations aren't charged.

Your PDF usage count goes up by the number of files produced: a merge adds 1, a split into 24 files adds 24, even though it's charged once. A background job needs enough credits for the highest band when it's submitted; it's charged at the actual band when it finishes.

Limits

Instant (/pdf/merge, /pdf/split)Background job (/pdf/jobs)
Upload size4 MB in total150 MB per file, 150 MB in total
Files in a merge20100
Pages processed5005000
Files from one split100500
Total output size300 MB1 GB
Time limit25 seconds15 minutes
Running at once2 jobs per account
Output retention7 days7 days

See Limits for every limit across the API.

Next Steps