API 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 queued or running. | Poll again or wait for a webhook. |
completed |
Processing finished successfully. | Read extraction fields for extraction mode, or download the JPEG thumbnail for thumbnail mode. |
error |
Processing failed. | Inspect processing_error and show a retry/support path. |
blocked |
Work was intentionally not queued, commonly because credits were unavailable. | Inspect blocked_reason and resolve the account condition. |
Thumbnail-only documents move through queued, thumbnailing, and completed, or reach failed with document status: "error". They use the normal document webhooks but do not create an extraction run or spend an extraction credit.
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 uploads reserve credits and should be connected to production automation. |
Test uploads do not spend live credits, but they are limited per user, account, and token each day. Limit responses use 429 with code test_mode_limit_exceeded.