PDF Editor
Edit one PDF, page by page. You describe the changes as a recipe — an ordered list of operations — and they are all applied in a single request. A whole review costs one operation, however many changes it carries.
Nothing is re-rendered: pages are changed in place, so everything you did not touch stays byte-for-byte as it was.
| Endpoint | What it does |
|---|---|
POST /pdf/upload-url | Get a link to upload the document |
POST /pdf/inspect | Open it: page count, page ids, sizes. Free |
POST /pdf/edit/preflight | See what a recipe would do, before doing it. Free |
POST /pdf/edit | Apply the recipe and get the document back |
All four are shown together in The shape of a request. Every endpoint takes an API key in X-API-Key; there is nothing the dashboard editor can do that these cannot.
When to Use
✅ Use the editor when:
- You want to stamp, number or watermark a document before sending it
- You are preparing a bundle and need pages rotated, removed or reordered
- You need to strip metadata, or remove JavaScript and embedded files, before a document leaves the building
- You want to add a line of text — a received date, a reference, a note
❌ Use something else when:
- You are combining or splitting documents — that is PDF Tools
- You are creating a PDF from scratch — that is
/quickjobor Templates - The document is longer than 200 pages — split it first, then edit the parts
Limits
| Pages in the document | 200 |
| Document size | 150 MB |
| Size of the saved document | 200 MB |
| Changes in one save | 200 |
| Time | 25 seconds |
| Downloads kept | 7 days |
A document over 200 pages is refused when you open it, not after you have worked on it. There is no background-job version: 200 pages finish well inside the time limit, so POST /pdf/edit always returns the finished document.
Your plan needs the editor enabled, otherwise every editor endpoint returns 403 PDF_EDITOR_NOT_ENABLED.
The shape of a request
# 1. Upload
curl -X POST https://api.podpdf.com/pdf/upload-url \
-H "X-API-Key: $PODPDF_API_KEY" \
-d '{"content_length_bytes": 482913}'
# → { "upload_url": "https://…", "s3_key": "pdf-uploads/…" }
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @binder.pdf
# 2. Open it, and get the page ids
curl -X POST https://api.podpdf.com/pdf/inspect \
-H "X-API-Key: $PODPDF_API_KEY" \
-d '{"s3_key": "pdf-uploads/…", "for": "edit"}'
# → { "page_count": 12, "pages": [ { "id": "p0", "width": 595, "height": 842, "rotation": 0 }, … ] }
# 3. Apply the changes
curl -X POST https://api.podpdf.com/pdf/edit \
-H "X-API-Key: $PODPDF_API_KEY" \
-d '{
"source_key": "pdf-uploads/…",
"recipe": [
{ "op": "text.add", "pages": ["p0"], "text": "Received 16 September 2026" },
{ "op": "bates.stamp", "prefix": "ACME-", "start": 1, "digits": 6 },
{ "op": "metadata.strip" }
]
}'
# → { "status": "completed", "download_url": "https://…", "output_pages": 12 }
Sending the file inline
A document under 4 MB can skip the upload and go straight to /pdf/edit, /pdf/edit/preflight or /pdf/inspect. As multipart, each part of the request is its own form field — the same convention as split= and page_ranges= on PDF Tools:
curl -X POST https://api.podpdf.com/pdf/edit \
-H "X-API-Key: $PODPDF_API_KEY" \
-F "file=@binder.pdf" \
-F 'recipe=[{"op": "pages.delete", "pages": ["p0"]}]' \
-F 'options={"output_filename": "binder-trimmed"}' \
-F "commit_token=01JB7QRSTUV"
| Field | |
|---|---|
file | The PDF |
recipe | The recipe, as JSON |
options | JSON. output_filename names the result |
commit_token | See Saving safely |
Or as JSON, with the file base64-encoded:
{
"input": { "pdf_base64": "JVBERi0xLjcK…", "filename": "binder.pdf" },
"recipe": [{ "op": "pages.delete", "pages": ["p0"] }]
}
Anything over 4 MB has to use the upload flow above: the 4 MB is a limit on the request body, not on the document.
Choosing pages
Operations that act on pages take a pages field, in either of two forms:
{ "op": "pages.rotate", "pages": "2,5-7", "degrees": 90 } // page numbers
{ "op": "pages.rotate", "pages": ["p1","p4","p6"], "degrees": 90 } // page ids
Page ids are the safer of the two. Operations run in order, so after a pages.delete the page numbers have shifted — "8" may no longer be the page you meant. A page id always refers to the same physical page, whatever happened earlier in the recipe. Ids come from /pdf/inspect and run p0, p1, p2… in the document's original order.
Leaving pages out means every page.
Checking before you commit
POST /pdf/edit/preflight takes the same body as /pdf/edit and tells you what would happen. It writes nothing and costs nothing, so you can call it as often as you like.
{
"ok": true,
"operations": [
{ "index": 0, "op": "text.add", "ok": true, "resolved_pages": [1] },
{ "index": 1, "op": "bates.stamp", "ok": true,
"effects": { "first": "ACME-000001", "last": "ACME-000012", "next_start": 13 } }
],
"document": {
"page_count_before": 12,
"page_count_after": 12,
"signatures_invalidated": false
}
}
If something cannot be applied, the operation that caused it is marked "ok": false with a reason, and ok at the top is false. A save applies exactly what preflight showed you, or fails — it never applies part of a recipe.
effects.next_start on bates.stamp is the number the next document would continue from, so a multi-document bundle can be numbered continuously.
Saving safely
Pass a commit_token you generate — any string up to 64 characters — and reuse it if you have to retry:
{ "source_key": "…", "recipe": [ … ], "commit_token": "01JB7QRSTUV" }
The first request to use a token does the work; any repeat gets the same result back with "repeated": true and is not charged again. Without a token, a retry after a dropped connection is a second charge. A save that fails releases its token, so retrying is a fresh attempt.
Operations
Pages
| Operation | What it does |
|---|---|
pages.rotate | Turn pages. degrees in steps of 90; relative: true adds to the current rotation |
pages.delete | Remove pages. Will not empty the document |
pages.reorder | order lists every page exactly once, in the order you want |
pages.extract | pages lists the ones to keep; everything else goes |
pages.insert | Blank pages. at a page id, a page number or "end"; position before or after; count how many (1–200, default 1); size a preset (A4, Letter, …) or { width, height } in points. Left out, it matches its neighbour. Inserted pages get ids n0, n1… that later operations can target |
Text and marks
| Operation | What it does |
|---|---|
text.add | Put text on the page. Top-left at 12 pt by default. text can contain newlines and is drawn as a block |
watermark.text | The same, centred by default, for a watermark. Add rotation: 45 for the diagonal look |
watermark.image | A PNG or JPEG, given as base64 in data, sized with width/height or scale |
header_footer | Running text. {page}, {pages} and {date} are filled in. Bottom centre by default |
bates.stamp | Sequential numbering: prefix, suffix, start, digits. Bottom right by default |
Which options each one takes — anything else is refused by name, so a typo is an error rather than silently ignored:
pages | size font color | opacity | position margin_pt | x y | rotation | Own options | |
|---|---|---|---|---|---|---|---|
text.add | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | text, line_height |
watermark.text | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | text, line_height |
watermark.image | ✓ | ✓ | ✓ | ✓ | ✓ | data, width, height, scale | |
header_footer | ✓ | ✓ | ✓ | ✓ | text | ||
bates.stamp | ✓ | ✓ | ✓ | ✓ | prefix, suffix, start, digits |
header_footer and bates.stamp are placed by position only: running text belongs in a margin, and a fixed point would land in a different place on every page size. margin_pt is the distance from the page edge when placing by position.
position is one of top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right — and it means the corner as a reader sees it, so a stamp on a rotated scan lands in the visual corner, the right way up.
Giving x and y instead puts the text baseline at that exact point, measured in points from the bottom-left of the page.
font is one of helvetica (the default), helvetica-bold, helvetica-oblique, times, times-bold, times-italic, courier, courier-bold, or a bundled Unicode face, unicode / unicode-bold.
The built-in fonts cover Latin only. If your text needs more — an accented name, Russian, Greek — a Unicode face is used automatically and the result tells you so with font_substituted. You can also ask for unicode directly.
Supported: Latin, Latin-Extended, Greek, Cyrillic and Vietnamese. Chinese, Japanese, Korean and right-to-left scripts such as Arabic and Hebrew are not supported, and are refused at preflight naming the character rather than drawn as boxes or blanks.
The whole document
| Operation | What it does |
|---|---|
metadata.set | title, author, subject, keywords, creator, producer, creation_date and modification_date (ISO 8601, e.g. "2026-09-16T10:30:00Z") |
metadata.strip | Clears the document properties and the XMP block. keep: ["title"] to hold on to some |
sanitize | Removes active content and tells you what it removed. remove picks from javascript, embedded_files, open_action, auto_actions, launch_actions and external_links; left out, it is everything except external_links, so hyperlinks survive unless you ask |
metadata.strip and sanitize remove the content from the file rather than just unlinking it, so what came out is genuinely gone.
Form fields
| Operation | What it does |
|---|---|
form.fill | Fills form fields by name. fields maps a field name to its value |
{
"op": "form.fill",
"fields": {
"applicant.name": "Ada Lovelace",
"agree": true,
"country": "Ireland",
"languages": ["English", "Welsh"]
}
}
Text fields take a string, check boxes true or false, radio groups and dropdowns one option, and list boxes one option or an array. Use the names exactly as /pdf/inspect reports them — they are fully qualified, so a field inside a group is applicant.name.
Call /pdf/inspect with "for": "edit" first. The response carries a form object listing every field with its type, current value, the options it offers, whether it is required or read-only, and which page it sits on — everything you need to build the fill without guessing.
Values are checked before anything is written. A value that is not one of the field's options, text longer than the field allows, a line break in a single-line field, or a read-only field are all refused at preflight, with details saying what the field would have accepted. A viewer would take some of these and quietly correct them; preflight tells you instead.
Filling the same field twice in one recipe is fine — the last value wins.
font sets the face the filled values are drawn in, from the same list as marks. Leave it out and a font that can draw the values is chosen for you.
Non-Latin values work. The text that appears in a filled field is drawn at save time, and the font is chosen during preflight, so a name in Cyrillic or Greek is drawn rather than failing when the file is written.
Documents with no form fields are refused with PDF_NO_FORM, and XFA (LiveCycle) forms with PDF_XFA_FORM — their fields live in an XML payload this API does not write. Inspecting a document never adds a form to it and never damages an XFA one.
Flattening
| Operation | What it does |
|---|---|
form.flatten | Draws the fields onto the page and removes them from the form |
{ "op": "form.flatten", "fields": ["applicant.name", "agree"] }
Leave fields out to flatten everything that can be flattened. The filled values become part of the page: still visible, no longer editable, and no longer extractable as form data. This cannot be undone — effects.irreversible marks it.
Fill and flatten in the same recipe to produce a finished, non-editable document in one call:
[
{ "op": "form.fill", "fields": { "applicant.name": "Ada Lovelace" } },
{ "op": "form.flatten" }
]
If you name a field that cannot be flattened — a signature field, or one with nothing to draw — the recipe is refused with FIELD_NOT_FLATTENABLE and a reason for each. If you leave fields out, those are skipped instead and listed in effects.skipped, so one signature field does not stop the rest.
Annotations
| Operation | What it does |
|---|---|
annotate.highlight | Highlights text — found by what it says with text, or at coordinates with rects |
annotate.note | Leaves a sticky note |
annotate.stamp | Places an image as a stamp |
annotations.flatten | Draws annotations into the page and removes them |
| Options | |
|---|---|
annotate.highlight | text or rects; pages; match_case, whole_word (with text); color, opacity, contents, author |
annotate.note | x, y, contents (required); icon — Note (default), Comment, Key, Help, NewParagraph, Paragraph, Insert; open to show it expanded; color, author; pages |
annotate.stamp | data (base64 PNG or JPEG), x, y; size with width/height or scale; opacity, contents, author; pages |
annotations.flatten | pages; types — annotation subtypes such as ["Highlight", "Stamp"]. Left out, everything that can be flattened |
contents is the comment text a reader shows; author is who it is from.
Highlighting text by what it says
Give text and every place it appears is highlighted — no coordinates needed:
{
"op": "annotate.highlight",
"text": "Confidential",
"whole_word": true,
"contents": "Remove before sending"
}
Leave pages out to search the whole document, or narrow it with "pages": ["p3", "p4"]. Each occurrence becomes its own highlight, so each shows as a separate comment. A phrase that wraps onto the next line is still found, and is one highlight across both lines.
match_case | true to match capitals exactly. Default false |
whole_word | true so port does not match inside report. Default false |
Worth knowing:
- It searches the document as you uploaded it. It still lands on the right page after an earlier
pages.reorderorpages.deletein the same recipe, but it will not find text atext.addearlier in the recipe put there, and a pagepages.insertadded is blank. - It needs a text layer. A scanned page is a picture of text, so there is nothing to find until it has been through OCR. If none of the pages searched has any text at all, the error says so rather than just "not found".
- Not found is an error,
TEXT_NOT_FOUND, rather than a save that quietly changes nothing. Preflight reports it before anything is charged. On success it returnseffects.matches— which pages matched and how many times — andeffects.highlights, one entry per highlight with itspage_idandrects, so you can show where each one will land before saving. - At most 500 highlights from one operation. Past that it is refused with
TOO_MANY_MATCHESand the count; narrow it withpages, a longer phrase,match_caseorwhole_word. - Text drawn at an angle within the page — not a rotated page, which is fine — is not searched.
- A match that is a whole word or label is placed exactly. A match in the middle of a longer run of text is placed by estimated character widths, which can put its edges a fraction of a character out.
Highlighting by coordinates
{
"op": "annotate.highlight",
"pages": ["p2"],
"rects": [{ "x": 72, "y": 320, "width": 260, "height": 13 }],
"contents": "Check this clause",
"author": "Ada"
}
Several rectangles make one highlight, which is what you want for a selection that wraps across lines — pass one rectangle per line. This is what the dashboard sends when you select text on the page.
Coordinates are PDF user space — bottom-left origin, page rotation not applied — the same as x and y on any mark. annotate.stamp places an image with its bottom-left corner at x, y, exactly as watermark.image does, so the same point gives the same placement whichever you use.
An annotation placed by coordinates goes on exactly one page. Leaving pages out means "every page" for a watermark, but the same coordinates on every page is not something anyone wants, so here it is refused. (text is the exception: each page gets its own matches, so leaving pages out searches them all.)
An annotation is not part of the page. It can be selected, edited and deleted in any reader. That is usually the point — it is a comment, not a change. When you want it permanent, flatten it:
[
{ "op": "annotate.highlight", "pages": ["p0"], "rects": [{ "x": 72, "y": 700, "width": 200, "height": 14 }] },
{ "op": "annotations.flatten" }
]
Flattening cannot be undone. annotations.flatten will not touch form fields (use form.flatten), hyperlinks, or popups, and naming one of those in types is refused. Sticky notes cannot be flattened — a reader draws their icon, so there is no artwork to burn in, and throwing the note away would lose its text. They are left alone and listed in effects.skipped.
Highlights are drawn with a multiply blend, so the words stay readable through the colour rather than being painted over.
Deleting a page deletes its fields
pages.delete and pages.extract remove any form field left with no widget on a surviving page. Without that the field would stay in the form — invisible and unfillable, but still listed when you inspect the result. A field with widgets on several pages keeps the ones still there.
Errors
| Status | Code | What happened |
|---|---|---|
| 400 | INVALID_RECIPE | The recipe is empty, not an array, or longer than 200 operations |
| 400 | UNKNOWN_EDIT_OP | No such operation. details.supported lists them |
| 400 | INVALID_EDIT_OP | An option is missing, out of range, or misspelled. details.field says which |
| 400 | INVALID_PAGE_SELECTION | pages is neither a page id nor a range |
| 400 | PAGE_ID_UNKNOWN | There is no page with that id |
| 400 | PAGE_ID_DELETED | An earlier operation in this recipe removed that page |
| 400 | FONT_GLYPH_UNSUPPORTED | No available font can draw a character. details.character, and details.suggested_font where one exists |
| 400 | UNKNOWN_FIELD | No form field of that name. details.available lists real ones |
| 400 | INVALID_FIELD_VALUE | The field would not accept that value. details.options, details.max_length |
| 400 | FIELD_READ_ONLY | That field is marked read-only in the document |
| 400 | FIELD_NOT_FLATTENABLE | A named field cannot be flattened. details.fields gives a reason each |
| 400 | PDF_PAGE_LIMIT_EXCEEDED | More than 200 pages. Split it first |
| 400 | TOO_MANY_MATCHES | A text highlight would add more than 500. details.matches has the count |
| 400 | PDF_INPUT_TOO_LARGE | The document is over 150 MB, or over 4 MB sent inline |
| 403 | PDF_EDITOR_NOT_ENABLED | Your plan does not include the editor |
| 403 | FORBIDDEN | That source_key belongs to another account |
| 404 | UPLOAD_NOT_FOUND | Nothing at that source_key, or the upload expired |
| 408 | PDFOP_TIMEOUT | The save took longer than 25 seconds. Nothing is charged; try fewer changes in one save |
| 409 | COMMIT_IN_PROGRESS | That commit_token is already being processed |
| 422 | PDF_ENCRYPTED | Password-protected. Unlock it first and upload the unlocked copy |
| 422 | INVALID_PDF | Not a PDF, or damaged |
| 422 | PDF_NO_PAGES | The document has no pages, or the recipe would remove them all |
| 422 | PDF_NO_FORM | form.fill on a document with no form fields |
| 422 | PDF_XFA_FORM | An XFA (LiveCycle) form, which cannot be filled |
| 422 | NO_ANNOTATIONS | There are no annotations to flatten |
| 422 | TEXT_NOT_FOUND | A text highlight found nothing. details.has_text_layer is false when the pages are scans |
Every error names the operation that caused it with details.index and details.op.
Things worth knowing
- Saving rewrites the whole file. If the document carries a digital signature, that signature will no longer validate afterwards. Preflight tells you in advance with
signatures_invalidated. - Bookmarks are not carried over when pages are deleted or reordered.
- Password-protected documents cannot be edited. Unlock the document with
POST /pdf/unlockfirst, and protect the edited copy again afterwards if it needs it. - Forms are preserved, because pages are changed in place rather than copied into a new document.