A small, predictable HTTP API for the same PDF tools that power arawapdf.com — merge, compress, convert, OCR and more. Privacy-first and asynchronous: every file auto-deletes on a ~2 hour TTL.
# Compress a PDF — start a task
curl -X POST https://arawapdf.com/v1/tools/compress/start \
-H "Authorization: Bearer awa_live_…"
# → { "taskId": "ckv9q2…", "status": "pending" }The Arawa PDF API exposes our PDF tool catalogue as a small, predictable REST API that speaks JSON. For any server-side tool you drive a short-lived task through five steps — start, upload, process, poll, download — and stream the result back as a file attachment.
Arawa PDF is privacy-first. The files you upload and the outputs we produce are ephemeral: they live only for the duration of a task and are automatically deleted roughly 2 hours after creation (a ~1h TTL). Nothing is retained beyond that window or used for training. There is no file listing endpoint and no archive — the API is built entirely around the transient model.
REST + JSON
Raw JSON requests and responses (no envelope), except file uploads (multipart/form-data) and downloads (a binary stream).
Asynchronous tasks
Processing is enqueued; you poll a status endpoint every 1–2 s until the task reaches success.
One error shape
Every failure returns the same normalized envelope — { statusCode, error, message, code?, details? }.
Either credential, or none
An API key, the browser session cookie, or fully anonymous. A credential unlocks Premium limits and tools.
Arawa PDF has two kinds of tools, and only one is reachable through this API. Server-side tools (e.g. compress, pdf-to-word, ocr, protect, html-to-pdf) run on our workers and expose the full task pipeline. Client-side-only tools run entirely in the browser on their tool page for maximum privacy and have no API surface.
Both can be true
Several tools are both browser-first and ship a server handler (for example merge, split, rotate, watermark, crop, pdf-to-jpg) — those are valid API tools too. What matters for the API is whether a server handler exists, not whether a browser mode also exists. See Tools & parameters for the canonical, code-derived list.
This is the full task lifecycle for one concrete tool — Compress PDF — from a key to the compressed bytes on disk. Every server tool follows the exact same five steps, so once Compress works, every other tool works the same way with a different slug and a different params body.
POST /v1/tools/{tool}/startCreate the task. Returns { taskId, status }.
POST /v1/tools/{tool}/{taskId}/uploadAttach files as multipart/form-data, field files (repeat up to 50).
POST /v1/tools/{tool}/{taskId}/processEnqueue with optional { "params": { … } }.
GET /v1/tools/{tool}/{taskId}/statusPoll every 1–2 s until success or error.
GET /v1/tools/{tool}/{taskId}/downloadStream the result (Content-Disposition: attachment).
The process body takes a single params key whose shape is per-tool. Compress accepts one field, level (extreme · recommended · less). Run the example to the right end-to-end — it assumes ARAWA_API_KEY is exported and an input.pdf exists.
An API key is optional here
The tools pipeline also accepts the session cookie or no credential at all. A key just gives you stable task ownership, Premium entitlements (200 MB uploads, no daily cap), and a clean header-based flow for scripts.
#!/usr/bin/env bash
set -euo pipefail
BASE="https://arawapdf.com/v1"
TOOL="compress"
AUTH="Authorization: Bearer ${ARAWA_API_KEY}"
# 1) Start — create the task.
TASK_ID=$(curl -fsS -X POST "${BASE}/tools/${TOOL}/start" \
-H "${AUTH}" | python3 -c "import sys,json; print(json.load(sys.stdin)['taskId'])")
# 2) Upload — multipart field name MUST be "files". Repeat -F to add more.
curl -fsS -X POST "${BASE}/tools/${TOOL}/${TASK_ID}/upload" \
-H "${AUTH}" \
-F "files=@input.pdf;type=application/pdf"
# 3) Process — pass tool params as JSON under "params".
curl -fsS -X POST "${BASE}/tools/${TOOL}/${TASK_ID}/process" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d '{ "params": { "level": "recommended" } }'
# 4) Poll status until it is "success" (or fail on "error").
while :; do
STATUS=$(curl -fsS "${BASE}/tools/${TOOL}/${TASK_ID}/status" \
-H "${AUTH}" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
case "${STATUS}" in
success) break ;;
error) echo "Processing failed." >&2; exit 1 ;;
*) sleep 2 ;;
esac
done
# 5) Download — stream the result to disk.
curl -fsS -o compressed.pdf "${BASE}/tools/${TOOL}/${TASK_ID}/download" \
-H "${AUTH}"// quickstart.mjs — run: node quickstart.mjs (Node 18+)
import { readFile, writeFile } from "node:fs/promises";
const BASE = "https://arawapdf.com/v1";
const TOOL = "compress";
const API_KEY = process.env.ARAWA_API_KEY;
const authHeaders = { Authorization: `Bearer ${API_KEY}` };
// Throw the normalized error envelope on any non-2xx response.
async function ok(res) {
if (res.ok) return res;
const body = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${body.code ?? body.error}: ${body.message}`);
}
async function compress(inputPath, outputPath) {
// 1) Start.
const { taskId } = await ok(
await fetch(`${BASE}/tools/${TOOL}/start`, { method: "POST", headers: authHeaders }),
).then((r) => r.json());
// 2) Upload — multipart field name is "files".
const bytes = await readFile(inputPath);
const form = new FormData();
form.append("files", new Blob([bytes], { type: "application/pdf" }), "input.pdf");
await ok(
await fetch(`${BASE}/tools/${TOOL}/${taskId}/upload`, {
method: "POST",
headers: authHeaders, // do NOT set Content-Type; fetch adds the boundary
body: form,
}),
);
// 3) Process — params go under "params".
await ok(
await fetch(`${BASE}/tools/${TOOL}/${taskId}/process`, {
method: "POST",
headers: { ...authHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ params: { level: "recommended" } }),
}),
);
// 4) Poll status every 2s until success.
for (;;) {
const { status } = await ok(
await fetch(`${BASE}/tools/${TOOL}/${taskId}/status`, { headers: authHeaders }),
).then((r) => r.json());
if (status === "success") break;
if (status === "error") throw new Error("Processing failed");
await new Promise((r) => setTimeout(r, 2000));
}
// 5) Download — save the bytes.
const out = await ok(
await fetch(`${BASE}/tools/${TOOL}/${taskId}/download`, { headers: authHeaders }),
);
await writeFile(outputPath, Buffer.from(await out.arrayBuffer()));
}
await compress("input.pdf", "compressed.pdf");# quickstart.py — run: python quickstart.py (pip install requests)
import os
import time
import requests
BASE = "https://arawapdf.com/v1"
TOOL = "compress"
AUTH = {"Authorization": f"Bearer {os.environ['ARAWA_API_KEY']}"}
def raise_for_envelope(resp: requests.Response) -> requests.Response:
"""Raise with the normalized error envelope on any non-2xx response."""
if resp.ok:
return resp
try:
body = resp.json()
except ValueError:
body = {}
code = body.get("code") or body.get("error") or ""
raise RuntimeError(f"{resp.status_code} {code}: {body.get('message')}")
def compress(input_path: str, output_path: str) -> None:
# 1) Start.
started = raise_for_envelope(
requests.post(f"{BASE}/tools/{TOOL}/start", headers=AUTH)
).json()
task_id = started["taskId"]
# 2) Upload — the multipart field name must be "files".
with open(input_path, "rb") as fh:
files = {"files": ("input.pdf", fh, "application/pdf")}
raise_for_envelope(
requests.post(f"{BASE}/tools/{TOOL}/{task_id}/upload", headers=AUTH, files=files)
)
# 3) Process — tool params live under "params".
raise_for_envelope(
requests.post(
f"{BASE}/tools/{TOOL}/{task_id}/process",
headers={**AUTH, "Content-Type": "application/json"},
json={"params": {"level": "recommended"}},
)
)
# 4) Poll status every 2s until success.
while True:
status = raise_for_envelope(
requests.get(f"{BASE}/tools/{TOOL}/{task_id}/status", headers=AUTH)
).json()["status"]
if status == "success":
break
if status == "error":
raise RuntimeError("Processing failed")
time.sleep(2)
# 5) Download — stream the bytes to disk.
with requests.get(
f"{BASE}/tools/{TOOL}/{task_id}/download", headers=AUTH, stream=True
) as out:
raise_for_envelope(out)
with open(output_path, "wb") as dest:
for chunk in out.iter_content(chunk_size=64 * 1024):
dest.write(chunk)
if __name__ == "__main__":
compress("input.pdf", "compressed.pdf")API keys are the primary credential for developers. A full key looks like awa_live_<publicId>_<secret> and is sent as a bearer token:
Authorization: Bearer awa_live_aB3dE7gH1k_Hs8s...e0Qawa_live | Fixed brand/environment namespace (note the underscore). |
publicId | 10 URL-safe random chars — the public, non-secret lookup id. |
secret | 32 random bytes, base64url — 256 bits of entropy, never stored. |
Only the non-secret prefix (awa_live_<publicId>) and a SHA-256 hash of the full key are stored. Create keys from the dashboard at /user/developer or directly via POST /v1/me/api-keys, authenticated with your browser session cookie.
The key is shown exactly once
The plaintext key is returned only at creation and can never be retrieved again — copy it immediately and store it as a secret. Creating a key requires an active Premium or Business plan; otherwise the call returns 402 with code: "premium_required".
Each key carries a set of authorization scopes, enforced on every request:
| Scope | Type | Description |
|---|---|---|
tasks:readOptional | Type: scope | Read task state (status) and download outputs. |
tasks:writeOptional | Type: scope | Create, upload to, process, and delete tasks. Implies tasks:read. |
Omitting scopes grants full access (both). Because tasks:write implies tasks:read, a write-capable key can still poll status and download its own results. A key missing the required scope is rejected with 403 and code: "insufficient_scope". Scopes constrain API-key callers only — cookie and anonymous calls are unaffected.
The web app authenticates with an httpOnly wlp_session cookie (SameSite=lax, 30-day lifetime) issued by POST /v1/auth/login or /v1/auth/signup; from fetch you must opt in with credentials: "include". The tools pipeline also works fully anonymous — resolution order is API key → cookie → anonymous, and task ownership binds to whichever identity resolved.
A bad key falls back to anonymous
On the tools pipeline an invalid, expired, or revoked key is not a 401 — it silently resolves to anonymous, so you lose Premium entitlements rather than erroring (and most often see a later 403 on a task an authenticated identity created). The /v1/me/api-keys management routes do require a real session and reject bad credentials with 401.
curl -X POST https://arawapdf.com/v1/me/api-keys \
-H "Content-Type: application/json" \
--cookie "wlp_session=<your-session-cookie>" \
-d '{
"name": "CI pipeline",
"scopes": ["tasks:read", "tasks:write"],
"expiresAt": "2027-01-01T00:00:00.000Z"
}'{
"id": "key_…",
"name": "CI pipeline",
"key": "awa_live_a1B2c3D4e5_Zm9...full-secret...Qk",
"prefix": "awa_live_a1B2c3D4e5",
"scopes": ["tasks:read", "tasks:write"],
"createdAt": "2026-06-02T18:00:00.000Z"
}# Send the full key as a Bearer token on any tools-pipeline request.
curl -X POST https://arawapdf.com/v1/tools/compress/start \
-H "Authorization: Bearer awa_live_aB3dE7gH1k_Hs8s...e0Q"const res = await fetch("https://arawapdf.com/v1/tools/compress/start", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ARAWA_API_KEY}` },
});import os, requests
auth = {"Authorization": f"Bearer {os.environ['ARAWA_API_KEY']}"}
res = requests.post("https://arawapdf.com/v1/tools/compress/start", headers=auth)The pipeline is a five-step flow per task — start → upload → process → status (poll) → download — with an optional sixth step to delete the task early. All routes are namespaced under /v1/tools/{tool} where {tool} is a tool slug.
DELETE removes the task early — otherwise files auto-expire on the ~2 hour TTL.status moves through pending → uploading → processing → success (or error). Every step accepts the same credential — API key, cookie, or none — and the resolved identity must own the task. The one exception to "upload first" is html-to-pdf, which takes its input as URLs in the process body and skips the upload step entirely.
POST /v1/tools/{tool}/start → { taskId, status }
POST /v1/tools/{tool}/{taskId}/upload → { taskId, status, added }
POST /v1/tools/{tool}/{taskId}/process → { taskId, status }
GET /v1/tools/{tool}/{taskId}/status → { taskId, status, outputUrl?, errorCode? }
GET /v1/tools/{tool}/{taskId}/download → binary stream
DELETE /v1/tools/{tool}/{taskId} → { taskId, deleted }Creates a task for the given tool and returns its taskId.
/v1/tools/{tool}/startSuccess — 201
{ "taskId": "ckv9q2x8b0000abcd1234efgh", "status": "pending" }Errors
400Unknown tool slug.429 · daily_limitBasic user over the 10 tasks/day quota.curl -X POST https://arawapdf.com/v1/tools/compress/start \
-H "Authorization: Bearer $ARAWA_API_KEY"const r = await fetch(`${BASE}/tools/${tool}/start`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
});
const { taskId, status } = await r.json();r = requests.post(f"{BASE}/tools/{tool}/start", headers=auth)
task_id = r.json()["taskId"]Attaches one or more files to the task. The body must be multipart/form-data; repeat the files field once per file (up to 50). Files are validated by magic bytes — PDF, image, or Office document.
/v1/tools/{tool}/{taskId}/uploadSuccess — 201
{ "taskId": "ckv9q2x8b0000abcd1234efgh", "status": "uploading", "added": 2 }Errors
400No files, or an unsupported file type.400 · file_too_largePremium user over 200 MB.402 · premium_requiredBasic user uploading a file over 50 MB.403The task belongs to a different identity.404Task not found.# Repeat -F "files=@..." for up to 50 files in one call.
curl -X POST https://arawapdf.com/v1/tools/merge/$TASK_ID/upload \
-H "Authorization: Bearer $ARAWA_API_KEY" \
-F "files=@chapter-1.pdf" \
-F "files=@chapter-2.pdf"const form = new FormData();
form.append("files", new Blob([bytes], { type: "application/pdf" }), "input.pdf");
await fetch(`${BASE}/tools/${tool}/${taskId}/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` }, // no Content-Type!
body: form,
});with open("input.pdf", "rb") as fh:
files = {"files": ("input.pdf", fh, "application/pdf")}
requests.post(f"{BASE}/tools/{tool}/{task_id}/upload", headers=auth, files=files)Enqueues processing of the uploaded files with optional tool-specific params. Returns immediately with status: "processing"; poll status for completion. params may be omitted for tools that take no options.
/v1/tools/{tool}/{taskId}/processRequest body
{ "params": { "level": "recommended" } }Success — 201
{ "taskId": "ckv9q2x8b0000abcd1234efgh", "status": "processing" }Errors
400Invalid params, or no input files uploaded.402 · premium_requiredThe tool is Premium-only and the caller is not Premium.403Ownership mismatch, or key missing tasks:write (insufficient_scope).404Task not found.curl -X POST https://arawapdf.com/v1/tools/compress/$TASK_ID/process \
-H "Authorization: Bearer $ARAWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "params": { "level": "recommended" } }'await fetch(`${BASE}/tools/${tool}/${taskId}/process`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ params: { level: "recommended" } }),
});requests.post(
f"{BASE}/tools/{tool}/{task_id}/process",
headers={**auth, "Content-Type": "application/json"},
json={"params": {"level": "recommended"}},
)Returns the task's current state. Poll roughly every 1–2 seconds until status is success or error. Status values: pending · uploading · processing · success · error.
/v1/tools/{tool}/{taskId}/statusSuccess — 200
{
"taskId": "ckv9q2x8b0000abcd1234efgh",
"status": "success",
"outputUrl": "/v1/tools/compress/ckv9q2x8b0000abcd1234efgh/download"
}On failure
{
"taskId": "ckv9q2x8b0000abcd1234efgh",
"status": "error",
"errorCode": "processing_failed"
}outputUrl is present only on success; errorCode only on error. A worker failure surfaces here as status: "error" with an errorCode rather than as an HTTP error.
# Poll every 1–2s until status is "success" or "error".
curl https://arawapdf.com/v1/tools/compress/$TASK_ID/status \
-H "Authorization: Bearer $ARAWA_API_KEY"for (;;) {
const { status } = await fetch(
`${BASE}/tools/${tool}/${taskId}/status`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
).then((r) => r.json());
if (status === "success") break;
if (status === "error") throw new Error("Processing failed");
await new Promise((r) => setTimeout(r, 2000));
}while True:
status = requests.get(
f"{BASE}/tools/{tool}/{task_id}/status", headers=auth
).json()["status"]
if status == "success":
break
if status == "error":
raise RuntimeError("Processing failed")
time.sleep(2)Streams the processed result as a binary attachment. Content-Type is set from the output's MIME type and Cache-Control: private, no-store. Outputs are ephemeral — fetch within the ~1h TTL.
/v1/tools/{tool}/{taskId}/downloadErrors
400Output not ready (task unfinished) or already expired.403The task belongs to a different identity.404Task not found, or the output expired (~1h TTL).Outputs are not durable storage
The download URL is not permanent. If you need the result long-term, persist the bytes on your side immediately after downloading.
curl -L https://arawapdf.com/v1/tools/compress/$TASK_ID/download \
-H "Authorization: Bearer $ARAWA_API_KEY" \
-o compressed.pdfconst out = await fetch(`${BASE}/tools/${tool}/${taskId}/download`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
await writeFile("compressed.pdf", Buffer.from(await out.arrayBuffer()));with requests.get(
f"{BASE}/tools/{tool}/{task_id}/download", headers=auth, stream=True
) as out:
with open("compressed.pdf", "wb") as dest:
for chunk in out.iter_content(chunk_size=64 * 1024):
dest.write(chunk)Immediately deletes the task and any associated files rather than waiting for the TTL. Idempotent in spirit — a missing task is a 404.
/v1/tools/{tool}/{taskId}Success — 200
{ "taskId": "ckv9q2x8b0000abcd1234efgh", "deleted": true }Errors
403The task belongs to a different identity.404Task not found.curl -X DELETE https://arawapdf.com/v1/tools/compress/$TASK_ID \
-H "Authorization: Bearer $ARAWA_API_KEY"await fetch(`${BASE}/tools/${tool}/${taskId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
});requests.delete(f"{BASE}/tools/{tool}/{task_id}", headers=auth)A tool is callable via the API only if it has a server-side worker handler. The list below is code-derived from the canonical registry, so it can never drift from the real API — 47 tools across seven families. Tools that take no options accept an empty { "params": {} }.
delete-blank-pages-pdfDelete Blank Pagesdeskew-pdfDeskew PDFextract-pagesExtract pagesmergeMerge PDForganizeOrganize PDFremove-pagesRemove pagesrepairRepair PDFresize-pdfResize PDFrotateRotate PDFscanScan to PDFsplitSplit PDFsplit-pdf-into-bookletPDF BookletcompressCompress PDFgrayscale-pdfGrayscale PDFlinearize-pdfLinearize PDFexcel-to-pdfExcel to PDFhtml-to-pdfHTML to PDFjpg-to-pdfJPG to PDFodt-to-pdfODT to PDFpowerpoint-to-pdfPowerPoint to PDFrtf-to-pdfRTF to PDFtiff-to-pdfTIFF to PDFword-to-pdfWord to PDFextract-images-from-pdfExtract Images from PDFpdf-to-csvPDF to CSVpdf-to-excelPDF to Excelpdf-to-jpgPDF to JPGpdf-to-markdownPDF to Markdownpdf-to-pdfaPDF to PDF/Apdf-to-pngPDF to PNGpdf-to-powerpointPDF to PowerPointpdf-to-tiffPDF to TIFFpdf-to-wordPDF to WordcropCrop PDFpage-numbersPage numberspdf-formsPDF formswatermarkWatermarkchange-pdf-permissionsChange PDF PermissionsprotectProtect PDFsanitize-pdfSanitize PDFunlockUnlock PDFanonymize-pdfAnonymize PDFPremiumchat-with-pdfChat with PDFPremiumextract-data-from-pdfExtract Data from PDFPremiumocrOCR PDFsummarizeSummarize PDFPremiumtranslateTranslate PDFPremiumPremium-only tools
These return 402 with code: "premium_required" at the process step unless the caller is on an active Premium/Business plan: anonymize-pdf, chat-with-pdf, extract-data-from-pdf, summarize, translate. The authoritative, always-current set is the live OpenAPI spec.
Only tools that define a params schema are detailed below. Wrap the object under a top-level params key.
compressCompress PDF| Parameter | Type | Description |
|---|---|---|
levelRequired | Type: string | Compression strength. extreme = smallest file, less = best fidelity, recommended = balanced.Allowed:extremerecommendedless |
Example process body
{ "params": { "level": "recommended" } }splitSplit PDF| Parameter | Type | Description |
|---|---|---|
modeRequired | Type: string | How to split the document.Allowed:rangesfixedsize |
rangesOptional | Type: string | Page ranges, e.g. "1-3,5,8-10". Used with mode "ranges". |
everyNPagesOptional | Type: number | Split every N pages. Used with mode "fixed". |
Example process body
{ "params": { "mode": "ranges", "ranges": "1-3,5,8-10" } }pdf-to-wordPDF to Word / Excel / PowerPointpdf-to-word, pdf-to-excel and pdf-to-powerpoint share this shape.
| Parameter | Type | Description |
|---|---|---|
modeRequired | Type: string | ocr reconstructs text from scanned pages.Allowed:editableocr |
Example process body
{ "params": { "mode": "editable" } }pdf-to-jpgPDF to JPG| Parameter | Type | Description |
|---|---|---|
modeRequired | Type: string | pages = one JPG per page; extract = pull embedded images.Allowed:pagesextract |
Example process body
{ "params": { "mode": "pages" } }jpg-to-pdfJPG to PDFUpload one or more images; output is a PDF.
| Parameter | Type | Description |
|---|---|---|
orientationOptional | Type: string | Allowed:portraitlandscape |
pageSizeOptional | Type: string | Allowed:a4letterfit |
marginOptional | Type: string | Allowed:nonesmallbig |
mergeAllOptional | Type: boolean | Combine all images into one PDF. |
Example process body
{
"params": {
"orientation": "portrait",
"pageSize": "a4",
"margin": "small",
"mergeAll": true
}
}watermarkWatermark| Parameter | Type | Description |
|---|---|---|
typeRequired | Type: string | Allowed:textimage |
textOptional | Type: string | Required when type is "text". |
mosaicOptional | Type: boolean | Tile the watermark across the page. |
transparencyOptional | Type: number | Opacity percentage (0–100). |
rotationOptional | Type: number | Rotation angle in degrees. |
layerOptional | Type: string | Draw above or beneath content.Allowed:overbelow |
fromPageOptional | Type: number | Optional first page (1-based). |
toPageOptional | Type: number | Optional last page (1-based). |
Example process body
{
"params": {
"type": "text",
"text": "CONFIDENTIAL",
"mosaic": false,
"transparency": 50,
"rotation": 45,
"layer": "over"
}
}rotateRotate PDF| Parameter | Type | Description |
|---|---|---|
rotationsRequired | Type: { page, deg }[] | Per-page rotations. page is 1-based; deg is 90, 180 or 270. |
Example process body
{
"params": {
"rotations": [
{ "page": 1, "deg": 90 },
{ "page": 2, "deg": 180 }
]
}
}organizeOrganize PDFReorder, delete, insert blanks, and rotate pages in one pass.
| Parameter | Type | Description |
|---|---|---|
orderOptional | Type: number[] | New page order (1-based indices). |
deletionsOptional | Type: number[] | Pages to delete. |
insertBlanksOptional | Type: { afterPage }[] | Insert a blank page after each listed page. |
rotationsOptional | Type: { page, deg }[] | Per-page rotation in degrees. |
Example process body
{
"params": {
"order": [2, 1, 3],
"deletions": [4],
"insertBlanks": [{ "afterPage": 1 }],
"rotations": [{ "page": 3, "deg": 90 }]
}
}html-to-pdfHTML to PDFSpecial case — no upload step. Input URLs are passed in the process body; call process directly after start.
| Parameter | Type | Description |
|---|---|---|
urlsRequired | Type: string[] | One or more absolute URLs to render. |
screenSizeOptional | Type: string | Allowed:desktoptabletmobile |
pageSizeOptional | Type: string | Allowed:a3a4a5letter |
orientationOptional | Type: string | Allowed:portraitlandscape |
marginOptional | Type: string | Allowed:nonesmallbig |
Example process body
{
"params": {
"urls": ["https://example.com", "https://example.com/pricing"],
"screenSize": "desktop",
"pageSize": "a4",
"orientation": "portrait",
"margin": "small"
}
}protectProtect PDF| Parameter | Type | Description |
|---|---|---|
passwordRequired | Type: string | Password to apply to the output PDF. |
Example process body
{ "params": { "password": "s3cret" } }unlockUnlock PDFRemoves password protection. Supply the document's owner password if it has one.
| Parameter | Type | Description |
|---|---|---|
ownerPasswordOptional | Type: string | Owner password of the input PDF. |
Example process body
{ "params": { "ownerPassword": "s3cret" } }page-numbersPage numbers| Parameter | Type | Description |
|---|---|---|
modeOptional | Type: string | Allowed:singlefacing |
marginOptional | Type: string | Allowed:smallrecommendedbig |
firstNumberOptional | Type: number | Starting number. |
textFormatOptional | Type: string | Template, e.g. "{n}" or "Page {n} of {total}". |
fromPageOptional | Type: number | Optional first page (1-based). |
toPageOptional | Type: number | Optional last page (1-based). |
Example process body
{
"params": {
"mode": "single",
"margin": "recommended",
"firstNumber": 1,
"textFormat": "Page {n}"
}
}cropCrop PDFCrop page margins. Apply to all pages, the current page, or a range.
| Parameter | Type | Description |
|---|---|---|
applyOptional | Type: string | Allowed:allcurrentrange |
marginsOptional | Type: { top, bottom, left, right } | Margins to crop, all numbers. |
Example process body
{
"params": {
"apply": "all",
"margins": { "top": 20, "bottom": 20, "left": 15, "right": 15 }
}
}pdf-to-pdfaPDF to PDF/A| Parameter | Type | Description |
|---|---|---|
standardRequired | Type: string | Allowed:1a1b2b2u3b |
Example process body
{ "params": { "standard": "2b" } }ocrOCR PDFAdds a searchable text layer. Runs server-side via this API.
| Parameter | Type | Description |
|---|---|---|
languagesRequired | Type: string[] | Language codes, e.g. ["eng"], ["eng","deu"]. |
Example process body
{ "params": { "languages": ["eng"] } }pdf-formsPDF formsFill (and optionally flatten) an interactive PDF form.
| Parameter | Type | Description |
|---|---|---|
fieldValuesOptional | Type: Record<string,string> | Map of form field name to value. |
flattenOptional | Type: boolean | true makes fields non-editable in the output. |
Example process body
{
"params": {
"fieldValues": { "full_name": "Ada Lovelace", "country": "UK" },
"flatten": true
}
}remove-pagesRemove pages / Extract pagesremove-pages and extract-pages share this shape.
| Parameter | Type | Description |
|---|---|---|
pagesRequired | Type: string | Pages to remove or keep, e.g. "2,4-6". |
Example process body
{ "params": { "pages": "2,4-6" } }summarizeSummarize PDFPremium-only — returns 402 premium_required at process without an active plan.
| Parameter | Type | Description |
|---|---|---|
lengthOptional | Type: string | Allowed:shortmediumlong |
outputLanguageOptional | Type: string | Target language, e.g. "en". |
Example process body
{ "params": { "length": "medium", "outputLanguage": "en" } }translateTranslate PDFPremium-only — returns 402 premium_required at process without an active plan.
| Parameter | Type | Description |
|---|---|---|
sourceLangOptional | Type: string | Source language code, e.g. "en". |
targetLangOptional | Type: string | Target language code, e.g. "fr". |
preserveLayoutOptional | Type: boolean | Keep the original layout in the translated PDF. |
Example process body
{
"params": {
"sourceLang": "en",
"targetLang": "fr",
"preserveLayout": true
}
}Every error — a validation failure, a missing task, a rate limit, a premium gate, or an unexpected fault — returns the same normalized JSON envelope. You never have to special-case different error formats. message is always a non-empty, user-safe string; branch on code, not on message text.
{
"statusCode": 402,
"error": "Payment Required",
"message": "This tool requires Arawa PDF Premium",
"code": "premium_required",
"details": ["optional field-level validation messages"]
}HTTP status codes
| Status | Label | When it happens |
|---|---|---|
| 400 | Bad Request | Unknown tool slug, no files uploaded, unsupported file type, invalid params, or a file over the Premium 200 MB cap (code: file_too_large). |
| 401 | Unauthorized | Missing or invalid credential on an endpoint that requires auth (e.g. /v1/me/api-keys, /v1/auth/me), or bad login credentials. |
| 402 | Payment Required | A Premium entitlement is required — a Premium-only tool, a Basic file over 50 MB, or creating an API key without a plan (code: premium_required). |
| 403 | Forbidden | Task ownership mismatch (IDOR protection), or an API key missing the required scope (code: insufficient_scope). |
| 404 | Not Found | Unknown taskId, an output that already expired (~1h TTL), or an API key id that isn't yours. |
| 409 | Conflict | Signing up with an email that already has an account. |
| 413 | Payload Too Large | Upload over the hard 200 MB ceiling — rejected by the gateway, no business code. |
| 429 | Too Many Requests | Rate limit exceeded (120/min per IP, 5/min on auth routes), or the Basic 10 tasks/day quota (code: daily_limit). |
| 500 | Internal Server Error | An unexpected server fault. Always the generic "Something went wrong. Please try again." — internals never leak. |
Business codes — branch on code
| Code | HTTP | Meaning |
|---|---|---|
daily_limit | HTTP: 429 | A signed-in Basic account exceeded its 10 tasks/day quota (counted at start). Anonymous and Premium are exempt. |
premium_required | HTTP: 402 | Needs an active Premium/Business plan: a Premium-only tool at process, creating an API key, or a Basic user uploading a file over 50 MB. |
insufficient_scope | HTTP: 403 | The API key lacks the scope the operation requires — tasks:write for start/upload/process/delete, tasks:read for status/download. |
file_too_large | HTTP: 400 | A Premium user uploaded a file larger than the 200 MB cap. (A Basic over-50 MB file is premium_required / 402 instead.) |
processing_failed | HTTP: — | The worker failed to process the job. Not an HTTP error — surfaced as errorCode on the task status when status is "error". |
Validation errors carry details
On input-validation 400s, the per-field messages are both joined into message and preserved as a details string array. Unexpected server faults collapse to a generic 500 ("Something went wrong. Please try again.") and never leak internals.
Every endpoint under /v1 is rate-limited. The defaults:
| Limit | Value | Applies to |
|---|---|---|
| Request rate (global) | 120 / min | Per client IP |
| Request rate (API key) | 600 / min | Per developer key — its own bucket, isolated from IP and other keys |
| Request rate (auth routes) | 5 / min | Per IP, on login / signup / forgot / reset |
| Per-file size — Basic | 50 MB | Each file, free & anonymous (402 premium_required over) |
| Per-file size — Premium | 200 MB | Each file, paid plans (400 file_too_large over) |
| Files per task | 50 | One upload call can hold up to 50 files |
| Daily tasks — Basic | 10 / day | Signed-in free accounts (429 daily_limit) |
| Daily tasks — anon & Premium | Unlimited | Rate limit still applies |
| Output retention (TTL) | ~1 hour | Every task's inputs and output |
Informative headers ride on every response (not only 429s), so you can stay ahead of the limit:
| Header | Type | Description |
|---|---|---|
X-RateLimit-LimitOptional | Type: number | Max requests allowed in the current window. |
X-RateLimit-RemainingOptional | Type: number | Requests left before throttling (floored at 0). |
X-RateLimit-ResetOptional | Type: number | Absolute UNIX epoch seconds at which the window resets (GitHub/Stripe convention — not a countdown). |
Retry-AfterOptional | Type: number | Sent only on a 429. Seconds to wait before retrying. |
API-key traffic is metered per key
A request carrying a developer key counts against that key's own 600/min bucket (reported by the X-RateLimit-* headers), isolated from the per-IP bucket — so your server-to-server traffic never competes with anonymous or cookie traffic on the same IP. The bucket is keyed by a daily-salted SHA-256 hash of your IP for anonymous/cookie callers, consistent with the privacy-first promise.
Both the per-minute rate limit and the daily task quota surface as 429, but they want different handling. Branch on the body code:
if (res.status === 429) {
const body = await res.json();
if (body.code === "daily_limit") {
// Basic 10 tasks/day reached — prompt upgrade; retrying sooner won't help.
} else {
// IP rate limit (no code) — honour Retry-After / X-RateLimit-Reset, then retry.
}
}A polling cadence of one status check every 1–2 s per task stays comfortably inside these limits.
# Every response carries your current budget — inspect without doing work.
curl -sI https://arawapdf.com/v1/openapi.json | grep -i x-ratelimit
# x-ratelimit-limit: 120
# x-ratelimit-remaining: 119
# x-ratelimit-reset: 1717369200 (absolute UNIX epoch seconds)async function withRateLimitRetry(doRequest) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await doRequest();
if (res.status !== 429) return res;
// Prefer Retry-After; fall back to the absolute reset header.
const retryAfter = Number(res.headers.get("retry-after"));
const reset = Number(res.headers.get("x-ratelimit-reset"));
const waitSec = retryAfter > 0
? retryAfter
: Math.max(1, reset - Math.floor(Date.now() / 1000));
await new Promise((r) => setTimeout(r, waitSec * 1000));
}
throw new Error("Rate limit: exhausted retries");
}Create a key from your dashboard, then run the quickstart end-to-end.