Your first PDF through the API

Start from an invoice, statement, report or certificate. Open the layout in the editor, or copy it through the API and supply your data. Ordinary PDF text stays selectable; long tables continue onto new pages.

  1. Choose a layout

    Document starters are marked PDF in the gallery. GET /api/v1/templates/:id returns every authored page, shared layer and sample. A copy has no dependency on future catalogue changes.

  2. Save your own copy

    POST /api/v1/documents with template_id saves an owned document. Writing requires a full-scope key. In MCP, use save_document {template_id}, then get_document {document_id}.

  3. Supply data and check

    dynamic_data replaces captions; rows replaces table data. Row values are JSON strings. Supply ordinary invoice totals separately. dry_run: true returns final pages, row ranges, warnings and cost_credits without charging.

  4. Create the file

    Submit the same input without dry_run. The 202 response contains job_id. Rendering runs in a queue; retain this ID and poll its status. A full queue returns 429 before charging.

  5. Download the result

    GET /api/v1/render/status/:job_id returns done with result_url, or error. Failed rendering refunds the charge. Stopping your poll does not cancel the job; resume with the same ID.

A standard PDF or HTML document costs 2 credits for up to 20 pages; each further started block of 20 pages adds 1 credit. Large pages flattened entirely to pixels can add a compute surcharge; a free dry run quotes the exact cost.

Up to 20 pages in a layout and 200 pages in the file after table continuation. Memory and file-size admission also apply; the free dry run checks them before payment.

Input is a Rendry JSON layout plus data. Arbitrary HTML, web pages and DOCX are not PDF sources. The editable invoice does not claim Factur-X conformance; a separate compliance mode creates a structured electronic invoice.

Complete example

curl

# Requires curl and jq. Set RENDRY_API_KEY to a full-scope key.
set -eu
: "${RENDRY_API_KEY:?Set a full-scope API key}"
api=https://rendry.io/api/v1
request() { curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $RENDRY_API_KEY" -H 'Content-Type: application/json' "$@"; }
doc_id=$(request "$api/documents" -d '{"template_id":"pdf-invoice"}' | jq -er '.id')
body='{
  "document_id": "DOCUMENT_ID",
  "format": "pdf",
  "dynamic_data": {
    "organisation": "Example Studio",
    "reference": "INV-1043",
    "recipient": "Example Client",
    "total": "200.00 €"
  },
  "rows": {
    "items": [
      {
        "description": "Design services",
        "quantity": "2",
        "unit_price": "100.00",
        "amount": "200.00"
      }
    ]
  }
}'
body=$(printf '%s' "$body" | jq --arg id "$doc_id" '.document_id=$id')
request "$api/render/document" -d "$(printf '%s' "$body" | jq '.dry_run=true')" | jq .
# Submit after inspecting pages, row_ranges, warnings and cost_credits above.
job_id=$(request "$api/render/document" -d "$body" | jq -er '.job_id')
started=$(date +%s)
while :; do
  status=$(request "$api/render/status/$job_id")
  state=$(printf '%s' "$status" | jq -er '.status')
  if [ "$state" = done ]; then
    url=$(printf '%s' "$status" | jq -er '.result_url')
    case "$url" in https://*) ;; /*) url="https://rendry.io$url" ;; *) exit 1 ;; esac
    case "$url" in https://rendry.io/*) request "$url" -o invoice.pdf ;; *) curl --fail-with-body --silent --show-error "$url" -o invoice.pdf ;; esac
    break
  fi
  if [ "$state" = error ]; then printf '%s\n' "$status" >&2; exit 1; fi
  if [ "$(( $(date +%s) - started ))" -ge 7200 ]; then
    printf '%s\n' "Polling stopped; resume GET /render/status/$job_id" >&2; exit 1
  fi
  sleep 2
done

Node.js

