Lottie Studio — API

Describe the motion — production-ready Lottie JSON, previewed live, out.

API tokens Open the app

Author Lottie files from your own tools

Send a written brief and the canvas you need — width, height, frame rate, duration, background — and get back one JSON object whose animation field is a complete Lottie (Bodymovin) document: v, fr, ip, op, w, h, nm, ddd, assets and a non-empty layers array, sized exactly to the canvas you asked for and authored against the constraints of lottie-web's light build: no expressions, no layer effects, no image assets, text as shape paths or system fonts only. Write that field to a .json file and any Lottie player — lottie-web, lottie-react, the iOS and Android runtimes, After Effects via Bodymovin — will play it. The same endpoints also take an existing animation back in for a plain-words refine. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug lottie-studio. The slug is not a header — it is carried by the token: you mint one for this app with POST /guest and a {"slug": "lottie-studio"} body (or take the personal token this app already holds from the token page), and every later call simply sends Authorization: Bearer <token>. JSON bodies go up with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Estimates are free; runs are metered against your credit balance. There are two run tasks and no session state — generate makes a new animation from a brief, refine takes an animation you already have plus feedback and edits it.

StatusMeaning
401Missing or expired token — mint a new one.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest submitting a very large refine payload).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — or read it from process.argv[2]

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered animation runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved — and the slug in its body is what binds the token to this app.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"lottie-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "lottie-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "lottie-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "lottie-studio"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"lottie-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "lottie-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "lottie-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "lottie-studio" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:lottie-studio, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Worth checking before a long run: an animation cut short by an exhausted balance still costs what it burned.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — which matters here, because a long duration on a big canvas means a lot of baked keyframes and a correspondingly large reply.

