Skip to content
CastPDF

Generate a PDF from JSON data

To generate a PDF from JSON, design the document once as HTML with Liquid placeholders, save it as a template, then send its template_id and your JSON as data in one request. The fields fill in, loops repeat rows and conditions hide blocks. Our PDF generation API returns the finished file.

  • Updated October 2026
  • 100 free PDFs a month

The idea: layout once, data every time

Most documents an app produces share one layout and differ only in their values. A packing slip always has an address block, a list of items and a note for the picker. Only the order behind it changes. So it makes sense to store the layout once and send nothing but the order each time.

That is what a template does. You write the page in HTML and CSS, and you mark the places where values go. Your code then sends a small JSON object instead of a whole web page. The request stays tiny, the layout lives in one place, and a designer can change it without a deploy of your app.

This guide builds a packing slip for a small online shop from start to finish. The same steps work for any document: a statement, a delivery note, a class schedule or a contract.

From JSON to PDF in five steps

  1. Sketch the data. Write down one realistic JSON object for the document. Group related values (the order, the address, the items) so the template reads naturally.
  2. Write the HTML with Liquid. Build the page in HTML and CSS. Put {{ ... }} where a value goes, {% for %} around repeating rows and {% if %} around optional blocks.
  3. Save it as a template. Paste the HTML, CSS and your JSON as sample data into the dashboard editor, or send them to POST /v1/templates. Check the live preview.
  4. Send the template id and data. Call POST /v1/pdf with the template_id and a data object that has the same shape as your sample data.
  5. Pin the version you trust. Once production uses the template, pin the current version. Later edits then wait for your approval before they reach real documents.

Step 1: shape the JSON for the page

Start from the document, not from your database. Ask what the reader sees, then name one key per visible value. Nested objects keep the template tidy: ship_to.city reads better than a flat shipping_city_line.

packing-slip.json
{
  "order": { "number": "SO-50817", "placed": "2026-10-01", "channel": "Web shop" },
  "ship_to": { "name": "Mara Lindqvist", "street": "Kungsgatan 12", "city": "112 35 Stockholm", "country": "Sweden" },
  "items": [
    { "sku": "MUG-STN-01", "name": "Stoneware mug, sand", "qty": 2, "fragile": true },
    { "sku": "TEA-ERL-250", "name": "Earl Grey loose leaf, 250 g", "qty": 1, "fragile": false },
    { "sku": "CRD-THX-A6", "name": "Thank-you card", "qty": 1, "fragile": false }
  ],
  "gift": { "wrapped": true, "message": "Happy housewarming! From Jonas" }
}
  • Send raw values. Pass dates as ISO strings and amounts as numbers. The template formats them, so the same data works for every language.
  • Use arrays for anything that repeats. Items, payments and attendees all become loops.
  • Use booleans for choices. A flag such as fragile is easier to test than a string like "yes".

Step 2: fields, loops and conditions in Liquid

Templates use Liquid, a small language made for exactly this job. You only need three pieces. Double braces print a value. A for tag repeats markup once per array item. An if tag shows markup only when a test passes.

packing-slip.html
<header class="slip-head">
  <h1>Packing slip</h1>
  <p>Order {{ order.number }} &middot; {{ order.placed | format_date: "long" }}</p>
</header>

<section class="ship-to">
  <p class="label">Ship to</p>
  <p><strong>{{ ship_to.name }}</strong><br>{{ ship_to.street }}<br>{{ ship_to.city }}<br>{{ ship_to.country | upcase }}</p>
</section>

