Documents in, JSON out
Send a PDF or a photo of an invoice, receipt, bank statement, ID or business card and read the extracted fields as JSON. Made for scripts and for AI agents. A key is included on every plan, the free one too.
Quick start
Three commands. Create the key under API in the app first.
# 1. Create your key in the app (every plan, free included): https://app.whappscan.com -> API
export WHAPPSCAN_KEY=wsk_xxxxxxxxxxxxxxxx
BASE=https://api.whappscan.com
# 2. Upload a PDF or a photo -> HTTP 202 and a result_id
curl -s -X POST "$BASE/api/v1/process" \
-H "X-API-Key: $WHAPPSCAN_KEY" \
-F "file=@invoice.pdf;type=application/pdf"
# 3. Poll until "status" is "done" (every 2-3 seconds)
curl -s "$BASE/api/v1/results/RESULT_ID" -H "X-API-Key: $WHAPPSCAN_KEY"Authentication
Send your key in the X-API-Key header on every request. Keys start with wsk_ and are created (or regenerated, which invalidates the old one at once) in the app, under API. Every plan has a key; the free plan uses its own 20 pages a month, shared with the web app, WhatsApp, email and Telegram.
How it works
Processing is asynchronous: an upload returns at once with a result_id and you poll for the result.
1. Upload
POST /api/v1/process lets WhappScan pick the right template; POST /api/v1/scanners/{id}/upload uses a template you choose. Multipart form with a field named file. HTTP 202 with a result_id.
2. Poll
GET /api/v1/results/{result_id} every 2-3 seconds. status goes queued, processing, then done. Typical time is 3-20 seconds; a long bank statement can take about 2 minutes.
3. Read
When status is done, extracted_data.fields[] has one entry per field with value, confidence (0-1) and source_fragment. Table fields (invoice lines, statement rows) come as an array of row objects. error and rejected carry error_code and error_message; rejected means no pages were charged.
Result status
| status | |
|---|---|
| queued / processing | Keep polling. |
| done | Data available in extracted_data. |
| error | Processing failed (see error_code). |
| rejected | Refused before processing, nothing charged (for example NO_TEXT: nothing readable in the file). |
A finished result
{
"success": true,
"result_id": "bd929a0f-de95-4321-97a4-abdc5d078da2",
"status": "done",
"scanner_name": "Business card",
"tokens_used": 1,
"confidence": 0.95,
"extracted_data": {
"fields": [
{
"name": "contacts",
"value_type": "table",
"confidence": 0.95,
"value": [
{ "full_name": "Sofia Wong", "company_name": "Sunway Packaging",
"job_title": "Procurement Lead", "phone": "+60 17-716 7035",
"email": "sofia.wong@sunwaypackaging.example", "website": null }
]
}
]
}
}Limits
| Free plan | Paid plans | |
|---|---|---|
| Pages per month | 20 (renew monthly) | 200 to 15,000, plus optional packs |
| Requests per minute per key | 60 | 120 |
| File size | about 4.4 MB (413 above that) | about 4.4 MB |
| Pages per document | 50 | 50 |
| Templates | 10 in total (new accounts start with 8) | depends on the plan |
| Formats | PDF, JPEG, PNG | PDF, JPEG, PNG |
Each page of a document uses one page of quota (a photo counts as one). Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; on HTTP 429 wait Retry-After seconds. For photos, 2,600 pixels on the long side is enough for OCR and keeps the file small.
Templates (scanners)
A template defines which fields are extracted. New accounts come with ready-made templates (invoice, receipt, delivery note, quote, purchase order, ID and passport, business card, bank statement); list them with GET /api/v1/scanners or create your own. Write a short description per field saying where it appears and how to format it: the model reads it.
Create a template
curl -s -X POST "$BASE/api/v1/scanners" \
-H "X-API-Key: $WHAPPSCAN_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Supplier invoice",
"fields": [
{"name": "invoice_number", "type": "string", "description": "Invoice number as printed"},
{"name": "total_amount", "type": "currency"},
{"name": "lines", "type": "table", "item_fields": [
{"name": "description", "type": "string"},
{"name": "quantity", "type": "number"},
{"name": "amount", "type": "currency"}]}
]}'Errors
Errors are JSON with success false, a stable error_code and a message. Cases that depend on what you do next carry a next_action.
| HTTP | error_code |
|---|---|
| 401 | API_KEY_REQUIRED / INVALID_API_KEY |
| 402 | INSUFFICIENT_TOKENS / PAYMENT_REQUIRED |
| 429 | RATE_LIMITED |
| 400 | UNSUPPORTED_FILE_TYPE / NO_FILE / DOCUMENT_TOO_LONG / VALIDATION_ERROR / INVALID_JSON |
| 403 | SCANNER_LIMIT |
| 404 | SCANNER_NOT_FOUND / RESULT_NOT_FOUND |
| 413 | — |
| 503 | BUSY |
| 500 | INTERNAL_ERROR |
A 413 comes from the gateway before the API runs and its body is {"message":"Request Too Long"}. An unknown URL answers 403 with {"message":"Missing Authentication Token"}.
For AI agents and scripts
Everything an agent needs is machine-readable: the OpenAPI 3.1 spec at /openapi.json has one operationId per action (processDocument, getResult, listScanners…) and descriptions written for models, and /llms.txt indexes the site. The recipe below uploads, polls with backoff and handles 402, 413 and 429.
Paste this into your agent (Claude Code, Cursor, a custom agent…)
Use the WhappScan API to extract data from documents.
Spec: https://whappscan.com/openapi.json (docs: https://whappscan.com/en/api)
Auth: header X-API-Key from the env var WHAPPSCAN_KEY.
Flow: POST /api/v1/process (multipart, field "file") -> result_id -> poll GET /api/v1/results/{id} every 2-3 s until status is "done".
Read extracted_data.fields[].value. On 402 tell me the next_action; on 429 wait Retry-After; files must be under ~4.4 MB.Python
import mimetypes, os, time, requests
BASE = "https://api.whappscan.com"
HEADERS = {"X-API-Key": os.environ["WHAPPSCAN_KEY"]}
def extract(path, scanner_id=None, timeout=180):
"""Upload a document and return the extracted fields as a dict {name: value}."""
url = f"{BASE}/api/v1/scanners/{scanner_id}/upload" if scanner_id else f"{BASE}/api/v1/process"
mime = mimetypes.guess_type(path)[0] or "application/pdf" # the API needs the part's content type
with open(path, "rb") as f:
r = requests.post(url, headers=HEADERS, files={"file": (os.path.basename(path), f, mime)})
if r.status_code == 402: # no pages left: r.json()["next_action"] says what to do
raise RuntimeError(r.json()["next_action"])
if r.status_code == 413: # over ~4.4 MB: lower the resolution or compress the PDF
raise RuntimeError("file too large")
r.raise_for_status()
result_id = r.json()["result_id"]
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(2.5)
res = requests.get(f"{BASE}/api/v1/results/{result_id}", headers=HEADERS)
if res.status_code == 429: # rate limited: wait what the server says
time.sleep(int(res.headers.get("Retry-After", 5)))
continue
res.raise_for_status()
data = res.json()
if data["status"] == "done":
return {f["name"]: f["value"] for f in data["extracted_data"]["fields"]}
if data["status"] in ("error", "rejected"):
raise RuntimeError(f'{data.get("error_code")}: {data.get("error_message")}')
raise TimeoutError("still processing")
print(extract("invoice.pdf"))Node.js
// Node 18+ (fetch, FormData and Blob are built in)
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const BASE = "https://api.whappscan.com";
const headers = { "X-API-Key": process.env.WHAPPSCAN_KEY };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function extract(path, mime = "application/pdf", scannerId) {
const form = new FormData();
form.append("file", new Blob([await readFile(path)], { type: mime }), basename(path));
const url = scannerId ? `${BASE}/api/v1/scanners/${scannerId}/upload` : `${BASE}/api/v1/process`;
const up = await fetch(url, { method: "POST", headers, body: form });
if (up.status === 402) throw new Error(JSON.stringify((await up.json()).next_action));
if (!up.ok) throw new Error(`upload failed: ${up.status} ${await up.text()}`);
const { result_id } = await up.json();
for (let i = 0; i < 80; i++) {
await sleep(2500);
const res = await fetch(`${BASE}/api/v1/results/${result_id}`, { headers });
if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") || 5) * 1000); continue; }
const data = await res.json();
if (data.status === "done") return Object.fromEntries(data.extracted_data.fields.map((f) => [f.name, f.value]));
if (["error", "rejected"].includes(data.status)) throw new Error(`${data.error_code}: ${data.error_message}`);
}
throw new Error("still processing");
}
console.log(await extract("invoice.pdf"));Questions
Is the API free?
A key is included on every plan. The free plan uses its own quota of 20 pages per month and 60 requests per minute; paid plans have more pages and 120 requests per minute.
How long does a document take?
Usually 3 to 20 seconds. Long bank statements can take about 2 minutes. Poll every 2-3 seconds.
Can I upload a file bigger than 4.4 MB?
Not through this endpoint: the gateway rejects it with HTTP 413. Lower the resolution of photos (2,600 px on the long side is enough) or compress the PDF. The web app does this for you automatically for photos.
Is there a webhook instead of polling?
Not yet. Poll GET /api/v1/results/{id} every 2-3 seconds.
What happens to my files?
A file is kept in a temporary AWS bucket in the EU (Frankfurt) only while it is processed, then deleted; whatever happens, a safety rule removes anything left after 7 days. The extracted data stays in your account. Extraction uses AI model providers; the privacy policy has the details.