Input fieldTypeNotes
taskstring, required"generate" to author a new animation from brief, or "refine" to edit one you already have. Refine additionally requires feedback and current_animation.
briefstring, requiredWhat to animate, in plain text. It may carry SVG path data, hex brand colors, the exact copy to render and the numbers a counter should hit — supplied assets are treated as ground truth, so geometry, colors and figures come back unchanged. The app clips the brief at 8000 characters, keeping the head and appending a marker line where it cut; do the same, or send something shorter. On a refine the brief stays as originally sent — the change goes in feedback.
canvasobject, required{w, h, fps, duration_frames}. w and h are pixels, 16–4096. fps is 12–120 (the web UI offers 24, 25, 30 and 60). duration_frames is 10–1800, or null to let the animation choose a length that fits the content. These are used exactly: the returned document's w, h and fr equal what you sent, and op - ip matches the duration you asked for.
backgroundstring"auto" (decided by use case), "transparent", or a "#rrggbb" hex. A hex means a full-canvas background layer is baked into the bottom of the document rather than left to the player; "transparent" means no background layer at all.
stylestringA preset name, or "" to let the brief decide: premium-settle, kinetic-snap, soft-interface, technical-trace, ambient-loop, playful-pop, data-confirm, phase-field. This sets pacing and easing character — it never adds chrome the brief did not ask for.
feedbackstring, refine onlyWhat to change, in plain words — "hold the final frame half a second longer", "make the ring 4px thinner". The edit is applied surgically: timing, palette and layer names the feedback does not mention are preserved rather than regenerated.
$modelstring, optionalPer-run model override, the platform's reserved key: "gpt-sol" runs this one job on the frontier tier (best layout quality, higher hold) while the app's default stays gpt-terra. Any platform model id or tier alias is accepted; /estimate honors it too, so quote before you run.
current_animationobject, refine onlyThe full Lottie JSON being refined — the same document you got back in animation, or a file you already have. Send the whole thing, not a summary.
prescan_factsarray, optionalWhat a client-side linter mechanically found in current_animation: a list of {id, sev, label}. sev is block | warn | info; ids are the deterministic checks that fired — expressions, images, effects, kf-order, parent-cycle, parent-missing, font-files, hold-only, size, range, missing:op and friends. The web UI fills this from its own scan before a refine; API callers may omit the field entirely.
cat > input.json <<'JSON'
{
  "task": "generate",
  "brief": "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on clockwise from 12 o'clock over the first third, then a white check strokes in inside it with a trim path, overshooting 4% and settling. Hold the finished mark for the last third. Nothing else on the canvas - no card, no border, no shadow.",
  "canvas": { "w": 512, "h": 512, "fps": 60, "duration_frames": 90 },
  "background": "transparent",
  "style": "premium-settle"
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
BRIEF = (
    "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on "
    "clockwise from 12 o'clock over the first third, then a white check strokes in "
    "inside it with a trim path, overshooting 4% and settling. Hold the finished mark "
    "for the last third. Nothing else on the canvas - no card, no border, no shadow."
)

payload = {
    "task": "generate",
    "brief": BRIEF,
    "canvas": {"w": 512, "h": 512, "fps": 60, "duration_frames": 90},
    "background": "transparent",
    "style": "premium-settle",
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const brief = [
  "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on",
  "clockwise from 12 o'clock over the first third, then a white check strokes in",
  "inside it with a trim path, overshooting 4% and settling. Hold the finished mark",
  "for the last third. Nothing else on the canvas - no card, no border, no shadow.",
].join(" ");

const payload = {
  task: "generate",
  brief,
  canvas: { w: 512, h: 512, fps: 60, duration_frames: 90 },
  background: "transparent",
  style: "premium-settle",
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const brief = "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) " +
	"draws on clockwise from 12 o'clock over the first third, then a white check " +
	"strokes in inside it with a trim path, overshooting 4% and settling. Hold the " +
	"finished mark for the last third. Nothing else on the canvas - no card, no " +
	"border, no shadow."

payload := map[string]any{
	"task":  "generate",
	"brief": brief,
	"canvas": map[string]any{
		"w": 512, "h": 512, "fps": 60, "duration_frames": 90,
	},
	"background": "transparent",
	"style":      "premium-settle",
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String brief = """
    A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on \
    clockwise from 12 o'clock over the first third, then a white check strokes in \
    inside it with a trim path, overshooting 4% and settling. Hold the finished mark \
    for the last third. Nothing else on the canvas - no card, no border, no shadow.""";

String jsonPayload = """
    {"task": "generate",
     "brief": %s,
     "canvas": {"w": 512, "h": 512, "fps": 60, "duration_frames": 90},
     "background": "transparent",
     "style": "premium-settle"}
    """.formatted(toJsonString(brief));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
BRIEF = "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws " \
        "on clockwise from 12 o'clock over the first third, then a white check strokes " \
        "in inside it with a trim path, overshooting 4% and settling. Hold the finished " \
        "mark for the last third. Nothing else on the canvas - no card, no border, no shadow."

payload = { task: "generate",
            brief: BRIEF,
            canvas: { w: 512, h: 512, fps: 60, duration_frames: 90 },
            background: "transparent",
            style: "premium-settle" }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$brief = <<<'BRIEF'
A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on
clockwise from 12 o'clock over the first third, then a white check strokes in
inside it with a trim path, overshooting 4% and settling. Hold the finished mark
for the last third. Nothing else on the canvas - no card, no border, no shadow.
BRIEF;

$payload = [
    "task"       => "generate",
    "brief"      => $brief,
    "canvas"     => ["w" => 512, "h" => 512, "fps" => 60, "duration_frames" => 90],
    "background" => "transparent",
    "style"      => "premium-settle",
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var brief = """
    A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on
    clockwise from 12 o'clock over the first third, then a white check strokes in
    inside it with a trim path, overshooting 4% and settling. Hold the finished mark
    for the last third. Nothing else on the canvas - no card, no border, no shadow.
    """;

var payload = new {
    task = "generate",
    brief,
    canvas = new { w = 512, h = 512, fps = 60, duration_frames = 90 },
    background = "transparent",
    style = "premium-settle",
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

A refine costs more than the same brief did to generate, because current_animation goes up with the request and the whole edited document comes back down. Estimate before you loop: three rounds of feedback on a 200 KB document is not three cheap calls. Keep documents lean — under roughly 350 KB and 60 layers — and both the price and the renderer stay comfortable.

Step 4 — Run it and wait for the animation

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Animation runs are long — every keyframe is written out by hand, so a 90-frame composition with a trim-path draw-on is usually 60–180 s. Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — usually nested as output.output, and as a JSON string, so parse defensively; step 6 has the parser. The samples below fetch the job and hand the raw text to that parser.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ls-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }

# the reply object, still a JSON string one level down
echo "$JOB" | jq -r '.data.output.output' > reply.json
jq -r '"\(.title) — \(.summary)"' reply.json
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "ls-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
# raw is the reply JSON as text — parse_reply() in step 6 turns it into the object
const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
// raw is the reply JSON as text — parseReply() in step 6 turns it into the object
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

// job.Output is {"output": "<json string>"} — unwrap once, then see step 6:
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
raw := wrapper.Output
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The reply is at data.output.output as a JSON string — parse it again to get
// {title, summary, background, loop, notes[], animation}. The animation object is
// a complete Lottie document: v, fr, ip, op, w, h, nm, ddd, assets[], layers[].
// Step 6 shows the fence-stripping parse and how to write animation.json.
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
# raw is the reply JSON as text — parse_reply in step 6 turns it into the object
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
// $raw is the reply JSON as text — parse_reply() in step 6 turns it into the array
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var raw = job.GetProperty("output").GetProperty("output").GetString();
// raw is the reply JSON as text — ParseReply() in step 6 turns it into a JsonDocument

If the balance runs out mid-run the stream stops where it stopped, and what arrived is a truncated JSON string rather than a complete document. The app recovers the partial text and says so; over the API, treat a parse failure on an otherwise succeeded job as exactly that — check charged_credits against your estimate, top up, and run again with a fresh idempotency key.

The reply object — output schema

One JSON object, always the same shape. The interesting field is animation: a complete Lottie document, not a fragment and not a description of one.

FieldTypeMeaning
titlestringA short human name for the animation, also written into the document's own nm.
summarystringOne or two sentences: what it shows and what the motion idea is.
backgroundstring"transparent" or a "#rrggbb" hex — what the document was authored against. A hex here means the bottom layer of animation.layers is a full-canvas rect with that fill; "transparent" means there is no background layer and your page or app shows through.
loopboolWhether the composition is built to loop seamlessly. Pass it straight to your player's loop option — a false here means the last frame is the intended resting still.
notesstring[]Short usage notes — which layer holds the swappable copy, which slot carries the accent color, where to cut for a shorter version. Often empty.
animationobjectThe deliverable. A complete Lottie (Bodymovin) document. Fields below.

Inside animation:

FieldMeaning
vBodymovin schema version, e.g. "5.7.4".
frFrame rate — equals the canvas.fps you sent.
ip / opIn point (inclusive) and out point (exclusive). ip: 0, op: 90, fr: 60 renders frames 0–89, one and a half seconds. op - ip matches the canvas.duration_frames you asked for, or the length chosen for you when you sent null.
w / hCanvas size in pixels — equals the canvas.w and canvas.h you sent.
nmComposition name, set to title.
ddd0 — this is a 2D composition.
assetsUsually []. Precomps may appear here; image assets never do.
layersNon-empty. Shape layers (ty: 4), precomps (ty: 0), solids (ty: 1), nulls (ty: 3) and, sparingly, system-font text (ty: 5).

What the document is guaranteed not to contain, because lottie-web's light build cannot render it:

Never presentWhy, and what is used instead
Expressions (x script fields)The light build has no expression engine. Counters, orbits, staggered offsets and physics are baked into explicit keyframes, which is also why long compositions produce large replies.
Layer effects (ef)Stripped by the light build. Glow is faked with soft-edged duplicate shapes at low opacity; motion blur with offset duplicates.
Image assets, base64 payloads, external URLs, audioNothing is fetched at play time. Everything is vector shapes, so the file is self-contained and safe to serve from your own origin.
Font filesText is either converted to shape paths, or uses a system family (Arial, Helvetica Neue, Georgia, Times New Roman, Courier New) with fOrigin: 0. Nothing is downloaded.

A small, real reply for the brief above (the layer list is abbreviated — a real one is much longer):

{
  "title": "Checkout success mark",
  "summary": "A teal ring draws on clockwise, then a white check strokes in with a
              4% overshoot and settles; the finished mark holds for the last third.",
  "background": "transparent",
  "loop": false,
  "notes": [
    "Layer \"ring\" carries the accent — change its stroke color to rebrand.",
    "Cut op to 60 for a snappier version; the hold is the last 30 frames."
  ],
  "animation": {
    "v": "5.7.4",
    "fr": 60,
    "ip": 0,
    "op": 90,
    "w": 512,
    "h": 512,
    "nm": "Checkout success mark",
    "ddd": 0,
    "assets": [],
    "layers": [
      {
        "ty": 4, "ind": 1, "nm": "check", "sr": 1, "ip": 0, "op": 90, "st": 0, "bm": 0,
        "ks": { "o": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [256, 256, 0] },
                "a": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] },
                "r": { "a": 0, "k": 0 } },
        "shapes": [ /* path + stroke + trim (ty "tm") animating e from 0 to 104 to 100 */ ]
      },
      {
        "ty": 4, "ind": 2, "nm": "ring", "sr": 1, "ip": 0, "op": 90, "st": 0, "bm": 0,
        "ks": { "o": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [256, 256, 0] },
                "a": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] },
                "r": { "a": 0, "k": -90 } },
        "shapes": [ /* ellipse + stroke + trim drawing 0 -> 100 over frames 0-30 */ ]
      }
    ]
  }
}

