Merging, splitting and rotating PDFs in one chained pipeline
Each call returns a pdf_id the next one accepts, so a multi-step pipeline never moves bytes through your process. Page ranges, rotation, and ordering.
The useful property of Infrai’s PDF operations is that they compose. POST /v1/pdf/merge returns a pdf_id; POST /v1/pdf/rotate accepts a pdf_id as its input; so does POST /v1/pdf/split, POST /v1/pdf/compress and everything else in the namespace. A four-step pipeline is four calls passing an identifier, and the document bytes never travel back to your process between steps.
That’s what makes a document pipeline cheap to run on a small server.
Merge in the order you pass
curl -sS -X POST "https://api.infrai.cc/v1/pdf/merge" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"inputs": [
"https://files.example.com/cover.pdf",
"pdf_2fVc8nRqLmT4xBzY",
"https://files.example.com/terms.pdf"
],
"store": true
}'
{
"ok": true,
"data": {
"pdf_id": "pdf_6hJk1pWsQnD9rGtU",
"url": "https://files.infrai.cc/pdf/6hJk1pWsQnD9rGtU.pdf",
"size_bytes": 512000,
"page_count": 14,
"sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
"created_at": "2026-09-21T03:45:00Z",
"retention_days": 7,
"source": "merge"
}
}
inputs is an ordered list and it accepts mixed forms — a URL, a stored pdf_id from an earlier step, or base64 data. Mixing them is normal: a static cover page from your bucket, a generated body from the previous call, and a static terms appendix.
page_count is how you verify the merge did what you meant. Assert it in a test against known fixtures; an off-by-one in your input list is otherwise invisible until someone reads the document.
Split by range
curl -sS -X POST "https://api.infrai.cc/v1/pdf/split" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"pdf": "pdf_6hJk1pWsQnD9rGtU", "ranges": ["1-2", "3-12", "13-14"], "store": true}'
Three ranges, three outputs: cover, body, appendix. Ranges are the unit rather than “split every N pages”, which means the caller decides the semantics — and for a document assembled from known parts you already know where the boundaries are.
Rotate specific pages
curl -sS -X POST "https://api.infrai.cc/v1/pdf/rotate" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"pdf": "pdf_6hJk1pWsQnD9rGtU", "degrees": 90, "pages": "5,9-11", "store": true}'
degrees is a closed set — 0, 90, 180, 270 — and pages limits the rotation to the ones that need it. That combination is what you want for a scanned batch where a few sheets went through the feeder sideways: rotate those pages, leave the rest.
A pipeline that chains ids
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def step(path: str, body: dict) -> dict:
resp = SESSION.post(f"{API}{path}", json={**body, "store": True}, timeout=180)
payload = resp.json()
if not payload.get("ok"):
raise RuntimeError(f"{path} failed: {payload.get('error', {}).get('code')}")
return payload["data"]
def assemble_report(cover_url: str, body_pdf_id: str, terms_url: str,
sideways_pages: str | None = None) -> dict:
"""Four operations, one document, zero bytes through this process: each step
hands the next a pdf_id. Keeping the intermediate ids means a failure at step
three doesn't cost you steps one and two."""
trail = []
merged = step("/v1/pdf/merge", {"inputs": [cover_url, body_pdf_id, terms_url]})
trail.append(("merge", merged["pdf_id"], merged["page_count"]))
current = merged["pdf_id"]
if sideways_pages:
rotated = step("/v1/pdf/rotate", {"pdf": current, "degrees": 90, "pages": sideways_pages})
trail.append(("rotate", rotated["pdf_id"], rotated["page_count"]))
current = rotated["pdf_id"]
marked = step("/v1/pdf/watermark", {"pdf": current, "text": "CONFIDENTIAL",
"opacity": 0.12, "position": "center"})
trail.append(("watermark", marked["pdf_id"], marked["page_count"]))
final = step("/v1/pdf/compress", {"pdf": marked["pdf_id"], "mode": "balanced"})
trail.append(("compress", final["pdf_id"], final["page_count"]))
return {"pdf_id": final["pdf_id"], "url": final["url"],
"pages": final["page_count"], "sha256": final["sha256"], "trail": trail}
if __name__ == "__main__":
print(assemble_report("https://files.example.com/cover.pdf",
os.environ["BODY_PDF_ID"],
"https://files.example.com/terms.pdf",
sideways_pages="5,9-11"))
Keeping the trail is worth the four lines. When a document comes out with the wrong page count, the trail tells you which step introduced it — and every intermediate pdf_id is still addressable, so you can look at the document as it was between steps.
Order matters more than it looks
| Sequence | Result |
|---|---|
| merge → watermark | every page marked, including the cover |
| watermark → merge | only the marked input carries it |
| rotate → split | ranges refer to the rotated document |
| compress → merge | compression undone by the merge’s re-encode |
| merge → compress | one pass over the final document |
Compress last, always. Compressing inputs and then merging them means the merge re-encodes what you just optimised, and you paid for two operations to get the result of one.
Archive the output, don’t rely on retention
Each result carries retention_days. For anything you’re keeping, copy it into your own bucket:
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/reports/2026/report-4821.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op": "put", "expires_seconds": 900, "content_type": "application/pdf"}'
Then the archive, the generation that produced the body, the email that delivers it and the cost of all three sit on one credential and in one GET /v1/account/usage — which for a document pipeline is the difference between one integration and three.
Limitations
Page ranges are one-based strings, and an out-of-range value is a request error rather than a silent clamp — worth validating against page_count before you send it, because a range derived from user input is the most likely source of a failed call here.
There’s also no page-level reordering primitive: shuffling pages means split into ranges then merge in the order you want, which works but costs two operations. And no bookmark or outline editing, so a merged document’s navigation is whatever the inputs brought with them. Self-hosted tooling built on the underlying libraries gives you those, and a specialist like ILovePDF exposes more manipulation options — worth it if document surgery is the product rather than a step in a workflow.
Operations bill per call, with the live figures in GET /v1/discovery/pdf.merge and its siblings (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.