---
seo_title: Job API sync vs async e polling | EnConvert
meta_desc: Come EnConvert sceglie tra restituire il risultato inline e mettere in coda un job, più il ciclo di vita del job, il contratto di polling e gli stati terminali.
keywords: api sync vs async, parametro async_mode, polling stato job api, endpoint stato batch api, recupero job_id dopo timeout, api conversione 202 accepted, stati terminali di un job api, come fare polling di una conversione
---

# Job sync e async

La maggior parte delle chiamate a EnConvert ti consegna il risultato finito nel body della risposta. Alcune ti consegnano invece un id e svolgono il lavoro in background. Quale delle due ottieni dipende dall'endpoint che chiami e, su alcuni endpoint, da che cosa metti nella richiesta.

---

## Che cosa decide la modalità

| Endpoint | Modalità |
|----------|------|
| Tutte le conversioni con caricamento file (documenti, formati dati, immagini) | Sempre sync. Su questi endpoint `async_mode` non viene mai letto. |
| `url-to-pdf`, `url-to-screenshot`, `url-to-markdown` | Sync per impostazione predefinita. Async quando imposti `async_mode: true`, oppure quando `url` è un array. |
| `website-to-pdf`, `website-to-screenshot` | Sempre async. Entrambi rispondono `202` con un `batch_id` e `output_format: "zip"`. |
| `POST /v2/perceive` | Sempre sync. Un singolo URL viene renderizzato nella richiesta stessa e non esiste un interruttore async. |
| `POST /v2/perceive/batch` | Sync fino a 10 URL, async oltre. |
| `POST /v2/ingest`, `POST /v2/ingest/files` | Sempre async. Entrambi rispondono `202` con un `job_id`. |

Le chiavi pubbliche e dashboard sono vincolate a richieste sync con URL singolo sugli endpoint URL V1, qualunque cosa dica il body.

| | Modalità Sync | Modalità Async |
|---|---|---|
| **Trigger** | Predefinita per URL singolo / caricamento file | URL multipli, oppure `async_mode: true` |
| **Risposta** | `200 OK` con il risultato | `202 Accepted` con `batch_id` |
| **Consegna del risultato** | Byte del file o URL presigned nella risposta | Polling, webhook o email |
| **Tipi di chiave** | Chiavi private e pubbliche | Solo chiavi private |
| **Requisito del piano** | Tutti i piani | Richiede l'accesso async (Indie+) |

<div class="alert alert-info">
<strong>Limitazioni del piano:</strong> la modalità async non è disponibile nel piano free. Se provi a impostare <code>async_mode: true</code> o a inviare più URL con un piano free, la risposta è <code>403 Forbidden</code>.
</div>

Async e batch appartengono entrambi ai piani a pagamento. Il piano Founding non ha né l'uno né l'altro, ed è per questo che un primo test con una chiave free che invia tre URL torna indietro `403` e non `202`. I numeri per piano, incluso il tetto sulla dimensione del batch, stanno in [Rate limit e quote](/it/docs/reference/rate-limits.md).

---

## Chiedere esplicitamente l'async