The canvas contract is worth asserting on in an automated pipeline: compare animation.w, animation.h, animation.fr and animation.op - animation.ip against the canvas you sent, and background against what you asked for. That is exactly the delivery check the app runs after every metered run, and it catches a drifted reply before it reaches a renderer.

Step 5 — Stream the animation as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — which matters here, because a full Lottie document with baked keyframes takes a while to write out. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "title", "summary", "background" and "animation" keys as they arrive, then for "layers" once the document body starts.
done{job_id, status, charged_credits, output}The final, authoritative result — read the reply from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ls-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"title\":\"Checkout success mark\""}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":742,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "ls-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

raw = result["output"]["output"]                          # authoritative
print("\ncharged:", result["charged_credits"], "credits")
# hand `raw` to parse_reply() in step 6
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const raw = done.output.output;
console.log(`\n${done.charged_credits} credits`);
// hand `raw` to parseReply() in step 6
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "ls-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // Lottie replies are large
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}

raw := final["output"].(map[string]any)["output"].(string)
// hand `raw` to the parser in step 6
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "ls-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// {title, summary, background, loop, notes[], animation}. Step 6 writes the
// animation object out as animation.json.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "ls-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

raw = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits"
# hand `raw` to parse_reply in step 6
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: ls-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$raw = $done["output"]["output"];
echo "\n{$done['charged_credits']} credits\n";
// hand $raw to parse_reply() in step 6
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "ls-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var raw = final.RootElement.GetProperty("output").GetProperty("output").GetString();
Console.WriteLine($"\n{final.RootElement.GetProperty("charged_credits")} credits");
// hand `raw` to ParseReply() in step 6

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.

