Kalpana Docs
Getting started

Recipes

End-to-end flows that combine several endpoints, with curl, JavaScript, and Python.

Each recipe uses only endpoints documented in this reference. Samples read the key from KALPANA_API_KEY and use the workspace slug acme. The Python samples need requests; the JavaScript samples run on Node 18 or later.

Running consumes credits. Start with a few rows while you test.

Render a batch from rows and collect the files

Find a template and its pages, check your rows, create the batch with run set to true, wait for it to finish, and download the outputs. Each row is rendered on every page you name, so two pages make two files per row.

BASE="https://app.kalpana.one/api"
AUTH="x-api-key: $KALPANA_API_KEY"

# 1. Find a template and read its pages and layer names
curl "$BASE/templates?workspace=acme&search=product" --header "$AUTH"
curl "$BASE/templates/TEMPLATE_ID/summary?workspace=acme" --header "$AUTH"

# 2. Check the rows. Nothing is created or charged.
curl --request POST "$BASE/batches/validate" --header "$AUTH" \
  --header "content-type: application/json" \
  --data '{
    "workspace": "acme",
    "pages": ["k7fq2m8a", "p3rtv9we"],
    "rows": [
      { "modifications": { "Headline": "Weekend offer", "Photo": "shoe.png" } },
      { "modifications": { "Headline": "New arrivals", "p3rtv9we@Headline": "New in stories" } }
    ]
  }'

# 3. Create the batch and run it
curl --request POST "$BASE/batches" --header "$AUTH" \
  --header "content-type: application/json" \
  --data '{
    "workspace": "acme",
    "name": "September products",
    "pages": ["k7fq2m8a", "p3rtv9we"],
    "rows": [
      { "modifications": { "Headline": "Weekend offer", "Photo": "shoe.png" } },
      { "modifications": { "Headline": "New arrivals", "p3rtv9we@Headline": "New in stories" } }
    ],
    "run": true
  }'

# 4. Check progress, then download
curl "$BASE/batches/BATCH_ID?workspace=acme" --header "$AUTH"
curl "$BASE/batches/BATCH_ID/jobs/download?workspace=acme" --header "$AUTH"
const BASE = "https://app.kalpana.one/api";
const headers = {
	"x-api-key": process.env.KALPANA_API_KEY,
	"content-type": "application/json",
};

const call = async (method, path, params = {}) => {
	const isGet = method === "GET";
	const query = isGet ? `?${new URLSearchParams(params)}` : "";
	const response = await fetch(`${BASE}${path}${query}`, {
		method,
		headers,
		body: isGet ? undefined : JSON.stringify(params),
	});
	if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
	return response.json();
};

// 1. Find a template and its pages
const { templates } = await call("GET", "/templates", { workspace: "acme", search: "product" });
const template = await call("GET", `/templates/${templates[0].id}`, { workspace: "acme" });
const pages = template.pages.map((page) => page.id);

const rows = ["Weekend offer", "New arrivals"].map((headline) => ({
	modifications: { Headline: headline },
}));

// 2. Check the rows. The answer lists every problem; nothing is created.
const check = await call("POST", "/batches/validate", { workspace: "acme", pages, rows });
if (!check.valid) throw new Error(JSON.stringify(check.issues));

// 3. Create the batch and run it
const batch = await call("POST", "/batches", {
	workspace: "acme",
	name: "September products",
	pages,
	rows,
	run: true,
});

// 4. Poll until it finishes
let state;
do {
	await new Promise((resolve) => setTimeout(resolve, 3000));
	state = await call("GET", `/batches/${batch.id}`, { workspace: "acme" });
} while (state.status === "pending" || state.status === "processing");

// 5. Download the outputs
const { files } = await call("GET", `/batches/${batch.id}/jobs/download`, { workspace: "acme" });
for (const file of files) console.log(file.displayName, file.url);
import os
import time

import requests

BASE = "https://app.kalpana.one/api"
session = requests.Session()
session.headers["x-api-key"] = os.environ["KALPANA_API_KEY"]


def call(method, path, params):
    if method == "GET":
        response = session.get(BASE + path, params=params)
    else:
        response = session.request(method, BASE + path, json=params)
    response.raise_for_status()
    return response.json()


# 1. Find a template and its pages
templates = call("GET", "/templates", {"workspace": "acme", "search": "product"})["templates"]
template = call("GET", f"/templates/{templates[0]['id']}", {"workspace": "acme"})
pages = [page["id"] for page in template["pages"]]

rows = [{"modifications": {"Headline": h}} for h in ["Weekend offer", "New arrivals"]]

# 2. Check the rows. The answer lists every problem; nothing is created.
check = call("POST", "/batches/validate", {"workspace": "acme", "pages": pages, "rows": rows})
if not check["valid"]:
    raise SystemExit(check["issues"])

# 3. Create the batch and run it
batch = call("POST", "/batches", {
    "workspace": "acme",
    "name": "September products",
    "pages": pages,
    "rows": rows,
    "run": True,
})

# 4. Poll until it finishes
while True:
    time.sleep(3)
    state = call("GET", f"/batches/{batch['id']}", {"workspace": "acme"})
    if state["status"] not in ("pending", "processing"):
        break

