API Reference
Integrate with the OCR API directly from your own systems.
Authentication
Every request carries an API key from the API Keys page, as either Authorization: Bearer <key> or X-API-Key: <key>. The raw key is shown once when it is minted and is not recoverable afterwards — only its hash is stored, so a lost key must be replaced rather than looked up. Keys can be given an expiry, and revoking one takes effect within a minute.
Quickstart
Send the document in the file field and any options as a JSON string in args. Exactly one file per request.
import { readFile } from "node:fs/promises";
const form = new FormData();
// Exactly one file per request. The filename travels for logging only — the type
// is sniffed from the bytes, so the extension decides nothing.
form.set("file", new Blob([await readFile("invoice.pdf")]), "invoice.pdf");
// args is a JSON *string* in one field, not a field per argument.
form.set("args", JSON.stringify({ format: "markdown" }));
const res = await fetch("https://api.heirs-ocr.example/v1/ocr/TEXT_EXTRACTION", {
method: "POST",
// No Content-Type header: fetch sets it, with the multipart boundary. Setting
// it by hand omits the boundary and the upload is rejected as malformed.
headers: { Authorization: `Bearer ${process.env.HEIRS_API_KEY}` },
body: form,
});
const body = await res.json();
if (!res.ok) {
const { code, message, requestId } = body.error;
throw new Error(`${code}: ${message} (${requestId})`);
}
// 200 → { requestId, function, result, meta }; 202 → a queued job, see below.
console.log(res.status, body.result);The file type is determined from the content, not the filename or the declared MIME type — a .pdf that is really a JPEG is processed as an image.
{
"requestId": "req_01J...",
"function": "TEXT_EXTRACTION",
"result": { "text": "..." },
"meta": {
"provider": "azure-document-intelligence",
"pageCount": 3,
"cached": false,
"durationMs": 1840
}
}Log the requestId on every response. It appears on the Request Logs page and is what support needs to trace a specific call.
Large documents
Documents past a size or page threshold are queued instead of processed inline, and the call returns 202 with a job id. Treat a 202 as success and poll the status URL; a job ends as completed or failed. You can also watch them on the Job Queues page.
# Large documents return 202 instead of a result.
{ "jobId": "1734", "statusUrl": "/v1/ocr/jobs/1734" }
# Poll until status is "completed" or "failed".
curl https://api.heirs-ocr.example/v1/ocr/jobs/1734 \
-H "Authorization: Bearer $HEIRS_API_KEY"Functions
Every function shares the contract above; they differ only in what they return and what they accept. The per-function detail — description, accepted file types, page cap and arguments schema — is generated from the live catalog, so it is rendered in the portal rather than hard-coded here. Any valid API key can read the same catalog from GET /v1/ocr/functions.
The function catalog is in the portal
Sign in to browse every function with its arguments schema, accepted file types and page limits — always matching what the service currently offers.
Errors
Failures use one envelope. retryable says whether repeating the same request could succeed — a 429 will, a 400 will not.
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Document allowance for this period is exhausted",
"requestId": "req_01J...",
"retryable": true
}
}| Code | Status | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing, unknown, revoked or expired API key. |
| FORBIDDEN | 403 | The key or plan does not include this function. |
| PAYMENT_REQUIRED | 402 | Subscription expired, canceled or suspended. |
| QUOTA_EXCEEDED | 429 | Period or trial document allowance exhausted. |
| RATE_LIMITED | 429 | Too many requests in the current window. Retry after a pause. |
| INVALID_ARGS | 400 | Malformed args, or a missing file field. |
| UNSUPPORTED_MEDIA_TYPE | 415 | The sniffed file type is not accepted by this function. |
| PAGE_LIMIT_EXCEEDED | 400 | Document exceeds the function's or your plan's page cap. |
| PROVIDER_UNAVAILABLE | 503 | A backing store or vendor is unreachable. Retryable. |
| INTERNAL | 500 | Server-side fault. Not retryable — quote the requestId. |
Rate limits
Requests are counted per organisation over a fixed window; the ceiling comes from your plan. Exceeding it returns RATE_LIMITED, which is retryable — back off and retry rather than looping. Separately, your plan caps documents per billing period; exhausting that returns QUOTA_EXCEEDED. Both appear on the Request Logs page, which is the only place a refused call is visible.
Webhooks
Register an endpoint under Webhooks to receive document.processed and document.failed events. Each delivery carries X-Heirs-Signature, X-Heirs-Delivery (stable across retries — use it to make your handler idempotent) and X-Heirs-Event.
Always verify the signature before trusting a payload, and respond 2xx quickly. Anything else is retried with exponential backoff up to six attempts, then marked dead.
Webhooks are part of the Business and Enterprise plans, and an organisation may register up to 10 endpoints. Each URL must be https and resolve to a public address — one pointed at a private, loopback or link-local host is refused when you save it, and re-checked before every delivery.
import crypto from "node:crypto";
// The raw body — parse only after verifying, or the bytes you check
// are not the bytes that were signed.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Reject anything older than the tolerance: the timestamp is inside the
// signed string, so a captured delivery cannot be replayed later.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}