Step 6 — Parse the reply and save the .json

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does. Then lift .animation out of the envelope and write that to disk: the envelope's title, summary, loop and notes are metadata for you, while animation alone is the file a Lottie player loads. Save it compact — pretty-printing a keyframed document doubles its size for no benefit.

# reply.json came from step 4 (or from the done event in step 5).
# Strip a code fence if one slipped in, then keep only the object:
sed -e 's/^```json$//' -e 's/^```$//' reply.json \
  | tr -d '\000' > reply.clean.json

# the deliverable is the .animation field, on its own, compact
jq -c '.animation' reply.clean.json > animation.json

# metadata for your pipeline
jq -r '"title:   \(.title)",
       "loop:    \(.loop)",
       "bg:      \(.background)",
       "canvas:  \(.animation.w)x\(.animation.h) @ \(.animation.fr)fps, \(.animation.op - .animation.ip) frames",
       "layers:  \(.animation.layers | length)",
       (.notes[]? | "note:    \(.)")' reply.clean.json

# assert the canvas you asked for is the canvas you got
jq -e '.animation.w == 512 and .animation.h == 512 and .animation.fr == 60' \
  reply.clean.json > /dev/null || { echo "canvas drifted"; exit 1; }
import json

def parse_reply(raw):
    """Turn the model's text into the reply object, fences and all."""
    if isinstance(raw, dict):
        return raw
    text = raw.strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[1] if "\n" in text else ""
        text = text.rsplit("```", 1)[0]
    start, end = text.find("{"), text.rfind("}")
    if start < 0 or end < start:
        raise ValueError("no JSON object in the reply")
    return json.loads(text[start:end + 1])

