REST API · OpenAPI 3.1 · free key

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. 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. 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. 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 / processingKeep polling.
doneData available in extracted_data.
errorProcessing failed (see error_code).
rejectedRefused 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 planPaid plans
Pages per month20 (renew monthly)200 to 15,000, plus optional packs
Requests per minute per key60120
File sizeabout 4.4 MB (413 above that)about 4.4 MB
Pages per document5050
Templates10 in total (new accounts start with 8)depends on the plan
FormatsPDF, JPEG, PNGPDF, 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.

HTTPerror_code
401API_KEY_REQUIRED / INVALID_API_KEY
402INSUFFICIENT_TOKENS / PAYMENT_REQUIRED
429RATE_LIMITED
400UNSUPPORTED_FILE_TYPE / NO_FILE / DOCUMENT_TOO_LONG / VALIDATION_ERROR / INVALID_JSON
403SCANNER_LIMIT
404SCANNER_NOT_FOUND / RESULT_NOT_FOUND
413—
503BUSY
500INTERNAL_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"));

openapi.json · llms.txt

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.