# 5. Download the outputs
files = call("GET", f"/batches/{batch['id']}/jobs/download", {"workspace": "acme"})["files"]
for file in files:
    print(file["displayName"], file["url"])

Notes:

  • Layer names and page ids come from Get a template summary. See Modifications for image and visibility values, and for <pageId>@<layer> keys that fill one page only.
  • A key that no named page has is refused, with every such problem listed together, in both the check and the create. A typo never reaches the renderer.
  • Without run, the batch is a draft for review in the Studio. It spends nothing until it is run, in the Studio or with Run a batch.
  • A batch with some failed or cancelled jobs reports partial, not completed. Compare jobs.failed and jobs.cancelled with jobs.total before treating the run as clean.
  • url values from the download call are temporary. Copy the files, or store the creative ids and list them again later with List creatives.

Render files without a batch

Create jobs with run set to true. The call waits up to 30 seconds for the renders, so most images come back with their files. Renders still going when the wait ends answer 202; poll the jobs until the request settles. One request carries up to 10 jobs, which suits a carousel, every size of an ad, or a few variants of one page.

BASE="https://app.kalpana.one/api"
AUTH="x-api-key: $KALPANA_API_KEY"

# 1. Create and run two jobs. -i prints the status and the Location header.
curl -i --request POST "$BASE/jobs" --header "$AUTH" \
  --header "content-type: application/json" \
  --header "Prefer: wait=25" \
  --data '{
    "workspace": "acme",
    "jobs": [
      { "page": "k7fq2m8a", "modifications": { "Headline": "Weekend offer", "Photo": "shoe.png" } },
      { "page": "p3rtv9we", "modifications": { "Headline": "Weekend offer" }, "output": { "format": "jpeg" } }
    ],
    "run": true
  }'

# 2. On a 202, poll the jobs (the Location URL) until status is done
curl "$BASE/jobs?workspace=acme&ids=JOB_ID_1,JOB_ID_2" --header "$AUTH"
const BASE = "https://app.kalpana.one/api";
const key = process.env.KALPANA_API_KEY;

// 1. Create and run the jobs, waiting up to 25 seconds for the files
const response = await fetch(`${BASE}/jobs`, {
	method: "POST",
	headers: {
		"x-api-key": key,
		"content-type": "application/json",
		prefer: "wait=25",
	},
	body: JSON.stringify({
		workspace: "acme",
		jobs: [
			{ page: "k7fq2m8a", modifications: { Headline: "Weekend offer", Photo: "shoe.png" } },
			{ page: "p3rtv9we", modifications: { Headline: "Weekend offer" }, output: { format: "jpeg" } },
		],
		run: true,
	}),
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
let request = await response.json();

// 2. Still rendering (202): poll until no job is processing
while (request.status === "processing") {
	await new Promise((resolve) => setTimeout(resolve, 5000));
	const ids = request.jobs.map((job) => job.id).join(",");
	const poll = await fetch(`${BASE}/jobs?workspace=acme&ids=${ids}`, {
		headers: { "x-api-key": key },
	});
	request = await poll.json();
}

// 3. A settled request still has one status per job
for (const job of request.jobs) {
	if (job.status === "success") console.log(job.file.fileName, job.file.url);
	else console.error(job.id, job.status, job.error);
}
import os
import time

import requests

BASE = "https://app.kalpana.one/api"
session = requests.Session()
session.headers["x-api-key"] = os.environ["KALPANA_API_KEY"]

# 1. Create and run the jobs, waiting up to 25 seconds for the files
response = session.post(
    BASE + "/jobs",
    headers={"Prefer": "wait=25"},
    json={
        "workspace": "acme",
        "jobs": [
            {"page": "k7fq2m8a", "modifications": {"Headline": "Weekend offer", "Photo": "shoe.png"}},
            {"page": "p3rtv9we", "modifications": {"Headline": "Weekend offer"}, "output": {"format": "jpeg"}},
        ],
        "run": True,
    },
)
response.raise_for_status()
request = response.json()

# 2. Still rendering (202): poll until no job is processing
while request["status"] == "processing":
    time.sleep(5)
    ids = ",".join(job["id"] for job in request["jobs"])
    request = session.get(BASE + "/jobs", params={"workspace": "acme", "ids": ids}).json()

# 3. A settled request still has one status per job
for job in request["jobs"]:
    if job["status"] == "success":
        print(job["file"]["fileName"], job["file"]["url"])
    else:
        print(job["id"], job["status"], job["error"])

Notes:

  • If a request is refused with 400, nothing was created: data.issues lists every problem across all the jobs, each with its job index. Fix them together and send the request again.
  • A request is done when no job is rendering or waiting, even if some jobs failed. Always read each job's status.
  • Every call creates new jobs. If a call times out on your side, the jobs still render and are charged once; find their files with List creatives before you try again.
  • Add webhookUrl to get each result as a callback instead of polling. See Callback URLs.
  • file.url is a download link. Copy the file, or keep file.creativeId and list the creative again later.

Replace polling with a webhook

Instead of polling, add a webhook endpoint once and let Kalpana tell you when a batch finishes. The batch.completed event carries the batch id and counts; then fetch the files as above.

curl --request POST "https://app.kalpana.one/api/webhooks" \
  --header "x-api-key: $KALPANA_API_KEY" \
  --header "content-type: application/json" \
  --data '{
    "workspace": "acme",
    "name": "Batch finished",
    "url": "https://example.com/hooks/kalpana",
    "events": ["batch.completed"]
  }'
// Register the endpoint once. Store signingSecret to verify deliveries.
const endpoint = await call("POST", "/webhooks", {
	workspace: "acme",
	name: "Batch finished",
	url: "https://example.com/hooks/kalpana",
	events: ["batch.completed"],
});
console.log(endpoint.signingSecret);

// In your handler, after verifying the signature on the raw body:
// const event = JSON.parse(rawBody);
// if (event.type === "batch.completed") {
//   const { files } = await call("GET", `/batches/${event.data.batch.id}/jobs/download`, { workspace: "acme" });
// }
# Register the endpoint once. Store signingSecret to verify deliveries.
endpoint = call("POST", "/webhooks", {
    "workspace": "acme",
    "name": "Batch finished",
    "url": "https://example.com/hooks/kalpana",
    "events": ["batch.completed"],
})
print(endpoint["signingSecret"])

# In your handler, after verifying the signature on the raw body:
# event = json.loads(raw_body)
# if event["type"] == "batch.completed":
#     files = call("GET", f"/batches/{event['data']['batch']['id']}/jobs/download",
#                  {"workspace": "acme"})["files"]

The endpoint URL must be a public https:// address, adding an endpoint needs an active subscription, and only a workspace Owner's key can add one. Verify every delivery with the steps in Receiving webhooks and answer with a 2xx within 15 seconds. Use the event id to ignore duplicates.

Upload images from URLs and use them in modifications

Fetch product photos into the workspace, then reference the returned asset ids from an image layer. The photo hosts must be on the workspace's domain whitelist.

BASE="https://app.kalpana.one/api"
AUTH="x-api-key: $KALPANA_API_KEY"

# 1. Upload an image from a URL. The response has the asset id.
curl --request POST "$BASE/assets/from-url" --header "$AUTH" \
  --header "content-type: application/json" \
  --data '{"workspaceId":"acme","url":"https://cdn.shopify.com/s/files/blue-shirt.jpg","filename":"blue-shirt.jpg"}'

# 2. Use the asset id in a row ("Hero image" is an image layer of the page)
curl --request POST "$BASE/batches" --header "$AUTH" \
  --header "content-type: application/json" \
  --data '{
    "workspace": "acme",
    "name": "Shirts",
    "pages": ["k7fq2m8a"],
    "rows": [
      { "modifications": { "Hero image": { "asset": "ASSET_ID", "fit": "contain" } } }
    ],
    "run": true
  }'