reply = parse_reply(raw)
anim = reply["animation"]

# the canvas contract — assert it before anything downstream trusts the file
canvas = payload["canvas"]
assert anim["w"] == canvas["w"] and anim["h"] == canvas["h"], "canvas size drifted"
assert anim["fr"] == canvas["fps"], "frame rate drifted"
assert anim["layers"], "animation.layers is empty"

print(reply["title"], "-", reply["summary"])
print(f'{anim["w"]}x{anim["h"]} @ {anim["fr"]}fps, '
      f'{anim["op"] - anim["ip"]} frames, {len(anim["layers"])} layers, '
      f'loop={reply["loop"]}, bg={reply["background"]}')
for n in reply.get("notes", []):
    print("  note:", n)

# animation.json is the file any Lottie player loads
with open("animation.json", "w", encoding="utf-8") as fh:
    json.dump(anim, fh, separators=(",", ":"))
with open("reply.json", "w", encoding="utf-8") as fh:
    json.dump(reply, fh, indent=2)
import { writeFileSync } from "node:fs";

function parseReply(raw) {
  if (typeof raw !== "string") return raw;
  let text = raw.trim();
  if (text.startsWith("```")) {
    text = text.slice(text.indexOf("\n") + 1);
    text = text.slice(0, text.lastIndexOf("```"));
  }
  const start = text.indexOf("{"), end = text.lastIndexOf("}");
  if (start < 0 || end < start) throw new Error("no JSON object in the reply");
  return JSON.parse(text.slice(start, end + 1));
}

const reply = parseReply(raw);
const anim = reply.animation;

const { canvas } = payload;
if (anim.w !== canvas.w || anim.h !== canvas.h) throw new Error("canvas size drifted");
if (anim.fr !== canvas.fps) throw new Error("frame rate drifted");
if (!anim.layers?.length) throw new Error("animation.layers is empty");

console.log(`${reply.title} - ${reply.summary}`);
console.log(`${anim.w}x${anim.h} @ ${anim.fr}fps, ${anim.op - anim.ip} frames, ` +
            `${anim.layers.length} layers, loop=${reply.loop}, bg=${reply.background}`);
for (const n of reply.notes ?? []) console.log("  note:", n);