Questi sono i campi della richiesta che decidono la modalità o che ti danno un riferimento al job risultante. Tutto il resto della richiesta (opzioni di rendering, opzioni PDF, denominazione dell'output) non cambia tra le due modalità.

| Parametro | Tipo | Predefinito | Descrizione | Limitazioni del piano |
|-----------|------|---------|-------------|-------------|
| `async_mode` | `boolean` | `false` | Mette il lavoro in coda e risponde `202` invece di tenere aperta la connessione. Letto solo da `url-to-pdf`, `url-to-screenshot` e `url-to-markdown`. | Richiede l'accesso async |
| `url` (array) | `string[]` | -- | Più di un URL forza `async_mode` a `true` che tu lo imposti o meno, e viene verificato rispetto al limite di batch del tuo piano. | Richiede l'accesso batch |
| `job_id` | `string` | `null` | Un id che generi tu, usato per recuperare il risultato se la richiesta stessa muore. Inviato nel body JSON sugli endpoint URL e come campo del form sugli endpoint di caricamento file. Funziona con qualsiasi tipo di chiave. | -- |
| `callback_url` | `string` | `null` | URL webhook che riceve un POST al completamento. | Richiede l'accesso webhook |
| `notification_email` | `string` | Email del proprietario del progetto | Indirizzo email da notificare al completamento. Se omesso, viene usata l'email del proprietario del progetto. | -- |
| `direct_download` | `boolean` | Dipende dall'endpoint | Non può essere combinato con `async_mode: true` né con più URL. Entrambe le combinazioni restituiscono `400`. Vedi [URL firmati](/it/docs/concepts/signed-urls.md). | -- |

Un invio async minimo:

```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}'
```

---

## Che cosa torna indietro in ciascuna modalità

### Sync

Una conversione V1 che si completa nella richiesta stessa risponde `200` con i metadati e un link firmato all'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
}
```

Le chiavi pubbliche e dashboard ottengono gli stessi cinque campi più `job_id`, e gli stessi valori replicati negli header di risposta `X-Object-Key`, `X-File-Size`, `X-Conversion-Time` e `X-Filename`.

Anche `POST /v2/perceive` è sync, ma il suo body è il risultato completo del perceive: `operation_id`, `status`, `render_quality`, una mappa `outputs` di artefatti firmati e il blocco `structured` inline. Questa forma è documentata nella [pagina di perceive](/it/docs/endpoints/perceive.md).

### Async

Un invio V1 async o batch risponde `202` e nient'altro:

```json
{
    "status": "processing",
    "batch_id": "550e8400-e29b-41d4-a716-446655440000",
    "url_count": 3,
    "output_format": "individual"
}
```

Un batch di perceive troppo grande per essere eseguito inline risponde `202` con un `job_id`:

```json
{
    "job_id": "bat_8c1a...",
    "status": "queued",
    "output_mode": "manifest",
    "total": 40,
    "completed": 0,
    "failed": 0,
    "pending": 40
}
```

Il body completo del batch trasporta anche `zip`, `items` e `warnings`. Un invio di ingest risponde `202` con un id preceduto dal prefisso `ing_`:

```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>Il riferimento ha due nomi.</strong> V1 restituisce <code>batch_id</code>. V2 restituisce <code>job_id</code>. Sono la stessa idea (una stringa opaca con cui fare polling) ma sono campi diversi su endpoint diversi, e nulla converte l'uno nell'altro. Leggi il campo che l'endpoint che hai chiamato restituisce davvero.
</div>

C'è un altro caso che vale la pena conoscere. Un batch di perceive di 10 URL o meno normalmente viene eseguito inline e risponde `200` con ogni elemento popolato, ma se quell'esecuzione inline supera la sua finestra di attesa di 240 secondi, degrada a un `202` con `status: "processing"` e un warning che ti dice di fare polling. Quindi considera `202` possibile su ogni chiamata batch, non solo su quelle grandi.

---

## Endpoint di stato e stati terminali

| Job | Polling | Non terminali | Terminali |
|-----|------|--------------|----------|
| Conversione V1 async o batch | `GET /v1/convert/batch/{batch_id}` | `processing` | `completed`, `partial`, `failed` |
| Conversione V1 sync con un tuo `job_id` | `GET /v1/convert/status/{job_id}` | `processing` | `success`, `failed` |
| Batch di perceive V2 | `GET /v2/perceive/batch/{job_id}` | `queued`, `processing` | `completed`, `partial`, `failed`, `canceled` |
| Ingest V2 | `GET /v2/ingest/{job_id}` | `queued`, `discovering`, `processing` | `completed`, `failed`, `canceled` |

`partial` significa che il job è terminato e alcune unità sono fallite. È terminale. Non trattarlo da solo come un segnale di retry; leggi le righe per singolo elemento e riprova solo i fallimenti.

All'interno di un batch di perceive V2, ogni elemento porta con sé il proprio `status` tra `queued`, `processing`, `completed` o `failed`. Non esiste `partial` né `canceled` a livello di elemento, solo sul batch.

