Skip to content
CastPDF
Guides

PDFs from a spreadsheet

Turn a CSV or XLSX file into one PDF per row with a template. Check the file, map columns to fields, run it as a batch and email each PDF.

On this page

Send a CSV or XLSX file with a template, and CastPDF makes one PDF per row: a certificate for every participant, an invoice for every customer, a letter for every address. Each row fills the template's fields, and each PDF can be emailed to the address in its row.

It works in two calls. POST /v1/spreadsheets/parse reads the file and suggests which column fills which field, so you can check everything before anything is made. Then POST /v1/batches with the same file runs it. If you would rather not write code, the dashboard does the same thing with an upload, and the free certificate generator shows the idea in your browser.

1. Prepare the file

  • First row: the column names. Every other row becomes one PDF. A blank column name becomes Column 1, Column 2 and so on (by position), and a repeated name gets (2), (3).
  • Empty rows are skipped, so a blank line at the end does no harm. Spaces around a value are removed.
  • Name columns like the template's fields (name, course, customer.email), and the mapping is found for you. Case, spaces and punctuation do not matter: a column Customer Name matches a field customer_name.
  • CSV: save it as UTF-8 (with or without a byte order mark); accents and other scripts come out wrong in any other encoding. Commas, semicolons and tabs all work as separators: the one that appears most often in the first line is used. Put a value that contains the separator or a line break in double quotes, and write a quote inside a quoted value as "". Files may end in .csv, .tsv or .txt.
  • XLSX: only the first sheet is read. Cells formatted as dates become YYYY-MM-DD (YYYY-MM-DD HH:MM with a time). Formulas give their last saved value, and error cells are empty. Old .xls files are not supported: save them as .xlsx or .csv.
  • Values: a CSV cell is always text, such as "1234.50". An XLSX number cell stays a number and a TRUE or FALSE cell a boolean. Format dates and amounts the way they should appear, or format them in the template with Liquid filters.

2. Check the file

Send the file, base64-encoded, to POST /v1/spreadsheets/parse. Add template_id to get the template's fields and the suggested mapping. Nothing is rendered or counted.

curl
{
  printf '{"filename": "participants.csv", "template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b", "content_base64": "'
  base64 < participants.csv | tr -d '\n'
  printf '"}'
} | curl https://api.castpdf.com/v1/spreadsheets/parse \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @-

The JSON is piped into curl, so a file of several megabytes does not hit the shell's limit on command length.

200
{
  "format": "csv",
  "columns": ["Name", "Course", "Completed on", "Email"],
  "row_count": 248,
  "max_rows": 500,
  "preview": [
    { "Name": "Ada Lovelace", "Course": "Safety training", "Completed on": "2026-10-02", "Email": "[email protected]" }
  ],
  "fields": ["name", "course", "date"],
  "mapping": { "name": "Name", "course": "Course", "date": null }
}
FieldDescription
formatcsv or xlsx.
columnsThe column names, from the first row.
row_countData rows, without the header and empty rows: the number of PDFs a batch would make.
max_rowsThe most rows your plan runs at a time.
previewThe first 5 rows, keyed by column name. Check that dates and amounts look right.
fieldsThe template's fields (empty without template_id).
mappingFor each field, the column that fills it, or null when no column matches.

3. Read the suggested mapping

The template's fields are the plain values in its sample data, written as paths: sample data { "name": "...", "customer": { "email": "..." } } has the fields name and customer.email (up to 200). Lists, such as invoice line items, cannot be filled from a single row, so they are not fields here.

A column matches a field when their names agree after ignoring case, spaces and punctuation, either with the whole path or with its last part: the column Email fills customer.email. A field with no matching column is null. In the example above, date found no column, because the column is called Completed on. You can rename the column, or send your own mapping in the next step.

4. Run it as a batch

Send the same file as spreadsheet to POST /v1/batches, instead of items:

FieldDescription
filenameRequiredThe file name, with its extension.
content_base64RequiredThe file, base64-encoded.
mappingTemplate field to column name, such as { "date": "Completed on" }. Without it, the suggested mapping is used, or every column under its own name when the template has no sample data.
email_columnThe column with each row's email address. Rows with an address are emailed their PDF; rows with an empty cell are not. One address that is not valid refuses the whole batch (invalid_request), before anything is made.