// animation.json is the file any Lottie player loads:
//   lottie.loadAnimation({ container, renderer: "svg",
//                          loop: reply.loop, autoplay: true, path: "animation.json" })
writeFileSync("animation.json", JSON.stringify(anim));
writeFileSync("reply.json", JSON.stringify(reply, null, 2));
func parseReply(raw string) (map[string]any, error) {
	text := strings.TrimSpace(raw)
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text, "\n"); i >= 0 {
			text = text[i+1:]
		}
		if i := strings.LastIndex(text, "```"); i >= 0 {
			text = text[:i]
		}
	}
	start, end := strings.Index(text, "{"), strings.LastIndex(text, "}")
	if start < 0 || end < start {
		return nil, fmt.Errorf("no JSON object in the reply")
	}
	var reply map[string]any
	err := json.Unmarshal([]byte(text[start:end+1]), &reply)
	return reply, err
}

reply, err := parseReply(raw)
if err != nil {
	log.Fatal(err)
}
anim := reply["animation"].(map[string]any)

if int(anim["w"].(float64)) != 512 || int(anim["fr"].(float64)) != 60 {
	log.Fatal("canvas drifted from the request")
}
layers := anim["layers"].([]any)
fmt.Printf("%s - %s\n", reply["title"], reply["summary"])
fmt.Printf("%v x %v @ %v fps, %v frames, %d layers, loop=%v, bg=%v\n",
	anim["w"], anim["h"], anim["fr"],
	anim["op"].(float64)-anim["ip"].(float64), len(layers),
	reply["loop"], reply["background"])

// animation.json is the file any Lottie player loads
out, _ := json.Marshal(anim)
os.WriteFile("animation.json", out, 0o644)
os.WriteFile("reply.json", []byte(raw), 0o644)
// Java 17+, with your JSON library of choice.
static String stripFence(String raw) {
    String text = raw.strip();
    if (text.startsWith("```")) {
        text = text.substring(text.indexOf('\n') + 1);
        text = text.substring(0, text.lastIndexOf("```"));
    }
    int start = text.indexOf('{'), end = text.lastIndexOf('}');
    if (start < 0 || end < start) throw new IllegalStateException("no JSON object in the reply");
    return text.substring(start, end + 1);
}

// Parse stripFence(raw) into an object tree, then:
//   reply.title, reply.summary, reply.background, reply.loop, reply.notes[]
//   reply.animation — the complete Lottie document
// Assert the canvas before trusting it:
//   animation.w == 512, animation.h == 512, animation.fr == 60,
//   animation.op - animation.ip == 90, animation.layers is non-empty.
// Then write the animation node out compact — that file, and only that file,
// is what a Lottie player loads:
//   Files.writeString(Path.of("animation.json"), mapper.writeValueAsString(animationNode));
//   Files.writeString(Path.of("reply.json"), stripFence(raw));
require "json"

def parse_reply(raw)
  return raw if raw.is_a?(Hash)
  text = raw.strip
  if text.start_with?("```")
    text = text.split("\n", 2)[1].to_s
    text = text.rpartition("```").first
  end
  start_i = text.index("{")
  end_i = text.rindex("}")
  raise "no JSON object in the reply" if start_i.nil? || end_i.nil?
  JSON.parse(text[start_i..end_i])
end

reply = parse_reply(raw)
anim = reply["animation"]

canvas = payload[:canvas]
raise "canvas size drifted" unless anim["w"] == canvas[:w] && anim["h"] == canvas[:h]
raise "frame rate drifted" unless anim["fr"] == canvas[:fps]
raise "animation.layers is empty" if anim["layers"].to_a.empty?

puts "#{reply["title"]} - #{reply["summary"]}"
puts "#{anim["w"]}x#{anim["h"]} @ #{anim["fr"]}fps, #{anim["op"] - anim["ip"]} frames, " \
     "#{anim["layers"].length} layers, loop=#{reply["loop"]}, bg=#{reply["background"]}"
(reply["notes"] || []).each { |n| puts "  note: #{n}" }

