API documentation
Version 1 · Last updated: October 2026
Overview
The PPWEditor.com API runs the web editor’s PDF processing for these features: text and image watermarks, headers and footers with page numbers, document metadata, redaction, rasterizing pages to images, password protection (encryption) and a digital signature. You upload a PDF as multipart/form-data, set the options in a few form fields, and get the processed PDF back, either directly in the response or as a job that you poll and then download.
API usage and web editor usage share one account: the same monthly free pages and the same page-credit balance.
| Method | Path | What it does |
|---|---|---|
| POST | /process | Process one PDF. Returns the PDF, or a job when pages are rasterized. |
| POST | /batch | Process up to 50 PDFs with the same options. Always a job; the result is a ZIP. |
| GET | /jobs/{jobId} | Status and progress of a job. |
| GET | /jobs/{jobId}/result | Download a finished job’s file. |
| GET | /usage | Your monthly free pages, the pages used this month and your page-credit balance. |
Getting access
API access is approved by hand, per account.
- Sign in on PPWEditor.com and open /developers.
- Request API access. Add a short note about what you’ll use it for (up to 500 characters).
- We review the request. Once it’s approved, create a key on the same page and give it a name you’ll recognize.
- Copy the key right away. It is shown once: we store only a hash of it, so it can’t be shown again. If you lose it, revoke it and create a new one.
You can have up to 5 active keys. Revoke keys you no longer use on /developers; a revoked key stops working on the next request. If API access for your account is revoked, all of its keys are revoked with it and stay revoked even if access is approved again later.
Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer ppw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx- Keys start with
ppw_. A request without a usableAuthorization: Bearer <key>header gets401 UNAUTHORIZED: the header is missing, empty (for example because the environment variable that should hold the key is unset) or malformed. - A token that doesn’t start with
ppw_, or isn’t an active key, gets401 INVALID_API_KEY. A key stops being active when it is revoked or deleted, or when its account no longer exists. - A request that carries a Bearer token is judged on that key alone, never on a browser session cookie.
- Server-side only. The API sends no CORS headers, so browsers block calls from web pages on other origins, and a key placed in front-end code is a leaked key. Call the API from your backend, scripts or scheduled jobs, keep the key in an environment variable or secrets store, and never commit it.
- If a key leaks, revoke it on /developers and create a new one.
Base URL and request format
https://ppweditor.com/api/v1- Use HTTPS. All paths below are relative to this base URL.
POSTrequests aremultipart/form-data. Files go in file fields; each option group (watermark,headerFooter,metadata,redact,protect) is a JSON object sent as a text field;forceRender,flattenQualityandfilenameSuffixare plain text fields. See the Options reference for defaults.- The whole request body, all files and fields together, is limited to 100 MB (104,857,600 bytes) by the web server. Larger requests are answered with
413before they reach the API, and that response is not JSON. - Responses are JSON (
application/json) or the processed file. - With curl, send JSON fields with
--form-stringrather than-F:-Fgives special meaning to a leading@or<and to;type=inside the value.
Endpoints
POST/process
Processes one PDF.
| Field | Type | Required | Description |
|---|---|---|---|
file | file | yes | The PDF; must start with %PDF. It counts toward the 100 MB limit on the whole request. Password-protected or permission-restricted PDFs are rejected (422). |
watermark | JSON string | no | WatermarkOptions — see Watermark. |
watermarkImage | file | no | PNG or JPEG for an image watermark, recognized by its content. Used only when watermark.mode is "image" and watermark.imageData is empty. |
headerFooter | JSON string | no | HeaderFooterOptions — see Header and footer. |
metadata | JSON string | no | MetadataOptions — see Metadata. |
redact | JSON string | no | RedactOptions — see Redaction. |
protect | JSON string | no | ProtectOptions: a password to open the output, and the digital signature — see Password protection and Digital signature. |
forceRender | text | no | true rasterizes every page. Any other value, or no field, means off. |
flattenQuality | text | no | excellent (200 DPI), good (150 DPI, default) or draft (96 DPI). Used whenever pages are rasterized. |
Response: the PDF, or a job
Both shapes use HTTP 200. Which one you get depends on whether pages have to be rasterized:
- Direct PDF: when nothing has to be rasterized, that is when the watermark is off or sent with
"flatten": false,forceRenderis not the texttrue, andredacthas no areas. The file is processed within the request and returned asContent-Type: application/pdfwithContent-Disposition: attachment; filename="ppweditor-<timestamp>.pdf". The response also carries anX-PPW-Jobheader: if the download breaks off, get the same file free fromGET /jobs/{X-PPW-Job}/resultwithin 15 minutes instead of processing it again. - Job: otherwise, that is when
watermark.enabledistrueandwatermark.flattenis notfalse, whenforceRenderistrue, or whenredactis enabled with at least one area. Rasterizing runs in the background and the response is JSON with ajobId. PollGET /jobs/{jobId}, then downloadGET /jobs/{jobId}/result(named<name>-flattened.pdf, or<name>-redacted.pdfwhen only redacted pages were rasterized).
HTTP/1.1 200 OK
Content-Type: application/json
{"jobId":"0b6f3c1e-…","status":"processing","filename":"report.pdf"}Content-Type (application/pdf vs application/json) rather than re-deriving the rule from your inputs. A watermark without "flatten": false always produces a job, even when it draws nothing (empty text, or image mode without an image).Order of operations: watermark → header/footer → redaction boxes → rasterize (if needed) → metadata → signature or password protection. Headers and footers are therefore rasterized along with the page, and the signature or the encryption always covers the final file.
Pages are copied into a new PDF, so document-level data from the source, such as its existing title, author and bookmarks, is not kept. Set the metadata you want in metadata.
Before any work starts the request is checked: request body over 100 MB → 413 from the web server; missing file → 400; not a PDF → 400; an option group that isn’t a JSON object, password protection without a userPassword, or a watermark image that isn’t PNG or JPEG → 400 BAD_OPTION; PDF that can’t be opened → 422; PDF that is password-protected, encrypted or permission-restricted → 422 ENCRYPTED_PDF; not enough pages left → 402. Nothing is charged for a rejected request.
POST/batch
Processes several PDFs with the same options and returns one ZIP. Always runs as a job, even for a single file.
| Field | Type | Description |
|---|---|---|
files | file, repeated | One files field per PDF. |
same as /process | watermark, watermarkImage, headerFooter, metadata, redact, protect, forceRender, flattenQuality — applied to every file. Each PDF in the ZIP is encrypted on its own when password protection is on. | |
filenameSuffix | text | Optional. Appended to each output file name and to the ZIP name, after a space. |
| Limit | Value |
|---|---|
| Files per request | 1 to 50 |
| Request size | 100 MB (104,857,600 bytes) for the whole request: all files together plus the option fields. Larger requests get 413 from the web server. Split bigger sets over several requests. |
Every file is checked before any work starts. If one file isn’t a PDF (400), can’t be opened (422) or is password-protected or restricted (422 ENCRYPTED_PDF, with the file name in the message), or the pages of all files together exceed what you have left (402), the whole request is rejected and nothing is charged.
The response is JSON. total is the number of files, totalPages the pages across all of them:
{
"jobId": "5f0c2a9d-…",
"status": "processing",
"filename": "ppweditor-batch _final.zip",
"total": 2,
"totalPages": 14
}The ZIP
- ZIP name:
ppweditor-batch.zip, orppweditor-batch <filenameSuffix>.zipwhen a suffix is set. - One PDF per input, named after the uploaded file:
contract.pdfwith suffix_finalbecomescontract _final.pdf. Directory parts of uploaded names are dropped. When two inputs share a name, the later one gets(n), where n is its 1-based position in the request. - If some files fail while processing, the others are still delivered. The ZIP then contains a
FAILED.txtlisting each failed file and why, and the job’sfailuresarray has the same list. Failed files are not charged. If every file fails, the job ends with statuserrorand nothing is charged.
GET/jobs/{jobId}
Status of a job created by /process or /batch.
| Field | Type | Description |
|---|---|---|
jobId | string | The job ID. |
status | string | processing, done or error. |
filename | string | For /process jobs the uploaded file name; for /batch jobs the ZIP name. |
progress | object | {"done": n, "total": n}, counted in pages. |
error | string | Present only when status is error. |
failures | array | Batch jobs only, once finished: [{"filename": "…", "error": "…"}]. Empty when every file succeeded. |
// still running
{"jobId":"5f0c2a9d-…","status":"processing","filename":"ppweditor-batch _final.zip","progress":{"done":5,"total":14}}
// finished (failures is only present on batch jobs)
{"jobId":"5f0c2a9d-…","status":"done","filename":"ppweditor-batch _final.zip","progress":{"done":14,"total":14},"failures":[]}
// failed
{"jobId":"5f0c2a9d-…","status":"error","error":"Processing timed out. Please try again with a smaller file or lower flatten quality.","filename":"ppweditor-batch _final.zip","progress":{"done":3,"total":14}}- A job that is still processing 20 minutes after it was created is reported as
errorwith the message “Processing timed out. Please try again with a smaller file or lower flatten quality.” This check runs here, when you poll this endpoint. - Jobs belong to the account that created them. Someone else’s job answers
404, exactly like an unknown ID. - Jobs are short-lived: a job’s record may be removed 2 hours after its last update, and from then on this endpoint answers
404. Download results as soon as they are ready. - Poll every 1 to 5 seconds: start at 1 s and back off. Every poll counts toward the rate limit.
GET/jobs/{jobId}/result
Downloads the file of a finished job.
| Status | When | Body |
|---|---|---|
| 200 | Job is done | The file: application/pdf for /process jobs, application/zip for /batch jobs, with a Content-Disposition file name. |
| 202 | Job is still processing | {"error": "Not ready", "code": "NOT_READY"}. Poll /jobs/{jobId} instead: the 20-minute timeout is applied there, not here. |
| 404 | Unknown job, another account’s, or removed: a job is removed about 2 hours after its last update, together with a result that was never downloaded | {"error": "Job not found"} |
| 402 | Free pages left plus page credits no longer cover the result, for example because other results were downloaded in the meantime | {"error": "…", "code": "QUOTA_EXCEEDED"}. The file is kept: buy page credits, then request it again. |
| 410 | The result has expired: it is deleted 15 minutes after its first download | {"error": "Result expired", "code": "RESULT_GONE"} |
| 422 | Job ended with status error | {"error": "…", "code": "JOB_FAILED"} with the job’s error message. Batch jobs also include failures. |
HEAD request. Downloading the same result again within 15 minutes is free, so repeat a download that breaks off. After that the file is deleted, so keep your own copy: a later request returns 410 RESULT_GONE, while /jobs/{jobId} still says "done". Wait for status: "done" on /jobs/{jobId} instead of polling this endpoint.GET/usage
Your page allowance for the current calendar month and your credit balance.
| Field | Type | Description |
|---|---|---|
freePages | number | Free pages per calendar month (100). |
usedPages | number | Pages processed this calendar month, web editor and API together, including pages paid with credits. A file is counted when the first download of its result starts. |
creditPages | number | Page credits left on your account. |
Free pages left this month = max(0, freePages − usedPages).
Options reference
Each option group (watermark, headerFooter, metadata, redact, protect) is a JSON object sent as a text field. Leave a group out, or send it empty, and that feature stays off. Inside a group, keys you leave out or set to null take the editor’s defaults, listed in the Default column. No key is required: send enabled plus whatever you want to change.
A group that isn’t a JSON object (invalid JSON, an array, null, true, a number, or a file part instead of a text field) is rejected with 400 BAD_OPTION, and the message names the field, for example "watermark" is not valid JSON.
true/false), not strings. Switches are tested for truthiness, so the string "false" counts as on. Likewise only a JSON false turns watermark.flatten off.Watermark — watermark
Drawn on every page. By default a watermark rasterizes every page: the output pages are images, so the watermark can’t be removed, but their text can no longer be selected, searched or copied. The request becomes a job.
Send "flatten": false to keep the pages as they are instead: the watermark is drawn on top as vector text or as an image, the document’s text stays selectable, and when nothing else rasterizes pages (forceRender, redaction) the PDF comes back directly in the response. Such a watermark can be removed with a PDF editor.
{"enabled": true} alone draws CONFIDENTIAL diagonally across every page (60 pt, gray, 25% opacity, rotated 45°). For an image watermark, send "mode": "image" and the image in the watermarkImage file field.
| Field | Type | Values | Default |
|---|---|---|---|
enabled | boolean | true turns the watermark on. | false |
flatten | boolean | true rasterizes every page so the watermark can’t be removed; the text can no longer be selected. false draws the watermark on top of the original pages and keeps their text. | true |
mode | string | "text" or "image". Image mode without an image draws nothing, but pages are still rasterized unless flatten is false. | "text" |
text | string | Text mode: the text to draw, in Noto Sans. "" draws nothing, but pages are still rasterized unless flatten is false. | "CONFIDENTIAL" |
fontSize | number | Text mode: size in points. The editor offers 10–120. | 60 |
color | string | Text mode: #RRGGBB. Invalid values fall back to gray. | "#808080" |
opacity | number | 0 to 1. The editor offers 0.05–1. | 0.25 |
rotation | number | Degrees; positive rotates clockwise. The editor offers −180 to 180. | 45 |
position | string | diagonal, center, top-left, top-right, bottom-left, bottom-right. diagonal and center both center the mark; the angle always comes from rotation. Corners are inset by 4% of the page width. | "diagonal" |
imageScale | number | Image mode: the image fits in a square whose side is this fraction of the page width (editor: 0.05–1). It is never enlarged beyond 1 pixel = 1 point. | 0.3 |
imageData | string | Image mode: a data URL, data:image/png;base64,… or data:image/jpeg;base64,…. Or leave it empty and send the image as the watermarkImage file field. | "" (uses watermarkImage) |
Text watermark with every key spelled out. Apart from enabled, these are the defaults, so {"enabled": true} alone gives the same result:
{
"enabled": true,
"flatten": true,
"mode": "text",
"text": "CONFIDENTIAL",
"imageData": "",
"imageScale": 0.3,
"opacity": 0.25,
"fontSize": 60,
"color": "#808080",
"rotation": 45,
"position": "diagonal"
}Image watermark with curl. Only PNG and JPEG work. The format is recognized from the image’s content (its first bytes), not from the part’s type or the data URL. Any other format, for example WEBP, GIF or SVG, is rejected with 400 BAD_OPTION (“The watermark image must be a PNG or JPG file.”) before any work starts, and nothing is charged.
--form-string 'watermark={"enabled":true,"mode":"image","imageScale":0.25,"opacity":0.4,"rotation":0,"position":"bottom-right"}' \
-F "watermarkImage=@logo.png;type=image/png"Header and footer — headerFooter
Three text positions at the top and three at the bottom of every page, drawn upright even on rotated pages. The text sits a margin of 1.2% of the page height (at least 6 pt) from the edges. Header and footer alone don’t rasterize anything; the PDF comes back directly.
Page numbers are on by default: {"enabled": true} alone prints 3 / 12 centered in the footer of each page. Send "includePageNumbers": false to leave that spot empty.
| Field | Type | Values | Default |
|---|---|---|---|
enabled | boolean | true turns headers and footers on. | false |
headerLeft headerCenter headerRight footerLeft footerCenter footerRight | string | Text for each position, with optional tokens. Send only the positions you need. | "" (empty) |
includePageNumbers | boolean | If footerCenter is empty, prints 3 / 12 (current / total) there. Send false to keep it empty. | true |
includeDate | boolean | If headerCenter is empty, prints the date there. | false |
fontSize | number | Size in points. The editor offers 4–16. | 9 |
fontFamily | string | Helvetica, Times-Roman or Courier. Other values fall back to Helvetica. Text this font can’t show is drawn in Noto Sans (see below). | "Helvetica" |
color | string | #RRGGBB. Invalid values fall back to gray. | "#000000" |
opaqueBackground | boolean | Draws a white band behind the header and the footer to cover what is already printed there. | false |
Tokens
| Token | Renders as | Example |
|---|---|---|
{page} | Current page number | Page {page} of {total} → Page 3 of 12 |
{total} | Total number of pages | {page} / {total} → 3 / 12 |
{date} | Server date at processing time, DD.MM.YYYY | 12.10.2026 |
{page} used to print the current page and the total together (3/12). It now prints only the current page number. To keep the old output, write {page}/{total}. Every occurrence of a token is now replaced, and {date} is always DD.MM.YYYY.- Every occurrence of a token in a field is replaced, not just the first one.
- Page numbers count the pages of each file, so in a
/batchrequest every file starts at 1. - Text is drawn in the chosen standard PDF font when that font can show it (Western European characters, Windows-1252). A field with other characters, for example Turkish ğ, ş, İ, ı, Polish ł, Greek, Cyrillic or №, is drawn in an embedded Noto Sans font instead. The other fields keep the chosen font. Characters that Noto Sans doesn’t have either, such as arrows (→), emoji or Chinese, Japanese and Korean characters, are left out; the rest of the text is still printed and the request doesn’t fail.
Every key spelled out. Only enabled and the keys you change are needed; here includePageNumbers doesn’t matter, because footerCenter has its own text:
{
"enabled": true,
"headerLeft": "Acme Ltd",
"headerCenter": "",
"headerRight": "{date}",
"footerLeft": "Internal",
"footerCenter": "Page {page} of {total}",
"footerRight": "",
"includePageNumbers": false,
"includeDate": false,
"fontSize": 9,
"fontFamily": "Helvetica",
"color": "#000000",
"opaqueBackground": false
}Metadata — metadata
| Field | Type | Values |
|---|---|---|
title | string | Document title. |
author | string | Author. |
subject | string | Subject. |
keywords | string | Keywords, as one string (for example comma-separated). |
Each field is written when it is non-empty; there is no on/off switch, and the enabled key the editor sends is ignored. So is creator: the server sets the PDF Creator and Producer fields itself.
{
"title": "Q3 Report",
"author": "Acme Ltd",
"subject": "Quarterly results",
"keywords": "finance, quarterly, 2026"
}Redaction — redact
Covers areas of pages with opaque black or white boxes and removes what was underneath. The pages that have boxes are rasterized, so the covered text and images are gone from the file, not just hidden. Redaction always makes the request a job.
| Field | Type | Values | Default |
|---|---|---|---|
enabled | boolean | true applies the areas in regions. With no areas nothing happens. | false |
color | string | "black" or "white". Other values give black. | "black" |
regions | array | One object per box: {"page": n, "x": n, "y": n, "w": n, "h": n}. page is the page index, starting at 0. x, y, w and h are fractions from 0 to 1 of the page’s width and height as it is displayed (after its rotation), measured from the top-left corner. Boxes on pages the file doesn’t have are ignored. | [] |
- When redaction is the only reason to rasterize (no
forceRender, and no watermark or one with"flatten": false), only the pages that have boxes are rasterized, at theflattenQualityresolution. Where possible, text on those pages that doesn’t touch a box is put back as an invisible layer, so it can still be selected and searched; the other pages are copied unchanged. The result is named<name>-redacted.pdf, and the job’sprogresscounts only the redacted pages. - Together with
forceRenderor a rasterizing watermark, every page is rasterized. - Redaction is strict. If a page can’t be rasterized, the job fails (in
/batch, that file is listed infailures) instead of returning a page with the original content still under the box. Nothing is charged for a failed file. - In
/batch, the same boxes are applied to every file, at the same page index and relative position.
A box 35% of the page wide and 5% high, 10% from the left and 20% from the top of the first page, and a second one near the bottom of the third page:
{
"enabled": true,
"color": "black",
"regions": [
{ "page": 0, "x": 0.1, "y": 0.2, "w": 0.35, "h": 0.05 },
{ "page": 2, "x": 0.55, "y": 0.8, "w": 0.3, "h": 0.1 }
]
}Rasterizing — forceRender and flattenQuality
forceRender=true turns every page into an image, even without a watermark, so the text can’t be selected, copied or edited. The request becomes a job. flattenQuality sets the resolution whenever pages are rasterized, by forceRender, a watermark or redaction:
| flattenQuality | Resolution |
|---|---|
excellent | 200 DPI |
good (default; also used for unknown values) | 150 DPI |
draft | 96 DPI |
Password protection
Send protect with "enabled": true and a userPassword to encrypt the output with AES-256. The file then asks for a password when it is opened.
| Field | Type | Values | Default |
|---|---|---|---|
enabled | boolean | true encrypts the output. Without it, the passwords are ignored. | false |
userPassword | string | Password needed to open the file. Required when enabled is true: if it is missing, empty or only spaces, the request is rejected with 400 BAD_OPTION (“Password protection needs a password to open the file.”) before any work starts. | "" (empty) |
ownerPassword | string | Password that lifts the restrictions below. Empty means a random owner password is used, so nobody can lift them; set your own if you need to. | "" (random) |
sign | boolean | See Digital signature. Ignored when the file is encrypted. | false |
{
"enabled": true,
"userPassword": "open-sesame",
"ownerPassword": "a-different-long-secret"
}- Opened with the user password, the file can be printed in high resolution, its form fields filled in and its content read by accessibility tools. Changing it, copying text or images, adding comments, and inserting, deleting or rotating pages are not allowed. PDF readers apply these restrictions; the owner password lifts them.
- Encryption is the last step, after every other change, in every case: a PDF returned directly, a
/processjob and each PDF in a/batchZIP (the ZIP itself isn’t encrypted). - An encrypted file is not signed: when password protection is on,
signis ignored, because a signature and the encryption can’t both cover the final file. - Password protection doesn’t change whether the response is a direct PDF or a job.
- Password-protected PDFs can’t be uploaded again (
422 ENCRYPTED_PDF), so keep an unencrypted copy if you’ll need to process the file later.
Digital signature
Send protect={"sign": true} to sign the output. Without it, or with "sign": false, the PDF is not signed. (The web editor turns signing on by default; the API does not.) A file with password protection is never signed.
- The signature is added last, after every other change, so any later modification of the file invalidates it.
- It uses a self-signed certificate (common name
ppweditor.com, reason “Certified by ppweditor.com”). It lets a reader detect changes made after signing; it does not prove who you are. PDF readers such as Acrobat show the signer’s identity as not verified, because the certificate is not issued by a trusted authority. The certificate can change over time, so don’t pin it. - If signing fails, the server returns the unsigned PDF without reporting an error. If the signature matters to you, check that it is present.
- Signing does not change whether the response is a direct PDF or a job.
Quotas and page credits
- Every account has 100 free pages per calendar month, shared between the web editor and the API. Unused free pages don’t carry over.
- Every page of every processed file counts, whatever the options.
- Pages beyond the free allowance are paid with page credits from your balance, one credit per page. Credits are used only for the part above the allowance: credits needed =
max(0, pages − free pages left). - The check runs before processing (for
/batch, on the pages of all files together). Pages of your jobs that are still running or whose results you have not downloaded yet count as used here, and the credits they will need are held from your balance, until they are downloaded, fail or expire. If the credits needed exceed your balance, the request fails with402 QUOTA_EXCEEDEDand nothing is processed or charged. - Pages are counted and credits deducted only for results you download, when the first download starts: a PDF returned directly as its response starts, a job result on the first
/jobs/{jobId}/resultrequest. A file that fails and a job that times out or is never downloaded cost nothing. Downloading the same result again within 15 minutes is free. Because the balance can change in between, a job’s first download checks it again and may answer402 QUOTA_EXCEEDED; the result is kept so you can retry after buying credits. - Check your numbers any time with
GET /usage.
Rate limit
120 requests per minute per key, counted over a sliding 60-second window across all endpoints, status polls included. Above that the API answers 429 RATE_LIMITED with a Retry-After: 60 header. Rejected requests count toward the window too, so wait the Retry-After seconds instead of retrying straight away.
Errors
Errors from the API are JSON with a human-readable error message, and most also carry a machine-readable code. Branch on the HTTP status first and use code to tell cases apart where it is present. The exception is 413, which comes from the web server in front of the API and is not JSON.
| HTTP | code | Meaning |
|---|---|---|
| 400 | — | Missing file/files, a file that is not a PDF, or more than 50 files. |
| 400 | BAD_OPTION | An option group (watermark, headerFooter, metadata, redact, protect) isn’t a JSON object: invalid JSON, an array, null, true, or a file part; the message names the field. Also: password protection without a userPassword (“Password protection needs a password to open the file.”), or a watermark image that isn’t PNG or JPEG (“The watermark image must be a PNG or JPG file.”). Nothing was charged. |
| 401 | UNAUTHORIZED | No usable Authorization: Bearer <key> header: missing, empty (for example an unset environment variable) or malformed. |
| 401 | INVALID_API_KEY | The token doesn’t start with ppw_, or isn’t an active key: revoked, deleted, or its account no longer exists. |
| 402 | QUOTA_EXCEEDED | Free pages left plus page credits don’t cover the request; nothing was processed. From /jobs/{jobId}/result: they no longer cover the result; it is kept for a retry. |
| 403 | API_ACCESS_NOT_APPROVED | The key is valid but API access for the account is not approved. |
| 404 | — | Job not found: unknown, created by another account, or removed (about 2 hours after its last update, together with a result that was never downloaded). |
| 410 | RESULT_GONE | /jobs/{jobId}/result: the result has expired (it is deleted 15 minutes after its first download). |
| 413 | — | The request body is over 100 MB. Returned by the web server before the request reaches the API; the body is not JSON. |
| 422 | — | The PDF can’t be opened, for example because it is damaged. Nothing was charged. |
| 422 | ENCRYPTED_PDF | The PDF is password-protected, encrypted or permission-restricted: “This PDF is password-protected or has restrictions. Remove the protection first.” (In /batch the message starts with the file name.) It is rejected before processing, and nothing was charged. |
| 422 | JOB_FAILED | /jobs/{jobId}/result for a job that ended with status error. The body has the job’s error message; batch jobs also include failures. |
| 429 | RATE_LIMITED | More than 120 requests in a minute with this key. Wait for Retry-After (60 s). |
| 500 | — | Unexpected processing error. |
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"error":"Rate limit: at most 120 requests per minute per key","code":"RATE_LIMITED"}Failures inside a job are not HTTP errors: the request that created the job succeeded, and GET /jobs/{jobId} later reports status: "error" with an error message. Asking for that job’s result returns 422 JOB_FAILED.
Examples
The examples read the key from the PPW_API_KEY environment variable.
Metadata and header/footer in one request (direct PDF)
curl https://ppweditor.com/api/v1/process \
-H "Authorization: Bearer $PPW_API_KEY" \
-F "file=@report.pdf" \
--form-string 'metadata={"title":"Q3 Report","author":"Acme Ltd"}' \
--form-string 'headerFooter={"enabled":true,"headerLeft":"Acme Ltd","headerRight":"{date}","footerCenter":"Page {page} of {total}"}' \
-o report-out.pdf \
-w "%{http_code} %{content_type}\n"
# "200 application/pdf" -> report-out.pdf is the finished file
# anything else -> report-out.pdf holds the error body
# (JSON, except a 413 from the web server)The header shows the company name on the left and the processing date on the right; the footer shows Page 3 of 12 in the center. Keys left out of headerFooter take their defaults.
Password-protected PDF with a selectable-text watermark (direct PDF)
curl https://ppweditor.com/api/v1/process \
-H "Authorization: Bearer $PPW_API_KEY" \
-F "file=@offer.pdf" \
--form-string 'watermark={"enabled":true,"text":"DRAFT","flatten":false}' \
--form-string 'protect={"enabled":true,"userPassword":"open-sesame"}' \
-o offer-protected.pdf \
-w "%{http_code} %{content_type}\n"
# "200 application/pdf" -> offer-protected.pdf asks for "open-sesame" when openedWith "flatten": false the watermark is drawn on top of the pages and nothing is rasterized, so the encrypted PDF comes back in the same response and its pages keep their real text instead of becoming images.
Batch, poll, download
# 1. Start the batch (always a job). The watermark rasterizes every page.
# {"enabled":true} alone draws CONFIDENTIAL diagonally (the defaults).
curl https://ppweditor.com/api/v1/batch \
-H "Authorization: Bearer $PPW_API_KEY" \
-F "files=@contract-a.pdf" \
-F "files=@contract-b.pdf" \
--form-string 'watermark={"enabled":true}' \
--form-string 'protect={"sign":true}' \
-F "flattenQuality=good" \
--form-string "filenameSuffix=_final"
# {"jobId":"5f0c2a9d-…","status":"processing","filename":"ppweditor-batch _final.zip","total":2,"totalPages":14}
# 2. Poll the job until its status is "done" or "error".
JOB_ID=5f0c2a9d-…
while :; do
JOB=$(curl -s -H "Authorization: Bearer $PPW_API_KEY" https://ppweditor.com/api/v1/jobs/$JOB_ID)
STATUS=$(echo "$JOB" | jq -r '.status // .code // "unknown"')
case "$STATUS" in
processing) sleep 3 ;;
RATE_LIMITED) sleep 60 ;;
*) break ;;
esac
done
echo "$JOB" # "done", "error" with an "error" message, or an error body such as 404
# 3. Download the ZIP. Pages are counted and credits used when this first
# download starts. Downloading it again within 15 minutes is free, so a
# download that breaks off is simply repeated; after that it gets 410.
[ "$STATUS" = "done" ] && curl -s -H "Authorization: Bearer $PPW_API_KEY" \
-o contracts.zip -w "%{http_code}\n" https://ppweditor.com/api/v1/jobs/$JOB_ID/result
# 200 -> contracts.zip is the archive; 410 or 422 -> it holds the JSON errorUsage
curl -H "Authorization: Bearer $PPW_API_KEY" https://ppweditor.com/api/v1/usage
# {"freePages":100,"usedPages":37,"creditPages":250}Python (requests): direct or job
Sends a watermarked, signed PDF. The watermark makes this a job; the code branches on Content-Type, so it also works for requests that return the PDF directly.
import json
import os
import time
import requests
BASE = "https://ppweditor.com/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["PPW_API_KEY"]}
class ApiError(Exception):
pass
def call(method, url, **kwargs):
"""Sends a request, waits out the rate limit, raises ApiError on 4xx/5xx."""
while True:
r = requests.request(method, url, headers=HEADERS, timeout=300, **kwargs)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
if r.status_code >= 400:
try:
body = r.json()
except ValueError: # 413 comes from the web server, not as JSON
body = {"error": r.reason}
# For example 400 BAD_OPTION, 422 ENCRYPTED_PDF, 410 RESULT_GONE
raise ApiError(r.status_code, body.get("code"), body.get("error"))
return r
with open("contract.pdf", "rb") as f:
pdf_in = f.read() # bytes, so a retry after 429 sends the whole file again
r = call(
"POST",
f"{BASE}/process",
files={"file": ("contract.pdf", pdf_in, "application/pdf")},
data={
# Keys left out take the defaults: diagonal, 45°, gray, 25% opacity.
"watermark": json.dumps({"enabled": True, "text": "DRAFT"}),
"metadata": json.dumps({"title": "Contract"}),
"protect": json.dumps({"sign": True}),
"flattenQuality": "good",
},
)
if r.headers["Content-Type"].startswith("application/pdf"):
pdf = r.content # processed within the request
else:
job_id = r.json()["jobId"]
delay = 1
while True:
time.sleep(delay)
delay = min(delay + 1, 5)
job = call("GET", f"{BASE}/jobs/{job_id}").json()
if job["status"] == "error":
raise ApiError("job failed", job.get("error"), job.get("failures"))
if job["status"] == "done":
break
# Billed when the first download starts; repeats within 15 minutes are free.
pdf = call("GET", f"{BASE}/jobs/{job_id}/result").content
with open("contract-out.pdf", "wb") as out:
out.write(pdf)Your data
For how uploaded and processed files are handled, see the Privacy Policy. Questions about the API: hello@ppweditor.com.
