---
seo_title: API síncrona y asíncrona: jobs y sondeo | EnConvert
meta_desc: Cómo decide EnConvert entre devolver el resultado inline y encolar un job, más el ciclo de vida del job, el contrato de sondeo y los estados terminales.
keywords: api síncrona vs asíncrona, parámetro async_mode, sondeo de jobs api, endpoint de estado de lote, recuperar conversión tras timeout, 202 accepted api conversión, estados terminales de un job, cómo consultar el estado de una conversión
---

# Trabajos síncronos y asíncronos

La mayoría de las llamadas a EnConvert te entregan el resultado terminado en el cuerpo de la respuesta. Algunas te entregan un id en su lugar y hacen el trabajo en segundo plano. Cuál de las dos te toca depende del endpoint que llames y, en unos pocos endpoints, de lo que pongas en la solicitud.

---

## Qué decide el modo

| Endpoint | Modo |
|----------|------|
| Todas las conversiones de subida de archivos (documentos, formatos de datos, imágenes) | Siempre síncronas. `async_mode` nunca se lee en estos endpoints. |
| `url-to-pdf`, `url-to-screenshot`, `url-to-markdown` | Síncronos por defecto. Asíncronos cuando defines `async_mode: true`, o cuando `url` es un array. |
| `website-to-pdf`, `website-to-screenshot` | Siempre asíncronos. Ambos responden `202` con un `batch_id` y `output_format: "zip"`. |
| `POST /v2/perceive` | Siempre síncrono. Una sola URL se renderiza dentro de la solicitud y no hay interruptor asíncrono. |
| `POST /v2/perceive/batch` | Síncrono para 10 URLs o menos, asíncrono por encima de eso. |
| `POST /v2/ingest`, `POST /v2/ingest/files` | Siempre asíncronos. Ambos responden `202` con un `job_id`. |

Las claves públicas y de dashboard quedan restringidas a solicitudes síncronas de una sola URL en los endpoints de URL de V1, diga lo que diga el cuerpo.

| | Modo síncrono | Modo asíncrono |
|---|---|---|
| **Activación** | Predeterminado para una sola URL / subida de archivo | Múltiples URLs, o `async_mode: true` |
| **Respuesta** | `200 OK` con el resultado | `202 Accepted` con `batch_id` |
| **Entrega del resultado** | Bytes del archivo o URL prefirmada en la respuesta | Sondeo, webhook o correo electrónico |
| **Tipos de clave** | Claves privadas y públicas | Solo claves privadas |
| **Requisito de plan** | Todos los planes | Requiere acceso asíncrono (Indie+) |

<div class="alert alert-info">
<strong>Restricción por plan:</strong> El modo asíncrono no está disponible en el plan gratuito. Intentar establecer <code>async_mode: true</code> o enviar múltiples URLs en un plan gratuito devuelve <code>403 Forbidden</code>.
</div>

El modo asíncrono y los lotes pertenecen ambos a los planes de pago. El plan Founding no tiene ninguno de los dos, y por eso una primera prueba con una clave gratuita que envía tres URLs vuelve como `403` y no como `202`. Las cifras por plan, incluido el tope de tamaño de lote, están en [Límites de frecuencia y cuotas](/es/docs/reference/rate-limits.md).

---

## Pedir el modo asíncrono explícitamente

Estos son los campos de la solicitud que deciden el modo o que te dan un identificador con el que seguir el job resultante. Todo lo demás de la solicitud (opciones de renderizado, opciones de PDF, nombrado de la salida) no cambia entre los dos modos.