# animation.json is the file any Lottie player loads
File.write("animation.json", JSON.generate(anim))
File.write("reply.json", JSON.pretty_generate(reply))
function parse_reply($raw): array {
    if (is_array($raw)) { return $raw; }
    $text = trim($raw);
    if (str_starts_with($text, "```")) {
        $nl = strpos($text, "\n");
        $text = $nl === false ? "" : substr($text, $nl + 1);
        $last = strrpos($text, "```");
        if ($last !== false) { $text = substr($text, 0, $last); }
    }
    $start = strpos($text, "{");
    $end   = strrpos($text, "}");
    if ($start === false || $end === false) {
        throw new Exception("no JSON object in the reply");
    }
    return json_decode(substr($text, $start, $end - $start + 1), true);
}

$reply = parse_reply($raw);
$anim  = $reply["animation"];

$canvas = $payload["canvas"];
if ($anim["w"] !== $canvas["w"] || $anim["h"] !== $canvas["h"]) {
    throw new Exception("canvas size drifted");
}
if ($anim["fr"] !== $canvas["fps"]) { throw new Exception("frame rate drifted"); }
if (empty($anim["layers"])) { throw new Exception("animation.layers is empty"); }

echo "{$reply['title']} - {$reply['summary']}\n";
echo "{$anim['w']}x{$anim['h']} @ {$anim['fr']}fps, "
   . ($anim["op"] - $anim["ip"]) . " frames, "
   . count($anim["layers"]) . " layers, bg={$reply['background']}\n";
foreach ($reply["notes"] ?? [] as $n) { echo "  note: $n\n"; }

// animation.json is the file any Lottie player loads
file_put_contents("animation.json", json_encode($anim));
file_put_contents("reply.json", json_encode($reply, JSON_PRETTY_PRINT));
static JsonDocument ParseReply(string raw)
{
    var text = raw.Trim();
    if (text.StartsWith("```"))
    {
        text = text[(text.IndexOf('\n') + 1)..];
        text = text[..text.LastIndexOf("```")];
    }
    int start = text.IndexOf('{'), end = text.LastIndexOf('}');
    if (start < 0 || end < start) throw new Exception("no JSON object in the reply");
    return JsonDocument.Parse(text[start..(end + 1)]);
}

using var replyDoc = ParseReply(raw!);
var reply = replyDoc.RootElement;
var anim = reply.GetProperty("animation");

if (anim.GetProperty("w").GetInt32() != 512 || anim.GetProperty("fr").GetInt32() != 60)
    throw new Exception("canvas drifted from the request");

var layers = anim.GetProperty("layers").GetArrayLength();
Console.WriteLine($"{reply.GetProperty("title")} - {reply.GetProperty("summary")}");
Console.WriteLine($"{anim.GetProperty("w")}x{anim.GetProperty("h")} @ " +
                  $"{anim.GetProperty("fr")}fps, " +
                  $"{anim.GetProperty("op").GetInt32() - anim.GetProperty("ip").GetInt32()} frames, " +
                  $"{layers} layers, loop={reply.GetProperty("loop")}, " +
                  $"bg={reply.GetProperty("background")}");
if (reply.TryGetProperty("notes", out var notes))
    foreach (var n in notes.EnumerateArray()) Console.WriteLine($"  note: {n}");

// animation.json is the file any Lottie player loads
await File.WriteAllTextAsync("animation.json", anim.GetRawText());
await File.WriteAllTextAsync("reply.json", reply.GetRawText());

animation.json is self-contained: no fonts, no images, no network calls at play time, so you can serve it from your own origin and hand it to lottie-web, lottie-react, lottie-ios, lottie-android or dotLottie unchanged. Pass reply.loop as the player's loop option and paint reply.background behind the canvas yourself when it is a hex — the document already carries that background layer, but matching the surrounding page keeps the edges clean. To iterate, send the same document straight back as current_animation with task: "refine" and a sentence of feedback.