API Async jobs & webhooks
API

Async jobs & webhooks

On this page

Create and poll jobs

POST /v1/jobsGET /v1/jobs/:id GA

POST /v1/jobs accepts the same body as /v1/extract and returns 202 { job_id } before any model call. Poll GET /v1/jobs/:id, or set options.webhook_url per job. The same `Idempotency-Key` header works here.

POST /v1/jobs
curl https://api.velrim.com/v1/jobs \
-H "Authorization: Bearer $VELRIM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": { "type": "object", "properties": { "total": { "type": "number" } } },
"options": { "webhook_url": "https://example.com/hooks/velrim" },
"document": { "bytes_base64": "<base64 of your PDF>" }
}'
# → 202 { "job_id": "job_…", "status": "running", "request_id": "req_…" }
GET /v1/jobs/:id
{ "job_id": "job_…", "status": "running" }
// succeeded: the full extract response, inlined
{ "job_id": "job_…", "status": "succeeded", "result": { "data": {}, "fields": {}, "meta": {} } }
// failed: a stable error code
{ "job_id": "job_…", "status": "failed", "error": { "code": "extraction_failed" } }

A succeeded job’s result stays available on GET /v1/jobs/:id for 24 hours; after that the lookup returns 404. Fetch the result and store it on your side. Polling GET /v1/jobs/:id is not rate limited.

Additional job options

options.webhook_url string optional
An https URL, max 2048 characters, on a plain public hostname: no ports, no IP literals, no localhost or internal suffixes. Called when the job completes or fails.
options.batch boolean optional
Opts the job into the batch tier.

Verify webhook deliveries

Deliveries follow the Standard Webhooks scheme. Each POST carries three headers: webhook-id, webhook-timestamp (unix seconds) and webhook-signature (v1,<base64>), an HMAC-SHA256 over id.timestamp.body.

The secret is the whole whsec_… string from the dashboard: Keys, on the “Webhook signing secret” card. Pass it as is to any standard-webhooks library, or use the SDK helper: verifyWebhook in TypeScript, verify_webhook in Python. Verify the signature before parsing the body; verification rejects timestamps more than 300 seconds from your clock.

Rotate the secret from the same card. For 24 hours after a rotation, deliveries carry signatures under both the new and the previous secret (webhook-signature is a space-delimited list; standard-webhooks libraries accept any matching entry), so the old secret keeps verifying while you switch. After that, only the new secret verifies.

delivery body
// webhook-id: msg_… (stable across redeliveries; your dedupe key)
// webhook-timestamp: 1751500000 (unix seconds; equals body created_at)
// webhook-signature: v1,<base64 HMAC-SHA256 of "id.timestamp.body">
{
"type": "job.completed",
"job_id": "job_…",
"result_url": "https://api.velrim.com/v1/jobs/job_…",
"billed_pages": 12,
"created_at": 1751500000,
"request_id": "req_…"
}
verify.ts
import { verifyWebhook } from "@velrim/sdk/webhook";
// Throws unless the signature matches and the timestamp is within 300 s.
const event = await verifyWebhook(rawBody, request.headers, secret);
if (event.type === "job.completed") {
const job = await velrim.jobs.get(event.job_id);
if (job.status === "succeeded") await handle(job.result);
}
Acknowledge
Any 2xx acknowledges the delivery. Redirects are not followed; every other outcome is retried.
Retries
Delivery is at-least-once: 5 attempts with exponential backoff from 20 seconds, capped at 15 minutes, then the delivery is dead-lettered. Dedupe on webhook-id; it is identical across redeliveries of the same event.
Events
One terminal event per job: job.completed or job.failed. Only job.completed carries a result_url. Ignore event types you do not recognize; new types are additive.

Bodies carry ids and page counts, never extracted content. Fetch the result from result_url with your API key.