Quick start
Three ways in. Pick the one that matches how you work; each part stands on its own.
1 · From an AI agent (MCP)Claude Code, Cursor, VS Code, Claude Desktop, claude.ai. The API as five tools; the agent uploads the file, picks the tool and shows the before/after preview in the conversation.
Install
| Client | How |
|---|---|
| Claude Code | claude mcp add pdfedit -- npx -y @niubai/pdfedit-mcp |
| Cursor | Add to Cursor or Settings → MCP → Add server, command npx -y @niubai/pdfedit-mcp |
| VS Code | Add to VS Code or Command Palette → MCP: Add Server → Command (stdio), command npx -y @niubai/pdfedit-mcp, name pdfedit |
| Claude Desktop | In claude_desktop_config.json: |
| claude.ai (web) | Cannot run a local server: add the hosted one as a custom connector, https://pdfedit-mcp-remote.niubai-pdfedit-remote-worker.workers.dev/mcp. Takes PDF URLs only. |
| No Node (Codex, Gemini CLI, any shell agent) | The same API as an Agent Skill, curl or python3 only: npx skills add zp130221/pdfedit-mcp. Claude Code can take MCP server and skill together as a plugin: /plugin marketplace add zp130221/pdfedit-mcp, then /plugin install pdfedit@pdfedit. |
No key is needed: without one the server uses the free tier (20 pages a day per IP, outputs carry a small SAMPLE mark). For more, add -e PDFEDIT_API_KEY=sk_live_your_key to the Claude Code line or an env entry to the JSON; a free key is 200 pages a month and takes one email. Step by step with screenshots: Claude · Cursor · VS Code.
Say what you want
A real run in Claude Code on the hosted sample: a name, a total and an IBAN, three edits, each verified.
Replace
In ./quote.pdf replace "Acme Inc." with
"Acme GmbH" and change "Total: 199.00 USD"
to "Total: 249.00 USD".The agent uploads the file, calls pdf_edit, writes quote.edited.pdf next to the original (the original is never touched) and shows a before/after image of the changed page.
Redact
Redact every IBAN and every email address
in ./statement.pdf before I share it.Regex matches, bytes removed (not a black box drawn over them), metadata swept, and a report of anywhere the same string still appears.
Batch
Use ./offer-template.pdf and ./hires.csv:
one PDF per row, filling {name} and
{salary} from the columns.pdf_batch returns a job id; pdf_job hands back one finished document per row when it is done.
What comes back
One sentence the agent reads first (Replaced 2 occurrences on page 1. Output: ./quote.edited.pdf.), up to three before/after previews as images, and the full result as structured data: which font was written, anything unmatched or rolled back, and warnings such as approx_font_used spelled out so the agent tells you to check the preview.
The five tools
| Tool | What it does |
|---|---|
pdf_edit | Replace, delete, redact, insert or fill, in one call, with previews of the changed pages. |
pdf_find | Every matching span with page, box, font and span id, so an edit can be scoped to exactly one place. |
pdf_preview | Render a page or a region as a PNG, optionally with highlight boxes, to look before editing. |
pdf_batch | The same edits once per record over one template; returns a job id. |
pdf_job | The result of an async edit or a batch. |
Local install: tools take local paths or URLs and write the result beside the input. Hosted server: URLs only. Either way nothing is kept on our side beyond 24 hours. Full tool reference and what the server refuses, and why: the package README.
2 · From your own code (SDKs)The same API behind a typed client. Uploads, polling of large files and the error envelope are handled for you.
Get a key
Sign in at https://api.niubai.vip/account and press Create key. No account yet? Enter your email on the home page; the free plan is 200 pages a month and needs no card. The key starts with sk_live_ and is shown once.
Edit a file
sk_live_your_key below is the key from your account page.
TypeScript
npm install @niubai/pdfeditimport { PdfEdit, isQueued } from '@niubai/pdfedit';
const pdfedit = new PdfEdit({ apiKey: 'sk_live_your_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}`); // big files come back as a job
console.log(r.status, r.output?.url);Python
Not on PyPI yet (the name there is another project); install from the repo:
pip install "git+https://github.com/zp130221/pdfedit-mcp.git#subdirectory=sdk/python"from pdfedit import Client
client = Client(api_key="sk_live_your_key")
r = client.edit_file("./quote.pdf", [{"find": "Acme Inc.", "replace": "Acme GmbH"}])
print(r["status"], r["output"]["url"])The result is the response described below, as an object. Edits take the same five operations; a 429 or a queued job is retried and polled by the client, and every other error arrives as an exception carrying the error code.
3 · Over plain HTTP (REST)From a key to your first edited PDF. The first call needs no file of yours: it edits a sample we host.
Get a key
Sign in at https://api.niubai.vip/account and press Create key. No account yet? Enter your email on the home page; the free plan is 200 pages a month and needs no card. The key starts with sk_live_ and is shown once. To try first, leave the Authorization header out: 20 pages a day per IP, outputs carry a small SAMPLE mark.
Make one call
Replace a company name and redact an IBAN in the hosted sample. Paste, put your key in (sk_live_your_key is the one from your account page, or drop that line to try without one), run:
curl -sS https://api.niubai.vip/v1/edit \
-H "Authorization: Bearer sk_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"source": {"url": "https://api.niubai.vip/static/examples/sample-quotation.pdf"},
"edits": [
{"find": "Acme Inc.", "replace": "Acme GmbH"},
{"op": "redact", "find": "DE89 3704 0044 0532 0130 00"}
]
}'
Open the output.url from the response: page 1 says Acme GmbH in the document's own font and the IBAN is gone. The original, for comparison.
Read the response
{
"status": "done", // done | partial (some edits failed) | failed
"changes": [ // one per applied edit
{"page": 1, "op": "replace", "find": "Acme Inc.", "replace": "Acme GmbH",
"font_source": "exact-embedded", "font_used": "Calibri", ...},
{"page": 1, "op": "redact", ...}
],
"unmatched": [], // edits whose text was not found: nothing changed, nothing billed
"failed": [], // edits found but rolled back, with a reason
"warnings": [], // e.g. approx_font_used: worth reading; the document is still usable
"output": {"url": "https://api.niubai.vip/v1/files/f_…/content?…", "expires_at": "…"}, // the edited PDF, 24 h
"usage": {"pages_billed": 1, "remaining": 199}
}
Three lists tell you what happened: changes (applied, with a before/after check and the font that was written), unmatched (text not found; nothing was changed or billed) and failed (found, but rolled back because something else on the page would have moved; the reason says what). partial means at least one of each. A page is billed when an edit on it is applied.
Your own file
Send the PDF as a form upload. Add Accept: application/pdf to get the edited file back directly instead of JSON:
curl -sS https://api.niubai.vip/v1/edit \
-H "Authorization: Bearer sk_live_your_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
Files up to the plan's size limit go straight through; large ones come back as 202 with a job_id to poll at GET /v1/jobs/{job_id}. The SDKs do that for you.
The five operations
| op | What it does | Example edit |
|---|---|---|
replace | Change a phrase; same font, same spot, the rest of the line stays. | {"find": "Acme Inc.", "replace": "Acme GmbH"} |
delete | Remove a phrase and tidy the gap it leaves. | {"op": "delete", "find": "DRAFT"} |
redact | Remove it for good: bytes gone, pixels scrubbed, metadata swept. | {"op": "redact", "find": "DE89 3704 0044 0532 0130 00"} |
insert | Add a line at a point or next to existing text, in that text's style. | {"op": "insert", "text": "Paid in full", "at": {"after": "Total: 199.00 USD"}} |
form | Fill form fields by name. | {"op": "form", "fields": {"name": "Jane Roe", "agree": true}} |
Any edit takes match (exact, icase, word, regex) and scope (pages, a region, an anchor, the nth occurrence). The full shapes are in the API reference; ten real before/after pairs with their JSON are on the examples page.
Reference for the SDKs and RESTThe MCP server handles all of this for the agent.
When something goes wrong
Every error is one JSON envelope: {"error": {"code", "message", "hint"}}. Switch on code; it is a closed list. Quote the X-Request-Id response header when you write to support.
The error codes
| code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | The request is malformed, an edit is impossible as written, or an Idempotency-Key was reused with a different body. |
unauthorized | 401 | No key, an unknown key, or a revoked key. |
quota_exceeded | 402 | The month's allowance and any credit-pack pages are used up; buy a pack or upgrade on the account page. |
forbidden_watermark_off | 403 | options.watermark "off" on a flagged document needs a plan that allows it and accepted terms. |
not_found | 404 | No such file, job or route for this account. |
conflict | 409 | A request with this Idempotency-Key is still running; wait and re-poll, do not start another. |
too_large | 413 | The document exceeds the plan's size or page limit. |
document_encrypted | 422 | The PDF is encrypted and no (or a wrong) password was given. |
document_unsupported | 422 | The file is not a PDF the engine can open (damaged, a portfolio, a non-PDF). |
signed_document | 422 | The document carries a digital signature; pass options.break_signature true to edit it anyway. |
edit_failed | 422 | Every requested edit failed verification and was rolled back; see failed[] for the reasons. |
rate_limited | 429 | Over the plan's requests-per-minute, or the service is busy; honour Retry-After. |
engine_error | 500 | The engine crashed on this document; retry once, then report the X-Request-Id. |
upstream_error | 502 | source.url (or records_url) could not be fetched. |
engine_timeout | 504 | The synchronous run did not finish in time; send it again with options.async true. |
Retries: send an Idempotency-Key header on /v1/edit and /v1/batch; the same key returns the first result instead of charging twice, and a duplicate that arrives while the first is still running waits, then answers 409.
Get told when a job finishes
Instead of polling GET /v1/jobs/{job_id}, add "options": {"async": true, "webhook_url": "https://your.app/pdfedit"}. When the job finishes the gateway POSTs the result there.
What the gateway sends
POST https://your.app/pdfedit
Content-Type: application/json
X-Signature: 3f1c… // hex HMAC-SHA256 of the raw body, keyed with your webhook secret
{"event": "job.done", "job_id": "01J…", "status": "done",
"changes": [...], "unmatched": [], "failed": [], "warnings": [],
"output": {"url": "…", "expires_at": "…"}, "usage": {"pages_billed": 3}}Verify the signature before trusting the body: compute HMAC-SHA256 over the exact bytes received with your account's webhook secret and compare it to X-Signature in constant time. Answer 2xx within a few seconds; anything else is retried after 1, 5 and 30 minutes. The secret is on your account page.
Fonts
New text is set in the document's own font when the file embeds the glyphs it needs (any page's subset of that font counts). When it does not, a metric twin with the same widths stands in for the common Office, web and PostScript faces (Arial, Times New Roman, Courier New, Calibri, Cambria, Georgia, Segoe UI, Palatino, Book Antiqua, Bookman, Century Gothic, New Century Schoolbook, Arial Narrow), so the line ends where it did. Only when neither exists is a look-alike used, and then warnings carries approx_font_used; "options": {"font": {"policy": "metric_only"}} refuses that case instead. Every change reports font_source and font_used.
Good to know
- Ordinary documents are never watermarked. Documents the engine flags (bank statements, IDs, contracts with signatures) get a small mark on the free and Starter plans; Growth and Scale may turn it off.
- Scanned pages have no text layer; pass
"options": {"ocr": true}and the text is found by OCR first. - Outputs are kept 24 hours, then deleted. Download what you need.
- Requests are rate limited per key, generously for normal use. A
429(your limit, or the service momentarily busy) comes withRetry-After: wait that long and retry, or send the edit with"options": {"async": true}to queue it as a job. Send anIdempotency-Keyheader to make retries safe. - Usage and keys live on your account page;
GET /v1/usagereturns the same numbers.