La risposta batch V1 mescola le maiuscole: lo `status` aggregato è in minuscolo (`processing`, `completed`, `partial`, `failed`) mentre lo `status` di ogni elemento ha le iniziali maiuscole (`Success`, `In Progress`, `Failed`). Confronta in modo esatto, oppure normalizza prima di confrontare.

Entrambi i tipi di job V2 possono essere annullati: `DELETE /v2/perceive/batch/{job_id}` e `DELETE /v2/ingest/{job_id}`. Entrambi sono idempotenti, entrambi fermano il worker tra un'unità e l'altra, e il lavoro già completato conserva i suoi artefatti.

---

## Il contratto di polling

L'API non ti dice con quale frequenza fare polling. Non c'è un header `Retry-After` su un `202` e non c'è un intervallo consigliato nel body. Il contratto è solo questo: il `202` trasporta l'id, tu fai una GET sull'endpoint di stato corrispondente e ti fermi quando `status` raggiunge un valore terminale.

Che cosa usare nella pratica:

- **Cinque secondi** sono un valore predefinito ragionevole per i batch V1 e per i job di ingest. Entrambi passano gran parte della loro vita su render del browser che richiedono all'incirca dai 10 ai 30 secondi per pagina, quindi fare polling più in fretta ti compra soprattutto richieste in più.
- **Tre secondi** è quello che usano gli SDK ufficiali per il recupero dopo un timeout su una singola conversione, dove la risposta di solito è a pochi secondi di distanza.
- Metti una scadenza. Gli SDK usano di default un'attesa di 30 minuti sui batch di interi siti e di 5 minuti sul recupero dopo un timeout.
- Le letture di stato sono GET. Il rate limiter si applica solo alle richieste POST, quindi il polling non pesa sul tuo limite al minuto e leggere uno stato non consuma una op.

Un ciclo di polling su un job di ingest:

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

Ogni polling conia un nuovo set di URL di download firmati sugli stessi oggetti archiviati, quindi un link scaduto mentre lo stavi leggendo viene sostituito semplicemente rifacendo polling. Se ne parla in [URL firmati](/it/docs/concepts/signed-urls.md).

Se preferisci essere avvisato invece di chiedere, registra un webhook e salta del tutto il ciclo. Vedi [Webhook](/it/docs/guides/webhooks.md) per i payload, lo schema di firma e la politica di retry.

---

## Recupero dopo un timeout: invia un tuo job id

Le conversioni lunghe hanno un problema di connessione, non di elaborazione. Il render di una pagina pesante o un documento di grandi dimensioni possono durare più del reverse proxy davanti all'API (tipicamente dai 60 ai 120 secondi), e il gateway stesso annulla qualsiasi richiesta che non ha iniziato a rispondere entro 300 secondi, rispondendo `504` con `{"error": "Request timeout"}`. In entrambi i casi la conversione spesso si completa comunque sul server. Il risultato esiste. È solo la tua connessione a non essere sopravvissuta per vederlo.

La soluzione è dare un nome al job prima di avviarlo:

1. Genera un UUID e invialo come `job_id`, nel body JSON sugli endpoint URL o come campo del form sui caricamenti di file.
2. Se la richiesta restituisce un 5xx o la connessione cade, non reinviare. Fai polling su `GET /v1/convert/status/{job_id}`.
3. Fermati quando `status` è `success` o `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()
```

L'endpoint di stato risponde sempre `200` con uno di tre body, quindi controlla il campo `status` anziché il codice HTTP:

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

Un id sconosciuto restituisce `404`, e un id di proprietà di un altro progetto restituisce `403`. Riutilizzare uno dei tuoi id azzera quella riga di job, quindi scegli un UUID nuovo per ogni richiesta; rivendicare un id già in possesso di un altro progetto restituisce `409` con `job_id already in use`.

<div class="alert alert-info">
<strong>Gli SDK lo fanno per te.</strong> Ogni SDK ufficiale genera un <code>job_id</code> per ciascuna conversione V1 e, se la chiamata restituisce 5xx, passa silenziosamente al polling di <code>GET /v1/convert/status/{job_id}</code> finché il job non è <code>success</code> o <code>failed</code>. Non scrivi nessun codice di recupero. Vedi <a href="/it/docs/guides/integrations/sdks">SDK</a>.
</div>

