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.

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 }

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

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; 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.

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

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, and dates
metadata.stripClears the document properties and the XMP block. keep: ["title"] to hold on to some
sanitizeRemoves 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

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.

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 undoneeffects.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 one or more rectangles on a page
annotate.noteLeaves a sticky note
annotate.stampPlaces an image as a stamp
annotations.flattenDraws 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

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
403PDF_EDITOR_NOT_ENABLEDYour plan does not include the editor
403UPLOAD_FORBIDDENThat source_key belongs to another account
404UPLOAD_NOT_FOUNDNothing at that source_key, or the upload expired
409COMMIT_IN_PROGRESSThat commit_token is already being processed
413PDF_INPUT_TOO_LARGEThe document is over 150 MB
422PDF_ENCRYPTEDPassword-protected. Remove the protection and upload it again
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

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.