•5 min read

Generate images from a spreadsheet: one template, one image per row

Turn every row of a Google Sheet, Excel file or CSV into its own image: product cards, speaker cards, listings. How columns map onto a design, and three ways to get the files out: a ZIP from a CSV upload, a bulk API call, or a live image link per row.

apibulkautomationtutorials

To generate images from a spreadsheet, you need one design with named slots and one row of data per image. Each column fills one slot. Each row becomes one file. You upload the sheet as a CSV (or send the rows to an API) and get back a ZIP with one image per row, or a link per row you can paste back into the sheet.

This post shows how to set up the sheet so the columns line up with the design, and three ways to run it: no code, a bulk API call, and a signed link per row.

The problem: 200 rows, one design

You have the data already. Product names and prices in Google Sheets. Speakers and talk titles in Airtable. Listings exported from your CMS as Excel. What you need is a picture for each row: a product card, a speaker card, a "just listed" post.

Making them by hand works for ten. At two hundred, someone spends a day copying cells into a design tool, and the next price change means doing it again.

The fix is to stop treating each image as a separate design. There is one design, and the spreadsheet fills it in.

How it works: your header row is the variable list

In bannx you design the image once in the editor. Then you mark the parts that change as variables: the product name text, the price text, the photo layer, the badge colour. Each variable has a name.

The spreadsheet's header row uses those same names. That's the whole mapping:

Header cellFills the template variableBound to
titletitlethe product name text layer
pricepricethe price text layer
photophotothe image layer's source
badgebadgea "New" or "Sale" sticker

Columns are matched to variables by name. A column called title fills the title variable. A variable your sheet has no column for keeps the value saved in the template, so a logo or a footer line only needs setting once. More on declaring variables in the variables docs.

Publish the template when the design is ready. Bulk renders use the published version.

Prepare the sheet

A few rules make the first run go right:

  • Header names match variable names exactly. Price and price are different names. Copy them from the template's Variables panel.
  • Image columns hold URLs. A cell with a pasted picture won't travel through a CSV. Put an https:// link to the image in the cell instead (your shop's product image URL works).
  • One row per image. If a product needs a square and a story version, that's either two rows or one row with a _size column (below).
  • Optional id column. It names that row's file inside the ZIP, so linen-shirt.png is easy to match back to its row. A name key does the same.
  • Optional _size column. Put a size preset like story or instagram-post in it, and that row renders at that size from the same template. Leave it empty to use the template's own size. The _size column never reaches the design as a variable.

Example products.csv:

id,title,price,photo,_size
linen-shirt,Linen shirt,€49,https://example.com/img/shirt.jpg,
canvas-tote,Canvas tote,€19,https://example.com/img/tote.jpg,story
wool-scarf,Wool scarf,€29,https://example.com/img/scarf.jpg,instagram-post

In Google Sheets this is File → Download → Comma-separated values. In Excel it's Save As → CSV UTF-8. Pick the UTF-8 option so currency signs and accents survive.

Route 1, no code: CSV in, ZIP out

The fastest way for a one-off batch.

  1. Open your template's page and switch to the Bulk tab, then Playground.
  2. Upload the CSV. The header row is read as variable names.
  3. Pick the output format and run it.

You get one file per row, packed into a ZIP. Stills come out as PNG, SVG or PDF. If the template is animated, each row can be an MP4, WebM or GIF instead.

If the data already lives in bannx as content (a collection of products or speakers), you don't need a CSV at all. In the same Bulk tab, pick the content type, check the rows you want, and hover a row to preview it on the design before anything renders. Render runs the batch and emails you the ZIP when it's done. The templates docs cover that flow.

Route 2, API: send rows, poll for the ZIP

When the sheet updates every week, or the export comes out of another system, call the bulk endpoint. Each spreadsheet row is a JSON object whose keys are the variable names. You need the template ID (from the editor URL) and an API key with the template.render scope.

