How to Generate Thousands of Personalized Images from One Template Using a REST API and a CSV

You have a template. You have a CSV with a few thousand rows - names, companies, maybe a product or a score. You need one personalized image per row. Doing it by hand in a design tool is obviously impossible at that volume, so the right approach is a REST API: loop over the CSV, send each row's data to the API, get back a finished image.

This guide shows exactly how, with copy-paste Python. It also covers the decision most people get wrong at the start - whether to generate image files at all, or use runtime URLs instead - because for email specifically, the second approach saves you the whole batch job.

Two ways to "generate thousands of images" (pick the right one first)

Before writing any code, decide which of these you actually need. They lead to very different amounts of work.

Pre-generated image files. You call the API once per recipient, and it returns a rendered image file (JPG or PDF) that you save or upload somewhere. Use this when the file itself is the deliverable - social posts, ad variants, certificates, PDFs, or images you'll host yourself.

Runtime image URLs. Instead of generating files, you build one URL per recipient containing their data as parameters. The image renders the moment it's requested - when the email is opened. Nothing is stored, and there's no batch job at all. Use this for email personalization, where the image lives inside an <img> tag.

If your endgame is personalized images in email, jump to the runtime-URL section - it's dramatically less work. If you genuinely need image files, keep reading.

Generating image files from a CSV: the full walkthrough

We'll use the OKZest API. The pattern is identical for any row-based data source; a CSV is just the simplest example.

Prerequisites

  1. An OKZest account and an API key (Settings -> API Keys).
  2. A design with at least one dynamic text layer. Note two IDs: the design ID (on the design's share page) and each layer ID (click the element in the editor; the ID shows in the side panel).
  3. Python 3 with the requests library (pip install requests).
  4. A CSV whose column headers you can map to your layer IDs.

Step 1 - Understand the single API call

Everything scales up from one request. To personalize a design, you POST the recipient's data to the images endpoint:

curl -X POST https://api.okzest.com/v1/images \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_API_KEY" \
  -d '{
        "design_id": "dcH69PLkb2YctQunAVZLQ",
        "layers": [
          { "layer_id": "name",    "text": "Sarah Chen" },
          { "layer_id": "company", "text": "Acme Corp" }
        ]
      }'

The response is a rendered image (a JPG by default). The layers array is where personalization happens - one entry per dynamic layer, with the text value for that recipient. You can also change layer colors, swap image layers, or set a background; see the images reference for the full set of options.

Step 2 - Prepare your CSV

Give your CSV headers that map cleanly to your layer IDs. For a design with name and company layers:

name,company,filename
Sarah Chen,Acme Corp,sarah-chen
James Okafor,Globex,james-okafor
Priya Patel,Initech,priya-patel

The extra filename column just gives each output image a predictable name. It's optional but makes matching images back to recipients much easier.

Step 3 - Loop over the CSV and save each image

This script reads the CSV, calls the API once per row, and saves each returned image to an output/ folder:

import csv
import os
import requests

API_KEY  = "YOUR_API_KEY"
DESIGN_ID = "dcH69PLkb2YctQunAVZLQ"
ENDPOINT = "https://api.okzest.com/v1/images"

os.makedirs("output", exist_ok=True)

with open("recipients.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        payload = {
            "design_id": DESIGN_ID,
            "layers": [
                {"layer_id": "name",    "text": row["name"]},
                {"layer_id": "company", "text": row["company"]},
            ],
        }
        resp = requests.post(
            ENDPOINT,
            json=payload,
            headers={"x-api-key": API_KEY},
            timeout=30,
        )
        resp.raise_for_status()

        out_path = f"output/{row['filename']}.jpg"
        with open(out_path, "wb") as img:
            img.write(resp.content)
        print(f"saved {out_path}")

Run it, and you get one personalized image file per CSV row. That's the whole job for a few hundred rows.

Step 4 - Make it robust at thousands of rows

A naive loop is fine for small lists. At thousands of rows you want three things: retries, gentle pacing, and parallelism. This version adds all three:

import csv
import os
import time
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API_KEY   = "YOUR_API_KEY"
DESIGN_ID = "dcH69PLkb2YctQunAVZLQ"
ENDPOINT  = "https://api.okzest.com/v1/images"
MAX_WORKERS = 5          # keep modest to respect rate limits
os.makedirs("output", exist_ok=True)

def generate(row):
    payload = {
        "design_id": DESIGN_ID,
        "layers": [
            {"layer_id": "name",    "text": row["name"]},
            {"layer_id": "company", "text": row["company"]},
        ],
    }
    for attempt in range(3):                       # simple retry
        resp = requests.post(ENDPOINT, json=payload,
                             headers={"x-api-key": API_KEY}, timeout=30)
        if resp.status_code == 429:                # rate limited - back off
            time.sleep(2 ** attempt)
            continue
        resp.raise_for_status()
        out_path = f"output/{row['filename']}.jpg"
        with open(out_path, "wb") as img:
            img.write(resp.content)
        return out_path
    raise RuntimeError(f"failed after retries: {row['filename']}")

with open("recipients.csv", newline="", encoding="utf-8") as f:
    rows = list(csv.DictReader(f))

with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
    futures = {pool.submit(generate, r): r for r in rows}
    for fut in as_completed(futures):
        row = futures[fut]
        try:
            print(f"done: {fut.result()}")
        except Exception as e:
            print(f"ERROR {row['filename']}: {e}")

Three practical notes for high volume:

  • Respect rate limits. Start with a low worker count (5 is a safe default) and increase only if you're not hitting 429 responses. The retry with exponential back-off handles the occasional limit gracefully.
  • Make it resumable. For very large lists, skip rows whose output file already exists, so an interrupted run picks up where it left off rather than starting over.
  • Watch your storage and hosting. Ten thousand images is ten thousand files you now have to store and serve. This is exactly the cost that the runtime-URL approach avoids.

The shortcut for email: runtime URLs instead of files

If those images are going into emails, you probably don't need the batch job at all.

Instead of generating a file per recipient, you use a single image URL with the data as parameters:

https://media.okzest.com/img?c=your-design-id&name=Sarah%20Chen&company=Acme%20Corp

Drop that URL into an <img> tag in your email template, but replace the values with your email platform's merge tags:

<img src="https://media.okzest.com/img?c=your-design-id&name={{ first_name }}&company={{ company }}" alt="A message for {{ first_name }}" />

Now your ESP substitutes each recipient's data at send time, and the image renders when they open the email. No API loop, no thousands of files, no storage. One template, one URL, unlimited recipients. (For a platform-specific walkthrough, see our guide to personalized images in Klaviyo.)

Which approach should you use?

Use the REST API with a CSV when you need the image files themselves - to host, attach, post to social, or archive. The Python scripts above take you from a CSV to a folder of finished images.

Use runtime URLs when the images live in email or on the web and you want them to render on demand - no batch job, no storage, and the freedom to update the template without regenerating anything.

Plenty of teams use both: runtime URLs for their email sends, and the batch API for the occasional job where they need downloadable files.

Start generating

Both approaches run on the same OKZest account and the same API key. Build one template, then either loop a CSV through the REST API or drop a runtime URL into your email - whichever fits the job.

Create a free account, no credit card required, and try it against your own data. For the full parameter reference, see the API documentation; for no-code automation, OKZest also connects through Zapier.


Related reading: get an image via the REST API, personalized images in email, and choosing image software for agencies.