# Batches (/docs/gateway/batches)

Fill a template or a creative from a CSV file or a sheet — one creative per row, with optional per-row language and render, status export and a ZIP of the videos.

A **batch** turns a table into creatives: each row of a CSV file (or of a sheet creative) fills the variables of a
[template](./creatives.mdx#templates) or of a creative and becomes a new creative of its own. Rows can be translated
into their own language and rendered to MP4. Batches run in the background; you follow them by polling.

Creating a batch is **free**. Translations and renders made for the rows are billed exactly like the same calls made
by hand (see [Billing](#billing)).

| Method | Path | Result |
| --- | --- | --- |
| `POST` | `/v1/studio/batches` | Validate (`dry_run`) or create a batch (`201`) |
| `GET` | `/v1/studio/batches?limit=&before=` | `{ batches, next_before }`, newest first |
| `GET` | `/v1/studio/batches/{id}?rows=&status=&offset=&limit=` | The batch, optionally with its rows |
| `POST` | `/v1/studio/batches/{id}/cancel` | Cancel the rows not started yet |
| `POST` | `/v1/studio/batches/{id}/retry` | Retry the failed rows |
| `GET` | `/v1/studio/batches/{id}/export.csv` | Download the status of every row as CSV |
| `POST` | `/v1/studio/batches/{id}/zip` | Build a ZIP of the finished renders (`202`) |

## Sources and data [#sources-and-data]

The **source** is exactly one of `{ "template_id": "<id>" }` (a template with at least one variable) or
`{ "creative_id": "<id>", "seq"?: <version> }` (a creative with at least one unlocked variable; default the head).
Otherwise the answer is `400 no_vars`.

The **data** is exactly one of:

- `csv` — CSV text with a header row, up to **2 MB**. The parser follows RFC 4180: quoted fields, `""` inside quotes,
  line breaks inside quoted cells, CRLF / LF / CR line endings; a UTF-8 BOM is removed. The delimiter is detected from
  the header (`,`, `;` or tab — a spreadsheet exported with a Romanian locale usually uses `;`) or forced with
  `delimiter`. Header cells are trimmed, short rows are padded with empty cells and empty trailing rows are dropped.
- `sheet` — `{ "creative_id": "<sheet creative>", "node_id"?: "<grid id>" }`: the first (or given) grid or table of a
  sheet, doc or deck creative. The header is the column headers when every column has one, otherwise the first row;
  dates become ISO dates, booleans `true` / `false`, numbers plain text and formula cells are left empty.

**Limits.** At most **500 data rows** and **50 columns**. CSV errors answer `400` with the code: `empty`, `ragged` (a
row longer than the header, with its index), `unterminated_quote`, `too_many_columns`, `too_many_rows`.

## Mapping columns [#mapping-columns]

Each column maps to a **variable**, to a **reserved column** or to nothing. Without a `mapping` the columns are matched
automatically: diacritics are removed, letters lowercased and every other character run becomes `_`, then the column
name is compared with each variable key and label (`Titlu principal` matches the variable `titlu_principal`).

| Reserved column | Also matches | Meaning |
| --- | --- | --- |
| `title` | `titlu` | Title of the row's creative (default: the source title) |
| `locale` | `limba`, `language`, `lang` | Language of the row (BCP-47, e.g. `en`, `de-DE`) — see [Languages](#languages) |
| `format` | `aspect` | Default format of the row's creative (a format id of the source) |

`mapping` overrides the automatic match per column: `{ "Headline EN": "headline", "Notes": null, "Lang": "locale" }`
(`null` ignores the column). A target that is neither a variable nor a reserved column answers `400 invalid_mapping`.

**Cell values.** An empty cell keeps the variable's default. Numbers accept `1.5`, `1,5` and `1 234,5`; booleans
`true` / `false`, `yes` / `no`, `da` / `nu`, `1` / `0` and `x`; colours `#rgb`, `#rrggbb` and `#rrggbbaa`; texts up to
5000 characters.

**Image cells.** An image variable takes one of:

- an **asset id** of one of your ready images;
- an **asset name** — the newest of your ready images whose name or file name matches (case-insensitive);
- an **https URL** — imported into your Studio library when the row runs (public addresses only, every redirect
  re-checked; PNG, JPEG, WebP or GIF up to 10 MB). The same URL is imported once per batch.

## Dry run [#dry-run]

Send `"dry_run": true` to check everything without storing anything (free):

```bash
curl https://ai.codai.ro/v1/studio/batches \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "source": { "template_id": "<template id>" }, "csv": "headline,photo,locale\nAutumn sale,https://example.com/a.jpg,en\nReduceri de toamnă,poza-1,ro\n", "dry_run": true }'
```

```json
{
  "dry_run": true,
  "columns": [{ "name": "headline", "index": 0, "maps_to": "headline", "kind": "var" }],
  "unmapped_vars": [],
  "rows": 2,
  "valid": 2,
  "errors": [],
  "preview": [{ "row": 0, "title": "Promo 9:16", "locale": "en", "format": null, "vars": { "headline": "Autumn sale" } }],
  "estimate": { "renders": 0, "localized_rows": 2 }
}
```

`errors` lists up to 100 problems as `{ "row", "column", "message" }` (an invalid number, an unknown image, a bad
language, a format the source does not have); `preview` shows the first three valid rows; `estimate` counts the renders
and translations the batch would bill. Fix the mapping and try again until it looks right.

## Create and follow a batch [#create-and-follow-a-batch]

The same body without `dry_run` creates the batch and answers **201** `{ "batch": { … } }`. Invalid rows are stored as
`failed` with their error and the rest run; when every row is invalid the answer is `400 invalid_rows` with the
errors.

| Field | Meaning |
| --- | --- |
| `source` | `{ template_id }` or `{ creative_id, seq? }` (required) |
| `csv` / `sheet` | The data, exactly one (required) |
| `delimiter` | `,`, `;` or `\t` (default: detected) |
| `mapping` | Column → variable key, `title`, `locale`, `format` or `null` |
| `title` | Batch title, up to 200 characters |
| `render` | `true` renders every row to MP4 (video sources only, otherwise `400 unsupported_kind`; default `false`) |
| `quality` | `draft` (default) or `final` |
| `format` | Format id of the renders (default: the row's or the source's default format) |
| `localize` | `{ "tone"?, "glossary"? }` used for rows with a language |
| `dry_run` | `true` validates only |

Each row goes through `pending` → `created` → (`localized`) → (`rendering`) → `done`, or ends as `failed` or
`skipped`. `GET /v1/studio/batches/{id}` advances the batch a little on every poll and answers
`{ "batch": { "id", "title", "status", "source", "total", "done", "failed", "pending", "skipped", "options",
"columns", "cost_micro_usd", "zip", "created_at", "updated_at", "finished_at" } }`; with `rows=1` it adds `rows`
(`offset`, `limit` up to 100, optional `status` filter), each
`{ "idx", "status", "creative_id", "render_id", "render_url", "locale", "title", "error", "cost_micro_usd" }`
(`render_url` is a signed link once the render is ready). `columns` is `{ "header", "columns", "unmapped_vars" }`: the
CSV header, the same column mapping as the dry run and the variables no column fills. `cost_micro_usd` is a decimal
string (USD micros). The batch `status` is `queued`, `running`, `completed`,
`failed` or `cancelled`. A batch that is not yours answers `404 Batch not found.`.

**Cancel and retry.** `POST …/cancel` marks the rows not started yet as `skipped` and the batch `cancelled` (`409
not_cancellable` once it is finished). `POST …/retry` puts the failed rows back in the queue — each row is tried at most
3 times (`409 nothing_to_retry` when no row can be retried). Both answer with the batch.

## Languages [#languages]

With a `locale` column, each row's creative is first translated into that language with the model (the same
translation as [Localisation](./creatives.mdx#localisation), with the batch's `localize.tone` and `localize.glossary`)
and then becomes a creative **in that language**, ready to render. Leave the cell empty to keep the source language.

## Export, ZIP and limits [#export-zip-and-limits]

`GET …/export.csv` downloads the original columns plus `creative_id`, `status`, `error` and `render_url` for every row
(`text/csv; charset=utf-8`, `<title>-status.csv`). Cells starting with `=`, `+`, `-`, `@`, a tab or a carriage return
get a leading `'`, so a spreadsheet never runs them as formulas. Free.

`POST …/zip` builds a ZIP of every finished render (`<idx>-<title>.mp4`, at most 500 files) in the background and
answers **202** with the batch (`zip.status: "building"`); poll the batch until `zip.status` is `ready` and download
`zip.url` (signed for 1 hour) — or `failed` with `zip.error`. No finished render answers `409 no_renders`. Free.

| Limit | Value |
| --- | --- |
| Data rows | 500 |
| Columns | 50 |
| CSV size | 2 MB |
| Imported image | 10 MB, PNG / JPEG / WebP / GIF |
| Attempts per row | 3 |
| Files in the ZIP | 500 |

## Billing [#billing]

- Creating, validating, polling, cancelling, retrying, exporting and building the ZIP are **free**.
- Each row with a language is translated and billed like `POST …/localize` (usage rows `codai::localize`).
- Each row render is billed like any render of the same quality, when it has succeeded.
- `cost_micro_usd` sums what the batch and each row cost so far.

## SDKs and agent tools [#sdks-and-agent-tools]

```ts
const check = await codai.batches.dryRun({ source: { template_id: tpl.id }, csv });
if (check.valid === check.rows) {
  const { batch } = await codai.batches.create({ source: { template_id: tpl.id }, csv, render: true });
  await codai.batches.wait(batch.id); // polls every 3 s, up to 10 min
  const statusCsv = await codai.batches.exportCsv(batch.id);
  await codai.batches.zip(batch.id);
}
```

```python
check = client.batches.dry_run(source={"template_id": tpl["id"]}, csv=csv_text)
batch = client.batches.create(source={"template_id": tpl["id"]}, csv=csv_text, render=True)["batch"]
client.batches.wait(batch["id"])  # polls every 3 s, up to 10 min
status_csv = client.batches.export_csv(batch["id"])
```

Agents on desktop, phone and VS Code use `creative_batch` (`template_id` or `creative_id`, `csv` or
`sheet_creative_id`, `mapping`, `render`, `quality`, `format`, `dry_run`) and `creative_batch_status` (`batch_id`,
`rows`).

## Hub (internal) [#hub-internal]

The hub calls the same routes under `/internal/studio/batches…` with a service token and a `user_id`; bodies and errors
are identical.