# 1. start the job: one object per spreadsheet row, keys = template variable names
curl -X POST https://bannx.com/api/v1/templates/TEMPLATE_ID/bulk \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "png",
    "rows": [
      { "id": "linen-shirt", "title": "Linen shirt", "price": "€49", "photo": "https://example.com/img/shirt.jpg" },
      { "id": "canvas-tote", "title": "Canvas tote", "price": "€19", "photo": "https://example.com/img/tote.jpg", "_size": "story" }
    ]
  }'
# → { "ok": true, "jobId": "…", "total": 2 }

# 2. poll until state is "completed", then download zipUrl
curl https://bannx.com/api/v1/templates/bulk/JOB_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
# → { "jobId": "…", "state": "active", "total": 2, "completed": 1, "succeeded": 1, "failed": 0, "errors": [] }
# → { …, "state": "completed", "zipUrl": "https://…/renders.zip" }

The job is queued, so the first call returns straight away with a jobId. Poll the second URL until state is completed (or failed). Rows that didn't render are listed in errors, and the rest of the batch still finishes.

The same thing from Node, reading a CSV export. It uses a real CSV parser (csv-parse), because product names have commas in them and splitting lines on , breaks on the first one. Sheets with more than 500 rows are split into one job per 500.

import { readFile } from "node:fs/promises";
import { parse } from "csv-parse/sync";

const BASE = "https://bannx.com/api/v1/templates";
const TEMPLATE_ID = process.env.BANNX_TEMPLATE_ID;
const headers = {
  Authorization: `Bearer ${process.env.BANNX_KEY}`,
  "Content-Type": "application/json",
};

// header row becomes the object keys: { id, title, price, photo, _size }
const rows = parse(await readFile("products.csv"), {
  columns: true,
  skip_empty_lines: true,
  bom: true,
}).map((row) =>
  // drop empty cells so they keep the template's saved value
  Object.fromEntries(Object.entries(row).filter(([, v]) => v !== "")),
);

async function startJob(chunk) {
  const res = await fetch(`${BASE}/${TEMPLATE_ID}/bulk`, {
    method: "POST",
    headers,
    body: JSON.stringify({ format: "png", rows: chunk }),
  });
  if (!res.ok) throw new Error(`Bulk start failed: ${res.status} ${await res.text()}`);
  return (await res.json()).jobId;
}

async function waitForZip(jobId) {
  for (;;) {
    const res = await fetch(`${BASE}/bulk/${jobId}`, { headers });
    const job = await res.json();
    if (job.state === "completed") return job;
    if (job.state === "failed") throw new Error(`Job ${jobId} failed`);
    await new Promise((r) => setTimeout(r, 3000));
  }
}

for (let i = 0; i < rows.length; i += 500) {
  const jobId = await startJob(rows.slice(i, i + 500));
  const job = await waitForZip(jobId);
  console.log(`rows ${i + 1}-${i + job.total}: ${job.succeeded} ok, ${job.failed} failed → ${job.zipUrl}`);
  for (const err of job.errors) console.warn(err);
}

Keep the key on the server or in your script's environment. It never belongs in a sheet, a browser or a public repo.

A few options worth knowing, all documented under bulk rendering:

  • format is png, svg, pdf, mp4, webm or gif. One PDF per row is the usual choice for print: name badges, shelf labels, flyers.
  • size on the request renders every row at another size, like "size": "story". A row's own _size (and _fit) overrides it, so one run can deliver a story for one record and a square for the next.
  • scale renders at 2× for retina screens without touching the design.

Sometimes you don't want files. You want a column in the sheet with an image URL per row, because the sheet feeds a CMS, a newsletter tool or a shop feed that reads image URLs.

That's what signed image URLs are for. Send up to 500 rows to the signed-urls endpoint and get one URL back per row, in the same order:

curl -X POST https://bannx.com/api/v1/templates/TEMPLATE_ID/signed-urls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "variables": { "title": "Linen shirt", "price": "€49", "photo": "https://example.com/img/shirt.jpg" } },
      { "variables": { "title": "Canvas tote", "price": "€19", "photo": "https://example.com/img/tote.jpg" } }
    ],
    "options": { "format": "webp" }
  }'
