Async Mode
Async mode lets the API return immediately with a job_id instead of holding the HTTP connection open for the full inference run. Your client polls a status endpoint until the job completes. Every long-running endpoint — /v1/parse, /v1/extract, /v1/classify, /v1/split — supports it.
Why use async
Extraction on a multi-page document can take 130–160 seconds. CloudFront, like most CDNs, drops idle connections at 60 seconds. A synchronous call to a slow endpoint can succeed inside our infrastructure and still hand your client a connection-reset error at the edge. Async mode sidesteps this: each individual HTTP request stays sub-second, so the call survives any CDN, proxy, or load-balancer timeout in the path between you and the API.
Use async whenever you can't guarantee the calling context (browser, mobile, serverless function with hard wall-clock limits) will hold a connection for ~3 minutes. For batch workers running inside your own infrastructure, synchronous is fine.
How to enable
Add the header X-Async: true to any inference request. The request body and query parameters are unchanged from the synchronous call.
curl -X POST "https://apis.hyperbots.com/v1/parse" \
-H "X-API-Key: hk_live_your_key_here" \
-H "X-Async: true" \
-F "document_key=02b95e79-bc17-4e71-be99-fd4338ba00bf"curl -X POST "https://apis.hyperbots.com/v1/parse" \
-H "X-API-Key: hk_live_your_key_here" \
-H "X-Async: true" \
-F "document_key=02b95e79-bc17-4e71-be99-fd4338ba00bf"The response is a small job receipt, returned within ~100 ms:
{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending",
"poll_url": "/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02"
}{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending",
"poll_url": "/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02"
}Polling the job
GET the poll_url from the submit response (or build it yourself from the job_id: /v1/jobs/{job_id}). Poll every 3 seconds as a sane default; longer is fine, shorter is wasted load.
curl "https://apis.hyperbots.com/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02" \
-H "X-API-Key: hk_live_your_key_here"curl "https://apis.hyperbots.com/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02" \
-H "X-API-Key: hk_live_your_key_here"Jobs are organization-scoped: any active API key in your organization can poll a job — including jobs you started in the Playground. A job is owned by your org, not by the key (or dashboard session) that created it. Jobs expire 1 hour after creation; a wrong-org key returns the same 404 as an expired job.
Job status values
| Status | Meaning |
|---|---|
pending | The job has been accepted and is queued or running on a worker. Continue polling. |
completed | The job finished successfully. The inference response is wrapped inside result (see shape below). |
failed | The job ran but raised an error. error is a plain string carrying the HTTP-style status code and human-readable reason. result is null. |
cancelled | You called DELETE /v1/jobs/{job_id} on the job. Surfaced only on GET /v1/jobs/recent — a direct GET /v1/jobs/{job_id} on a cancelled job returns status: failed. |
A typical pending poll response:
{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending"
}{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending"
}On completed the response wraps the synchronous inference response inside its own result field. Job-level metadata ( created_at, completed_at, org_id, filename, endpoint) is at the top level. The OCR/extraction payload is at result.result (the outer field is the job envelope; the inner field is what a synchronous call would have returned directly):
{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "completed",
"request_id": "b91eaa9b-c75d-402d-a8dc-cb3af247c267",
"endpoint": "/v1/parse",
"filename": "contract.pdf",
"document_key": "02b95e79-bc17-4e71-be99-fd4338ba00bf",
"created_at": 1779447738.16,
"completed_at": 1779447738.85,
"error": null,
"result": {
"status": "success",
"request_id": "b91eaa9b-c75d-402d-a8dc-cb3af247c267",
"task": "parse",
"model_used": "HyperLM",
"result": {
"ocr": "INVOICE\nAcme Corp ...",
"pages": [{ "page_number": 1, "text": "INVOICE\nAcme Corp ..." }]
},
"duration_ms": 657,
"metadata": {
"pages": 1,
"source": "document_key",
"filename": "contract.pdf",
"file_size": 5821
}
}
}{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "completed",
"request_id": "b91eaa9b-c75d-402d-a8dc-cb3af247c267",
"endpoint": "/v1/parse",
"filename": "contract.pdf",
"document_key": "02b95e79-bc17-4e71-be99-fd4338ba00bf",
"created_at": 1779447738.16,
"completed_at": 1779447738.85,
"error": null,
"result": {
"status": "success",
"request_id": "b91eaa9b-c75d-402d-a8dc-cb3af247c267",
"task": "parse",
"model_used": "HyperLM",
"result": {
"ocr": "INVOICE\nAcme Corp ...",
"pages": [{ "page_number": 1, "text": "INVOICE\nAcme Corp ..." }]
},
"duration_ms": 657,
"metadata": {
"pages": 1,
"source": "document_key",
"filename": "contract.pdf",
"file_size": 5821
}
}
}On failed the result field is null and error is a string carrying the HTTP-style status code and a human-readable reason:
{
"job_id": "b891d13b-e068-4210-a1d3-3ca013f5a715",
"status": "failed",
"request_id": "a7f215b8-3339-4a83-9f79-03372b9e9eb4",
"org_id": "f9030855-340d-4cfd-b7e7-1f7b39f4ba42",
"endpoint": "/v1/parse",
"filename": "notpdf.txt",
"created_at": 1779447744.92,
"completed_at": 1779447744.96,
"result": null,
"error": "415: Unsupported file type: text/plain. Supported: PDF, images."
}{
"job_id": "b891d13b-e068-4210-a1d3-3ca013f5a715",
"status": "failed",
"request_id": "a7f215b8-3339-4a83-9f79-03372b9e9eb4",
"org_id": "f9030855-340d-4cfd-b7e7-1f7b39f4ba42",
"endpoint": "/v1/parse",
"filename": "notpdf.txt",
"created_at": 1779447744.92,
"completed_at": 1779447744.96,
"result": null,
"error": "415: Unsupported file type: text/plain. Supported: PDF, images."
}The same conditions that would have raised a typed error on a synchronous call (415 for unsupported file types, 402 for insufficient credits, etc.) surface here as the leading numeric code in the error string. See Error Codes for the full set.
Large documents
By default an upload is capped at 50 MB and /v1/parse in mode=advanced at 60 pages. Those ceilings rise to 512 MiB and 500 pages for one case only: your organization is on the custom tier, the request comes directly from the API or an official SDK, and it carries X-Async: true. If any of the three doesn't hold — a dashboard or browser upload, another tier, or a synchronous call — an over-cap request returns the same 413 it always has. See Size limits for the table.
Async isn't optional here — it's what makes the raised ceiling possible. An over-cap document is processed as a series of page ranges, so you get a job_id immediately and poll GET /v1/jobs/{job_id} as usual. A 500-page document takes on the order of tens of minutes; the 3-second default poll interval is unnecessarily eager for a run that long.
While a segmented job runs, the poll response carries progress counters alongside the usual status:
{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending",
"segments_total": 10,
"segments_done": 4,
"segments_failed": 0
}{
"job_id": "9b330094-505d-480c-812c-86a698a3ff02",
"status": "pending",
"segments_total": 10,
"segments_done": 4,
"segments_failed": 0
}segments_total is the number of page ranges the document was split into, segments_done how many have finished, and segments_failed how many gave up after retries.
A non-zero segments_failed does not fail the job. The document still completes, and the result reports the page ranges that didn't make it in a gaps field — treat it as the authoritative list of what is missing from the output rather than assuming full coverage. You are billed only for pages that were processed successfully, so a run with gaps costs less than a clean one.
Cancelling a job
You can request cancellation of an in-flight job with DELETE /v1/jobs/{job_id}. A successful cancellation marks the job as cancelled in GET /v1/jobs/recent. Note that a direct GET /v1/jobs/{job_id} on a cancelled job currently returns status: failed rather than cancelled; the listing endpoint is authoritative for the cancellation state.
curl -X DELETE "https://apis.hyperbots.com/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02" \
-H "X-API-Key: hk_live_your_key_here"curl -X DELETE "https://apis.hyperbots.com/v1/jobs/9b330094-505d-480c-812c-86a698a3ff02" \
-H "X-API-Key: hk_live_your_key_here"Cancellation is best-effort: a job that has already started its model invocation may complete before the cancel signal lands. You won't be billed for cancelled jobs that didn't consume model time, but billing for a job that completed microseconds before your cancel request is final.
End-to-end example
Upload, submit asynchronously, and poll to completion:
# 1. Upload (always synchronous — returns a presigned URL)
DOC_RESP=$(curl -sS -X POST https://apis.hyperbots.com/v1/documents/upload \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"filename":"contract.pdf","content_type":"application/pdf"}')
DOC_KEY=$(echo "$DOC_RESP" | jq -r .document_key)
UPLOAD_URL=$(echo "$DOC_RESP" | jq -r .upload_url)
# 2. PUT the bytes directly to S3 (presigned URL, no auth header)
curl -sS -X PUT --data-binary @contract.pdf \
-H "Content-Type: application/pdf" \
-H "x-amz-server-side-encryption: AES256" \
"$UPLOAD_URL"
# 3. Submit the parse job asynchronously
JOB=$(curl -sS -X POST "https://apis.hyperbots.com/v1/parse" \
-H "X-API-Key: $API_KEY" -H "X-Async: true" \
-F "document_key=$DOC_KEY")
JOB_ID=$(echo "$JOB" | jq -r .job_id)
# 4. Poll every 3 seconds until status != "pending"
while true; do
RESP=$(curl -sS "https://apis.hyperbots.com/v1/jobs/$JOB_ID" \
-H "X-API-Key: $API_KEY")
STATUS=$(echo "$RESP" | jq -r .status)
if [ "$STATUS" != "pending" ]; then
echo "$RESP" | jq
break
fi
sleep 3
done# 1. Upload (always synchronous — returns a presigned URL)
DOC_RESP=$(curl -sS -X POST https://apis.hyperbots.com/v1/documents/upload \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"filename":"contract.pdf","content_type":"application/pdf"}')
DOC_KEY=$(echo "$DOC_RESP" | jq -r .document_key)
UPLOAD_URL=$(echo "$DOC_RESP" | jq -r .upload_url)
# 2. PUT the bytes directly to S3 (presigned URL, no auth header)
curl -sS -X PUT --data-binary @contract.pdf \
-H "Content-Type: application/pdf" \
-H "x-amz-server-side-encryption: AES256" \
"$UPLOAD_URL"
# 3. Submit the parse job asynchronously
JOB=$(curl -sS -X POST "https://apis.hyperbots.com/v1/parse" \
-H "X-API-Key: $API_KEY" -H "X-Async: true" \
-F "document_key=$DOC_KEY")
JOB_ID=$(echo "$JOB" | jq -r .job_id)
# 4. Poll every 3 seconds until status != "pending"
while true; do
RESP=$(curl -sS "https://apis.hyperbots.com/v1/jobs/$JOB_ID" \
-H "X-API-Key: $API_KEY")
STATUS=$(echo "$RESP" | jq -r .status)
if [ "$STATUS" != "pending" ]; then
echo "$RESP" | jq
break
fi
sleep 3
doneThe Python SDK handles submit + poll transparently — calling client.parse(file) blocks until the result is ready, but every individual HTTP request stays sub-second under the hood. See the SDKs page for installation and tuning knobs.
Job retention
Job records live in Redis with a 24-hour TTL. After the TTL expires, polling the job_id returns a 404. If you need to keep the result, fetch it once and persist it on your side — the result is also tied to the underlying document via request_id, which lasts longer in your usage records.
Page images and figure crops in a poll response are signed storage links with a short life — use them promptly, and don't store them. The response carries assets_expire_at so you always know its deadline, and you can read the whole result back by request_id with fresh links long after the job itself is gone. See Retrieving Results.
Recent jobs
Need to recover a job you submitted but lost the job_id for (browser tab closed, process crashed)? Use GET /v1/jobs/recent to list the most recent jobs for your API key within the 24-hour window. This is the same endpoint that powers the dashboard's Recent Runs panel.
limit accepts 1–100 and defaults to 20. Values outside that range are not clamped — the server falls back to 20, so limit=200 returns 20 rows, not 100. Add ?source=api or ?source=playground to list one channel at a time.
curl "https://apis.hyperbots.com/v1/jobs/recent?limit=100" \
-H "X-API-Key: hk_live_your_key_here"curl "https://apis.hyperbots.com/v1/jobs/recent?limit=100" \
-H "X-API-Key: hk_live_your_key_here"Rows are summaries — they carry no result. Fetch the full envelope for a single job with GET /v1/jobs/{job_id}.
Matching jobs back to your documents
Each row carries the document_key you received from POST /v1/documents/upload — the same value you passed back in when you submitted the job. If you submitted a batch of documents and didn't retain the job_ids, match on document_key to recover which job belongs to which document.
Match on document_key, not filename: filename is whatever you sent at upload and carries no uniqueness guarantee, so fifty documents named invoice.pdf produce fifty rows you cannot tell apart. document_key is null on jobs created before 2026-08-17.
Most reliable of all: record the job_id from the submit response at submission time — you already hold the document_key then, so the mapping costs nothing and doesn't depend on the 24-hour window.
Using the Python SDK? You usually don't need to think about any of this. The SDK does submit + poll transparently for every long-running operation. See the SDKs page for the explicit submit_*/wait_for_job pattern if you want fire-and-forget control.