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.
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 }
A document under 4 MB can skip the upload and be posted straight to /pdf/edit as multipart or base64, the same way merge and split accept files.
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; size a preset (A4, Letter, …) or { width, height } in points. Left out, it matches its neighbour |
Text and marks
All of these take pages, size, font, color, opacity, and either position or x/y.
| 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 |
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, and dates |
metadata.strip | Clears the document properties and the XMP block. keep: ["title"] to hold on to some |
sanitize | Removes JavaScript, embedded files, automatic actions and launch actions, and tells you what it removed. Add "external_links" to remove to take hyperlinks out too |
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.
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 one or more rectangles on a page |
annotate.note | Leaves a sticky note |
annotate.stamp | Places an image as a stamp |
annotations.flatten | Draws annotations into the page and removes them |
{
"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.
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 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.
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.
Extracting a box of text
POST /pdf/extract pulls the text inside a rectangle, from one page or from every page. Nothing is
written and no PDF comes back — it returns data.
The shape this is for is define a box once, take it from every page: statement packs, per-page invoice numbers, batch-scanned forms with a consistent layout.
{
"source_key": "pdf-uploads/u/statements.pdf",
"rect": { "x": 390, "y": 675, "width": 190, "height": 40 }
}
rect is in PDF user space — bottom-left origin, page rotation not applied — the same as x and
y anywhere else. Add "pages": [2, 5] for particular pages, or "format": "csv" for a CSV string
instead of rows.
You get one row per page whether or not anything was found, so rows always line up with pages:
{
"rows": [
{ "page": 1, "page_id": "p0", "text": "Ref: INV-1001", "items": 1 },
{ "page": 2, "page_id": "p1", "text": "", "items": 0 }
],
"found": 1,
"page_count": 2
}
A line is taken when its centre is inside the box, so a rectangle drawn tightly around a line still catches it even if an ascender pokes out. Text is returned in reading order — down the page, then across — rather than in the order the document happened to store it.
This is charged like a save, once, even though no file is produced.
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 |
| 403 | PDF_EDITOR_NOT_ENABLED | Your plan does not include the editor |
| 403 | UPLOAD_FORBIDDEN | That source_key belongs to another account |
| 404 | UPLOAD_NOT_FOUND | Nothing at that source_key, or the upload expired |
| 409 | COMMIT_IN_PROGRESS | That commit_token is already being processed |
| 413 | PDF_INPUT_TOO_LARGE | The document is over 150 MB |
| 422 | PDF_ENCRYPTED | Password-protected. Remove the protection and upload it again |
| 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 |
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. Remove the protection first.
- Forms are preserved, because pages are changed in place rather than copied into a new document. Filling form fields is not available yet.