# → { "templateId": "…", "published": true, "urls": [ { "url": "https://…/r/…&sig=…", "variables": { … } }, … ] }

Write each returned url into a new column next to the row it came from. The links:

  • Contain no API key. Anyone can open them, so it's safe to put them in a sheet the whole team (or a supplier) can see.
  • Can't be edited. Each URL carries a signature over every value in it. Change price=49 to price=4 and the link stops rendering.
  • Render once, then serve from cache. The first open renders the image, and every open after that gets the stored file. Signing itself is free.
  • Are stills only: PNG or WebP. Use the bulk API for PDFs or video.

Add exp (unix seconds) if the links should stop working after a sale ends. More detail in the signed URL docs and in our post on signed URL use cases.

There is also a plain GET render URL that takes api_token in the query string. Don't use it in a spreadsheet. Anyone who can see the sheet can copy the key out of the cell. If you must, the key has to be a render-only key, but signed URLs are the better fit for anything shared.

Automating it from Google Sheets with Make, Zapier or n8n

bannx doesn't have a Google Sheets connector, and there's no dedicated Make, Zapier or n8n app yet. The way in is the generic HTTP request module, which all three have. A typical scenario:

  1. Trigger: a new or updated row in the sheet.
  2. HTTP request: POST https://bannx.com/api/v1/templates/TEMPLATE_ID/render with the Bearer key and a body of { "format": "png", "variables": { "title": "{{row.title}}", "price": "{{row.price}}" } }. That renders one image per row as it changes. The response has a url.
  3. Update the row: write that url back into an "image" column.

For a whole sheet at once, point the HTTP step at the bulk endpoint instead and poll the job, or use a render.completed webhook so the scenario doesn't have to wait. The render endpoint allows 30 requests a minute per key, so for hundreds of rows the bulk endpoint is the better choice.

Limits worth knowing

  • 500 rows per API call, for both bulk jobs and signed URLs. Split bigger sheets into several calls, like the Node example does.
  • The balance must cover the whole job up front. If it doesn't, the job is refused with a 402 before anything renders, so you never get half a batch.
  • Failed rows cost nothing. They're listed in the job's errors with the reason, and the other rows still render.
  • Image cells must be URLs the renderer can fetch. A picture pasted into a cell doesn't export to CSV.
  • No spreadsheet connector. Export a CSV, or use an automation tool's HTTP step.

Where bannx fits

bannx is a template-to-file engine. You design once in the browser editor, mark what changes, and render one file per row from a CSV upload, the bulk API, a signed link or an AI agent through the MCP server. The same engine draws the editor preview and the server render, so the row you preview is the file you get.

It fits when you have data and volume: a catalog of products that need cards every season, an event with a hundred speakers, an agency producing the same post for twenty clients. For one image, any public template on bannx.com can be edited in the browser and downloaded free, no account and no watermark.

For a worked example of one use case end to end, see the certificate generator API post.

FAQ

Does it work with Excel, or only CSV?

Save the Excel file as CSV UTF-8 and upload that. The playground reads CSV or JSON rows, and the API takes JSON rows, so any tool that can export a table works.

How do I put a different picture in each image?

Add a column with an image URL per row and bind the template's image layer to a variable of the same name. Each row's URL becomes that row's picture.

Can one row come out in several sizes?

One row renders at one size per job. For two sizes, either add a second row with a different _size, or run the job twice with a different size on the request. The design reflows to each size if it's built with constraints or auto-layout.

Can I get PDFs instead of PNGs?

Yes. Set format to pdf and every row comes out as a vector PDF, with text kept as real text. That suits name badges, labels and anything printed.

Does the image update when the sheet changes?

Not on its own. A rendered file is fixed. Re-run the batch, or let a Make, Zapier or n8n scenario re-render the row when it changes. A signed URL is fixed too: new values mean a new URL.