Documentos dentro, JSON fuera
Envía un PDF o una foto de una factura, ticket, extracto bancario, DNI o tarjeta de visita y lee los campos extraídos en JSON. Pensada para scripts y para agentes de IA. La clave está incluida en todos los planes, también en el gratuito.
Empezar en 3 comandos
Primero crea la clave en la app, en el apartado API.
# 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"Autenticación
Envía la clave en la cabecera X-API-Key en cada petición. Las claves empiezan por wsk_ y se crean (o se regeneran, lo que invalida la anterior al instante) en la app, en API. Todos los planes tienen clave; el plan gratuito usa sus 20 páginas al mes, compartidas con la web, WhatsApp, el correo y Telegram.
Cómo funciona
El proceso es asíncrono: la subida responde enseguida con un result_id y consultas el resultado.
1. Subir
POST /api/v1/process deja que WhappScan elija la plantilla adecuada; POST /api/v1/scanners/{id}/upload usa la que tú elijas. Formulario multipart con un campo llamado file. Responde HTTP 202 con un result_id.
2. Consultar
GET /api/v1/results/{result_id} cada 2-3 segundos. El status pasa de queued a processing y a done. Lo normal son 3-20 segundos; un extracto bancario largo puede tardar unos 2 minutos.
3. Leer
Cuando status es done, extracted_data.fields[] trae un elemento por campo con value, confidence (0-1) y source_fragment. Los campos de tabla (líneas de factura, movimientos) llegan como un array de filas. error y rejected llevan error_code y error_message; rejected significa que no se cobró ninguna página.
Estado del resultado
| status | |
|---|---|
| queued / processing | Sigue consultando. |
| done | Datos disponibles en extracted_data. |
| error | El proceso falló (mira error_code). |
| rejected | Rechazado antes de procesar, sin cobro (por ejemplo NO_TEXT: no hay nada legible en el archivo). |
Un resultado terminado
{
"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 }
]
}
]
}
}Límites
| Plan gratuito | Planes de pago | |
|---|---|---|
| Páginas al mes | 20 (se renuevan cada mes) | de 200 a 15.000, más paquetes opcionales |
| Peticiones por minuto y clave | 60 | 120 |
| Tamaño de archivo | unos 4,4 MB (por encima, 413) | unos 4,4 MB |
| Páginas por documento | 50 | 50 |
| Plantillas | 10 en total (las cuentas nuevas empiezan con 8) | según el plan |
| Formatos | PDF, JPEG, PNG | PDF, JPEG, PNG |
Cada página de un documento gasta una página de la cuota (una foto cuenta como una). Las respuestas llevan X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset; ante un HTTP 429 espera los segundos de Retry-After. En fotos, 2.600 píxeles en el lado largo bastan para el OCR y el archivo pesa poco.
Plantillas (scanners)
Una plantilla define qué campos se extraen. Las cuentas nuevas traen plantillas listas (factura, ticket, albarán, presupuesto, pedido, DNI y pasaporte, tarjeta de visita, extracto bancario); lístalas con GET /api/v1/scanners o crea las tuyas. Escribe una descripción corta por campo diciendo dónde aparece y cómo darle formato: la lee el modelo.
Crear una plantilla
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"}]}
]}'Errores
Los errores son JSON con success false, un error_code estable y un message. Los casos que dependen de qué hagas después llevan un 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 |
El 413 lo devuelve la pasarela antes de que se ejecute la API y su cuerpo es {"message":"Request Too Long"}. Una URL que no existe responde 403 con {"message":"Missing Authentication Token"}.
Para agentes de IA y scripts
Todo lo que necesita un agente es legible por máquina: la especificación OpenAPI 3.1 en /openapi.json tiene un operationId por acción (processDocument, getResult, listScanners…) y descripciones escritas para modelos, y /llms.txt indexa el sitio. La receta de abajo sube, consulta con espera y maneja 402, 413 y 429.
Pega esto en tu agente (Claude Code, Cursor, un agente propio…)
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"));Preguntas
¿La API es gratis?
La clave está incluida en todos los planes. El plan gratuito usa su cuota de 20 páginas al mes y 60 peticiones por minuto; los planes de pago tienen más páginas y 120 peticiones por minuto.
¿Cuánto tarda un documento?
Normalmente de 3 a 20 segundos. Los extractos bancarios largos pueden tardar unos 2 minutos. Consulta cada 2-3 segundos.
¿Puedo subir un archivo de más de 4,4 MB?
No por este endpoint: la pasarela lo rechaza con HTTP 413. Baja la resolución de las fotos (2.600 px en el lado largo bastan) o comprime el PDF. La app web lo hace sola con las fotos.
¿Hay webhook en vez de consultar?
Todavía no. Consulta GET /api/v1/results/{id} cada 2-3 segundos.
¿Qué pasa con mis archivos?
El archivo se guarda en un bucket temporal de AWS en la UE (Fráncfort) solo mientras se procesa y después se borra; pase lo que pase, una regla de seguridad elimina lo que quede a los 7 días. Los datos extraídos quedan en tu cuenta. La extracción usa proveedores de modelos de IA; la política de privacidad tiene los detalles.