| Parámetro | Tipo | Por defecto | Descripción | Restricción por plan |
|-----------|------|---------|-------------|-------------|
| `async_mode` | `boolean` | `false` | Encola el trabajo y responde `202` en lugar de mantener la conexión abierta. Solo lo leen `url-to-pdf`, `url-to-screenshot` y `url-to-markdown`. | Requiere acceso asíncrono |
| `url` (array) | `string[]` | -- | Más de una URL fuerza `async_mode` a `true` lo hayas definido o no, y se comprueba contra el límite de lotes de tu plan. | Requiere acceso a lotes |
| `job_id` | `string` | `null` | Un id que generas tú, usado para recuperar el resultado si la propia solicitud muere. Se envía en el cuerpo JSON en los endpoints de URL y como campo de formulario en los endpoints de subida de archivos. Funciona con cualquier tipo de clave. | -- |
| `callback_url` | `string` | `null` | URL de webhook que recibe un POST al completarse. | Requiere acceso a webhooks |
| `notification_email` | `string` | Correo del propietario del proyecto | Dirección de correo a la que notificar al completarse. Si se omite, se usa por defecto el correo del propietario del proyecto. | -- |
| `direct_download` | `boolean` | Depende del endpoint | No se puede combinar con `async_mode: true` ni con varias URLs. Cualquiera de las dos combinaciones devuelve `400`. Consulta [URLs firmadas](/es/docs/concepts/signed-urls.md). | -- |

Un envío asíncrono mínimo:

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

---

## Qué vuelve en cada modo

### Síncrono

Una conversión V1 que termina dentro de la solicitud responde `200` con los metadatos y un enlace firmado a la salida:

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

Las claves públicas y de dashboard reciben los mismos cinco campos más `job_id`, y los mismos valores reflejados en las cabeceras de respuesta `X-Object-Key`, `X-File-Size`, `X-Conversion-Time` y `X-Filename`.

`POST /v2/perceive` también es síncrono, pero su cuerpo es el resultado completo de perceive: `operation_id`, `status`, `render_quality`, un mapa `outputs` de artefactos firmados y el bloque `structured` inline. Esa forma está documentada en [la página de perceive](/es/docs/endpoints/perceive.md).

### Asíncrono

Un envío V1 asíncrono o por lotes responde `202` y nada más:

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

Un lote de perceive demasiado grande para ejecutarse inline responde `202` con un `job_id`:

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

El cuerpo completo del lote lleva además `zip`, `items` y `warnings`. Un envío de ingest responde `202` con un id con el prefijo `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>El identificador tiene dos nombres.</strong> V1 devuelve <code>batch_id</code>. V2 devuelve <code>job_id</code>. Son la misma idea (una cadena opaca con la que sondeas), pero son campos distintos en endpoints distintos, y nada traduce entre ellos. Lee el campo que devuelve de verdad el endpoint que llamaste.
</div>

Hay un caso más que conviene conocer. Un lote de perceive de 10 URLs o menos normalmente se ejecuta inline y responde `200` con todos los elementos rellenos, pero si esa ejecución inline supera su ventana de espera de 240 segundos, degrada a un `202` con `status: "processing"` y una advertencia que te dice que sondees. Así que da por posible un `202` en cualquier llamada por lotes, no solo en las grandes.

---

## Endpoints de estado y estados terminales

| Job | Sondeo | No terminal | Terminal |
|-----|------|--------------|----------|
| Conversión V1 asíncrona o por lotes | `GET /v1/convert/batch/{batch_id}` | `processing` | `completed`, `partial`, `failed` |
| Conversión síncrona V1 con tu propio `job_id` | `GET /v1/convert/status/{job_id}` | `processing` | `success`, `failed` |
| Lote de 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 que el job terminó y que algunas unidades fallaron. Es terminal. No lo trates como señal de reintento por sí solo; lee las filas por elemento y reintenta solo los fallos.

Dentro de un lote de perceive V2, cada elemento lleva su propio `status`: `queued`, `processing`, `completed` o `failed`. No hay `partial` ni `canceled` a nivel de elemento, solo en el lote.

La respuesta de lote de V1 mezcla mayúsculas y minúsculas: el `status` agregado va en minúsculas (`processing`, `completed`, `partial`, `failed`) mientras que el `status` de cada elemento va con iniciales en mayúscula (`Success`, `In Progress`, `Failed`). Compara de forma exacta, o normaliza antes de comparar.

Los dos tipos de job de V2 se pueden cancelar: `DELETE /v2/perceive/batch/{job_id}` y `DELETE /v2/ingest/{job_id}`. Ambos son idempotentes, ambos detienen al worker entre unidades, y el trabajo ya terminado conserva sus artefactos.

---

## El contrato de sondeo

La API no te dice a qué ritmo sondear. No hay cabecera `Retry-After` en un `202` ni intervalo recomendado en el cuerpo. El contrato es solo este: el `202` lleva el id, haces un GET al endpoint de estado correspondiente y paras cuando `status` alcanza un valor terminal.

Qué usar en la práctica:

- **Cinco segundos** es un valor por defecto razonable para los lotes de V1 y para los jobs de ingest. Ambos pasan la mayor parte de su vida en renderizados de navegador que tardan entre 10 y 30 segundos por página, así que sondear más rápido sobre todo te compra solicitudes de más.
- **Tres segundos** es lo que usan los SDK oficiales para la recuperación tras timeout de una sola conversión, donde la respuesta suele estar a segundos de distancia.
- Pon un plazo límite. Los SDK esperan por defecto 30 minutos en los lotes de sitio completo y 5 minutos en la recuperación tras timeout.
- Las lecturas de estado son GET. El limitador de frecuencia solo se aplica a las solicitudes POST, así que sondear no cuenta contra tu límite por minuto, y leer un estado no factura ninguna op.

Un bucle de sondeo contra un job de 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
```