// Node.js with built-in fetch. Set RENDRY_API_KEY to a full-scope key.
import { writeFile } from 'node:fs/promises'
const origin = 'https://rendry.io'
const key = process.env.RENDRY_API_KEY
if (!key) throw new Error('Set RENDRY_API_KEY')
async function request(path, body) {
  const response = await fetch(origin + '/api/v1' + path, {
    method: body ? 'POST' : 'GET',
    headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },
    ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(30000),
  })
  const data = await response.json()
  if (!response.ok) throw new Error(JSON.stringify(data))
  return data
}
const doc = await request('/documents', { template_id: 'pdf-invoice' })
const input = { document_id: doc.id, format: 'pdf', ...{
  "dynamic_data": {
    "organisation": "Example Studio",
    "reference": "INV-1043",
    "recipient": "Example Client",
    "total": "200.00 €"
  },
  "rows": {
    "items": [
      {
        "description": "Design services",
        "quantity": "2",
        "unit_price": "100.00",
        "amount": "200.00"
      }
    ]
  }
} }
console.log(await request('/render/document', { ...input, dry_run: true }))
// Inspect the quote before submitting a paid render.
const job = await request('/render/document', input)
const deadline = Date.now() + 7200000
while (true) {
  const status = await request('/render/status/' + job.job_id)
  if (status.status === 'error') throw new Error('Rendering failed. Inspect job ' + job.job_id + ' in My files.')
  if (status.status === 'done') {
    const url = new URL(status.result_url, origin)
    if (url.protocol !== 'https:') throw new Error('Invalid file URL')
    const file = await fetch(url, {
      headers: url.origin === origin ? { Authorization: 'Bearer ' + key } : {}, signal: AbortSignal.timeout(300000),
    })
    if (!file.ok) throw new Error('Download failed: ' + file.status)
    await writeFile('invoice.pdf', new Uint8Array(await file.arrayBuffer()))
    break
  }
  if (Date.now() >= deadline) throw new Error('Resume polling job ' + job.job_id)
  await new Promise(resolve => setTimeout(resolve, 2000))
}

Python

# Python standard library. Set RENDRY_API_KEY to a full-scope key.
import json, os, time, urllib.request, urllib.parse
origin = 'https://rendry.io'
key = os.environ['RENDRY_API_KEY']
def request(path, body=None):
    payload = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(origin + '/api/v1' + path, data=payload,
        headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'})
    with urllib.request.urlopen(req, timeout=30) as response:
        return json.load(response)
doc = request('/documents', {'template_id': 'pdf-invoice'})
inputs = json.loads("{\"dynamic_data\":{\"organisation\":\"Example Studio\",\"reference\":\"INV-1043\",\"recipient\":\"Example Client\",\"total\":\"200.00 €\"},\"rows\":{\"items\":[{\"description\":\"Design services\",\"quantity\":\"2\",\"unit_price\":\"100.00\",\"amount\":\"200.00\"}]}}")
inputs.update(document_id=doc['id'], format='pdf')
print(request('/render/document', dict(inputs, dry_run=True)))
# Inspect the quote before submitting a paid render.
job = request('/render/document', inputs)
deadline = time.monotonic() + 7200
while True:
    status = request('/render/status/' + job['job_id'])
    if status['status'] == 'error':
        raise RuntimeError('Rendering failed. Inspect job ' + job['job_id'] + ' in My files.')
    if status['status'] == 'done':
        url = urllib.parse.urljoin(origin, status['result_url'])
        if urllib.parse.urlparse(url).scheme != 'https':
            raise RuntimeError('Invalid file URL')
        headers = {'Authorization': 'Bearer ' + key} if urllib.parse.urlparse(url).netloc == 'rendry.io' else {}
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=300) as response, open('invoice.pdf', 'wb') as file:
            while chunk := response.read(1024 * 1024):
                file.write(chunk)
        break
    if time.monotonic() >= deadline:
        raise TimeoutError('Resume polling job ' + job['job_id'])
    time.sleep(2)

In MCP: list_templates → save_document {template_id} → render_document {document_id, dynamic_data, rows, dry_run:true} → render_document → get_render_status.

More starters · All document fields and modes