Generators
A generator turns one template plus a list of rows into one render per row: name badges for an event, certificates for a cohort, a product card per SKU. You describe the rows (inline JSON or a CSV), MostlyRender renders what changed, and you read back one item per row with its output URLs.
Use batch for up to 50 one-off renders in a single response. Use a generator when you have more rows than that, when rows change over time (and only the changed ones should re-render), when you need a per-row QR code, or when you want a manifest, a zip or a merged PDF at the end.
# Concepts
| Term | What it is |
|---|---|
| Template params | The typed fields a template accepts (name, title, photo, qr…), declared by the template or implied by its layer names. See Templates as parameters. |
| Version | Publishing a template snapshots an immutable version. A generator pins one, so editing the template never changes already-rendered items by surprise. |
| Generator | A template + a pinned version + a mapping from your rows to the template params + the outputs to render per row (png, jpeg, webp, pdf). |
| Row key | The identity of a row (a guest id, an email, a SKU). Sending the same key again updates that row instead of adding one. |
| Job | One submission of rows. It reserves its renders up front, runs in the background and ends done, done_with_errors, failed or cancelled. Never a silent success. |
| Item | The state of one row: its params, status, output URLs, QR code and error. |
| Dataset | An uploaded CSV (or a zip of images) that a job reads instead of inline rows. |
# 1. Look at the template’s params
curl https://api.mostlyrender.com/v1/templates/tpl_badge/params \
-H "Authorization: Bearer mr_live_…"
{
"template_id": "tpl_badge", "version": 3, "declared": true, "engine": "layers",
"params": [
{ "key": "name", "type": "text", "required": true },
{ "key": "role", "type": "text", "default": "Guest" },
{ "key": "photo", "type": "image" },
{ "key": "qr", "type": "qr", "mode": "dynamic" }
]
}
?version= picks a published version (default: latest). A template that never declared
params still works: its params are its layer names and {{vars}}.
The template has to be published first (POST /v1/templates/:id/publish, or “Publish” in
the editor). Creating a generator from a never-published template is refused with
template-not-published.
# 2. Create the generator
curl https://api.mostlyrender.com/v1/generators \
-H "Authorization: Bearer mr_live_…" -H "Content-Type: application/json" \
-d '{
"template_id": "tpl_badge",
"name": "Conference badges",
"mapping": { "name": "$.Full Name", "role": { "tpl": "{{Role}} at {{Company}}" }, "tier": { "const": "guest" } },
"row_key": "$.Guest ID",
"outputs": ["png", "pdf"]
}'
Mapping turns your columns into template params. Each value is an expression:
"$.Column"reads a column (matched case-insensitively);{ "tpl": "{{First}} {{Last}}" }builds a string from several columns;{ "const": "guest" }is a fixed value.
A param you leave out of the mapping reads the column with the same name. Row values are checked against the param types strictly: an unknown key, an object where text is expected or a missing required param is an error on that row, not a blank render.
Row key is an expression too ("$.Guest ID"). Without one, the generator uses a
row_key column if the row has one, otherwise a hash of the mapped params (so re-sending an
identical row is a no-op).
Change a generator with PATCH /v1/generators/:gid (name, mapping, row key, outputs,
status: "paused", webhook_url, or the pinned version). Moving the version does not
re-render anything by itself: submit a job with rerender: true (dry-run it first for the
cost). DELETE /v1/generators/:gid archives it: running jobs are cancelled and nothing is
hard-deleted.
Motion templates are not supported in generators yet (motion-generator-unsupported).
# 3. Dry run, then submit rows
Always dry-run first. A dry run validates every row and tells you what the real job would do. It renders nothing, mints nothing and charges nothing.
curl https://api.mostlyrender.com/v1/generators/gen_x1y2z3/jobs \
-H "Authorization: Bearer mr_live_…" -H "Content-Type: application/json" \
-d '{ "dry_run": true, "rows": [
{ "Guest ID": "g-1", "Full Name": "Ada Lovelace", "Role": "Speaker", "Company": "Analytical Engines" },
{ "Guest ID": "g-2", "Role": "Press" }
] }'
{
"dry_run": true, "ok": false, "rows": 2, "valid": 1, "invalid": 1,
"to_render": 1, "to_skip": 0, "codes_to_mint": 1,
"errors": [{ "index": 1, "row_key": "g-2", "key": "name", "code": "param-required", "message": "name is required" }],
"preview": [{ "row_key": "g-1", "params": { "name": "Ada Lovelace", "role": "Speaker at Analytical Engines" } }],
"cost": { "units": 2 }
}
Fix the errors, then send the same body without dry_run. The job is accepted with 202:
{ "id": "j7Hk2Qm9aB", "generator_id": "gen_x1y2z3", "status": "queued",
"counts": { "total": 1, "queued": 1, "done": 0, "failed": 0, "skipped": 0, "cancelled": 0 },
"units_reserved": 2, "codes_to_mint": 1 }
- Upsert. Rows are matched by row key. A row whose params and template version are
unchanged is
skippedand costs nothing, so re-sending the whole list nightly only renders what changed. - Invalid rows refuse the whole job (
400 rows-invalid, with per-row errors and line numbers) unless you passskip_invalid: true, which renders the valid rows and reports the rest. - Idempotency. Send an
Idempotency-Keyheader. A retry with the same key returns the same job; the same key with a different body is409 idempotency-conflict. - Cost up front. A job reserves rows × outputs renders when it is created. A Free plan
short of renders is refused before anything runs with the exact shortfall
(
402 quota-insufficient: “needs 120 renders but 100 left, 20 short”). Paid plans use the allowance, then the wallet. Failed, skipped and cancelled items release their reservation. - Limits. Up to 1,000 inline rows per call, 10,000 rows per job (via CSV) and 100,000 items per generator.
# CSV datasets
For more than 1,000 rows, upload a CSV (up to 20 MB, 10,000 rows):
# 1. ask for an upload URL
curl https://api.mostlyrender.com/v1/datasets -H "Authorization: Bearer mr_live_…" \
-H "Content-Type: application/json" -d '{ "kind": "csv", "size": 48213, "name": "guests.csv" }'
# 2. PUT the file to upload_url (no API key on that request)
# 3. parse it
curl -X POST https://api.mostlyrender.com/v1/datasets/ds_123/finalize -H "Authorization: Bearer mr_live_…"
# 4. submit a job that reads it
curl https://api.mostlyrender.com/v1/generators/gen_x1y2z3/jobs -H "Authorization: Bearer mr_live_…" \
-H "Content-Type: application/json" -d '{ "dataset_id": "ds_123", "dry_run": true }'
Finalize detects the encoding (UTF-8, UTF-16, Windows-1252) and the delimiter, handles quoted
newlines and keeps line numbers for error reports. Image params can also come from a zip: upload
it as kind: "zip" and pass it as images_dataset_id; the CSV cells name the files. Raw
datasets contain personal data, so they are deleted 30 days after upload.
The SDKs do steps 1 to 4 in one call (submitDataset).
# 4. Follow the job
GET /v1/generators/:gid/jobs/:jid returns progress and counts. Poll it with backoff (the SDKs’
waitForJob does), or set a webhook_url on the generator and let MostlyRender call you.
| Job status | Meaning |
|---|---|
queued, running, finalizing |
In progress (progress is 0 to 1). |
done |
Every item rendered (or was skipped as unchanged). |
done_with_errors |
Some items failed. List them with GET …/items?status=failed, fix the rows, send them again. |
failed |
The job itself failed. |
cancelling, cancelled |
POST …/jobs/:jid/cancel stops dispatching; unrendered items are cancelled and not billed. |
POST …/jobs/:jid/retry re-queues only the failed items (renders are re-reserved first).
# 5. Read the items
curl "https://api.mostlyrender.com/v1/generators/gen_x1y2z3/items?status=failed&limit=100" \
-H "Authorization: Bearer mr_live_…"
{ "items": [{
"row_key": "g-1", "status": "done",
"outputs": { "png": { "url": "https://img.mostlyrender.com/mostlyrender/renders/gen_x1y2z3/ab12/3-9f2c.png" } },
"code": { "serial": "7K2M9XQ4PA3", "short_url": "https://mqr.sh/7K2M9XQ4PA3", "batch_id": "bq_1" },
"error": null
}],
"next_cursor": null }
Pass next_cursor back as cursor for the next page (up to 200 per page; the SDKs iterate for
you). One row: GET /v1/generators/:gid/items/:row_key. Update one row with
PATCH ({ "row": {…} } through the mapping, or { "params": {…} }; it re-renders only if the
params changed and keeps its QR code). DELETE removes the row and retires its QR code.
Output URLs are content-addressed (…/{version}-{params_hash}.png), so a URL never changes
under you: a re-render of changed params gets a new URL.
# Outputs per job
When a job finishes, its files hold signed, time-limited download links:
- Manifest.
GET …/jobs/:jid/manifest?format=csv|jsonlreturns{ format, url, rows, columns, expires_in }: one row per item in submission order (row_key,line,status,error_code,error_message,serial,short_url,card_url, one URL column per output format). CSV cells starting with= + - @are prefixed with'so a spreadsheet never runs them as formulas.409 manifest-not-readyuntil the job has finished.GET /v1/generators/:gid/manifestis the same for the whole generator (“latest”). - Zip and merged PDF. Turn them on with
aggregate: { "zip": true, "merged_pdf": true }when creating the generator (a merged PDF needspdfinoutputs). The finished job’sfilesthen holdszipandmerged_pdf; very large jobs split intozip_parts/merged_pdf_parts, items left out are listed inzip_skipped, and a refused aggregate explains itself inzip_error/merged_pdf_error.filesalso carriesmanifest_csv,manifest_jsonlandmanifest_rows, and is absent until the outputs are ready.
# Webhooks
Set webhook_url on the generator. Deliveries are HMAC-signed
(x-mostlyrender-signature, see Async & webhooks) and every event has a stable
id to dedupe on:
| Event | When |
|---|---|
job.completed |
A job ended done or done_with_errors (the status field says which). Includes counts and manifest links. |
job.failed |
A job ended failed or cancelled (status says which). |
items.failed |
Batched report of failed items: up to 100 row_keys plus failed_count and truncated, at most one per minute per job, so a bad batch never causes a webhook storm. |
Event ids look like evt_<hash> and are stable across retries: dedupe on them.
# Dynamic QR codes with Batch QR
Add a qr_policy to the generator (the template needs a qr param):
{ "qr_policy": { "param": "qr", "mode": "dynamic",
"batch": { "name": "Guest cards",
"rule": { "kind": "pattern", "pattern": "https://event.example/guest/{{guest_id}}", "fields": ["guest_id"] } } },
"mostlyqr_consent": { "terms_version": "2026-10", "accepted_at": "2026-10-07T10:00:00Z" } }
mode is dynamic (one new code per item); batch.id attaches one of your existing
batches instead (list them with GET /v1/mostlyqr/batches); batch.rule.kind is same,
pattern or page.
A template with a qr param in dynamic mode gets one MostlyQR code per row. The
generator creates one Batch QR in your MostlyQR workspace (named after the generator) and
mints one member per item. Each member is a non-guessable serial (mqr.sh/<11 characters>
with a check character), so a printed badge keeps working, can be repointed later, and shows
scan analytics per code and per batch. The item stores { serial, short_url, batch_id }.
- You decide what the codes open in MostlyQR: one shared URL, a URL built from row
fields (
https://event.com/guest/{{guest_id}}), or a profile page per member. Changing the rule repoints every printed code instantly, with no re-render. - Data minimisation. The generator’s QR policy names the only row fields sent to MostlyQR. The rest of the row stays in MostlyRender.
- Consent. Minting codes into your MostlyQR workspace needs you to accept the MostlyQR
Terms once: send
mostlyqr_consent: { terms_version, accepted_at }when you create or update the generator. Without it the call is refused with409 mostlyqr-consent-required, which carries theterms_urlandterms_versionto show. - Style. MostlyQR owns the look of the code (dots, eyes, colours, logo); MostlyRender owns
placement, size and everything around it. Rendered files never change silently when the
style changes: a generator stays pinned to its style version (
style_update_availableonGET /v1/generators/:gid).POST /v1/generators/:gid/adopt-stylepins the new style and prices the re-render (dry_run: trueonly prices it); re-render with a job{ "rerender": true }. - Lifecycle. Archiving the generator archives its batch (codes redirect to the batch’s archive page, never deleted). Deleting an item retires its code.
# Pricing
Batch QR members are billed per active member per month from your wallet:
$0.029 per active member per month, and the first 100 members are free. Renders are
priced separately (the normal render price). A dry run reports codes_to_mint so you see the
number of codes before you commit.
Codes minted by a job are billed from the shared wallet; when the wallet cannot cover them the
call is refused with 402 wallet-insufficient (with a top_up_url). If your workspace requires
approval for a batch change, PATCH /v1/generators/:gid answers 202 with
code: "batch-pending-approval" and a request_id: the change applies once approved. A batch
that has reached its member or override cap answers 429 override-cap.
# Limits
| What | Limit |
|---|---|
Inline rows per POST …/jobs |
1,000 |
| Rows per job | 10,000 |
| Items per generator | 100,000 |
| CSV upload | 20 MB, 10,000 rows |
| Output formats per generator | 4 |
| Items per page | 200 |
| Raw dataset retention | 30 days |
| Concurrent chunks per account | by plan (Free 1, Starter 2, Pro 4, Scale 10) |
# Error codes
Errors keep the standard { "error": "…" } shape and add a stable code (and, for row
problems, an errors array of { index, line, row_key, key, code, message }).
| Status | code |
Meaning |
|---|---|---|
| 400 | rows-invalid |
One or more rows failed validation (see errors); use skip_invalid to render the valid ones. |
| 400 | param-required |
A required template param is missing in a row. |
| 400 | mapping-target-unknown |
The mapping names a key that is not a param of this template. |
| 402 | quota-insufficient |
A hard-capped plan does not have enough renders for the job (exact shortfall in the message). |
| 402 | wallet-insufficient |
The wallet cannot cover the minted QR codes (top_up_url). |
| 404 | generator-not-found, job-not-found, item-not-found, dataset-not-found |
Unknown id. |
| 409 | template-not-published |
Publish the template first. |
| 409 | generator-archived |
The generator is archived. |
| 409 | idempotency-conflict |
The Idempotency-Key was already used for a different body. |
| 409 | job-not-retryable |
The job has no failed items to retry. |
| 409 | manifest-not-ready |
The job has not finished yet. |
| 409 | upload-missing |
Finalize was called before the file was uploaded. |
| 413 | rows-too-many, dataset-too-large |
Over the row or file limit. |
| 422 | motion-generator-unsupported |
Motion templates cannot be used in generators yet. |
| 409 | mostlyqr-consent-required |
The MostlyQR Terms have not been accepted yet (terms_url, terms_version). |
| 202 | batch-pending-approval |
Saved, but the Batch QR change waits for workspace approval (request_id). Not an error. |
| 429 | override-cap |
The batch has reached its member / override cap. |
| 409 | no-batch |
adopt-style on a generator that has no Batch QR yet. |
# From code
The Node, Python and Go SDKs, the MCP server, Zapier and n8n all expose the same flow:
const gen = await mr.createGenerator({
templateId: 'tpl_badge', name: 'Conference badges',
mapping: { name: '$.Full Name' }, rowKey: '$.Guest ID', outputs: ['png'],
});
// 1. dry run: validates, prices, changes nothing
const report = await mr.addRows(gen.id, rows, { dryRun: true });
if (!report.ok) console.log(report.errors);
// 2. for real; the key makes a retry return the same job
const job = await mr.addRows(gen.id, rows, { idempotencyKey: 'import-2026-10-07' });
const done = await mr.waitForJob(gen.id, job.id);
// 3. read the results
for await (const item of mr.listItems(gen.id, { status: 'failed' })) console.log(item.row_key, item.error);
const { url } = await mr.getManifest(gen.id, job.id, { format: 'csv' }); // signed download
job = mr.add_rows(gen["id"], rows, idempotency_key="import-2026-10-07")
done = mr.wait_for_job(gen["id"], job["id"])
for item in mr.list_items(gen["id"], status="failed"):
print(item["row_key"], item["error"])
For a CSV: await mr.submitDataset(gen.id, csvText, { dryRun: true }) (Node),
mr.submit_dataset(gen["id"], csv_text, dry_run=True) (Python), c.SubmitDatasetDryRun(ctx, id, csv, "guests.csv", mr.AddRowsOptions{}) (Go).
SDK errors carry the API’s code (e.code) and per-row errors.
AI agents: the MCP server has mostlyrender_create_generator,
mostlyrender_add_rows (dry run first), mostlyrender_get_job and mostlyrender_list_items
(plus item, manifest and Batch QR tools generated from the same spec).
In Zapier, Add Row to Generator (one field per template param) upserts a row by key, and
Find Generator Item and Generator Job Finished read the results back. n8n has the same
operations on the MostlyRender node.