diff --git a/README.md b/README.md
index ebd77b7..218d824 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Clone this repository, or open the directory of the example you want. Each examp
- [bulk-csv-videos](examples/bulk-csv-videos) renders one video per row of a CSV from a single template with merge fields, tracked in a resumable manifest, with an optional AI step where Claude writes each row's headline and image prompt. Companion code for [Generate videos in bulk with an API and an AI agent](https://shotstack.io/learn/bulk-create-videos-from-csv-and-ai/).
- [first-render](examples/first-render) the very basics: submit an Edit, poll the render status, and print the output URL, in Node.js and Python. Start here if you are new to the API. Companion code for [Render your first video with the Shotstack API](https://shotstack.io/learn/render-your-first-video-shotstack-api/).
- [instagram-ai-video](examples/instagram-ai-video) generates a script, voiceover and background image with AI, renders a 1080x1920 video, and publishes it as an Instagram Reel. Companion code for [How to automate Instagram posts with AI video](https://shotstack.io/learn/automate-instagram-posts-with-ai-video/).
+- [multi-client-video-automation](examples/multi-client-video-automation) renders branded promo videos for three clients in three aspect ratios from one master template, and records which render belongs to which client. Companion code for [A guide to automating video content production for multiple clients](https://shotstack.io/learn/automating-video-production-multiple-clients/).
- [rapidreels](examples/rapidreels) creates faceless short-form videos using generative AI. [View demo](https://shotstack.io/demos/social-media-video-maker/).
- [reelestate](examples/reelestate) turns static real estate images into fully edited video slideshows. [View demo](https://shotstack.io/demos/real-estate-video-listing-maker/).
diff --git a/examples/multi-client-video-automation/.env.example b/examples/multi-client-video-automation/.env.example
new file mode 100644
index 0000000..5e8bfd2
--- /dev/null
+++ b/examples/multi-client-video-automation/.env.example
@@ -0,0 +1,2 @@
+# https://dashboard.shotstack.io/register
+SHOTSTACK_API_KEY=
diff --git a/examples/multi-client-video-automation/.gitignore b/examples/multi-client-video-automation/.gitignore
new file mode 100644
index 0000000..09c29c2
--- /dev/null
+++ b/examples/multi-client-video-automation/.gitignore
@@ -0,0 +1,2 @@
+.env
+renders.jsonl
diff --git a/examples/multi-client-video-automation/README.md b/examples/multi-client-video-automation/README.md
new file mode 100644
index 0000000..4bd445d
--- /dev/null
+++ b/examples/multi-client-video-automation/README.md
@@ -0,0 +1,84 @@
+# Multi-client video automation
+
+One master template renders branded promo videos for three fictional clients, in three aspect
+ratios each: nine videos from one loop. Each client is a record with their own headline, font,
+footage, music and brand mark. A JSON-lines file records which render belongs to which client,
+because the API has no endpoint that lists renders.
+
+Companion code for [A guide to automating video content production for multiple clients](https://shotstack.io/learn/automating-video-production-multiple-clients/).
+
+## Requirements
+
+- A [Shotstack account](https://dashboard.shotstack.io/register) and your **sandbox** API key
+ (dashboard menu under your account name, top right, under **API Keys**)
+- Node.js 20 or later
+
+Sandbox renders are watermarked, and your account needs at least one credit to use the environment.
+
+## Setup
+
+```bash
+git clone https://github.com/shotstack/shotstack-cookbook.git
+cd shotstack-cookbook/examples/multi-client-video-automation
+```
+
+Copy the environment file. Add your sandbox key to `.env`.
+
+```bash
+cp .env.example .env
+```
+
+Load the file into your shell. Do this in each new terminal:
+
+```bash
+set -a
+source .env
+set +a
+```
+
+Create the template and keep the returned template id:
+
+```bash
+curl --fail-with-body \
+ --request POST \
+ "https://api.shotstack.io/edit/stage/templates" \
+ --header "Accept: application/json" \
+ --header "Content-Type: application/json" \
+ --header "x-api-key: ${SHOTSTACK_API_KEY}" \
+ --data-binary @template.json
+```
+
+```bash
+export SHOTSTACK_TEMPLATE_ID="your_template_id"
+```
+
+## Run
+
+Submit all nine renders:
+
+```bash
+node render.mjs
+```
+
+Then check them:
+
+```bash
+node status.mjs
+```
+
+Run `status.mjs` again until every render shows `done`.
+
+## What happens
+
+`render.mjs` expands each client record into merge fields, submits one template render per client
+and aspect ratio (nine in total), and appends one line per render to `renders.jsonl` with the
+render id, client and variant. It ends with the count of submitted renders.
+
+`status.mjs` reads `renders.jsonl` and checks each render once. A `done` render prints its video
+URL. A sandbox render finishes in under a minute.
+
+`renders.jsonl` only appends. Delete the file to start a new batch.
+
+The scripts render in the sandbox by default. To render in production, set `SHOTSTACK_ENV=v1` and
+put your production key in `.env`. Re-create the template with that key. Templates belong to the
+environment they were created in, and the ids differ.
diff --git a/examples/multi-client-video-automation/clients.mjs b/examples/multi-client-video-automation/clients.mjs
new file mode 100644
index 0000000..bb27f42
--- /dev/null
+++ b/examples/multi-client-video-automation/clients.mjs
@@ -0,0 +1,67 @@
+const TEMPLATE_V1 = process.env.SHOTSTACK_TEMPLATE_ID;
+
+export const clients = {
+ 'meridian-realty': {
+ name: 'Meridian Realty',
+ templateId: TEMPLATE_V1,
+ headline: 'Twelve new listings this week.',
+ font: 'Montserrat',
+ footage:
+ 'https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qxa25-864e6-zty3t-3sttne/source.mp4',
+ music:
+ 'https://s3-ap-southeast-2.amazonaws.com/shotstack-assets/music/moment.mp3',
+ brandMark:
+ ''
+ },
+
+ 'driftwood-retreats': {
+ name: 'Driftwood Retreats',
+ templateId: TEMPLATE_V1,
+ headline: 'Off-season rates end Sunday.',
+ font: 'Open Sans',
+ footage:
+ 'https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qxkz1-xz4yp-pfbj3-14r3s3/source.mp4',
+ music:
+ 'https://s3-ap-southeast-2.amazonaws.com/shotstack-assets/music/spirit.mp3',
+ brandMark:
+ ''
+ },
+
+ 'apex-skate': {
+ name: 'Apex Skate Co.',
+ templateId: TEMPLATE_V1,
+ headline: 'New deck drop. Friday.',
+ font: 'Permanent Marker',
+ footage:
+ 'https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/35tqpmb0ya/zzz01m08-qy60n-yb4w2-ks55h-tej73h/source.mp4',
+ music:
+ 'https://shotstack-assets.s3-ap-southeast-2.amazonaws.com/music/unminus/lit.mp3',
+ brandMark:
+ ''
+ }
+};
+
+/**
+ * Aspect ratio variants.
+ *
+ * Explicit width/height rather than output.aspectRatio: numeric fields accept
+ * "{{ PLACEHOLDER }}" strings, but aspectRatio is an enum and may reject one.
+ */
+export const variants = [
+ { name: '16x9', width: 1920, height: 1080 },
+ { name: '9x16', width: 1080, height: 1920 },
+ { name: '1x1', width: 1080, height: 1080 }
+];
+
+/** Expand a client + variant into the merge array the render endpoint expects. */
+export function mergeFieldsFor(client, variant) {
+ return [
+ { find: 'HEADLINE', replace: client.headline },
+ { find: 'FONT', replace: client.font },
+ { find: 'BRAND_MARK', replace: client.brandMark },
+ { find: 'FOOTAGE', replace: client.footage },
+ { find: 'MUSIC', replace: client.music },
+ { find: 'WIDTH', replace: variant.width },
+ { find: 'HEIGHT', replace: variant.height }
+ ];
+}
diff --git a/examples/multi-client-video-automation/db.mjs b/examples/multi-client-video-automation/db.mjs
new file mode 100644
index 0000000..7a6d820
--- /dev/null
+++ b/examples/multi-client-video-automation/db.mjs
@@ -0,0 +1,28 @@
+import { appendFile, readFile } from 'node:fs/promises';
+
+const FILE = new URL('./renders.jsonl', import.meta.url);
+
+export const db = {
+ renders: {
+ // One JSON object per line, appended. Nine renders submit concurrently, so
+ // reading the whole file, pushing a row and writing it back would lose
+ // rows: two writers read the same state and the second overwrites the
+ // first. Appends don't interleave.
+ async insert(row) {
+ await appendFile(FILE, JSON.stringify(row) + '\n');
+ },
+
+ async all() {
+ try {
+ const text = await readFile(FILE, 'utf8');
+ return text
+ .trim()
+ .split('\n')
+ .filter(Boolean)
+ .map(line => JSON.parse(line));
+ } catch {
+ return [];
+ }
+ }
+ }
+};
diff --git a/examples/multi-client-video-automation/render.mjs b/examples/multi-client-video-automation/render.mjs
new file mode 100644
index 0000000..34fc73b
--- /dev/null
+++ b/examples/multi-client-video-automation/render.mjs
@@ -0,0 +1,88 @@
+import { clients, variants, mergeFieldsFor } from './clients.mjs';
+import { db } from './db.mjs';
+
+if (!process.env.SHOTSTACK_API_KEY || !process.env.SHOTSTACK_TEMPLATE_ID) {
+ console.error(
+ 'Set SHOTSTACK_API_KEY and SHOTSTACK_TEMPLATE_ID before rendering.'
+ );
+ process.exit(1);
+}
+
+const ENV = process.env.SHOTSTACK_ENV ?? 'stage';
+if (!['stage', 'v1'].includes(ENV)) {
+ console.error('SHOTSTACK_ENV must be stage or v1.');
+ process.exit(1);
+}
+const API = `https://api.shotstack.io/edit/${ENV}`;
+
+/** Reduce an API error response to one line the user can act on. */
+async function apiError(res) {
+ const text = await res.text();
+ try {
+ const body = JSON.parse(text);
+ return (
+ body.errors?.[0]?.detail ?? body.response?.error ?? body.message ?? text
+ );
+ } catch {
+ return text;
+ }
+}
+
+async function renderVariant(clientId, client, variant) {
+ const res = await fetch(`${API}/templates/render`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-key': process.env.SHOTSTACK_API_KEY
+ },
+ body: JSON.stringify({
+ id: client.templateId,
+ merge: mergeFieldsFor(client, variant)
+ })
+ });
+
+ if (!res.ok)
+ throw new Error(
+ `${clientId}/${variant.name}: ${res.status} ${await apiError(res)}`
+ );
+
+ const { response } = await res.json();
+
+ await db.renders.insert({
+ renderId: response.id,
+ clientId,
+ variant: variant.name,
+ submittedAt: new Date().toISOString()
+ });
+
+ return response.id;
+}
+
+async function renderAll(concurrency = 10) {
+ // Every client × every variant, flattened into one work queue.
+ const jobs = Object.entries(clients).flatMap(([id, client]) =>
+ variants.map(variant => ({ id, client, variant }))
+ );
+
+ const results = [];
+
+ for (let i = 0; i < jobs.length; i += concurrency) {
+ const batch = jobs.slice(i, i + concurrency);
+ results.push(
+ ...(await Promise.allSettled(
+ batch.map(j => renderVariant(j.id, j.client, j.variant))
+ ))
+ );
+ }
+
+ return results;
+}
+
+const results = await renderAll();
+
+const rejected = results.filter(r => r.status === 'rejected');
+for (const r of rejected) console.error(r.reason.message);
+
+console.log(`${results.length - rejected.length}/${results.length} submitted`);
+
+if (rejected.length > 0) process.exitCode = 1;
diff --git a/examples/multi-client-video-automation/status.mjs b/examples/multi-client-video-automation/status.mjs
new file mode 100644
index 0000000..383e6f5
--- /dev/null
+++ b/examples/multi-client-video-automation/status.mjs
@@ -0,0 +1,42 @@
+import { db } from './db.mjs';
+
+if (!process.env.SHOTSTACK_API_KEY) {
+ console.error('Set SHOTSTACK_API_KEY before checking render status.');
+ process.exit(1);
+}
+
+const ENV = process.env.SHOTSTACK_ENV ?? 'stage';
+if (!['stage', 'v1'].includes(ENV)) {
+ console.error('SHOTSTACK_ENV must be stage or v1.');
+ process.exit(1);
+}
+const API = `https://api.shotstack.io/edit/${ENV}`;
+
+for (const row of await db.renders.all()) {
+ let res;
+ try {
+ res = await fetch(`${API}/render/${row.renderId}`, {
+ headers: { 'x-api-key': process.env.SHOTSTACK_API_KEY }
+ });
+ } catch {
+ console.error(
+ `${row.clientId} ${row.variant ?? ''}: the network request failed. Check your connection and run again.`
+ );
+ process.exitCode = 1;
+ continue;
+ }
+
+ if (!res.ok) {
+ console.error(
+ `${row.clientId} ${row.variant ?? ''}: status check failed (${res.status}). Check SHOTSTACK_API_KEY.`
+ );
+ process.exitCode = 1;
+ continue;
+ }
+
+ const { response } = await res.json();
+ console.log(`${row.clientId} ${row.variant ?? ''} → ${response.status}`);
+
+ if (response.status === 'done') console.log(` ${response.url}`);
+ if (response.status === 'failed') console.log(` error: ${response.error}`);
+}
diff --git a/examples/multi-client-video-automation/template.json b/examples/multi-client-video-automation/template.json
new file mode 100644
index 0000000..f006d0f
--- /dev/null
+++ b/examples/multi-client-video-automation/template.json
@@ -0,0 +1,76 @@
+{
+ "name": "Client promo v1",
+ "template": {
+ "timeline": {
+ "background": "#000000",
+ "tracks": [
+ {
+ "clips": [
+ {
+ "asset": {
+ "type": "rich-text",
+ "text": "{{ HEADLINE }}",
+ "font": {
+ "family": "{{ FONT }}",
+ "size": 48,
+ "weight": 700,
+ "color": "#ffffff"
+ },
+ "stroke": { "width": 3, "color": "#000000" },
+ "align": { "horizontal": "center", "vertical": "middle" },
+ "animation": { "preset": "fadeIn", "duration": 1 }
+ },
+ "start": 0.5,
+ "length": 4.5,
+ "width": 1000,
+ "height": 260
+ }
+ ]
+ },
+ {
+ "clips": [
+ {
+ "asset": { "type": "svg", "src": "{{ BRAND_MARK }}" },
+ "start": 0.5,
+ "length": 4.5,
+ "width": 120,
+ "height": 120,
+ "fit": "contain",
+ "position": "topLeft",
+ "offset": { "x": 0.06, "y": -0.06 }
+ }
+ ]
+ },
+ {
+ "clips": [
+ {
+ "asset": { "type": "video", "src": "{{ FOOTAGE }}" },
+ "start": 0,
+ "length": 5,
+ "fit": "crop",
+ "effect": "zoomIn"
+ }
+ ]
+ },
+ {
+ "clips": [
+ {
+ "asset": {
+ "type": "audio",
+ "src": "{{ MUSIC }}",
+ "volume": 0.35,
+ "effect": "fadeOut"
+ },
+ "start": 0,
+ "length": "end"
+ }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "format": "mp4",
+ "size": { "width": "{{ WIDTH }}", "height": "{{ HEIGHT }}" }
+ }
+ }
+}