Templates & inputs
A template is a frozen composition plus a manifest of what may vary. It is the only thing the API renders.
Authoring
Expose a field
In the editor’s Template fields panel, pick a layer and expose the property that changes. Each exposed field gets an id, a label and a kind.
Check the ids
The id is the key your automation sends. The editor slugs it from the layer name and it must satisfy:
/^[a-z][a-zA-Z0-9]{0,63}$/Starts with a lowercase letter; letters and digits only; at most 64 characters.
No underscores, dashes or dots — those look like internal ids and invite
someone to paste a layer UUID by mistake. Background Video becomes
backgroundVideo; a collision becomes background2.
Publish
Save as Animation Template. Publishing refuses if you are signed out, if no field is exposed, or if any id fails the rule above — the error names the offending ids.
Input kinds
| Kind | JSON type | Written to | Required by default |
|---|---|---|---|
text | string | Text layer content | no |
color | string | A colour property | no |
number | number | A numeric property | no |
image | string (URL) | An image src | yes |
media | string (URL) | A video or image src | yes |
image and media default to required because a template rendered with a
missing picture is not a template rendered wrong — it is a blank frame. A field
can override this explicitly with required.
Types are enforced. A number field rejects a numeric string; every other
kind rejects a non-string. image and media additionally require a public
http(s) URL. Invalid values come back as 400 with a per-field list —
nothing is rendered.
URL rules for image and media
Asset URLs are fetched server-side, so they go through an SSRF gate. Rejected:
- Anything that is not
http:orhttps: localhost,127.0.0.1, private IPv4 ranges, link-local addresses.localhost,.localand.internalhostnames- Raw IPv6 literals
- Cloud metadata hosts
The check is applied to the URL and to what DNS answers, and again across redirects — a hostname that resolves to a private address is refused even though it looks public.
Assets are downloaded into temporary storage before the render, counted against
your asset-processing quota, and capped at the smaller of your plan’s
maxUploadBytes and 80 MB.
Only fields whose kind is image or media are ingested. A text field
whose value happens to be a URL is left alone — it is a caption, not an
asset.
Every media layer needs a durable URL
This is the trap that catches a first template, and it is worth understanding before you publish one.
A media layer stores two things: an assetId naming the library entry, and a
src. For footage you imported from your own disk, that src is a blob:
URL — it belongs to the editor session that created it and means nothing
anywhere else. User library imports are deliberately never uploaded to the
cloud: they can be gigabytes of raw footage, so they live in local storage and
are re-bound to a fresh object URL every time you open the project.
Publishing captures the document exactly as it stands, blob: srcs included.
Publishing refuses a document whose media cannot be fetched. You get a
400 unrenderable_assets naming each layer and why:
{
"code": "unrenderable_assets",
"layers": [
{
"layer": "Hero Image",
"reason": "its footage was imported from local storage and was never uploaded, so only this machine can resolve it"
}
]
}This used to be silent. Publishing succeeded, the render job reported
completed, and the layer was simply missing from the MP4 — the renderer
fetched the src, got nothing, and carried on. If you have a template
published before this check existed, re-publish it to find out.
So every media layer in a template must be one of:
Exposed as an image or media input | Your automation supplies a public URL per render — the intended case |
Pointed at a public http(s) URL | A fixed background or logo hosted on your own CDN |
A cloud asset (/files/…) | Uploaded through the editor rather than imported locally |
If a layer is meant to stay the same on every render, it still needs a real URL — “fixed” is not the same as “local”.
Checking before you rely on it
Render the template once with real inputs and watch the output. A missing background or a blank picture box is the symptom; the fix is to re-host that asset and republish.
Automating a project you already have
POST /v1/templates/import takes a .motion project and gives you back a
template your workflow can drive — no editor, no field authoring.
curl -X POST $BASE/v1/templates/import -H "Authorization: Bearer $PREMATION_API_KEY" -F "file=@MyProject.motion.zip" -F "name=Weekly promo"{
"id": "tpl_7f8a9b",
"name": "Weekly promo",
"width": 1080, "height": 1920, "fps": 30, "durationSeconds": 6,
"inputs": [
{ "id": "backgroundVideo", "label": "Background Video", "kind": "media",
"required": false, "default": "https://cdn.example.com/bg.mp4" },
{ "id": "heroImage", "label": "Hero Image", "kind": "image", "required": true }
],
"requiredInputs": ["heroImage"],
"warnings": [
"\"Hero Image\" has no usable source in the project, so it is a required input — every render must supply a URL for it."
]
}That is the whole setup. From here it is
POST /v1/renders with { templateId, inputs: { heroImage: "https://…" } }.
Zip the bundle
A .motion project is a directory, and HTTP moves files — so send it as a
zip. Compressing the folder the normal way is correct: the importer tolerates
the whole bundle sitting under one top-level folder, which is what every
“compress this folder” command produces.
zip -r MyProject.motion.zip MyProject.motion/Legacy single-file .motion projects are accepted as-is, unzipped.
Only the chunk files are read — manifest.json, scene.json,
animation.json, timeline.json, meta.json. Asset blobs in the bundle are
ignored, because the render service cannot use bytes that live on your disk;
that is exactly what the derived inputs are for.
Inputs are derived from layer names
Every layer that shows a source becomes an input, and its id is a slug of the
layer name — Hero Image becomes heroImage. Never the internal node id:
addressing a layer by id would make Premation’s node graph a public contract
that no project could ever be refactored out of.
So name your layers before you import. Rename a layer and re-import and the input id changes with it, which is correct — the name is the thing you control.
| The layer’s source | What you get |
|---|---|
Local (blob:, a file path, empty) | A required input — every render must supply a URL |
Already a public http(s) URL | An optional input, with that URL as its default |
requiredInputs in the response is the list to send on every render. A missing
one is a 400 before the job is queued, not a blank frame afterwards.
| Form field | Default | Notes |
|---|---|---|
file | — | The zipped bundle. Required. |
name | The composition’s name | Template name |
description | — | Up to 500 characters |
compId | The project’s active composition | Which composition to render |
includeText | false | true also exposes every text layer as a text input |
Scope: templates:write — not granted by default, so ask for it when you
mint the key.
Import derives a manifest; it does not read your mind. A project with no media
layers is refused (nothing_to_expose) because there would be nothing for an
automation to change. If you want finer control — some layers exposed, some
locked, a text field renamed — author the template in the editor and publish
it instead. Import is the fast path, not the only one.
Publishing from the editor is still the richer path
The editor lets you choose exactly which layers are exposed, rename inputs,
group them and set slot fit policies. Import gives you every media layer under
its own name and nothing else. Both produce the same kind of template, and
POST /v1/renders cannot tell them apart.
The public manifest
GET /v1/templates/{id} returns the manifest your automation reads:
{
"id": "tpl_7f8a9b",
"name": "Anime cooking reaction",
"description": "Vertical reaction clip with a swappable character",
"width": 1080,
"height": 1920,
"fps": 30,
"durationSeconds": 6.5,
"inputs": [
{ "id": "character", "label": "Character", "kind": "media", "required": true },
{ "id": "headline", "label": "Headline", "kind": "text" },
{ "id": "accentColor", "label": "Accent", "kind": "color" }
],
"createdAt": "2026-08-20T11:04:00.000Z",
"updatedAt": "2026-08-20T11:04:00.000Z"
}The manifest deliberately carries no write targets. Which layer and which property a field writes to lives in the document, never in the public response — an automation should not need to know, and should not be able to depend on, Premation’s node graph. The two are joined server-side at render time.
Publishing over HTTP
POST /v1/templates is session-authenticated by default; an API key needs the
non-default templates:write scope.
{
"name": "Anime cooking reaction",
"description": "Optional, 500 characters max",
"document": { "…": "EditorDocument snapshot" },
"inputs": [
{ "id": "character", "label": "Character", "kind": "media", "required": true }
],
"width": 1080,
"height": 1920,
"fps": 30,
"durationSeconds": 6.5,
"projectId": "optional cloud project id"
}| Field | Constraint |
|---|---|
name | Non-empty string, truncated to 120 characters |
description | Truncated to 500 characters |
document | A JSON object — the full EditorDocument |
width, height | > 0, at most 8192 |
fps | > 0, at most 240 |
durationSeconds | > 0, at most 3600 |
In practice you do not hand-build document. Publish from the editor, which
captures the whole project — scene, animation, nested comps, timelines, motion
blur, guides and colour management — as one consistent snapshot.
Listing and deleting
GET /v1/templates?limit=50&offset=0
GET /v1/templates/{id}
DELETE /v1/templates/{id}Listing is paginated: limit defaults to 50 and is capped at 100. Every
response carries { items, total, limit, offset }. Templates are scoped to the
account that published them — another account’s id answers 404, not 403.
What is preserved across a render
Everything you did not expose. Replacing a media field’s URL writes exactly
one property — that layer’s src. Position, Scale, Rotation, Opacity, easing
curves, effects and parenting are untouched, because they live on different
props and the input application only ever writes the one target it resolved.
Animation templates are a different thing
/v1/animations is the cloud-synced animation preset library — reusable
keyframe presets that sync between your installs. It shares the /v1 prefix and
the templates:* scopes, but it is not what /v1/renders renders. If you are
automating video, you want /v1/templates.