MostlyRender

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

bash
curl https://api.mostlyrender.com/v1/templates/tpl_badge/params \
  -H "Authorization: Bearer mr_live_…"
json
{
  "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

bash
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.

bash
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" }
  ] }'
json
{
  "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:

json
{ "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 skipped and 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 pass skip_invalid: true, which renders the valid rows and reports the rest.
  • Idempotency. Send an Idempotency-Key header. A retry with the same key returns the same job; the same key with a different body is 409 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):

bash
# 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

bash
curl "https://api.mostlyrender.com/v1/generators/gen_x1y2z3/items?status=failed&limit=100" \
  -H "Authorization: Bearer mr_live_…"
json
{ "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|jsonl returns { 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-ready until the job has finished. GET /v1/generators/:gid/manifest is 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 needs pdf in outputs). The finished job’s files then holds zip and merged_pdf; very large jobs split into zip_parts / merged_pdf_parts, items left out are listed in zip_skipped, and a refused aggregate explains itself in zip_error / merged_pdf_error. files also carries manifest_csv, manifest_jsonl and manifest_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):

json
{ "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 with 409 mostlyqr-consent-required, which carries the terms_url and terms_version to 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_available on GET /v1/generators/:gid). POST /v1/generators/:gid/adopt-style pins the new style and prices the re-render (dry_run: true only 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:

js
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
python
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.