Skip to main content

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.

EndpointWhat it does
POST /pdf/upload-urlGet a link to upload the document
POST /pdf/inspectOpen it: page count, page ids, sizes. Free
POST /pdf/edit/preflightSee what a recipe would do, before doing it. Free
POST /pdf/editApply 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 /quickjob or Templates
  • The document is longer than 200 pages — split it first, then edit the parts

Limits​

Pages in the document200
Document size150 MB
Size of the saved document200 MB
Changes in one save200
Time25 seconds
Downloads kept7 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
fileThe PDF
recipeThe recipe, as JSON
optionsJSON. output_filename names the result
commit_tokenSee 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​

OperationWhat it does
pages.rotateTurn pages. degrees in steps of 90; relative: true adds to the current rotation
pages.deleteRemove pages. Will not empty the document
pages.reorderorder lists every page exactly once, in the order you want
pages.extractpages lists the ones to keep; everything else goes
pages.insertBlank 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​

OperationWhat it does
text.addPut text on the page. Top-left at 12 pt by default. text can contain newlines and is drawn as a block
watermark.textThe same, centred by default, for a watermark. Add rotation: 45 for the diagonal look
watermark.imageA PNG or JPEG, given as base64 in data, sized with width/height or scale
header_footerRunning text. {page}, {pages} and {date} are filled in. Bottom centre by default
bates.stampSequential 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:

pagessize font coloropacityposition margin_ptx yrotationOwn 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​

OperationWhat it does
metadata.settitle, author, subject, keywords, creator, producer, creation_date and modification_date (ISO 8601, e.g. "2026-09-16T10:30:00Z")
metadata.stripClears the document properties and the XMP block. keep: ["title"] to hold on to some
sanitizeRemoves 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​

OperationWhat it does
form.fillFills 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​

OperationWhat it does
form.flattenDraws 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​

OperationWhat it does
annotate.highlightHighlights text — found by what it says with text, or at coordinates with rects
annotate.noteLeaves a sticky note
annotate.stampPlaces an image as a stamp
annotations.flattenDraws annotations into the page and removes them
Options
annotate.highlighttext or rects; pages; match_case, whole_word (with text); color, opacity, contents, author
annotate.notex, y, contents (required); icon — Note (default), Comment, Key, Help, NewParagraph, Paragraph, Insert; open to show it expanded; color, author; pages
annotate.stampdata (base64 PNG or JPEG), x, y; size with width/height or scale; opacity, contents, author; pages
annotations.flattenpages; 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_casetrue to match capitals exactly. Default false
whole_wordtrue 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.reorder or pages.delete in the same recipe, but it will not find text a text.add earlier in the recipe put there, and a page pages.insert added 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 returns effects.matches — which pages matched and how many times — and effects.highlights, one entry per highlight with its page_id and rects, 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_MATCHES and the count; narrow it with pages, a longer phrase, match_case or whole_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​

StatusCodeWhat happened
400INVALID_RECIPEThe recipe is empty, not an array, or longer than 200 operations
400UNKNOWN_EDIT_OPNo such operation. details.supported lists them
400INVALID_EDIT_OPAn option is missing, out of range, or misspelled. details.field says which
400INVALID_PAGE_SELECTIONpages is neither a page id nor a range
400PAGE_ID_UNKNOWNThere is no page with that id
400PAGE_ID_DELETEDAn earlier operation in this recipe removed that page
400FONT_GLYPH_UNSUPPORTEDNo available font can draw a character. details.character, and details.suggested_font where one exists
400UNKNOWN_FIELDNo form field of that name. details.available lists real ones
400INVALID_FIELD_VALUEThe field would not accept that value. details.options, details.max_length
400FIELD_READ_ONLYThat field is marked read-only in the document
400FIELD_NOT_FLATTENABLEA named field cannot be flattened. details.fields gives a reason each
400PDF_PAGE_LIMIT_EXCEEDEDMore than 200 pages. Split it first
400TOO_MANY_MATCHESA text highlight would add more than 500. details.matches has the count
400PDF_INPUT_TOO_LARGEThe document is over 150 MB, or over 4 MB sent inline
403PDF_EDITOR_NOT_ENABLEDYour plan does not include the editor
403FORBIDDENThat source_key belongs to another account
404UPLOAD_NOT_FOUNDNothing at that source_key, or the upload expired
408PDFOP_TIMEOUTThe save took longer than 25 seconds. Nothing is charged; try fewer changes in one save
409COMMIT_IN_PROGRESSThat commit_token is already being processed
422PDF_ENCRYPTEDPassword-protected. Unlock it first and upload the unlocked copy
422INVALID_PDFNot a PDF, or damaged
422PDF_NO_PAGESThe document has no pages, or the recipe would remove them all
422PDF_NO_FORMform.fill on a document with no form fields
422PDF_XFA_FORMAn XFA (LiveCycle) form, which cannot be filled
422NO_ANNOTATIONSThere are no annotations to flatten
422TEXT_NOT_FOUNDA 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/unlock first, 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.