V2 non ha bisogno di questo trucco. Il suo lavoro di lunga durata restituisce già un oggetto job esplicito, quindi fai invece polling su `GET /v2/perceive/batch/{job_id}` o `GET /v2/ingest/{job_id}`.

---

## Quando l'async è l'unica scelta sensata

Alcuni job non stanno dentro una richiesta e l'API non finge il contrario:

- **Render di interi siti.** `website-to-pdf` e `website-to-screenshot` fanno il crawl di un sito e raggruppano l'output in uno ZIP. Sono solo async e rispondono sempre `202`.
- **Ingest.** Ogni pagina di un job di ingest passa attraverso un vero render del browser che richiede all'incirca dai 10 ai 30 secondi ciascuna, quindi qualsiasi crawl non banale supera la finestra di richiesta di 300 secondi prima di essere a metà. Entrambi i punti di ingresso di ingest sono `202` per costruzione.
- **Batch di perceive oltre 10 URL.** Dieci è il tetto per l'esecuzione inline. Oltre, ottieni un job.
- **Qualsiasi cosa per cui preferiresti non tenere aperto un socket.** Un batch di 40 URL è tecnicamente interrogabile con un solo ciclo, ma un webhook più una coda dalla tua parte sopravvive ai tuoi deploy e riavvii. I job batch sopravvivono a un riavvio del gateway e riprendono, quindi non reinvii mai.

Le conversioni con caricamento file sono l'eccezione a tutto questo. Non hanno alcuna modalità async, quindi una conversione lenta di un documento si recupera con il polling di `job_id` invece che con `async_mode`. Se il problema è il file stesso, controlla il tetto di upload per piano in [Rate limit e quote](/it/docs/reference/rate-limits.md) prima di dare per scontato un timeout.

Per la forma completa della richiesta batch, il raggruppamento in ZIP e i risultati per singolo elemento, vedi [Elaborazione batch](/it/docs/guides/batch-processing.md).

---

## Domande frequenti

### Come rendo asincrona una conversione EnConvert?

Imposta `async_mode: true` nel body JSON di `url-to-pdf`, `url-to-screenshot` o `url-to-markdown`, oppure passa un array di URL, che da solo forza l'async. La chiamata risponde `202` con un `batch_id` su cui fai polling a `GET /v1/convert/batch/{batch_id}`. Gli endpoint di caricamento file non leggono mai `async_mode` e girano sempre in modo sincrono.

### Quali sono gli stati terminali di un job EnConvert?

Un batch V1 termina su `completed`, `partial` o `failed`. Un batch di perceive V2 termina su `completed`, `partial`, `failed` o `canceled`. Un job di ingest V2 termina su `completed`, `failed` o `canceled`. Tutto il resto (`processing`, `queued`, `discovering`) significa continuare a fare polling.

### Con che frequenza devo interrogare un endpoint di stato di un job?

L'API non fissa una cadenza e non invia alcun header `Retry-After`. Cinque secondi sono un valore predefinito sensato per i batch e i job di ingest, dato che ogni render di pagina richiede all'incirca dai 10 ai 30 secondi. Le letture di stato sono GET, quindi restano fuori dal rate limiter e non consumano ops, ma non c'è comunque motivo di fare polling ogni 200 ms.

### La mia richiesta di conversione è andata in timeout. Il file è perso?

Di solito no. Se hai inviato un tuo `job_id`, fai polling su `GET /v1/convert/status/{job_id}`: la conversione spesso si completa sul server dopo che la connessione è già caduta. L'endpoint risponde `200` con `processing`, `success` o `failed`. Ogni SDK ufficiale esegue questo recupero automaticamente.

### Perché il mio batch restituisce batch_id ma la documentazione parla di job_id?

Esistono entrambi. Gli endpoint di conversione V1 restituiscono `batch_id` e si interrogano a `GET /v1/convert/batch/{batch_id}`. Gli endpoint V2 restituiscono `job_id` e si interrogano a `GET /v2/perceive/batch/{job_id}` o `GET /v2/ingest/{job_id}`. Leggi il campo che ha restituito l'endpoint che hai chiamato.
