Ir al contenido
getnextpdf.com

estabilidad: Beta

Renderizar una factura de principio a fin sobre REST

Llevar una factura desde JSON hasta un PDF verificado localmente a través de la superficie REST (Representational State Transfer) de NextPDF Connect, un intercambio de red a la vez. Esta receta envía un trabajo de renderizado a POST /api/v1/jobs, repite el envío con la misma Idempotency-Key para mostrar la ruta segura ante duplicados, sondea GET /api/v1/jobs/{id}, descarga el PDF desde GET /api/v1/jobs/{id}/result, comprueba los bytes con qpdf --check y elimina el trabajo finalizado.

Cada respuesta que aparece a continuación es una captura literal de un despliegue real de Connect de nivel core (nextpdf/server bajo RoadRunner, enlazado a http://localhost:8080). La única sustitución es la clave de API, mostrada como la variable de entorno $NEXTPDF_CONNECT_TOKEN; los identificadores de trabajo, los identificadores de solicitud, las marcas de tiempo, las cabeceras y los bytes del cuerpo son exactamente lo que devolvió el servidor. Los valores de cabecera como Date y X-Request-Id diferirán, por supuesto, en cada despliegue.

Esta receta procesa un único documento para poder leer cada intercambio por completo. Para múltiples documentos, concurrencia acotada y bucles de sondeo guiados por Retry-After, consultar Generar PDF por lotes con seguimiento de progreso, que utiliza la misma superficie de trabajos.

El lado del servidor es la distribución estándar de Connect:

Ventana de terminal
composer require nextpdf/server

El lado del cliente de esta receta es curl más qpdf, de modo que puede portarse a cualquier cliente HTTP. Exportar primero los valores del despliegue:

/docs/connect/quickstart/
export NEXTPDF_CONNECT_URL="http://localhost:8080"
export NEXTPDF_CONNECT_TOKEN="npk_live_{kid}_{secret}" # your real key
# Key provisioning and server startup live in the quickstart:

La superficie de trabajos asíncronos separa el envío de la recuperación: se envía una solicitud de renderizado, se recibe un registro de trabajo y se obtiene el resultado cuando el trabajo alcanza completed. La propia solicitud de renderizado es un array operations ordenado —los mismos tipos de operación (set_font, add_text, add_table, add_image, add_page) que respaldan las llamadas de herramienta de Connect en todos los transportes— más campos a nivel de documento (page_size, orientation, title, author).

Dos detalles del contrato dan forma a la transcripción que se va a leer a continuación:

  • Envío idempotente. Un envío identificado con Idempotency-Key devuelve 201 Created la primera vez y 200 OK con el mismo registro de trabajo al repetirse, de modo que un reintento de red nunca renderiza dos veces.
  • El envío puede ser ya terminal. La versión actual procesa el trabajo en línea antes de responder al POST, por lo que la respuesta del envío puede llevar ya status: "completed", como ocurre más abajo. El contrato de sondear-hasta-el-estado-terminal es la forma estable de la API: escribir el bucle de sondeo y aceptar un estado terminal en cualquier intento, incluido el primero.

Es posible confirmar lo que expone el despliegue antes de enviar nada: GET /api/v1/capabilities devuelve el catálogo de operaciones al que puede acceder el nivel de la clave de API. En el despliegue de nivel core capturado aquí solo aparecían las operaciones core; el catálogo de referencia es siempre la propia respuesta del servidor en ejecución, no esta página.

IntercambioMétodo y rutaEstado capturado
Enviar el trabajo de renderizadoPOST /api/v1/jobs201 Created
Repetir el mismo envíoPOST /api/v1/jobs (misma Idempotency-Key)200 OK
Sondear el registro del trabajoGET /api/v1/jobs/{id}200 OK
Descargar el PDFGET /api/v1/jobs/{id}/result200 OK, application/pdf
Eliminar el trabajo finalizadoDELETE /api/v1/jobs/{id}204 No Content

La autenticación es un token bearer en cada solicitud /api/v1/*: Authorization: Bearer npk_live_{kid}_{secret}. Las respuestas JSON correctas comparten el envoltorio { "data": ..., "meta": ... }; los campos sobre los que se actúa están bajo data.

Escribir la solicitud de renderizado en invoice.json. Es una lista de operaciones sencilla y determinista: una línea de cabecera en negrita, una línea de emisión y una tabla de líneas de detalle:

{
"page_size": "A4",
"orientation": "portrait",
"title": "Invoice INV-2026-0042",
"author": "Aurora Fixtures Ltd.",
"operations": [
{ "type": "set_font", "family": "helvetica", "style": "B", "size": 16 },
{ "type": "add_text", "text": "Invoice INV-2026-0042" },
{ "type": "set_font", "family": "helvetica", "style": "", "size": 10 },
{ "type": "add_text", "text": "Issued 2026-07-08 by Aurora Fixtures Ltd. Payment is due within 30 days.", "width": 0, "line_height": 5 },
{ "type": "add_table", "html": "<table><tr><th>Item</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr><tr><td>Cable tray, 300 mm</td><td>12</td><td>18.40</td><td>220.80</td></tr><tr><td>Mounting kit</td><td>4</td><td>9.75</td><td>39.00</td></tr><tr><td>Site delivery</td><td>1</td><td>25.00</td><td>25.00</td></tr><tr><td>Total (EUR)</td><td></td><td></td><td>284.80</td></tr></table>" }
]
}

Los campos de la factura aquí son datos de ejemplo. La forma de argumentos autoritativa para cada operación es la que informa el despliegue: sobre MCP, tools/list devuelve el esquema de entrada completo para cada tipo de operación que utiliza esta solicitud.

Ventana de terminal
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

El servidor responde 201 Created:

HTTP/1.1 201 Created
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c6ed-727c-958e-2fa4370ba97e
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c6ed-727c-958e-2fa4370ba97e",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 63.31,
"api_version": "v1"
}
}

El trabajo ya es terminal en esta captura —status es "completed" y result_url está presente— porque la versión actual renderiza en línea antes de responder. No conviene depender de ello: tratar la respuesta del envío como el primer resultado de sondeo y ramificar según data.status como en cualquier otro sondeo.

Repetir exactamente el mismo comando, con la misma Idempotency-Key y el mismo cuerpo:

Ventana de terminal
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

El servidor devuelve 200 OK —no 201— con el mismo job_id, y no se produce un segundo renderizado (comparar meta.duration_ms con la primera respuesta):

HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c756-7226-bc85-b255d75bd449
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c756-7226-bc85-b255d75bd449",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 1.03,
"api_version": "v1"
}
}
Ventana de terminal
curl -sS -i "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0038-7131-b71e-4c69e637233b
Date: Wed, 08 Jul 2026 03:48:02 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd7-0038-7131-b71e-4c69e637233b",
"timestamp": "2026-07-08T03:48:02+00:00",
"duration_ms": 0.24,
"api_version": "v1"
}
}

Este sondeo muestra un registro terminal, por lo que no hay cabecera Retry-After ni campo poll_url. Mientras un trabajo sigue en pending o running, el servidor establece Retry-After (un intervalo de 2 segundos) en cada sondeo; conviene respetarlo en lugar de sondear en un bucle ajustado.

Ventana de terminal
curl -sS -D result-headers.txt \
"$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-inv-2026-0042.pdf
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Disposition: attachment; filename="job-job_9bd0808960f10eb568484acc.pdf"
Content-Length: 3663
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/pdf
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0056-711e-acad-9f7ac5f21ba7
Date: Wed, 08 Jul 2026 03:48:02 GMT

El cuerpo es el binario del PDF —3663 bytes en esta captura, coincidiendo con la cabecera Content-Length— y se omite aquí. Se escribe en invoice-inv-2026-0042.pdf.

5. Verificar localmente los bytes descargados

Sección titulada «5. Verificar localmente los bytes descargados»

Un 200 con Content-Type: application/pdf no es, por sí solo, prueba de que el cuerpo sea un PDF bien formado. Ejecutar una comprobación estructural con qpdf:

Ventana de terminal
qpdf --check invoice-inv-2026-0042.pdf

Salida capturada para el archivo descargado anteriormente:

checking invoice-inv-2026-0042.pdf
PDF Version: 2.0
File is not encrypted
File is not linearized
No syntax or stream encoding errors found; the file may still contain
errors that qpdf cannot detect

La propia redacción de qpdf marca el límite honesto: se trata de una comprobación de sintaxis y flujos, no de una determinación de conformidad frente a ninguna norma.

Ventana de terminal
curl -sS -i -X DELETE "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 204 No Content
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 97
X-Request-Id: 019f3fd7-0084-722f-bf54-3dabead8aeea
Date: Wed, 08 Jul 2026 03:48:02 GMT

Tras la eliminación, el registro del trabajo y su resultado almacenado desaparecen; un GET posterior sobre el trabajo devuelve 404.

  • Ramificar según data.status, no solo según el estado HTTP. El envío, la repetición y el sondeo devuelven todos 2xx con un registro de trabajo; el estado del ciclo de vida vive en data.status (pending, running, completed, failed, cancelled).
  • Una clave repetida con un cuerpo diferente es un 409 Conflict. La repetición idempotente con 200 solo ocurre cuando el cuerpo coincide con el envío original. No reutilizar nunca una clave para contenido diferente.
  • /result antes de completarse es un 409. Descargar solo después de que un sondeo muestre completed. El 409 es una respuesta normal que inspeccionar, no un fallo de transporte: la misma separación entre transporte y estado que sigue cada receta de Connect (consultar Convenciones de las recetas).
  • Los trabajos están limitados por propietario. Un trabajo enviado con una clave de API es invisible para otra clave: un GET de otro propietario devuelve 404, no 403. Sondear con la misma credencial con la que se hizo el envío.
  • progress puede estar ausente. El registro capturado no lleva campo progress porque el trabajo ya era terminal. Cuando el servidor hace seguimiento del progreso de un trabajo no terminal, data.progress es un entero de 0 a 100; tratar un campo ausente como desconocido, no como cero.
  • Un trabajo failed lleva data.error. Registrarlo; no reenviar a ciegas.

Un trabajo de renderizado cuesta un envío, como mucho un puñado de sondeos y una descarga. Los valores capturados de meta.duration_ms lo cuentan todo: 63,31 ms para renderizar la factura en el envío, 1,03 ms para la repetición idempotente que no hizo ningún trabajo y lecturas de estado por debajo del milisegundo. Sondear con la cadencia Retry-After del servidor en lugar de un bucle ajustado; la lectura de estado es barata pero no gratuita, y el limitador de tasa la presupuesta (observar cómo X-Ratelimit-Remaining desciende en las cabeceras capturadas). Para lotes, acotar los trabajos en curso en lugar de enviarlo todo de una vez: la receta de lotes implementa ese bucle.

  • Mantener el token bearer únicamente en la cabecera Authorization. Nunca en una cadena de consulta, una línea de registro ni un archivo versionado. La transcripción anterior sustituye una variable de entorno precisamente por esa razón.
  • Validar los bytes descargados antes de confiar en ellos. El paso 5 forma parte del flujo, no es un extra opcional: comprobar que la respuesta es un PDF (la cabecera %PDF como mínimo, qpdf --check para la estructura) antes de archivarla o reenviarla.
  • Eliminar los trabajos finalizados que ya no se necesiten. El paso 6 elimina el resultado almacenado del servidor; de lo contrario, un trabajo completado sigue siendo descargable hasta que la recolección de basura de trabajos del servidor lo elimine.
  • Usar una clave de mínimo privilegio. Este flujo necesita una clave de renderizado de nivel core y nada más.

Esta receta no formula ninguna afirmación normativa de estándares. Ejercita los endpoints REST de trabajos asíncronos de Connect y lee los campos del registro de trabajo que define el servidor. El paso qpdf --check confirma únicamente la integridad estructural: «the file may still contain errors that qpdf cannot detect» es la propia advertencia de qpdf, citada literalmente más arriba. Determinar la conformidad con un estándar (PDF/A-4, PDF/UA) es tarea de un validador independiente y de una superficie diferente; consultar Ejecutar una comprobación de estándar concreto para ese límite.