Cada sondeo acuña un conjunto nuevo de URLs de descarga firmadas sobre los mismos objetos almacenados, así que un enlace que expiró mientras leías se reemplaza sin más que volver a sondear. Eso está cubierto en [URLs firmadas](/es/docs/concepts/signed-urls.md).

Si prefieres que te avisen a preguntar, registra un webhook y sáltate el bucle por completo. Consulta [Webhooks](/es/docs/guides/webhooks.md) para los payloads, el esquema de firma y la política de reintentos.

---

## Recuperación tras timeout: envía tu propio id de job

Las conversiones largas tienen un problema de conexión, no de procesamiento. Un renderizado de página pesado o un documento grande pueden durar más que el proxy inverso que está delante de la API (normalmente de 60 a 120 segundos), y la propia gateway cancela cualquier solicitud que no haya empezado a responder en 300 segundos, contestando `504` con `{"error": "Request timeout"}`. En ambos casos la conversión a menudo termina igualmente en el servidor. El resultado existe. Lo que no sobrevivió para verlo fue tu conexión.

La solución es dar nombre al job antes de arrancarlo:

1. Genera un UUID y envíalo como `job_id`, en el cuerpo JSON en los endpoints de URL o como campo de formulario en las subidas de archivos.
2. Si la solicitud devuelve un 5xx o la conexión se cae, no reenvíes. Sondea `GET /v1/convert/status/{job_id}`.
3. Para cuando `status` sea `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()
```

El endpoint de estado responde siempre `200` con uno de tres cuerpos, así que comprueba el campo `status` en lugar del código 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 desconocido devuelve `404`, y un id que pertenece a otro proyecto devuelve `403`. Reutilizar uno de tus propios ids reinicia esa fila de job, así que elige un UUID nuevo por solicitud; reclamar un id que ya tiene otro proyecto devuelve `409` con `job_id already in use`.

<div class="alert alert-info">
<strong>Los SDK hacen esto por ti.</strong> Todos los SDK oficiales generan un <code>job_id</code> por cada conversión V1, y si la llamada devuelve un 5xx cambian en silencio a sondear <code>GET /v1/convert/status/{job_id}</code> hasta que el job esté en <code>success</code> o <code>failed</code>. No escribes nada de código de recuperación. Consulta <a href="/es/docs/guides/integrations/sdks">SDK</a>.
</div>

V2 no necesita este truco. Su trabajo de larga duración ya devuelve un objeto de job explícito, así que en su lugar sondeas `GET /v2/perceive/batch/{job_id}` o `GET /v2/ingest/{job_id}`.

