Skip to content

Renders API

Download previously rendered PDF invoices.

Download Render

GET /api/v1/renders/{render_id}/download

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.

# download_url comes back complete from any render call. No key, no joining.
curl "https://invoicepdfs.com/api/v1/renders/rnd_01ABC/download?token=eyJrIjoi..." \
  --output invoice.pdf

Self-hosting? The origin comes from PUBLIC_BASE_URL; unset, it is the origin each request arrived on.

curl https://invoicepdfs.com/api/v1/renders/rnd_01ABC/download \
  -H "Authorization: Bearer inv_live_abc123..." \
  --output invoice.pdf
import httpx

resp = httpx.get(
    "https://invoicepdfs.com/api/v1/renders/rnd_01ABC/download",
    headers={"Authorization": "Bearer inv_live_abc123..."},
)
with open("invoice.pdf", "wb") as f:
    f.write(resp.content)
Try it

Get Render Metadata

GET /api/v1/renders/{render_id}

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.