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. 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. Keep polling or wait for a webhook until the document reaches one of the terminal states.
| Status | Meaning | Client behavior |
|---|---|---|
pending |
Work is queued or running. | Poll again or wait for a webhook. |
completed |
Processing finished successfully. | Request /documents/{document}/extractions. |
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. |
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.