Endpoint reference
POST /api/merge
Merge two or more PDFs into one. Pages are concatenated in the order the multipart fields appear in the request body. Returns the merged PDF as application/pdf.
Request
Method: POST. URL: https://pdfops.dev/api/merge. Body: multipart/form-data.
| Field | Type | Required | Description |
|---|---|---|---|
pdf (repeated) | File (PDF binary) | Yes, ≥2 | A PDF file. Repeat the field name pdf for each input file. Pages are concatenated in the order the fields appear in the request body. |
Response
On success: 200 OK, Content-Type: application/pdf, body is the merged PDF binary.
On any failure: 4xx or 500, Content-Type: application/json, body is { "error": "<code>", "details": "<explanation>" }.
Error codes
| Error code | Status | Trigger |
|---|---|---|
invalid_multipart | 400 | Request body could not be parsed as multipart/form-data. |
insufficient_pdfs | 400 | Fewer than 2 pdf file fields in the request. |
invalid_pdf | 400 | One of the input PDFs couldn't be parsed (corrupt, encrypted, not a PDF). details identifies which index failed. |
save_failed | 500 | PDF serialization failed after merging. Retryable. |
429 | 429 | Per-IP rate limit (100/month) exhausted. Retry-After header gives seconds until next calendar month. |
Example
curl -X POST https://pdfops.dev/api/merge \
-F "pdf=@cover.pdf" \
-F "pdf=@body.pdf" \
-F "pdf=@appendix.pdf" \
-o combined.pdf
Walkthrough: monthly per-customer statement bundles on a Cloudflare Cron Trigger — uses fill-form to generate per-customer statements, then merges them into a single PDF for archiving. Broader pattern: report PDFs at scale across statements, inspection reports, audit summaries, and KPI digests.
Code examples
The canonical curl above translated to common stacks. All use stdlib + one widely-adopted HTTP client (httpx for Python); no PDFops-specific SDK needed.
Node.js (22+) — built-in fetch + FormData
import { readFile, writeFile } from 'node:fs/promises';
const cover = await readFile('cover.pdf');
const body = await readFile('body.pdf');
const appendix = await readFile('appendix.pdf');
const fd = new FormData();
// Order matters — pages concatenate in field order, not filename order.
fd.append('pdf', new Blob([cover], { type: 'application/pdf' }), 'cover.pdf');
fd.append('pdf', new Blob([body], { type: 'application/pdf' }), 'body.pdf');
fd.append('pdf', new Blob([appendix], { type: 'application/pdf' }), 'appendix.pdf');
const res = await fetch('https://pdfops.dev/api/merge', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json();
throw new Error(`PDFops merge failed: ${err.error} — ${err.details}`);
}
await writeFile('combined.pdf', Buffer.from(await res.arrayBuffer()));
Python (3.10+) — httpx
import httpx
# httpx accepts a list of (fieldname, file-tuple) pairs for repeated fields.
files = [
('pdf', ('cover.pdf', open('cover.pdf', 'rb'), 'application/pdf')),
('pdf', ('body.pdf', open('body.pdf', 'rb'), 'application/pdf')),
('pdf', ('appendix.pdf', open('appendix.pdf', 'rb'), 'application/pdf')),
]
r = httpx.post('https://pdfops.dev/api/merge', files=files, timeout=30)
if r.status_code != 200:
err = r.json()
raise RuntimeError(f"PDFops merge failed: {err['error']} — {err['details']}")
with open('combined.pdf', 'wb') as f:
f.write(r.content)
Go (1.21+) — net/http + mime/multipart
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func merge(paths []string, out string) error {
var body bytes.Buffer
w := multipart.NewWriter(&body)
// Order matters: pages concatenate in the order the fields are written.
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
return err
}
part, err := w.CreateFormFile("pdf", p)
if err != nil {
return err
}
if _, err := part.Write(data); err != nil {
return err
}
}
w.Close()
req, err := http.NewRequest("POST", "https://pdfops.dev/api/merge", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
var apiErr struct {
Error string `json:"error"`
Details string `json:"details"`
}
json.NewDecoder(resp.Body).Decode(&apiErr)
return fmt.Errorf("merge failed: %s — %s", apiErr.Error, apiErr.Details)
}
f, err := os.Create(out)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
Behavioral notes
- Page order is multipart-field order, not filename order. If you assemble the request from a dictionary or hash, ensure iteration order matches your intent.
- All pages of each input are preserved. The merge calls
copyPageson every page of every input; no filtering, no extraction. - Document-level metadata (AcroForm, outline, attachments) does not union across inputs. The merged output starts fresh — fields belong to the document, not the pages, and the merge doesn't combine AcroForm dictionaries. Fill form PDFs before merging if you need filled fields preserved.
- Encrypted PDFs are not supported. Decrypt server-side first.
- Request size limit: ~10 MB total. For larger merges, chain calls in sequence.
FAQ
How many PDFs can I merge in one request?
No hard cap in code, but the multipart body has to fit in ~10 MB total. For typical 1-2 MB inputs, 5-10 per request is comfortable. For larger merges, chain multiple /api/merge calls.
What's the page order in the merged PDF?
The same order as the multipart fields in the request body. All pages of each input are preserved in their original order within that input.
Are form fields preserved across the merge?
Page-level content yes, document-level AcroForm metadata no. Fill form PDFs via /api/fill-form first, then merge the filled outputs.
Why does /api/merge require at least 2 PDFs?
If you have one PDF there's nothing to merge — return it unchanged on your side. The minimum-of-2 check surfaces misuse loudly rather than wasting a call.
Can I merge encrypted PDFs?
Not supported. Decrypt server-side first (qpdf, pdftk).
Common error: insufficient_pdfs
You sent fewer than 2 pdf multipart fields. The endpoint refuses a 1-input request because there's nothing to merge — return the one PDF unchanged on your side. If you're getting insufficient_pdfs on a call you thought had multiple inputs: check that all field names are pdf exactly (not pdfs or file); some HTTP clients silently rename multipart parts on conflict. Also check that you're not accidentally sending the same buffer twice with one field name — some FormData libraries dedupe by name unless explicitly told to repeat.
Common error: invalid_pdf
One of the input PDFs couldn't be parsed by pdf-lib. The details field identifies which input index failed (0-based). Common causes: the file isn't actually a PDF (check the first 4 bytes — should be %PDF); the PDF is encrypted (decrypt server-side first via qpdf or pdftk); the PDF is corrupt or truncated (re-read from source); the PDF uses a feature pdf-lib doesn't support yet (rare, but PDF 2.0 with object-streams + encryption can trip it). Debug: try opening the file locally in Acrobat or Preview — if those can read it, it's a pdf-lib quirk worth filing via the waitlist form's message field.
Common error: save_failed (500)
pdf-lib's save() threw after the merge succeeded structurally. Rare; usually indicates corruption that surfaced during serialization (e.g., a cross-reference table entry that pointed at deleted-during-merge content). Retryable — the next call may succeed if the failure was edge-case timing. If the same merge fails twice in a row with save_failed, one of the input PDFs is the likely root cause; try removing each input one at a time to isolate.