stability: Beta
Render an invoice end to end over REST
At a glance
Section titled “At a glance”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.
Install
Section titled “Install”The server side is the standard Connect distribution:
composer require nextpdf/serverThe client side of this recipe is curl plus qpdf, so you can port it
to any HTTP client. Export your deployment’s values first:
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:Conceptual overview
Section titled “Conceptual overview”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-Keyreturns201 Createdthe first time and200 OKwith 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 carrystatus: "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.
API surface
Section titled “API surface”| Exchange | Method and path | Captured status |
|---|---|---|
| Submit the render job | POST /api/v1/jobs | 201 Created |
| Replay the same submit | POST /api/v1/jobs (same Idempotency-Key) | 200 OK |
| Poll the job record | GET /api/v1/jobs/{id} | 200 OK |
| Download the PDF | GET /api/v1/jobs/{id}/result | 200 OK, application/pdf |
| Delete the finished job | DELETE /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.
The invoice request
Section titled “The invoice request”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.
End-to-end transcript
Section titled “End-to-end transcript”1. Submit the render job
Section titled “1. Submit the render job”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.jsonThe server answers 201 Created:
HTTP/1.1 201 CreatedCache-Control: no-storeContent-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'Content-Type: application/json; charset=utf-8Referrer-Policy: no-referrerVary: Accept-EncodingX-Content-Type-Options: nosniffX-Frame-Options: DENYX-Powered-By: NextPDF ConnectX-Ratelimit-Remaining: 99X-Request-Id: 019f3fd6-c6ed-727c-958e-2fa4370ba97eDate: Wed, 08 Jul 2026 03:47:48 GMTTransfer-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.
2. Replay the submit (idempotent path)
Section titled “2. Replay the submit (idempotent path)”Retry the exact same command — same Idempotency-Key, same body:
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.jsonThe 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 OKCache-Control: no-storeContent-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'Content-Type: application/json; charset=utf-8Referrer-Policy: no-referrerVary: Accept-EncodingX-Content-Type-Options: nosniffX-Frame-Options: DENYX-Powered-By: NextPDF ConnectX-Ratelimit-Remaining: 99X-Request-Id: 019f3fd6-c756-7226-bc85-b255d75bd449Date: Wed, 08 Jul 2026 03:47:48 GMTTransfer-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" }}3. Poll the job record
Section titled “3. Poll the job record”curl -sS -i "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \ -H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"HTTP/1.1 200 OKCache-Control: no-storeContent-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'Content-Type: application/json; charset=utf-8Referrer-Policy: no-referrerVary: Accept-EncodingX-Content-Type-Options: nosniffX-Frame-Options: DENYX-Powered-By: NextPDF ConnectX-Ratelimit-Remaining: 98X-Request-Id: 019f3fd7-0038-7131-b71e-4c69e637233bDate: Wed, 08 Jul 2026 03:48:02 GMTTransfer-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.
4. Download the PDF
Section titled “4. Download the PDF”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.pdfHTTP/1.1 200 OKCache-Control: no-storeContent-Disposition: attachment; filename="job-job_9bd0808960f10eb568484acc.pdf"Content-Length: 3663Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'Content-Type: application/pdfReferrer-Policy: no-referrerVary: Accept-EncodingX-Content-Type-Options: nosniffX-Frame-Options: DENYX-Powered-By: NextPDF ConnectX-Ratelimit-Remaining: 98X-Request-Id: 019f3fd7-0056-711e-acad-9f7ac5f21ba7Date: Wed, 08 Jul 2026 03:48:02 GMTThe 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.
5. Verify the downloaded bytes locally
Section titled “5. Verify the downloaded bytes locally”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:
qpdf --check invoice-inv-2026-0042.pdfCaptured output for the file downloaded above:
checking invoice-inv-2026-0042.pdfPDF Version: 2.0File is not encryptedFile is not linearizedNo syntax or stream encoding errors found; the file may still containerrors that qpdf cannot detectqpdf’s own wording is the honest boundary: this is a syntax and stream check, not a conformance determination against any standard.
6. Delete the finished job
Section titled “6. Delete the finished job”curl -sS -i -X DELETE "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \ -H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"HTTP/1.1 204 No ContentCache-Control: no-storeContent-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'Content-Type: application/json; charset=utf-8Referrer-Policy: no-referrerVary: Accept-EncodingX-Content-Type-Options: nosniffX-Frame-Options: DENYX-Powered-By: NextPDF ConnectX-Ratelimit-Remaining: 97X-Request-Id: 019f3fd7-0084-722f-bf54-3dabead8aeeaDate: Wed, 08 Jul 2026 03:48:02 GMTAfter the delete, the job record and its stored result are gone; a
subsequent GET on the job returns 404.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Branch on
data.status, not on the HTTP status alone. Submit, replay, and poll all return2xxwith a job record; the lifecycle state lives indata.status(pending,running,completed,failed,cancelled). - A replayed key with a different body is a
409 Conflict. The idempotent200replay only happens when the body matches the original submission. Never reuse a key for different content. /resultbefore completion is a409. Download only after a poll showscompleted. The409is 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
GETreturns404, not403. Poll with the credential you submitted with. progressmay be absent. The captured record carries noprogressfield because the job was already terminal. When the server tracks progress for a non-terminal job,data.progressis an integer from 0 to 100; treat a missing field as unknown, not zero.- A
failedjob carriesdata.error. Record it; do not resubmit blindly.
Performance
Section titled “Performance”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.
Security notes
Section titled “Security notes”- Keep the bearer token in the
Authorizationheader 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 (
%PDFheader at minimum,qpdf --checkfor 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.
Conformance
Section titled “Conformance”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.
See also
Section titled “See also”- Batch-generate PDFs with progress tracking — the same job surface driven as a bounded-concurrency batch.
- Generate your first PDF — the smallest Connect render.
- Drive an agent document session over MCP — the same engine, tool by tool, over the MCP stdio transport.
- Connect recipe conventions — the transport, tier, and conformance contract every Connect recipe follows.
- Exception-aware error handling over Connect — how to separate transport failures from non-success statuses.