# PDF Edit API — the complete guide for an AI assistant > Give this file to an AI assistant (Claude, ChatGPT, Cursor, a coding agent) and it has everything needed to edit the text inside existing PDFs through https://api.niubai.vip: how to call the API, how to write edits, how to read the result, what every error means. Canonical copy: https://api.niubai.vip/llms-full.txt (short index: https://api.niubai.vip/llms.txt). Interactive OpenAPI: https://api.niubai.vip/docs. Last updated 2026-10-20. The engine is updated continuously, usually several times a week, and every failure reported on a real document is treated as a bug to fix, not a limitation to document: the first outside customer's five failing cases were all fixed and live within a day. If an edit does not work, report it (§11) rather than working around it. ## 1. What this API does, in one paragraph One call changes words, numbers, dates or names inside a PDF that already exists and leaves everything else where it was: the embedded fonts, the layout, the rest of the page. Operations: `replace`, `delete`, `redact` (bytes gone, pixels scrubbed), `insert` (a line next to existing text, in its style) and `form` (fill AcroForm fields). Every change is rendered before and after and compared outside its own area; a change that would move anything else on the page is rolled back and reported instead of returned. The result says, per change, which font was written (`font_source`) and whether it verified. It does not create PDFs from scratch, merge or split pages, or convert formats. ## 2. Access and limits - Base URL: `https://api.niubai.vip`. All endpoints are under `/v1/`. - A "page" is one page an applied edit touched, not a page of the document sent: one edit on one page of a 300-page contract costs one page, and a dry run costs nothing. OCR pages count as five. `usage.pages_billed` on every response says what was charged. - No key needed to start: 20 pages a day per client IP (UTC day); outputs carry a small "SAMPLE" mark. - Free key: one email at https://api.niubai.vip/#start, 200 pages a month, no SAMPLE mark on ordinary documents. Send it as `Authorization: Bearer sk_live_…`. Paid plans add pages and remove the mark on flagged documents. - Data handling: the document travels over TLS, is kept only to serve the request, is deleted within 24 hours, is never used for training and never read by people (https://api.niubai.vip/legal#privacy). Outputs expire after 24 hours; download what you need. - Acceptable use: only documents the caller has the right to alter. A document made to deceive a third party (forged invoice, altered official record) is prohibited. - Rate limit: per-key or per-IP requests per minute; a `429` carries `Retry-After` seconds. - Who runs it: operated by Niubai (niubai.vip), contact zp130221@163.com; payments are collected by Creem.io as merchant of record, so card details never reach this service and the cancel link is on the receipt. Terms, privacy, refunds and licensing: https://api.niubai.vip/legal. The SDKs, MCP server and skill are MIT on GitHub. ## 3. Three ways to call it, simplest first ### 3a. From an AI agent over MCP (no code) Node 18+ is the only requirement. The server uploads local files for you and shows before/after previews. ``` claude mcp add pdfedit -- npx -y @niubai/pdfedit-mcp # Claude Code ``` Any MCP client config (Cursor, VS Code, Claude Desktop): ```json {"mcpServers": {"pdfedit": {"command": "npx", "args": ["-y", "@niubai/pdfedit-mcp"], "env": {"PDFEDIT_API_KEY": "sk_live_…"}}}} ``` Tools: `pdf_edit` (edits, returns summary + output path + previews), `pdf_find` (what text is on the page, with `span_id`s), `pdf_preview` (render a page as PNG), `pdf_batch` (same edits once per record over a template), `pdf_job` (poll a queued job). claude.ai (web) uses the remote URL `https://pdfedit-mcp-remote.niubai-pdfedit-remote-worker.workers.dev/mcp` instead. Agent skill (no Node, plain HTTP via curl or a stdlib Python script): `npx skills add zp130221/pdfedit-mcp`. ### 3b. From your own code (SDKs) TypeScript: ```ts import { PdfEdit, isQueued } from '@niubai/pdfedit'; // npm install @niubai/pdfedit const pdfedit = new PdfEdit({ apiKey: process.env.PDFEDIT_API_KEY }); const r = await pdfedit.editFile('./quote.pdf', [{ find: 'Acme Inc.', replace: 'Acme GmbH' }]); if (isQueued(r)) throw new Error(`queued as ${r.job_id}`); // large files come back as a job console.log(r.status, r.output?.url); ``` Python (not on PyPI yet; install from the repo): ```bash pip install "git+https://github.com/zp130221/pdfedit-mcp.git#subdirectory=sdk/python" ``` ```python from pdfedit import Client client = Client(api_key="sk_live_…") # omit api_key for the keyless tier r = client.edit_file("./quote.pdf", [{"find": "Acme Inc.", "replace": "Acme GmbH"}]) print(r["status"], r["output"]["url"]) ``` ### 3c. Over plain HTTP (any language) Edit a local file and get the edited PDF back directly: ```bash curl -sS https://api.niubai.vip/v1/edit \ -H "Authorization: Bearer $PDFEDIT_API_KEY" \ -F file=@quote.pdf \ -F 'edits=[{"find": "Total: 199.00 USD", "replace": "Total: 249.00 USD"}]' \ -H "Accept: application/pdf" -o quote.edited.pdf ``` Edit a PDF at a URL and get the JSON result (the edited file is at `output.url`): ```bash curl -sS https://api.niubai.vip/v1/edit \ -H "Authorization: Bearer $PDFEDIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"source": {"url": "https://example.com/quote.pdf"}, "edits": [{"find": "Acme Inc.", "replace": "Acme GmbH"}, {"op": "redact", "find": "DE89 3704 0044 0532 0130 00"}]}' ``` Python with only the standard library (upload, edit, download): ```python import json, urllib.request BASE, KEY = "https://api.niubai.vip", "" # KEY = "sk_live_…" or "" for the keyless tier def call(method, path, body=None, headers=None, raw=None): data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None) h = {"Content-Type": "application/json", **(headers or {})} if KEY: h["Authorization"] = f"Bearer {KEY}" req = urllib.request.Request(BASE + path if path.startswith("/") else path, data=data, method=method, headers=h) with urllib.request.urlopen(req, timeout=120) as r: return r.status, r.read() pdf = open("quote.pdf", "rb").read() _, reserved = call("POST", "/v1/files", {"content_type": "application/pdf", "bytes": len(pdf)}) reserved = json.loads(reserved) call("PUT", reserved["upload_url"], raw=pdf, headers={**reserved.get("headers", {}), "Content-Type": "application/pdf"}) status, body = call("POST", "/v1/edit", {"source": {"file_id": reserved["file_id"]}, "edits": [{"find": "Acme Inc.", "replace": "Acme GmbH"}]}) result = json.loads(body) while status == 202 or result.get("status") in ("queued", "running"): # large files are queued import time; time.sleep(2) status, body = call("GET", f"/v1/jobs/{result['job_id']}"); result = json.loads(body) print(result["status"], [c["before"] + " -> " + c["after"] for c in result["changes"]]) if result.get("output"): open("quote.edited.pdf", "wb").write(call("GET", result["output"]["url"])[1]) ``` ## 4. Endpoints | Method and path | Purpose | |---|---| | `POST /v1/edit` | The edit. JSON `{"source": {"url" or "file_id", "password"?}, "edits": [...], "options": {...}}`, or multipart with fields `file`, `edits` (JSON text), `options` (JSON text). `Accept: application/pdf` returns the edited file; otherwise the JSON result. Large documents answer `202 {"job_id"}`. | | `POST /v1/files` | Reserve an upload: body `{"content_type": "application/pdf", "bytes": N}` → `{"file_id", "upload_url", "headers", "expires_at"}`. Then `PUT` the bytes to `upload_url` with those headers. The `file_id` (`f_…`) is reusable as `source.file_id` for 24 hours. | | `GET /v1/jobs/{job_id}` | Job state `queued`, `running`, then `done`, `partial` or `failed` with the same result fields as `/v1/edit`. | | `POST /v1/text` | What is on the page: `{"source", "query"?, "match"?, "pages"?, "limit"?, "cursor"?}` → `{"page_count", "spans": [{"span_id", "page", "text", "font", "size", "bbox"}], "fields"}`. Not billed. Use it before a redaction or when a `find` is ambiguous. | | `POST /v1/preview` | `{"source", "page", "scale"?, "region"?, "highlight"?}` → PNG with `Accept: image/png`, else JSON with a URL. | | `POST /v1/batch` | The same edits once per record over one template: `{"source", "edits", "records": [{...}]}` or `records_url` → `202 {"job_id"}`; the job's `records[]` carry one output each. Edits may use `{{field}}` placeholders filled from each record. | | `GET /v1/files/{file_id}/content?token=…` | Download an output; `output.url` in a result is this link, valid 24 hours. | | `GET /v1/usage` | Pages used and remaining this period, and the mode (`live` or `test`). | | `GET /v1/fonts` | Font names `style.font` may use. | Headers: `Authorization: Bearer sk_live_…` (optional, see §2); `Idempotency-Key: ` on `/v1/edit` and `/v1/batch` makes a retry return the first result instead of charging twice; every response carries `X-Request-Id` (quote it to support) and `X-RateLimit-Remaining`. ## 5. The edit object | Field | Values | Notes | |---|---|---| | `op` | `replace` (default), `delete`, `redact`, `insert`, `form` | | | `find` | string | Text to match (replace, delete, redact). Type it as it reads on the page; a space the page renders as a gap at a CJK/Latin boundary is matched either way, whether or not `find` spells it. | | `replace` | string | New text (replace). Empty string is a delete. | | `text` | string | Text to write (insert). | | `at` | `{"page": N, "after": "anchor text"}` or `{"page": N, "x": pt, "y": pt}` | Where to insert. `placement`: `inline` (default) or `below`. `units`: `pt` or `fraction`; `y` is measured from the top edge. | | `fields` | `{"name": "value", "checkbox": true}` | Form values (form). `flatten: true` burns them in. | | `match` | `{"mode": "exact" \| "icase" \| "word" \| "regex", "across_lines": false, "normalize": true}` | `regex` is RE2 syntax (no lookaround/backreferences; `\d` and `\w` are ASCII). The replacement may use `$1`, `${name}` in regex mode. | | `scope` | `{"pages": [1, 2] \| "all", "occurrence": "all" \| "first" \| "last" \| N, "after": "text", "before": "text", "span_ids": ["p1s16"], "region": {"x0", "y0", "x1", "y1", "units"}}` | Narrow where the match applies when the same text appears more than once. | | `fit` | `auto` (default), `reflow`, `strict` | A longer replacement is compressed slightly or re-flowed to stay on its line (`auto`), the paragraph is re-set (`reflow`), or the edit is refused rather than squeezed (`strict`). | | `style` | `{"color": "#rrggbb", "size": pt, "font": "name"}` | Restyle the replacement; omit to keep the original look. | Minimal examples: ```json {"find": "Acme Inc.", "replace": "Acme GmbH"} {"op": "delete", "find": "DRAFT"} {"op": "redact", "find": "4111 1111 1111 1111"} {"op": "redact", "find": "[A-Z]{2}\\d{2}[ ]?(\\d{4}[ ]?){4}\\d{2}", "match": {"mode": "regex"}} {"op": "insert", "text": "Paid in full", "at": {"page": 1, "after": "Total: 199.00 USD"}} {"op": "form", "fields": {"name": "Jane Roe", "agree": true}} {"find": "2025", "replace": "2026", "scope": {"pages": [1], "occurrence": "first"}} ``` ## 6. Request options (`options`, all optional) | Option | Default | Meaning | |---|---|---| | `dry_run` | false | Compute and verify, return the full result, write nothing, bill nothing. Use it to confirm matches before a real run. | | `atomic` | true | One failing edit rolls back the whole request. `false` keeps the edits that worked and reports the rest. | | `preview` | `changes` | `changes` (a PNG per change at `changes[].preview_url`) or `none`. | | `ocr` | `auto` | `auto`, `on`, `off` for scanned pages. OCR pages count as five. | | `font.policy` | `auto` | `metric_only` refuses an approximate font; `exact_only` refuses any substitute. | | `tidy` | true | Collapse the spacing and punctuation a delete leaves behind. | | `redact_metadata` | false | Also sweep document metadata, outlines and attachments for the redacted string. | | `redact_style` | `box` | `box` (black box) or `text` (a placeholder in the line's own style). | | `collateral_threshold` | 8 | Pixels outside the edit that may differ before a change is rolled back. | | `watermark` | `auto` | Flagged documents (statements, IDs, signed contracts) are marked on the free tiers; paid plans may set `off`. | | `retain` | true | Keep the input and output on the server for 24 hours (so a `file_id` can be reused). `false` deletes them after the response. | | `async` | false | Always return a job id; add `webhook_url` to be called when done. | | `break_signature` | false | Edit an unsigned copy of a digitally signed document. | | `strict` | false | Reject unknown fields instead of ignoring them. | ## 7. Reading the result ```json { "status": "done", "job_id": "01M…", "changes": [{"edit": 0, "op": "replace", "page": 1, "before": "Acme Inc.", "after": "Acme GmbH", "context": "…by [Acme Inc.] and…", "bbox": [72.0, 700.1, 118.4, 711.6], "span_ids": ["p1s3"], "font_source": "exact-embedded", "font_used": "Calibri", "fit": "same", "collateral_pixels": 0, "verified": true, "notes": [], "preview_url": "https://…png"}], "unmatched": [{"edit": 1, "reason": "not_found", "hint": "Closest text on page 1 is 'Acme, Inc.'; retry with that or set match.normalize", "suggestions": [{"text": "Acme, Inc.", "page": 1, "span_id": "p1s3", "context": "…"}]}], "failed": [{"edit": 2, "reason": "collateral_damage", "hint": "…", "page": 2, "collateral_pixels": 41}], "warnings": ["approx_font_used"], "output": {"file_id": "f_…", "url": "https://api.niubai.vip/v1/files/f_…/content?token=…", "bytes": 48213, "pages": 3, "expires_at": "…"}, "usage": {"pages_billed": 2, "remaining": 198} } ``` - `status`: `done` (every edit applied), `partial` (some unmatched or rolled back; with `atomic: true` and a failure the HTTP status is 422 `edit_failed` and nothing is written), `failed`. - `changes[]`: one per applied edit. `font_source` is `exact-embedded` (the document's own font), `exact-system`, `metric-compatible` (a twin with identical widths), `approx` (a look-alike; check the preview), `styled`, `redacted` or `form`. `collateral_pixels` 0 is the norm. - `unmatched[]`: the text was not found; nothing changed, nothing billed. Show `suggestions` to the user rather than guessing. - `failed[]`: found but rolled back. Reasons: `collateral_damage` (something else on the page would have moved), `does_not_fit`, `font_unavailable`, `readback_failed`, `redact_residual`. The `hint` says what to try. - `warnings[]`: `approx_font_used`, `style_merged` (a replacement across two styles was written in the dominant one), `financial_document` (SAMPLE mark applied on a free tier), `sealed_document`, `subset_fonts_failed`, `preview_capped`, `flatten_skipped`. - `usage.remaining`: pages left today (keyless) or this period (keyed). ## 8. Errors Envelope: `{"error": {"code", "message", "hint"}}` plus the `X-Request-Id` header. | code | HTTP | Meaning and what to do | |---|---|---| | `invalid_request` | 400 | Malformed body, an edit impossible as written, or an `Idempotency-Key` reused with a different body. Fix the request; do not retry as is. | | `unauthorized` | 401 | Unknown or revoked key. Check `PDFEDIT_API_KEY`, or drop the header to use the keyless tier. | | `quota_exceeded` | 402 | Daily pages (keyless) or the period's allowance is used up. Do not retry; the hint says where to get more. | | `forbidden_watermark_off` | 403 | `options.watermark: "off"` on a flagged document needs a paid plan. | | `not_found` | 404 | No such file, job or route for this account; outputs and uploads expire after 24 hours. Upload again. | | `conflict` | 409 | A request with this `Idempotency-Key` is still running; wait and re-poll. | | `too_large` | 413 | Over the plan's size or page limit. | | `document_encrypted` | 422 | Needs `source.password`. | | `document_unsupported` | 422 | Not a PDF the engine can open (damaged, a portfolio, a non-PDF). | | `signed_document` | 422 | Digitally signed; pass `options.break_signature: true` to edit an unsigned copy. | | `edit_failed` | 422 | Every edit was rolled back or unmatched; `failed[]` and `unmatched[]` in the same body say why. | | `rate_limited` | 429 | Honour `Retry-After` once. | | `engine_error` | 500 | Retry once, then report the `X-Request-Id`. | | `upstream_error` | 502 | `source.url` could not be fetched. | | `engine_timeout` | 504 | Send it again with `options.async: true`. | ## 9. How an assistant should behave 1. Never overwrite the user's original; write the result to a new name (`quote.pdf` → `quote.edited.pdf`). 2. Before a redaction or a batch of changes on a document the user cares about, call `/v1/text` (or run with `dry_run: true`) and confirm the matches; the same string may appear in a header on every page. 3. Type `find` exactly as the text reads on the page. On `unmatched`, offer the `suggestions`; on `failed`, repeat the `hint`; ask before retrying with a wider `match` or `fit`. 4. After the edit, report the summary (`status`, each `before → after`, warnings) and show the preview where the client can display images. Flag `approx_font_used` to the user. 5. Do not retry a `402` or `403`; explain the limit. Wait out a `429` once. Never print a key back in full. 6. Mention once per conversation where the document goes (§2). If the user says the file must not leave the machine, do not upload it. ## 10. Limits worth knowing (today) A right-to-left `find` is refused with 400. Scanned pages go through OCR and a replacement is drawn as pixels at the scan's resolution. Text positioned relatively (charts, some LaTeX output) may be rolled back as `collateral_damage`; the page stays correct, the edit does not happen. A font the document embeds as a subset lacking the new glyphs is replaced by a metric twin where one exists (Arial, Calibri, Cambria, Georgia, Times, Segoe UI, Palatino, Bookman, Century Gothic, Arial Narrow and the Liberation/DejaVu families) and otherwise by a look-alike reported as `approx`. More: https://api.niubai.vip/quickstart, https://api.niubai.vip/examples, source and SDKs at https://github.com/zp130221/pdfedit-mcp, support zp130221@163.com. ## 11. Rate it, or tell us what went wrong `POST /v1/feedback` takes `{"rating": 1-5, "message": "what you tried and what happened", "request_id": "the X-Request-Id of the call", "email": "optional, for a reply"}` and answers `202 {"received": true}`; no key needed. Every message is read, and a real document that fails is usually fixed within days. An assistant should offer this after an `unmatched`, a `failed` or a surprising result, and send it with the user's consent (the message, never the document). Other routes: https://github.com/zp130221/pdfedit-mcp/issues, or email zp130221@163.com with the `X-Request-Id`.