Skip to content
getnextpdf.com

stability: Beta

Render an invoice end to end over REST

Take one invoice from JSON to a locally verified PDF over the NextPDF Connect Representational State Transfer (REST) surface, one wire exchange at a time. This recipe submits a render job to POST /api/v1/jobs, replays the submit under the same Idempotency-Key to show the duplicate-safe path, polls GET /api/v1/jobs/{id}, downloads the PDF from GET /api/v1/jobs/{id}/result, checks the bytes with qpdf --check, and deletes the finished job.

Every response below is a verbatim capture from a real core-tier Connect deployment (nextpdf/server under RoadRunner, bound to http://localhost:8080). The only substitution is the API key, shown as the $NEXTPDF_CONNECT_TOKEN environment variable; job identifiers, request identifiers, timestamps, headers, and body bytes are exactly what the server returned. Header values such as Date and X-Request-Id will of course differ on your deployment.

This recipe drives a single document so you can read each exchange in full. For many documents, bounded concurrency, and Retry-After-driven poll loops, see Batch-generate PDFs with progress tracking, which uses the same job surface.

The server side is the standard Connect distribution:

Terminal window
composer require nextpdf/server

The client side of this recipe is curl plus qpdf, so you can port it to any HTTP client. Export your deployment’s values first:

/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:

The async-job surface separates submission from retrieval: you submit a render request, receive a job record, and fetch the result when the job reaches completed. The render request itself is an ordered operations array — the same operation types (set_font, add_text, add_table, add_image, add_page) that back the Connect tool calls on every transport — plus document-level fields (page_size, orientation, title, author).

Two contract details shape the transcript you are about to read:

  • Idempotent submission. A submit keyed with Idempotency-Key returns 201 Created the first time and 200 OK with the same job record when replayed, so a network retry never renders twice.
  • Submission may already be terminal. The current release processes the job inline before answering the POST, so the submit response can already carry status: "completed" — as it does below. The poll-until-terminal contract is the stable API shape: write the poll loop, and accept a terminal state on any attempt, including the first.

You can confirm what your deployment exposes before submitting anything: GET /api/v1/capabilities returns the operation catalog your API key’s tier can reach. On the core-tier deployment captured here it listed the core operations only; the catalog of record is always the running server’s own response, not this page.

ExchangeMethod and pathCaptured status
Submit the render jobPOST /api/v1/jobs201 Created
Replay the same submitPOST /api/v1/jobs (same Idempotency-Key)200 OK
Poll the job recordGET /api/v1/jobs/{id}200 OK
Download the PDFGET /api/v1/jobs/{id}/result200 OK, application/pdf
Delete the finished jobDELETE /api/v1/jobs/{id}204 No Content

Authentication is a bearer token on every /api/v1/* request: Authorization: Bearer npk_live_{kid}_{secret}. Successful JSON responses share the { "data": ..., "meta": ... } envelope; the fields you act on live under data.

Write the render request to invoice.json. It is a plain, deterministic operation list — a bold header line, an issue line, and a line-item table:

{
"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>" }
]
}

The invoice fields here are sample data. The authoritative argument shape for each operation is the one your deployment reports — over MCP, tools/list returns the full input schema for every operation type this request uses.

Terminal window
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

The server answers 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"
}
}

The job is already terminal in this capture — status is "completed" and result_url is present — because the current release renders inline before answering. Do not depend on that: treat the submit response as the first poll result and branch on data.status like any other poll.

Retry the exact same command — same Idempotency-Key, same body:

Terminal window
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

The server returns 200 OK — not 201 — with the same job_id, and no second render happens (compare meta.duration_ms with the first response):

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"
}
}
Terminal window
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"
}
}

This poll shows a terminal record, so there is no Retry-After header and no poll_url field. While a job is still pending or running, the server sets Retry-After (a 2-second interval) on every poll — honor it instead of polling in a tight loop.

Terminal window
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

The body is the PDF binary — 3,663 bytes in this capture, matching the Content-Length header — and is elided here. It is written to invoice-inv-2026-0042.pdf.

A 200 with Content-Type: application/pdf is not, on its own, proof that the body is a well-formed PDF. Run a structural check with qpdf:

Terminal window
qpdf --check invoice-inv-2026-0042.pdf

Captured output for the file downloaded above:

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

qpdf’s own wording is the honest boundary: this is a syntax and stream check, not a conformance determination against any standard.

Terminal window
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

After the delete, the job record and its stored result are gone; a subsequent GET on the job returns 404.

  • Branch on data.status, not on the HTTP status alone. Submit, replay, and poll all return 2xx with a job record; the lifecycle state lives in data.status (pending, running, completed, failed, cancelled).
  • A replayed key with a different body is a 409 Conflict. The idempotent 200 replay only happens when the body matches the original submission. Never reuse a key for different content.
  • /result before completion is a 409. Download only after a poll shows completed. The 409 is a normal response to inspect, not a transport failure — the same transport-versus-status separation every Connect recipe follows (see Recipe conventions).
  • Jobs are owner-scoped. A job submitted under one API key is invisible to another key: a cross-owner GET returns 404, not 403. Poll with the credential you submitted with.
  • progress may be absent. The captured record carries no progress field because the job was already terminal. When the server tracks progress for a non-terminal job, data.progress is an integer from 0 to 100; treat a missing field as unknown, not zero.
  • A failed job carries data.error. Record it; do not resubmit blindly.

One render job costs one submit, at most a handful of polls, and one download. The captured meta.duration_ms values tell the story: 63.31 ms to render the invoice on submit, 1.03 ms for the idempotent replay that did no work, and sub-millisecond status reads. Poll on the server’s Retry-After cadence rather than a tight loop; the status read is cheap but not free, and the rate limiter budgets it (watch X-Ratelimit-Remaining tick down in the captured headers). For batches, bound in-flight jobs instead of submitting everything at once — the batch recipe implements that loop.

  • Keep the bearer token in the Authorization header only. Never in a query string, a log line, or a committed file. The transcript above substitutes an environment variable for exactly that reason.
  • Validate downloaded bytes before trusting them. Step 5 is part of the flow, not an optional extra: check the response is a PDF (%PDF header at minimum, qpdf --check for structure) before archiving or forwarding it.
  • Delete finished jobs you no longer need. Step 6 removes the stored result from the server; a completed job otherwise remains downloadable until the server’s job garbage collection removes it.
  • Use a least-privilege key. This flow needs a core-tier render key and nothing more.

This recipe makes no normative standards claim. It exercises the Connect async-job REST endpoints and reads the job-record fields the server defines. The qpdf --check step confirms structural integrity only — “the file may still contain errors that qpdf cannot detect” is qpdf’s own caveat, quoted verbatim above. Determining conformance to a standard (PDF/A-4, PDF/UA) is an independent validator’s job, and a different surface — see Run a named-standard check for that boundary.