API v1 documentation
Getting started
Upload a PDF, wait for processing, and read normalized extraction fields.
Base URL
All examples in these docs use the documented API base URL.
https://www.exdata.app/api/v1
Create a token
Start in the exdata app and create an account API token before you call the API.
- Open the exdata app and go to API Tokens.
- Create a test mode token while building the integration.
- Select the abilities your integration needs:
documents:writefor uploads anddocuments:readfor polling, previews, and extraction fields. - Copy the token when it is shown. The full token is shown once; after that, only the prefix is visible.
- Store it as an environment variable such as
EXDATA_API_TOKEN.
export EXDATA_API_TOKEN="your-test-token"
Upload, poll, read
Use a test-mode token and a real PDF on your machine. This example uses the default extraction processing mode: upload the document, poll until processing is terminal, then read the normalized extraction fields.
export EXDATA_API_TOKEN="your-test-token"
UPLOAD_RESPONSE=$(curl -sS -X POST "https://www.exdata.app/api/v1/documents" \
-H "Authorization: Bearer $EXDATA_API_TOKEN" \
-H "Idempotency-Key: invoice-$(date +%s)" \
-F "file=@./invoice.pdf" \
-F "locale=en" \
-F "custom_types[]=invoice")
DOCUMENT_ID=$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.id')
curl -sS "https://www.exdata.app/api/v1/documents/$DOCUMENT_ID" \
-H "Authorization: Bearer $EXDATA_API_TOKEN"
curl -sS "https://www.exdata.app/api/v1/documents/$DOCUMENT_ID/extractions" \
-H "Authorization: Bearer $EXDATA_API_TOKEN"
import fs from "node:fs";
const token = process.env.EXDATA_API_TOKEN;
const form = new FormData();
form.set("file", new Blob([fs.readFileSync("./invoice.pdf")]), "invoice.pdf");
form.set("locale", "en");
form.append("custom_types[]", "invoice");
const upload = await fetch("https://www.exdata.app/api/v1/documents", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Idempotency-Key": `invoice-${Date.now()}`,
},
body: form,
}).then((response) => response.json());
const documentId = upload.data.id;
const document = await fetch(`https://www.exdata.app/api/v1/documents/${documentId}`, {
headers: { Authorization: `Bearer ${token}` },
}).then((response) => response.json());
const extractions = await fetch(`https://www.exdata.app/api/v1/documents/${documentId}/extractions`, {
headers: { Authorization: `Bearer ${token}` },
}).then((response) => response.json());
console.log(document.data.status, extractions.data);
import os
import time
import requests
token = os.environ["EXDATA_API_TOKEN"]
headers = {
"Authorization": f"Bearer {token}",
"Idempotency-Key": f"invoice-{int(time.time())}",
}
with open("./invoice.pdf", "rb") as file:
upload = requests.post(
"https://www.exdata.app/api/v1/documents",
headers=headers,
files={"file": ("invoice.pdf", file, "application/pdf")},
data={"locale": "en", "custom_types[]": "invoice"},
timeout=60,
).json()
document_id = upload["data"]["id"]
document = requests.get(
f"https://www.exdata.app/api/v1/documents/{document_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
).json()
extractions = requests.get(
f"https://www.exdata.app/api/v1/documents/{document_id}/extractions",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
).json()
print(document["data"]["status"], extractions["data"])
<?php
$token = getenv('EXDATA_API_TOKEN');
$client = new GuzzleHttp\Client(['base_uri' => 'https://www.exdata.app/api/v1']);
$upload = $client->post('/documents', [
'headers' => [
'Authorization' => "Bearer {$token}",
'Idempotency-Key' => 'invoice-'.time(),
],
'multipart' => [
['name' => 'file', 'contents' => fopen('./invoice.pdf', 'r'), 'filename' => 'invoice.pdf'],
['name' => 'locale', 'contents' => 'en'],
['name' => 'custom_types[]', 'contents' => 'invoice'],
],
]);
$documentId = json_decode((string) $upload->getBody(), true)['data']['id'];
$document = $client->get("/documents/{$documentId}", [
'headers' => ['Authorization' => "Bearer {$token}"],
]);
$extractions = $client->get("/documents/{$documentId}/extractions", [
'headers' => ['Authorization' => "Bearer {$token}"],
]);
var_dump(json_decode((string) $document->getBody(), true)['data']['status']);
var_dump(json_decode((string) $extractions->getBody(), true)['data']);
Processing lifecycle
Treat document processing as asynchronous in both processing modes. Keep polling or wait for a webhook until the document reaches one of the terminal states. Use processing_mode to decide which result to read after completion.
| Status | Meaning | Client behavior |
|---|---|---|
pending |
Work is awaiting processing or running. | Poll again or wait for a webhook. |
completed |
Processing finished successfully. | Read extraction fields for extraction mode, analysis fields for analysis mode, latest_extraction_run.quality.structured for validation mode, or download the JPEG thumbnail for thumbnail mode. |
error |
Processing failed. Live credits are returned only when neither a usable result nor retrievable extracted fields remain. | Inspect processing_error and latest_extraction_run.credits_charged, then show a retry/support path. |
blocked |
Processing stopped because an account condition was not satisfied. This can happen asynchronously after 201 if analysis discovers an additional page band that available credits cannot cover. |
Inspect blocked_reason and resolve the account condition. |
A live extraction applies the profile rate to one initial band. After analysis determines the page count, exdata calculates usage as credit_cost × started 10-page bands and keeps that total when a usable result or retrievable extracted fields remain. Generic extraction has a rate of one. Terminal and preprocessing errors return credits only when neither remains. A page_band_insufficient_credits block also returns the initial credits, so the net charge is zero.
Thumbnail-only documents move through queued, thumbnailing, and completed, or reach failed with document status: "error". They can also report waiting_for_capacity, because thumbnail rendering uses standard processing capacity. They use the normal document webhooks but do not create an extraction run and have zero credit usage.
Test mode
Use test mode for development, QA, sample files, and webhook receiver testing. Test-mode tokens use the same endpoints, validation, idempotency behavior, processing states, request IDs, and webhook payload shape as live tokens.
| Step | What to do | What to check |
|---|---|---|
| Create token | Create a token with the test mode environment in the app. | The token is clearly marked as test mode in the API Tokens list. |
| Upload samples | Use the same upload endpoint and parameters as production. | Responses include mode as test. |
| Map fields | Poll the document or wait for webhooks, then read extraction fields. | Your integration handles pending, completed, error, and blocked. |
| Switch to live | Create a live token only after field mapping and webhook handling are working. | Live extraction uploads use result-based page-band credits and should be connected to production automation. |
Test uploads have zero live credit usage, but they are limited per user, account, and token each day. Extraction runs report credits_charged: 0. Limit responses use 429 with code test_mode_limit_exceeded.