// Reuses the call helper from the first recipe.
const products = [
	{ name: "Blue shirt", url: "https://cdn.shopify.com/s/files/blue-shirt.jpg" },
	{ name: "Green shirt", url: "https://cdn.shopify.com/s/files/green-shirt.jpg" },
];

// 1. Upload each image and keep its asset id
const rows = [];
for (const product of products) {
	const asset = await call("POST", "/assets/from-url", {
		workspaceId: "acme",
		url: product.url,
		filename: `${product.name}.jpg`,
	});
	rows.push({
		modifications: {
			Headline: product.name,
			"Hero image": { asset: asset.id, fit: "contain" },
		},
	});
}

// 2. Create the batch with the asset ids in the rows
const batch = await call("POST", "/batches", {
	workspace: "acme",
	name: "Shirts",
	pages: ["k7fq2m8a"],
	rows,
	run: true,
});
console.log("Created", batch.id);
# Reuses call() from the first recipe.
products = [
    {"name": "Blue shirt", "url": "https://cdn.shopify.com/s/files/blue-shirt.jpg"},
    {"name": "Green shirt", "url": "https://cdn.shopify.com/s/files/green-shirt.jpg"},
]

# 1. Upload each image and keep its asset id
rows = []
for product in products:
    asset = call("POST", "/assets/from-url", {
        "workspaceId": "acme",
        "url": product["url"],
        "filename": product["name"] + ".jpg",
    })
    rows.append({"modifications": {
        "Headline": product["name"],
        "Hero image": {"asset": asset["id"], "fit": "contain"},
    }})

# 2. Create the batch with the asset ids in the rows
batch = call("POST", "/batches", {
    "workspace": "acme",
    "name": "Shirts",
    "pages": ["k7fq2m8a"],
    "rows": rows,
    "run": True,
})
print("Created", batch["id"])

Follow and download the batch as in the first recipe. If a photo host is not whitelisted, or the file is not public, send the bytes with Upload an asset instead. Uploading the same image twice returns the existing asset, so the loop is safe to repeat. Once an image is an asset, a row can also name it by its file name, such as "Hero image": "blue-shirt.jpg".

Was this page helpful?

Updated 9 Nov 2026

On this page