When you send mapping, it replaces the suggestion, so include every field you want filled, and leave out the null ones: each value must be the name of a column in the file (a missing column is invalid_request). The other batch fields work as usual: filename_pattern uses the field names ("certificate-{{ name }}"), deliver.email sets the subject and message for every row, and "test": true makes a test batch.

An empty cell is left out of that row's data, so the template's default filter fills it, and the PDF reports a missing_variable warning if the template prints it anyway.

The batch answers 202 at once with "source": "spreadsheet". From there it is a normal batch: wait for batch.completed or poll it, check the items (index 0 is the first data row), and download the ZIP. See batches.

Example: certificates for a course

A template with the sample data { "name": "Ada Lovelace", "course": "Safety training", "date": "2026-10-02" }, and this file:

participants.csv
Name,Course,Completed on,Email
Ada Lovelace,Safety training,2026-10-02,[email protected]
Grace Hopper,Safety training,2026-10-02,[email protected]
Alan Turing,Safety training,2026-10-03,

The code reads the file, base64-encodes it, maps the one column whose name does not match, and emails each certificate (Alan Turing's row has no address, so his PDF is only in the ZIP):

Run a spreadsheet batch
import { readFile } from 'node:fs/promises';

const file = await readFile('participants.csv');

const res = await fetch('https://api.castpdf.com/v1/batches', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'safety-training-2026-10',
  },
  body: JSON.stringify({
    template_id: '8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b',
    spreadsheet: {
      filename: 'participants.csv',
      content_base64: file.toString('base64'),
      mapping: { name: 'Name', course: 'Course', date: 'Completed on' },
      email_column: 'Email',
    },
    filename_pattern: 'certificate-{{ name }}',
    deliver: { email: { subject: 'Your certificate', message: 'Congratulations! Your certificate is attached.' } },
  }),
});
const batch = await res.json();
if (!res.ok) throw new Error(`${batch.error.code}: ${batch.error.message}`);
console.log(batch.id, batch.total); // ..., 3
import base64

import requests

with open("participants.csv", "rb") as f:
    content = base64.b64encode(f.read()).decode("ascii")

res = requests.post(
    "https://api.castpdf.com/v1/batches",
    headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "safety-training-2026-10"},
    json={
        "template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
        "spreadsheet": {
            "filename": "participants.csv",
            "content_base64": content,
            "mapping": {"name": "Name", "course": "Course", "date": "Completed on"},
            "email_column": "Email",
        },
        "filename_pattern": "certificate-{{ name }}",
        "deliver": {"email": {"subject": "Your certificate", "message": "Congratulations! Your certificate is attached."}},
    },
    timeout=60,
)
batch = res.json()
if not res.ok:
    raise RuntimeError(f"{batch['error']['code']}: {batch['error']['message']}")
print(batch["id"], batch["total"])  # ..., 3

Run it with a test key first: the PDFs are watermarked and not counted, and in test mode emails go only to verified addresses of your own team, so you can check the result without writing to real participants. The email delivery page explains who can be emailed in live mode.

Limits

LimitValue
File size5 MB, before base64 encoding (more is details.limit: "spreadsheet_bytes")
Rows per fileFree 25, Starter 500, Growth 1,000, Pro 2,500 and Scale 5,000, the same as items per batch (more is details.limit: "spreadsheet_rows")
Columns500 (details.limit: "spreadsheet_columns")
Cells500,000 (details.limit: "spreadsheet_cells")
Time to read the file10 seconds

All of these are invalid_request (400), like a file that cannot be read: an unknown extension, an .xls file, a CSV that is not text, or a file with a header row and no data rows. The message says what is wrong. Above the row limit, split the file and run one batch per part. The other batch limits (background PDFs at once, emails per day) apply as for any batch.

Privacy

Spreadsheet files are read in memory and never stored. What a batch keeps is each row's template data (the values of the mapped columns, or of every column when the template has no sample data), with its batch item, only until that PDF is made. The PDFs themselves are stored like any other document, for your plan's retention period. The preview from POST /v1/spreadsheets/parse is returned to you and not kept.

Next steps

Updated Something unclear? [email protected], a real person replies.