<table class="lines">
  <thead><tr><th>SKU</th><th>Item</th><th>Qty</th></tr></thead>
  <tbody>
    {% for item in items %}
    <tr class="{% cycle 'odd', 'even' %}">
      <td>{{ item.sku }}</td>
      <td>{{ item.name }}{% if item.fragile %} <span class="tag">Fragile</span>{% endif %}</td>
      <td>{{ item.qty }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>

<p class="count">{{ items | map: "qty" | sum }} units in {{ items.size }} lines</p>

{% if gift.wrapped %}
<aside class="gift keep-together">
  <p class="label">Gift wrap this order</p>
  <p>{{ gift.message | default: "No card message." }}</p>
</aside>
{% endif %}

A few details in that markup are worth a second look. The cycle tag alternates row classes for zebra stripes. The map and sum filters add up the quantities without any code on your side. The keep-together class stops the gift note from splitting across two pages.

Values are HTML-escaped by default. A customer who types <b> into their name will see it printed as text, and your layout stays intact.

Step 3: save the template

The quickest way is the dashboard. Open the template editor, paste the HTML and CSS into their tabs, and paste the JSON from step 1 into the sample data tab. The preview redraws as you type. Colleagues who never touch HTML can still change the sample values in Simple mode, a plain form, to see how a long address or a big order looks.

You can also create the template from a script. Creating and changing templates needs a live key; test keys can read and preview them only.

Create the template (curl)
curl https://api.castpdf.com/v1/templates \
  -H "Authorization: Bearer $CASTPDF_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d @packing-slip-template.json

The file holds name, html, css, sample_data and settings. The response includes the new template id. Keep it in your app configuration, next to the key.

When you save, the HTML is checked at once. A typo such as an unclosed {% if %} or a filter that does not exist is refused straight away, so it never reaches a customer.

Step 4: send the template id and your data

Each document is now one small request. Here it is in Node.js 20 with the built-in fetch. Any HTTP client works the same way, and the full field list is in the API reference.

render-slip.mjs
import { readFile, writeFile } from 'node:fs/promises';

const data = JSON.parse(await readFile('packing-slip.json', 'utf8'));

const res = await fetch('https://api.castpdf.com/v1/pdf', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    template_id: process.env.SLIP_TEMPLATE_ID,
    data,
    filename: `slip-${data.order.number}.pdf`,
  }),
  signal: AbortSignal.timeout(60_000),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

await writeFile(`slip-${data.order.number}.pdf`, Buffer.from(await res.arrayBuffer()));
console.log('pages:', res.headers.get('x-pages'));

Your data replaces the sample data

The data you send is used as a whole. It is not merged with the sample data saved in the template. If you leave out gift, the gift block simply does not appear; it will not borrow the sample message. Only a request with no data at all falls back to the sample data, which is handy for a first test.

Missing fields print nothing

A key that is absent from your JSON prints an empty string rather than failing the document. That is forgiving, but it can hide a gap. Give important optional values a fallback with the default filter, as in {{ ship_to.phone | default: "No phone given" }}. Wrap whole blocks in {% if %} when an empty heading would look odd.

When the template has an error

Some problems only show up with real data, such as a date filter that receives the word "soon". Then the request fails with 422 and the code template_render_error. The details field points at the exact spot in your HTML:

422 response
{
  "error": {
    "code": "template_render_error",
    "message": "Template error: not a date: soon",
    "docs_url": "https://castpdf.com/docs/errors#template_render_error",
    "details": { "line": 3, "column": 34 }
  }
}

Line and column count from 1, so you can jump straight to the tag in your editor. Log the whole error object in your app: the message and position together usually explain the fault in one read.

Step 5: versions and pinning

Every save that changes the HTML, CSS, sample data or page settings creates a new numbered version. The latest 50 are kept, plus any pinned one. Each stored document records the version that made it, so you can always trace an odd PDF back to its layout.

Pinning protects production while you keep editing. Pin version 3, and requests that send only the template_id keep using version 3, however many times you save. Try version 4 with a preview, then pin it when you are happy:

Pin version 4 (curl)
curl https://api.castpdf.com/v1/templates/$SLIP_TEMPLATE_ID/pin \
  -H "Authorization: Bearer $CASTPDF_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"version": 4}'

A single request can also ask for a version directly with template_version. That suits a reprint, where the copy should match the original exactly. The templates guide covers the full rules.

Where to go next

Invoices are the most common JSON document of all, and they add money, tax and rounding to the mix. The invoice from JSON guide walks through them using the real invoice template. If you would rather begin with the markup, the HTML invoice template guide explains every print rule. To build invoices from your card payments, see custom invoice PDFs from payment events.

FAQ

Common questions

What JSON structure does a PDF template need?

Any object works, as long as its keys match the names used in the template. Nested objects and arrays are fine. Shape it around what the reader sees, not around your database tables.

Is the data I send merged with the template sample data?

No. The data in your request replaces the sample data as a whole. The sample data is used only when a request sends no data at all.

What happens if a field is missing from my JSON?

The placeholder prints nothing and the document still renders. Use the default filter to show a fallback, or wrap the block in an if tag so it disappears cleanly.

Can a template show or hide sections based on the data?

Yes. Liquid has if, unless and case tags. Any block inside them appears only when its condition is true, such as a gift note or a discount line.

How do I find the error in a template that fails?

The API answers with template_render_error and gives the line and column in your HTML. Syntax mistakes and unknown filters are caught when you save, so most errors never reach production.

Will editing a template change PDFs my app is already sending?

Only if the template is not pinned. Pin the version production uses, and new saves create new versions without affecting live requests until you pin one of them.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.