---
seo_title: Sync vs Async API Jobs and Polling | EnConvert
meta_desc: How EnConvert decides between returning a result inline and queueing a job, plus the job lifecycle, polling contract, and terminal statuses.
keywords: sync vs async api, async_mode parameter, api job polling, batch status endpoint, job id timeout recovery, 202 accepted conversion api, terminal job status, poll conversion job status
---

# 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+) |

<div class="alert alert-info">
<strong>Plan gating:</strong> Async mode is not available on the Founding plan. Attempting to set <code>async_mode: true</code> or submit multiple URLs there returns <code>403 Forbidden</code>.
</div>

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](/docs/reference/rate-limits.md).

---

## 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](/docs/concepts/signed-urls.md). | -- |

A minimal async submission:

```bash
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:

```json
{
    "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](/docs/endpoints/perceive.md).

### Async

A V1 async or batch submission answers `202` and nothing else:

```json
{
    "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`:

```json
{
    "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:

```json
{
    "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"
}
```

<div class="alert alert-warning">
<strong>The handle has two names.</strong> V1 returns <code>batch_id</code>. V2 returns <code>job_id</code>. 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.
</div>

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:

```python
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](/docs/concepts/signed-urls.md).

If you would rather be told than ask, register a webhook and skip the loop entirely. See [Webhooks](/docs/guides/webhooks.md) 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:

1. Generate a UUID and send it as `job_id`, in the JSON body on URL endpoints or as a form field on file uploads.
2. If the request returns a 5xx or the connection drops, do not resubmit. Poll `GET /v1/convert/status/{job_id}`.
3. Stop when `status` is `success` or `failed`.

```python
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:

```json
{"status": "processing"}
```

```json
{
    "status": "success",
    "presigned_url": "https://spaces.example.com/...signed...",
    "object_key": "env/files/4127/url-to-pdf/report_20260405_123456789.pdf"
}
```

```json
{"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`.

<div class="alert alert-info">
<strong>The SDKs do this for you.</strong> Every official SDK generates a <code>job_id</code> per V1 conversion, and if the call returns 5xx it silently switches to polling <code>GET /v1/convert/status/{job_id}</code> until the job is <code>success</code> or <code>failed</code>. You write no recovery code. See <a href="/docs/guides/integrations/sdks">SDKs</a>.
</div>

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-pdf` and `website-to-screenshot` crawl a site and bundle the output into a ZIP. They are async-only and always answer `202`.
- **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 `202` by 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](/docs/reference/rate-limits.md) before assuming a timeout.

For the full batch request shape, ZIP bundling and per-item results, see [Batch Processing](/docs/guides/batch-processing.md).

---

## 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.
