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

SequenceResult
merge → watermarkevery page marked, including the cover
watermark → mergeonly the marked input carries it
rotate → splitranges refer to the rotated document
compress → mergecompression undone by the merge’s re-encode
merge → compressone 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.

References

Browse more pdf developer guides