---

## Cuándo el modo asíncrono es la única opción sensata

Algunos jobs no caben en una solicitud y la API no va a fingir lo contrario:

- **Renderizados de sitio completo.** `website-to-pdf` y `website-to-screenshot` rastrean un sitio y empaquetan la salida en un ZIP. Son solo asíncronos y responden siempre `202`.
- **Ingest.** Cada página de un job de ingest pasa por un renderizado de navegador real de entre 10 y 30 segundos, así que cualquier rastreo no trivial supera la ventana de 300 segundos de la solicitud antes de ir por la mitad. Los dos puntos de entrada de ingest son `202` por construcción.
- **Lotes de perceive de más de 10 URLs.** Diez es el techo inline. Por encima, recibes un job.
- **Cualquier cosa por la que prefieras no mantener un socket abierto.** Un lote de 40 URLs se puede sondear técnicamente en un solo bucle, pero un webhook más una cola de tu lado sobrevive a tus propios despliegues y reinicios. Los jobs por lotes sobreviven a un reinicio de la gateway y se reanudan, así que nunca reenvías.

Las conversiones de subida de archivos son la excepción a todo esto. No tienen modo asíncrono en absoluto, así que una conversión de documento lenta se recupera sondeando con `job_id` y no con `async_mode`. Si el problema es el archivo en sí, comprueba el techo de subida por plan en [Límites de frecuencia y cuotas](/es/docs/reference/rate-limits.md) antes de dar por hecho que fue un timeout.

Para la forma completa de la solicitud por lotes, el empaquetado en ZIP y los resultados por elemento, consulta [Procesamiento por lotes](/es/docs/guides/batch-processing.md).

---

## Preguntas frecuentes

### ¿Cómo hago que una conversión de EnConvert sea asíncrona?

Define `async_mode: true` en el cuerpo JSON de `url-to-pdf`, `url-to-screenshot` o `url-to-markdown`, o pasa un array de URLs, que ya fuerza el modo asíncrono por sí solo. La llamada responde `202` con un `batch_id` que sondeas en `GET /v1/convert/batch/{batch_id}`. Los endpoints de subida de archivos nunca leen `async_mode` y siempre se ejecutan de forma síncrona.

### ¿Cuáles son los estados terminales de un job de EnConvert?

Un lote de V1 termina en `completed`, `partial` o `failed`. Un lote de perceive de V2 termina en `completed`, `partial`, `failed` o `canceled`. Un job de ingest de V2 termina en `completed`, `failed` o `canceled`. Todo lo demás (`processing`, `queued`, `discovering`) significa seguir sondeando.

### ¿Con qué frecuencia debo sondear un endpoint de estado de job?

La API no fija una cadencia y no envía cabecera `Retry-After`. Cinco segundos es un valor por defecto sensato para los lotes y los jobs de ingest, ya que cada renderizado de página tarda entre 10 y 30 segundos. Las lecturas de estado son GET, así que quedan fuera del limitador de frecuencia y no facturan ops, pero aun así no hay ninguna razón para sondear cada 200 ms.

### Mi solicitud de conversión ha agotado el tiempo de espera. ¿Se ha perdido el archivo?

Normalmente no. Si enviaste tu propio `job_id`, sondea `GET /v1/convert/status/{job_id}`: la conversión suele terminar en el servidor después de que la conexión ya se haya caído. El endpoint responde `200` con `processing`, `success` o `failed`. Todos los SDK oficiales hacen esta recuperación automáticamente.

### ¿Por qué mi lote devuelve batch_id pero la documentación menciona job_id?

Existen los dos. Los endpoints de conversión de V1 devuelven `batch_id` y se sondean en `GET /v1/convert/batch/{batch_id}`. Los endpoints de V2 devuelven `job_id` y se sondean en `GET /v2/perceive/batch/{job_id}` o `GET /v2/ingest/{job_id}`. Lee el campo que devolvió el endpoint que llamaste.
