Guide

Run asynchronous document extraction with retries and job recovery

All guides

For extraction that outlives a web request, submit a job and persist its ID before waiting for the result. If the client stops waiting, resume that job instead of creating another extraction. Your application needs to distinguish job admission, job completion and downstream processing; an HTTP 202 only establishes admission.

The example below splits submission from polling and stores a local receipt. It uses the public TypeScript SDK and one PDF per job. It is a starting point for a queue consumer, with explicit recovery steps for the gap between server acceptance and saving the job ID.

Give each logical extraction a durable identity

Choose an opaque key for one document and one schema configuration, and persist it before the first submission. Reuse that key with the exact same request body when reconciling an uncertain submission. Changing the PDF, schema, hints or options defines a different request. Avoid filenames as the only identifier: the same filename can later contain different bytes.

The SDK generates a key when omitted and keeps it across its automatic retries. Separate calls to jobs.create, however, need your persisted idempotencyKey to refer to the same logical submission. The server’s idempotency contract replays a stored success for 24 hours. It does not provide permanent deduplication for your application.

The sample records a document hash, the schema, key and local submission timestamp before calling the API. The hash helps check document identity; it is not an API idempotency key and does not reproduce the exact serialized request body. A production queue should keep durable request identity and schema version in its own store, with a uniqueness constraint for the logical task.

Submit once, then resume from the receipt

Use Node 22 or later, npm install @velrim/sdk zod and npm install --save-dev tsx. Download async-extraction.ts. Set VELRIM_API_KEY, then run npx tsx async-extraction.ts submit receipt.json invoice.pdf YOUR_STABLE_KEY. This creates a real job and page billing applies. Submission refuses to overwrite an existing receipt.

Run npx tsx async-extraction.ts resume receipt.json to wait for completion and save receipt.json.result.json. A timeout leaves the receipt in place: run the same resume command later. Use a PDF within the inline and page limits; the SDK also accepts an upload key for staged documents.

The polling deadline controls how long this client waits; it does not cancel the server’s extraction. A request already in flight can also consume its own transport timeout. The download is tested with synthetic responses and a fake transport, including resume without another POST. Running these commands with your key is a live operation.

async-extraction.ts
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { VelrimClient, type PollOptions } from '@velrim/sdk';
// Raw JSON Schema preserves partial data; validate before a downstream write.
export const schema = {
type: 'object',
properties: { invoice_number: { type: 'string' }, total: { type: 'number' } },
required: ['invoice_number', 'total'],
additionalProperties: false,
};
export async function submitJob(
client: VelrimClient,
bytes: Uint8Array,
idempotencyKey: string,
save: (receipt: { job_id: string; request_id: string }) => Promise<void>,
) {
if (!idempotencyKey || idempotencyKey.length > 255)
throw new Error('Supply a stable request key (1–255 characters).');
const job = await client.jobs.create(schema, { bytes }, { idempotencyKey });
// Persist before polling. A rejected save must reach the caller.
await save({ job_id: job.job_id, request_id: job.request_id });
return job;
}
export function resumeJob(client: VelrimClient, jobId: string, options: PollOptions = {}) {
return client.jobs.poll(jobId, schema, { intervalMs: 1500, timeoutMs: 60_000, ...options });
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [mode, receiptPath, pdfPath, key] = process.argv.slice(2);
if (
!receiptPath ||
(mode !== 'submit' && mode !== 'resume') ||
(mode === 'submit' && (!pdfPath || !key))
) {
throw new Error(
'Usage: npx tsx async-extraction.ts submit receipt.json invoice.pdf stable-key | resume receipt.json',
);
}
const client = new VelrimClient();
if (mode === 'submit') {
const bytes = await readFile(pdfPath);
const pending = {
idempotency_key: key,
created_at: new Date().toISOString(),
document_sha256: createHash('sha256').update(bytes).digest('hex'),
schema,
};
// Exclusive creation prevents accidental reuse of an existing receipt file.
await writeFile(receiptPath, JSON.stringify(pending, null, 2), {
encoding: 'utf8',
flag: 'wx',
});
await submitJob(client, bytes, key, async (job) => {
await writeFile(receiptPath, JSON.stringify({ ...pending, ...job }, null, 2), 'utf8');
});
console.log({ next: 'resume', receipt: receiptPath });
} else {
const receipt: unknown = JSON.parse(await readFile(receiptPath, 'utf8'));
if (
typeof receipt !== 'object' ||
receipt === null ||
!('job_id' in receipt) ||
typeof receipt.job_id !== 'string' ||
!receipt.job_id
) {
throw new Error(
'Pending receipt: reconcile submission using the original key and exact body; do not create a new key.',
);
}
const result = await resumeJob(client, receipt.job_id);
await writeFile(
`${receiptPath}.result.json`,
JSON.stringify({ data: result.data, fields: result.fields, meta: result.meta }, null, 2),
{ encoding: 'utf8', flag: 'wx' },
);
console.log({ request_id: result.meta.request_id, billed_pages: result.meta.billed_pages });
}
}

Handle each failure at the right boundary

A submission error does not always establish whether the server accepted a request. If a pending receipt has no job ID, stop and reconcile it. Within the replay window, a recovery process can resubmit using the recorded key and exact original body. Verify the document hash and schema, and preserve SDK serialization and options; do not use this as a reason to blindly mint another key.

This small CLI deliberately leaves pending-receipt reconciliation manual. It has no database transaction or atomic filesystem replacement, and cannot guarantee a receipt survives a process or disk failure. In a queue worker, await durable persistence before acknowledging the queue message, and treat an uncertain write as a recovery case.

Handle each failure at the right boundary
Observed stateNext action
202 / runningSave job_id and request_id; poll the saved job.
Client polling timeout or abortKeep the receipt and resume the existing job; this does not cancel server work.
409 idempotency_key_conflictInspect body mismatch or an in-flight submission. Do not automatically retry with a new key.
Job failedInspect the terminal error code and fix the cause before a deliberate new attempt.
Succeeded with partial dataSave the envelope; run application validation and field review before downstream actions.
404 on result lookupCheck account, job ID and retention. Reconcile stored results before authorizing another extraction.

Save results promptly and deduplicate downstream work

The jobs reference documents a 24-hour result availability window. Fetch and save the envelope in your application’s authorized storage; a receipt alone does not preserve extracted data. Keep fields and meta with data, and avoid logging values or source snippets. The raw-schema example intentionally leaves application validation to your processing stage.

Extraction idempotency does not deduplicate an accounting import, notification or other downstream action. Track processing by job ID and logical task identity in your own database. Use a transaction or outbox appropriate to your destination so a worker restart cannot repeat a completed write.

For many documents, use a bounded worker queue and admit one job per document. Choose concurrency from observed latency, account limits and your budget. This example does not demonstrate a native multi-document batch request or promise higher throughput.

Use webhooks as a completion signal when polling is inconvenient

A job can include a webhook URL. Follow the webhook verification example: verify the signature against the raw request body before processing, deduplicate delivery IDs, then fetch the job result with your API credentials. Completion notifications carry references and status metadata; retrieve the extraction separately.

Exercise running → succeeded, terminal failure, interrupted polling, a lost admission response and repeated completion handling before connecting a downstream destination. For review logic after completion, use the typed extraction guide and invoice line-item example. For provider cutover, the migration checklist covers preserving these application contracts.

Written by Velrim.