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.
| Endpoint | What it does |
|---|---|
POST /pdf/merge | Merge up to 20 PDFs (4 MB in total) and get the result immediately |
POST /pdf/split | Split a PDF of up to 4 MB into up to 100 files |
POST /pdf/inspect | Page count and password protection, free |
POST /pdf/upload-url | Upload a large PDF (up to 150 MB) |
POST /pdf/jobs | Merge 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
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.
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.
| Range | Pages |
|---|---|
3 | 3 |
1-3 | 1, 2, 3 |
8-end | 8 to the last page |
1,3,5-7 | 1, 3, 5, 6, 7 |
5,1 | 5, then 1 |
2,2 | 2 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)
| Field | Required | Description |
|---|---|---|
files | Yes | One part per PDF, in merge order |
page_ranges | No | JSON array with one entry per file: a page range, or null for every page |
options | No | JSON object, see below |
store | No | true 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.
| Option | Description |
|---|---|
output_filename | Name of the merged file, without .pdf (default merged) |
title | Document 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)
| Field | Required | Description |
|---|---|---|
file | Yes | The PDF |
split | Yes | JSON object, see modes below |
options | No | JSON object: filename_pattern |
store | No | true 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
mode | Settings | Output |
|---|---|---|
extract | pages: a page range | One PDF with those pages |
ranges | ranges: array of page ranges | One PDF per range (ranges may overlap) |
every_n | every_n: whole number ≥ 1 | A new file every N pages; the last one may be shorter |
each_page | — | One 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.
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.
| Field | Required | Description |
|---|---|---|
operation | Yes | merge or split |
inputs | Yes | [{ "s3_key", "filename", "pages" }] — 2 to 100 for merge, exactly 1 for split. pages (merge only) is a page range |
split | For split | Same object as /pdf/split |
options | No | output_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}/fileslists every output fileGET /jobs/{job_id}/files/{index}/downloadreturns a link to one fileGET /jobs/{job_id}/downloadreturns 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 }
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | MISSING_PDFS | A merge needs at least 2 PDFs |
| 400 | MISSING_PDF | No PDF in the request |
| 400 | TOO_MANY_FILES | More than 20 files (100 for a job) |
| 400 | PDF_INPUT_TOO_LARGE | Over 4 MB instant, 150 MB per upload or 150 MB per job |
| 400 | PDF_PAGE_LIMIT_EXCEEDED | More than 500 pages processed (5000 for a job) |
| 400 | INVALID_PAGE_RANGE | A page range can't be read; details.token shows the part |
| 400 | PAGE_OUT_OF_RANGE | A page past the end; details.total_pages has the count |
| 400 | INVALID_SPLIT_MODE | split.mode missing or unknown |
| 400 | OUTPUT_COUNT_EXCEEDED | The split would make more than 100 files (500 for a job) |
| 400 | OUTPUT_TOO_LARGE | The output files would be too large in total |
| 400 | INVALID_PARAMETER | A field has the wrong shape |
| 403 | PDF_TOOLS_NOT_ENABLED | Merge and split aren't enabled on the plan |
| 403 | INSUFFICIENT_CREDITS | Not enough credits for the operation |
| 403 | FORBIDDEN | The s3_key doesn't belong to your account |
| 404 | UPLOAD_NOT_FOUND | Nothing was uploaded to that s3_key, or it expired |
| 408 | PDFOP_TIMEOUT | An instant request took longer than 25 seconds; use a job |
| 409 | PDF_JOB_ALREADY_ACTIVE | 2 merge/split jobs are already running |
| 413 | — | The request is larger than the API accepts (about 6 MB); use an upload |
| 422 | PDF_ENCRYPTED | A file is password-protected or encrypted |
| 422 | INVALID_PDF | A file is damaged, truncated or not a PDF |
| 422 | PDF_NO_PAGES | A file has no pages |
| 422 | PDF_TOO_COMPLEX | A file is too large or complex to process |
input_index and filename in details identify which file caused the error.
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, rotation | Kept exactly |
| Links and annotations on a page | Kept |
| Fillable form fields | Shown, no longer fillable — warning FORM_FIELDS_NOT_PRESERVED |
| Bookmarks | Not kept — warning BOOKMARKS_NOT_PRESERVED |
| Digital signatures | No 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+)
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 processed | Multiplier |
|---|---|
| 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 size | 4 MB in total | 150 MB per file, 150 MB in total |
| Files in a merge | 20 | 100 |
| Pages processed | 500 | 5000 |
| Files from one split | 100 | 500 |
| Total output size | 300 MB | 1 GB |
| Time limit | 25 seconds | 15 minutes |
| Running at once | — | 2 jobs per account |
| Output retention | 7 days | 7 days |
See Limits for every limit across the API.
Next Steps
- Merge and split guide — choosing a path, page ranges and common workflows
- Jobs API — status, per-file results and downloads
- Webhooks — get notified when a job finishes