Renders API¶
Download previously rendered PDF invoices.
Download Render¶
Returns the PDF binary with Content-Type: application/pdf.
There are two ways to authorise this request, and you usually want the first.
The signed link. Every render response carries a complete download_url
with a token on it. That token is the permission — follow the URL with no
credentials at all and you get the PDF. Put it in a browser, an email, or a chat
message; hand it to whoever needs the invoice. The signature grants exactly one
thing: read this one render, until this render's expires_at.
Your API key. The endpoint also accepts normal authentication with no
token, which is what an integration that builds the path itself has always
done. Nothing about that changed.
Try it
Get Render Metadata¶
Response:
{
"data": {
"id": "rnd_01ABC",
"status": "completed",
"document_type": "invoice",
"format": "pdf",
"download_url": "https://invoicepdfs.com/api/v1/renders/rnd_01ABC/download?token=eyJrIjoicmVuZGVyIiwiaSI6InJuZF8wMUFCQyIsImUiOjE3ODQ4NTEyMDB9.abc123...",
"calculation": {
"subtotal": { "amount": "1500.00", "currency": "USD" },
"tax_total": { "amount": "133.13", "currency": "USD" },
"total": { "amount": "1633.13", "currency": "USD" }
},
"expires_at": "2026-07-20T01:00:00Z",
"created_at": "2026-07-20T00:00:00Z"
}
}
| Field | Description |
|---|---|
status |
queued, processing, completed or failed. Only a synchronous render is ever completed on arrival — see Asynchronous renders |
document_type |
Type of document rendered |
format |
Output format (pdf) |
download_url |
null until the render is completed. Otherwise a complete, signed URL to the PDF. Follow it as-is — the token carries the permission, so no API key is needed and nothing needs joining. Valid until expires_at |
calculation.subtotal |
Sum of line item net amounts, as a Money object |
calculation.tax_total |
Total taxes (inclusive + exclusive), as a Money object |
calculation.total |
Final document total, as a Money object |
failure |
Why a failed render failed: code, message and — for compliance_failed — details carrying the profile, ruleset version and every violation. null otherwise |
expires_at |
null until the render is completed — the download window starts when the file exists, not when it was asked for. Then: when the render, and the signature on its download_url, stop working. Set by expires_in on the render request (60s to 7 days, default 1 hour) |
created_at |
When the render was generated |
Try it
Asynchronous renders¶
Rendering is CPU-bound — roughly two documents a second per instance — so a burst queues behind itself whichever way you ask for it. The choice is only whether you hold a connection open while it does.
Send output.mode: "async" on POST /api/v1/documents/render and the response is 202
with the render in queued:
{
"data": {
"id": "rnd_01ABC",
"status": "queued",
"download_url": null,
"expires_at": null,
"calculation": { "total": { "amount": "1633.13", "currency": "USD" } },
"created_at": "2026-07-20T00:00:00Z"
}
}
The calculation is there immediately: totals are computed when the render is accepted, so
an arithmetic problem with the document is still reported in the response rather than
discovered later.
Poll GET /api/v1/renders/{render_id} until status is completed (then download_url is
populated) or failed (then failure says why). A worker picks the render up within a
couple of seconds; processing means one has it.
Errors arrive later, but they arrive whole. A document the renderer refuses is a 422 on
the synchronous path; asynchronously it is accepted with 202 and lands as a failed
render carrying the same information:
{
"status": "failed",
"download_url": null,
"failure": {
"code": "compliance_failed",
"message": "2 mandatory fields would be rejected under Factur-X (EN 16931). No PDF was produced.",
"details": {
"profile": "factur_x_en16931",
"ruleset_version": "EN16931-CII 1.3.16",
"violations": [
{ "rule": "BT-130", "path": "line_items[0].unit_code", "message": "...", "severity": "fatal" },
{ "rule": "BT-151", "path": "line_items[0].taxes", "message": "...", "severity": "fatal" }
]
}
}
}
failure.code is the same code the synchronous path returns, so compliance_failed — a
document that is well-formed and would be rejected — stays distinguishable from
unprocessable_entity, one the renderer could not make sense of. Every violation is listed
at once: a list of fields to go and fill in, not one per round trip.
GET /api/v1/renders/{render_id}/download on a failed render answers 409 and carries the
same details, so a caller who followed a link does not have to fetch the render separately
to find out what was wrong.
output.delivery: "binary" cannot be combined with async: there is no document to return
yet, so it is refused with 422.
Subscribe to render.queued, render.completed and render.failed to avoid polling
entirely — see Webhooks.
Render expiration
Renders expire after the expires_in duration specified at render time (60 seconds to 7 days, default 1 hour). After expiration, the download endpoint returns 404 — for the API key and the signed link alike. Re-render the invoice to get a new download URL.
Treat a signed link like the invoice itself
The token needs no API key, which is the point — but it means anyone holding the URL can fetch that PDF until it expires. It grants nothing else: it names one render, it cannot be edited, and it cannot be used to reach anything else on the account. Keep expires_in as short as the workflow allows, and prefer your API key for server-to-server calls where a link buys you nothing.