Sync and Async Jobs#
Most EnConvert calls hand you the finished result in the response body. Some hand you an id instead and do the work in the background. Which one you get depends on the endpoint you call and, on a few endpoints, on what you put in the request.
What decides the mode#
| Endpoint | Mode |
|---|---|
| All file upload conversions (documents, data formats, images) | Always sync. async_mode is never read on these endpoints. |
url-to-pdf, url-to-screenshot, url-to-markdown |
Sync by default. Async when you set async_mode: true, or when url is an array. |
website-to-pdf, website-to-screenshot |
Always async. Both answer 202 with a batch_id and output_format: "zip". |
POST /v2/perceive |
Always sync. A single URL renders in-request and there is no async switch. |
POST /v2/perceive/batch |
Sync for 10 URLs or fewer, async above that. |
POST /v2/ingest, POST /v2/ingest/files |
Always async. Both answer 202 with a job_id. |
Public and dashboard keys are held to sync, single-URL requests on the V1 URL endpoints regardless of what the body says.
| Sync Mode | Async Mode | |
|---|---|---|
| Trigger | Default for single URL / file upload | Multiple URLs, or async_mode: true |
| Response | 200 OK with result |
202 Accepted with batch_id |
| Result delivery | File bytes or presigned URL in response | Poll, webhook, or email |
| Key types | Private and public keys | Private keys only |
| Plan requirement | All plans | Requires async access (Indie+) |
async_mode: true or submit multiple URLs there returns 403 Forbidden.
Async and batch both belong to the paid plans. The Founding plan has neither, which is why a first test on a Founding key that submits three URLs comes back 403 rather than 202. Per-plan numbers, including the batch size cap, live in Rate Limits and Quotas.
Asking for async explicitly#
These are the request fields that decide the mode or that give you a handle on the resulting job. Everything else about the request (rendering options, PDF options, output naming) is unchanged between the two modes.
| Parameter | Type | Default | Description | Plan gating |
|---|---|---|---|---|
async_mode |
boolean |
false |
Queue the work and answer 202 instead of holding the connection open. Read only by url-to-pdf, url-to-screenshot and url-to-markdown. |
Requires async access |
url (array) |
string[] |
-- | More than one URL forces async_mode to true whether or not you set it, and is checked against your plan's batch limit. |
Requires batch access |
job_id |
string |
null |
An id you generate, used to recover the result if the request itself dies. Sent in the JSON body on URL endpoints and as a form field on file upload endpoints. Works with any key type. | -- |
callback_url |
string |
null |
Webhook URL to receive a POST on completion. | Requires webhook access |
notification_email |
string |
Project owner email | Email address to notify on completion. If omitted, defaults to the project owner's email. | -- |
direct_download |
boolean |
Endpoint-dependent | Cannot be combined with async_mode: true or with multiple URLs. Either combination returns 400. See Signed URLs. |
-- |
A minimal async submission:
curl -X POST https://api.enconvert.com/v1/convert/url-to-pdf \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very-long-report", "async_mode": true}'
What comes back in each mode#
Sync#
A V1 conversion that finishes in-request answers 200 with the metadata and a signed link to the output:
{
"presigned_url": "https://spaces.example.com/...signed...",
"object_key": "env/files/4127/url-to-pdf/example_20260405_123456789.pdf",
"filename": "example_20260405_123456789.pdf",
"file_size": 184320,
"conversion_time_seconds": 3.12
}
Public and dashboard keys get the same five fields plus job_id, and the same values mirrored into the X-Object-Key, X-File-Size, X-Conversion-Time and X-Filename response headers.
POST /v2/perceive is also sync, but its body is the full perceive result: operation_id, status, render_quality, an outputs map of signed artifacts, and the inline structured block. That shape is documented on the perceive page.
Async#
A V1 async or batch submission answers 202 and nothing else:
{
"status": "processing",
"batch_id": "550e8400-e29b-41d4-a716-446655440000",
"url_count": 3,
"output_format": "individual"
}
A perceive batch too large to run inline answers 202 with a job_id:
{
"job_id": "bat_8c1a...",
"status": "queued",
"output_mode": "manifest",
"total": 40,
"completed": 0,
"failed": 0,
"pending": 40
}
The full batch body also carries zip, items and warnings. An ingest submission answers 202 with an ing_-prefixed id:
{
"job_id": "ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
"status": "queued",
"mode": "crawl",
"pages_discovered": 0,
"pages_processed": 0,
"pages_failed": 0,
"total_chunks": 0,
"webhook_delivered": false,
"created_at": "2026-06-24T09:14:02.118Z"
}
batch_id. V2 returns job_id. They are the same idea (an opaque string you poll with) but they are different fields on different endpoints, and nothing translates between them. Read the field the endpoint you called actually returns.
A perceive batch of 10 URLs or fewer normally runs inline and answers 200 with every item populated, but if that inline run outlasts its wait window of 240 seconds, it degrades to a 202 with status: "processing" and a warning telling you to poll. So treat 202 as possible on every batch call, not only on large ones.
Status endpoints and terminal statuses#
| Job | Poll | Non-terminal | Terminal |
|---|---|---|---|
| V1 async or batch conversion | GET /v1/convert/batch/{batch_id} |
processing |
completed, partial, failed |
V1 sync conversion with your own job_id |
GET /v1/convert/status/{job_id} |
processing |
success, failed |
| V2 perceive batch | GET /v2/perceive/batch/{job_id} |
queued, processing |
completed, partial, failed, canceled |
| V2 ingest | GET /v2/ingest/{job_id} |
queued, discovering, processing |
completed, failed, canceled |
partial means the job finished and some units failed. It is terminal. Do not treat it as a retry signal on its own; read the per-item rows and retry only the failures.
Inside a V2 perceive batch, each item carries its own status of queued, processing, completed or failed. There is no partial or canceled at the item level, only on the batch.
The V1 batch response mixes cases: the aggregate status is lowercase (processing, completed, partial, failed) while each item's status is title case (Success, In Progress, Failed). Compare exactly, or normalize before you compare.
Both V2 job types can be cancelled: DELETE /v2/perceive/batch/{job_id} and DELETE /v2/ingest/{job_id}. Both are idempotent, both stop the worker between units, and work already finished keeps its artifacts.
The polling contract#
The API does not tell you how fast to poll. There is no Retry-After header on a 202 and no recommended interval in the body. The contract is only this: the 202 carries the id, you GET the matching status endpoint, and you stop when status reaches a terminal value.
What to use in practice:
- Five seconds is a reasonable default for V1 batches and for ingest jobs. Both spend most of their life on browser renders that take roughly 10 to 30 seconds per page, so polling faster mostly buys you extra requests.
- Three seconds is what the official SDKs use for timeout recovery on a single conversion, where the answer is usually seconds away.
- Add a deadline. The SDKs default to a 30 minute wait on whole-site batches and 5 minutes on timeout recovery.
- Status reads are GETs. The rate limiter only ever applies to POST requests, so polling does not count against your per-minute limit, and reading a status does not bill an op.
A poll loop against an ingest job:
import time
import requests
HEADERS = {"X-API-Key": "sk_your_private_key"}
TERMINAL = {"completed", "failed", "canceled"}
job = requests.post(
"https://api.enconvert.com/v2/ingest",
headers=HEADERS,
json={"mode": "sitemap", "url": "https://example.com", "max_pages": 200},
).json()
while True:
status = requests.get(
f"https://api.enconvert.com/v2/ingest/{job['job_id']}",
headers=HEADERS,
).json()
print(status["status"], status["pages_processed"], "pages")
if status["status"] in TERMINAL:
break
time.sleep(5)
if status["status"] == "completed":
print(status["output_url"]) # signed for 15 minutes
Every poll mints a fresh set of signed download URLs over the same stored objects, so a link that expired while you were reading is replaced by simply polling again. That is covered in Signed URLs.
If you would rather be told than ask, register a webhook and skip the loop entirely. See Webhooks for the payloads, the signature scheme, and the retry policy.
Timeout recovery: send your own job id#
Long conversions have a connection problem, not a processing problem. A heavy page render or a large document can outlast the reverse proxy in front of the API (typically 60 to 120 seconds), and the gateway itself cancels any request that has not started responding within 300 seconds, answering 504 with {"error": "Request timeout"}. In both cases the conversion often completes on the server anyway. The result exists. Your connection just did not survive to see it.
The fix is to name the job before you start it:
- Generate a UUID and send it as
job_id, in the JSON body on URL endpoints or as a form field on file uploads. - If the request returns a 5xx or the connection drops, do not resubmit. Poll
GET /v1/convert/status/{job_id}. - Stop when
statusissuccessorfailed.
import time
import uuid
import requests
HEADERS = {"X-API-Key": "sk_your_private_key"}
job_id = str(uuid.uuid4())
response = requests.post(
"https://api.enconvert.com/v1/convert/url-to-pdf",
headers=HEADERS,
json={"url": "https://example.com/heavy-report", "job_id": job_id},
)
if response.status_code >= 500:
while True:
status = requests.get(
f"https://api.enconvert.com/v1/convert/status/{job_id}",
headers=HEADERS,
).json()
if status["status"] != "processing":
break
time.sleep(3)
else:
status = response.json()
The status endpoint always answers 200 with one of three bodies, so check the status field rather than the HTTP code:
{"status": "processing"}
{
"status": "success",
"presigned_url": "https://spaces.example.com/...signed...",
"object_key": "env/files/4127/url-to-pdf/report_20260405_123456789.pdf"
}
{"status": "failed", "error": "Page load timeout"}
An unknown id returns 404, and an id owned by another project returns 403. Reusing one of your own ids resets that job row, so pick a fresh UUID per request; claiming an id that another project already holds returns 409 with job_id already in use.
job_id per V1 conversion, and if the call returns 5xx it silently switches to polling GET /v1/convert/status/{job_id} until the job is success or failed. You write no recovery code. See SDKs.
V2 does not need this trick. Its long-running work already returns an explicit job object, so you poll GET /v2/perceive/batch/{job_id} or GET /v2/ingest/{job_id} instead.
When async is the only sane choice#
Some jobs cannot fit in a request and the API will not pretend otherwise:
- Whole-site renders.
website-to-pdfandwebsite-to-screenshotcrawl a site and bundle the output into a ZIP. They are async-only and always answer202. - Ingest. Every page in an ingest job goes through a real browser render at roughly 10 to 30 seconds each, so any non-trivial crawl is past the 300 second request window before it is half done. Both ingest entry points are
202by construction. - Perceive batches over 10 URLs. Ten is the inline ceiling. Above it, you get a job.
- Anything you would rather not hold a socket open for. A 40 URL batch is technically pollable in one loop, but a webhook plus a queue on your side survives your own deploys and restarts. Batch jobs survive a gateway restart and resume, so you never resubmit.
File upload conversions are the exception to all of this. They have no async mode at all, so a slow document conversion is recovered with job_id polling rather than with async_mode. If the file itself is the problem, check the per-plan upload ceiling in Rate Limits and Quotas before assuming a timeout.
For the full batch request shape, ZIP bundling and per-item results, see Batch Processing.
Frequently asked questions#
How do I make an EnConvert conversion asynchronous?#
Set async_mode: true in the JSON body of url-to-pdf, url-to-screenshot or url-to-markdown, or pass an array of URLs, which forces async on its own. The call answers 202 with a batch_id you poll at GET /v1/convert/batch/{batch_id}. File upload endpoints never read async_mode and always run synchronously.
What are the terminal statuses for an EnConvert job?#
A V1 batch ends on completed, partial or failed. A V2 perceive batch ends on completed, partial, failed or canceled. A V2 ingest job ends on completed, failed or canceled. Everything else (processing, queued, discovering) means keep polling.
How often should I poll a job status endpoint?#
The API does not set a cadence and sends no Retry-After header. Five seconds is a sensible default for batches and ingest jobs, since each page render takes roughly 10 to 30 seconds. Status reads are GETs, so they are outside the rate limiter and bill no ops, but there is still no reason to poll every 200 ms.
My conversion request timed out. Is the file lost?#
Usually not. If you sent your own job_id, poll GET /v1/convert/status/{job_id}: the conversion often finishes on the server after the connection has already dropped. The endpoint answers 200 with processing, success or failed. Every official SDK does this recovery automatically.
Why does my batch return batch_id but the docs mention job_id?#
Both exist. V1 conversion endpoints return batch_id and are polled at GET /v1/convert/batch/{batch_id}. V2 endpoints return job_id and are polled at GET /v2/perceive/batch/{job_id} or GET /v2/ingest/{job_id}. Read whichever field the endpoint you called returned.