Lottie Ship — API

You have the animation. This is everything between it and production.

API tokens Open the app

Put a Lottie readiness gate in your pipeline

The app's analysis runs in a browser, but the three judgement lanes are a plain HTTP API. Send the facts about a Lottie document and get back a structured verdict you can fail a build on: a per-runtime compatibility report, a costed weight-reduction plan, or production embed code. Every example below appears in eight languages — pick one and it stays picked.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Every request carries Authorization: Bearer <token> and nothing else — the token is bound to this app, so there is no slug header. The one exception is POST /guest, which takes no auth and carries {"slug": "lottie-ship"} in the body.

Every reply uses the same envelope: {"ok":true,"data":{…}} on success and {"ok":false,"error":{"code":"…","message":"…"}} on failure. Branch on ok, never on the HTTP status alone.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream

Error codes

CodeMeansWhat to do
UNAUTHORIZEDMissing or expired tokenMint a new one with POST /guest, or sign in for a personal token.
INSUFFICIENT_CREDITSBalance below the holdCall /estimate first and compare hold_credits against /me.
VALIDATION_ERRORThe input was not acceptedCheck error.details; usually a missing task or a malformed probe.
RATE_LIMITEDToo many requestsBack off and retry; never tight-loop.
JOB_FAILEDThe run terminatedInspect error.message on the job; retry with the same Idempotency-Key.

The task field comes first

This app has three lanes and one system prompt. task selects the lane and is required on every run. The rest of the input, and the shape of the reply, both depend on it.

taskAnswersExtra inputExtra output
"compat"Will each runtime draw this file?targets[], support_table[], derived_verdicts[], contexttargets[]
"slim"How do I hit a KB budget?budget_kb_gzipped, must_be_smooth_on, fidelity_tradeoffsavings[], projected_gzip_bytes
"embed"What is the integration code?stack, playback, loading, contextcode[]

Two lanes over one document are two distinct runs. Put the lane in your Idempotency-Key or the second lane will return the first lane's cached answer.

Step 0 — A tiny client

Everything below uses this one helper. It sets the two required headers and unwraps the envelope.

export API="https://api.skillsafe.ai/v1/app-api"
export SLUG="lottie-ship"

# Every reply is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# jq -e '.ok' exits non-zero on the error envelope, so scripts can branch on it.
api() {  # api <METHOD> <PATH> [BODY]
  curl -sS -X "$1" "$API$2" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    ${3:+--data "$3"}
}
import json, urllib.request

API  = "https://api.skillsafe.ai/v1/app-api"
SLUG = "lottie-ship"
TOKEN = "YOUR_TOKEN"   # from step 1

def api(method, path, body=None, token=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(API + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + (token or TOKEN))
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        payload = json.loads(r.read())
    if not payload.get("ok"):
        raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
    return payload["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "lottie-ship";
let TOKEN = "YOUR_TOKEN";           // from step 1

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

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

const api = "https://api.skillsafe.ai/v1/app-api"
const slug = "lottie-ship"

var token = "YOUR_TOKEN" // from step 1

type envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func call(method, path string, body any) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
	}
	req, _ := http.NewRequest(method, api+path, rdr)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if !env.OK {
		return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
	}
	return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public final class LottieShip {
  static final String API  = "https://api.skillsafe.ai/v1/app-api";
  static final String SLUG = "lottie-ship";
  static String token = "YOUR_TOKEN";           // from step 1

  static final HttpClient HTTP = HttpClient.newBuilder()
      .connectTimeout(Duration.ofSeconds(10)).build();

  /** Returns the raw JSON body; use your JSON library of choice to read it. */
  static String call(String method, String path, String jsonBody) throws Exception {
    HttpRequest.BodyPublisher pub = jsonBody == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(jsonBody);
    HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
        .header("Authorization", "Bearer " + token)
        .header("Content-Type", "application/json")
        .method(method, pub)
        .build();
    HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
    return res.body();   // {"ok":true,"data":{...}} or {"ok":false,"error":{...}}
  }
}
require "json"
require "net/http"
require "uri"

API   = "https://api.skillsafe.ai/v1/app-api"
SLUG  = "lottie-ship"
TOKEN = "YOUR_TOKEN"   # from step 1

def api(method, path, body = nil, token: TOKEN)
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{token}"
  req["Content-Type"]  = "application/json"
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
  payload["data"]
end
<?php
const API   = "https://api.skillsafe.ai/v1/app-api";
const SLUG  = "lottie-ship";
$TOKEN = "YOUR_TOKEN";   // from step 1

function api(string $method, string $path, ?array $body = null, ?string $token = null): array {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . ($token ?? $TOKEN),
            "Content-Type: application/json",
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
    }
    return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

public static class LottieShip
{
    const string Api  = "https://api.skillsafe.ai/v1/app-api";
    const string Slug = "lottie-ship";
    public static string Token = "YOUR_TOKEN";   // from step 1

    static readonly HttpClient Http = new();

    public static async Task<JsonElement> CallAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (body is not null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");

        var res = await Http.SendAsync(req);
        using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        var root = doc.RootElement;
        if (!root.GetProperty("ok").GetBoolean())
        {
            var e = root.GetProperty("error");
            throw new Exception($"{e.GetProperty("code").GetString()}: {e.GetProperty("message").GetString()}");
        }
        return root.GetProperty("data").Clone();
    }
}

Step 1 — Get a token

A guest token needs no account and is enough for /estimate. For metered runs, use a personal token — sign in at /tokens.html and copy it, or copy the ready-made shell export from the same page.

# A guest token needs no account and is enough to call /estimate.
TOKEN=$(curl -sS -X POST "$API/guest" \
  -H "Content-Type: application/json" -d "{\"slug\":\"$SLUG\"}" | jq -r '.data.token')
echo "${TOKEN:0:12}…"
guest = api("POST", "/guest", {"slug": SLUG}, token="")   # /guest needs no auth
TOKEN = guest["token"]
print(TOKEN[:12] + "…")
const guest = await (await fetch(`${API}/guest`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: SLUG }),
})).json();
TOKEN = guest.data.token;
raw, err := call("POST", "/guest", map[string]string{"slug": slug})
if err != nil {
	panic(err)
}
var guest struct {
	Token string `json:"token"`
}
json.Unmarshal(raw, &guest)
token = guest.Token
String body = call("POST", "/guest", "{\"slug\":\"" + SLUG + "\"}");
// body -> {"ok":true,"data":{"token":"aut_…","expires_at":…}}
// pull data.token with your JSON library and assign it to `token`.
guest = api("POST", "/guest", { "slug" => SLUG }, token: "")
TOKEN_VALUE = guest["token"]
$guest = api("POST", "/guest", ["slug" => SLUG], "");
$TOKEN = $guest["token"];
var guest = await LottieShip.CallAsync(HttpMethod.Post, "/guest", new { slug = "lottie-ship" });
LottieShip.Token = guest.GetProperty("token").GetString()!;

Step 2 — Check who you are and your balance

/me tells you whether the token is a guest or a person, and what the balance is. Compare it against hold_credits from step 3 before you run, so a 402 is impossible.

api GET /me | jq '.data | {subject_type, credits}'
# {"subject_type":"guest","credits":0}
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
raw, _ := call("GET", "/me", nil)
fmt.Println(string(raw))
System.out.println(call("GET", "/me", null));
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await LottieShip.CallAsync(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());

Step 3 — Build the input and estimate the cost

The Lottie document itself is never sent. You send probe: derived facts about the document. Everything the model needs is in there, and it is two orders of magnitude smaller than the file.

Only document, layers, counts, features_used and weight are load-bearing; findings, assets and the context string sharpen the answer considerably. Omitted fields are treated as "not measured", never as zero.

/estimate is free, runs no job and charges nothing. Its hold_credits is what gets reserved, not what you pay — the actual charge is usually far lower.

Top-level keyWhenWhat it means
taskalwaysThe lane. See the table above.
probealwaysThe derived facts, shown below. This is the model's ground truth and every reply is reconciled against it.
contextoptionalFree text from the user, capped at 2000 characters. compat and embed only.
retry_noteon a reformat retrySent only when the previous reply did not parse as the envelope. The model is instructed to follow it.
probe_trimmedoversized probesSent when the payload exceeds 120 KB. The tail of findings, features_used and assets.images is dropped, largest entries kept, and this string names how many of each went. The model is instructed to declare the gap in notes[] and never to read an absent entry as an absent defect. Send it yourself if you trim the probe.

A font entry in probe.assets.fonts may also carry origin — the exporter's font-origin code (0 local, 1 CSS URL, 2 script URL, 3 font URL). It is often absent, and absent means the exporter recorded nothing, not that the font is local.

cat > input.json <<'JSON'
{
  "task": "compat",
  "probe": {
    "document": { "version": "5.9.6", "canvas": "512x512", "fps": 30,
                  "frames": 90, "duration_s": 3, "name": "Promo loop", "markers": 0 },
    "layers": { "total": 53, "by_type": { "shape": 48, "text": 1, "image": 2, "precomp": 1, "solid": 1 },
                "hidden": 3, "precomps": 1, "nesting_depth": 1 },
    "counts": { "keyframes": 444, "vertices": 2256, "shapes": 194,
                "masks": 0, "effects": 1, "expressions": 1, "staticProps": 310 },
    "features_used": [
      { "key": "expressions",  "label": "Expressions",            "count": 1, "example": "Promo loop > Badge" },
      { "key": "mergepaths",   "label": "Merge paths",            "count": 1, "example": "Promo loop > Badge" },
      { "key": "blend",        "label": "Blend modes",            "count": 1, "example": "Promo loop > Badge (multiply)" },
      { "key": "text",         "label": "Text layers",            "count": 1, "example": "Promo loop > Headline" },
      { "key": "externalassets","label": "External image assets", "count": 1, "example": "images/product-shot.png" }
    ],
    "assets": {
      "images": [ { "id": "img_0", "embedded": false, "format": "png",
                    "bytes": 0, "size": "400x300", "path": "images/product-shot.png" } ],
      "fonts":  [ { "fName": "Inter", "fFamily": "Inter", "fStyle": "Regular", "ascent": null } ],
      "baked_glyphs": 14
    },
    "findings": [
      { "id": "text-missing-animators", "severity": "block",
        "label": "Text layer missing its animators array",
        "occurrences": 1, "where": ["Promo loop > Headline"] },
      { "id": "font-missing-ascent", "severity": "block",
        "label": "Font entry with no ascent", "occurrences": 1, "where": ["Inter"] }
    ],
    "weight": { "bytes": 291117, "gzip_bytes": 106036, "gzip_measured": true,
                "embedded_image_bytes": 21063, "baked_glyph_bytes": 54817,
                "precision_saving_bytes": 57423, "long_floats": 20777,
                "total_floats": 21082, "bytes_per_layer": 5493 }
  },
  "targets": [
    { "id": "web-svg",  "label": "lottie-web (SVG)" },
    { "id": "skottie",  "label": "Skottie / CanvasKit" }
  ],
  "support_table": [
    { "feature": "Expressions", "used_times": 1,
      "support": { "web-svg": "partial - only in the full build", "skottie": "no - no expression interpreter" } },
    { "feature": "Merge paths", "used_times": 1,
      "support": { "web-svg": "no - not implemented", "skottie": "yes" } }
  ],
  "derived_verdicts": [
    { "renderer": "lottie-web (SVG)", "level": "blocked", "dropped": ["Merge paths"] },
    { "renderer": "Skottie / CanvasKit", "level": "blocked", "dropped": ["Expressions"] }
  ],
  "context": "hero animation on the marketing site"
}
JSON

api POST /estimate "$(cat input.json)" | jq '.data | {model, model_alias, hold_credits, min_credits}'
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","hold_credits":3480,"min_credits":420}
INPUT = {
    "task": "compat",
    "probe": probe,            # the object shown above
    "targets": [{"id": "web-svg", "label": "lottie-web (SVG)"},
                {"id": "skottie", "label": "Skottie / CanvasKit"}],
    "support_table": support_table,
    "derived_verdicts": derived_verdicts,
    "context": "hero animation on the marketing site",
}

est = api("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], "reserved")
const input = {
  task: "compat",
  probe,                       // the object shown above
  targets: [
    { id: "web-svg", label: "lottie-web (SVG)" },
    { id: "skottie", label: "Skottie / CanvasKit" },
  ],
  support_table: supportTable,
  derived_verdicts: derivedVerdicts,
  context: "hero animation on the marketing site",
};

const est = await api("POST", "/estimate", input);
console.log(est.model_alias, est.hold_credits);
input := map[string]any{
	"task":  "compat",
	"probe": probe, // the object shown above
	"targets": []map[string]string{
		{"id": "web-svg", "label": "lottie-web (SVG)"},
		{"id": "skottie", "label": "Skottie / CanvasKit"},
	},
	"context": "hero animation on the marketing site",
}

raw, err := call("POST", "/estimate", input)
if err != nil {
	panic(err)
}
fmt.Println(string(raw))
String input = """
  { "task": "compat",
    "probe": %PROBE%,
    "targets": [ { "id": "web-svg", "label": "lottie-web (SVG)" } ],
    "context": "hero animation on the marketing site" }
  """.replace("%PROBE%", probeJson);

System.out.println(call("POST", "/estimate", input));
input = {
  "task"    => "compat",
  "probe"   => probe,          # the object shown above
  "targets" => [{ "id" => "web-svg", "label" => "lottie-web (SVG)" }],
  "context" => "hero animation on the marketing site",
}

est = api("POST", "/estimate", input)
puts "#{est["model_alias"]} #{est["hold_credits"]}"
$input = [
    "task"    => "compat",
    "probe"   => $probe,        // the array shown above
    "targets" => [["id" => "web-svg", "label" => "lottie-web (SVG)"]],
    "context" => "hero animation on the marketing site",
];

$est = api("POST", "/estimate", $input);
echo $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = new {
    task = "compat",
    probe,                       // the object shown above
    targets = new[] { new { id = "web-svg", label = "lottie-web (SVG)" } },
    context = "hero animation on the marketing site",
};

var est = await LottieShip.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());

Step 4 — Run it and poll for the report

/run returns a job_id immediately. Poll /jobs/{id} until status is succeeded or failed; the reply text is at output.output.

Always send an Idempotency-Key, and include the lane in it. A network blip during a retry then costs nothing instead of billing the same work twice.

# Idempotency-Key must include the lane: two lanes over one document
# are two distinct runs and must not collide on one key.
KEY="lottie-ship:compat:$(shasum -a 256 input.json | cut -c1-32):a1"

JOB=$(curl -sS -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  --data @input.json | jq -r '.data.job_id')

until [ "$(api GET "/jobs/$JOB" | jq -r '.data.status')" = "succeeded" ]; do sleep 2; done
api GET "/jobs/$JOB" | jq -r '.data.output.output' > reply.json
import hashlib, time

key = "lottie-ship:compat:" + hashlib.sha256(
    json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:32] + ":a1"

req = urllib.request.Request(API + "/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
    job_id = json.loads(r.read())["data"]["job_id"]

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

reply = json.loads(job["output"]["output"])
import { createHash } from "node:crypto";

const key = "lottie-ship:compat:" +
  createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 32) + ":a1";

const res = await fetch(`${API}/run`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(input),
});
const { data: { job_id } } = await res.json();

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

const reply = JSON.parse(job.output.output);
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
key := fmt.Sprintf("lottie-ship:compat:%x:a1", sum[:16])

req, _ := http.NewRequest("POST", api+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var started struct {
	OK   bool `json:"ok"`
	Data struct {
		JobID string `json:"job_id"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&started)

for {
	raw, _ := call("GET", "/jobs/"+started.Data.JobID, nil)
	var job struct {
		Status string `json:"status"`
		Output struct {
			Output string `json:"output"`
		} `json:"output"`
	}
	json.Unmarshal(raw, &job)
	if job.Status == "succeeded" {
		fmt.Println(job.Output.Output)
		break
	}
	time.Sleep(2 * time.Second)
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
String hex = HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
String key = "lottie-ship:compat:" + hex.substring(0, 32) + ":a1";

HttpRequest run = HttpRequest.newBuilder(URI.create(API + "/run"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(input))
    .build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, then poll GET /jobs/{id} until status is succeeded or failed.
require "digest"

key = "lottie-ship:compat:" + Digest::SHA256.hexdigest(JSON.dump(input))[0, 32] + ":a1"

uri = URI(API + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]

loop do
  job = api("GET", "/jobs/#{job_id}")
  break (reply = JSON.parse(job["output"]["output"])) if job["status"] == "succeeded"
  raise "run failed" if job["status"] == "failed"
  sleep 2
end
$key = "lottie-ship:compat:" . substr(hash("sha256", json_encode($input)), 0, 32) . ":a1";

$ch = curl_init(API . "/run");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($input),
    CURLOPT_HTTPHEADER     => [
        "Authorization: Bearer " . $TOKEN,
        "Content-Type: application/json",
        "Idempotency-Key: " . $key,
    ],
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);

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

$reply = json_decode($job["output"]["output"], true);
using System.Security.Cryptography;

var json = JsonSerializer.Serialize(input);
var hex  = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..32].ToLowerInvariant();
var key  = $"lottie-ship:compat:{hex}:a1";

var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Authorization = new AuthenticationHeaderValue("Bearer", LottieShip.Token);
run.Headers.Add("Idempotency-Key", key);
run.Content = new StringContent(json, Encoding.UTF8, "application/json");

var started = await new HttpClient().SendAsync(run);
// read data.job_id, then poll GET /jobs/{id} until status is succeeded or failed.

Step 5 — Or stream it

/run-stream is the same run over server-sent events. Events are {"type":"job"}, {"type":"delta","text":"…"} and {"type":"done"}. Concatenating every delta.text yields exactly the string /jobs/{id} would have returned.

Use this when you want progress. The app itself streams and advances its progress card on the section headings as they arrive.

# -N disables buffering so the deltas print as they arrive.
curl -sSN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  --data @input.json
req = urllib.request.Request(API + "/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in [("Authorization", "Bearer " + TOKEN), ("Content-Type", "application/json"), ("Idempotency-Key", key),
             ("Accept", "text/event-stream")]:
    req.add_header(h, v)

buf = ""
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if line.startswith("data: "):
            evt = json.loads(line[6:])
            if evt.get("type") == "delta":
                buf += evt["text"]
            elif evt.get("type") == "done":
                break
reply = json.loads(buf)
const res = await fetch(`${API}/run-stream`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
    "Accept": "text/event-stream",
  },
  body: JSON.stringify(input),
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let pending = "", out = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  pending += dec.decode(value, { stream: true });
  const lines = pending.split("\n");
  pending = lines.pop() ?? "";
  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const evt = JSON.parse(line.slice(6));
    if (evt.type === "delta") out += evt.text;
  }
}
const reply = JSON.parse(out);
req, _ = http.NewRequest("POST", api+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")

res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

var out strings.Builder
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for sc.Scan() {
	line := sc.Text()
	if !strings.HasPrefix(line, "data: ") {
		continue
	}
	var evt struct {
		Type string `json:"type"`
		Text string `json:"text"`
	}
	json.Unmarshal([]byte(line[6:]), &evt)
	if evt.Type == "delta" {
		out.WriteString(evt.Text)
	}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .header("Accept", "text/event-stream")
    .POST(HttpRequest.BodyPublishers.ofString(input))
    .build();

StringBuilder out = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body()
    .filter(l -> l.startsWith("data: "))
    .forEach(l -> {
      // parse l.substring(6); append the "text" field when "type" is "delta"
      out.append(extractDeltaText(l.substring(6)));
    });
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = key
req["Accept"]          = "text/event-stream"
req.body = JSON.dump(input)

out = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        next unless line.start_with?("data: ")
        evt = JSON.parse(line[6..].strip)
        out << evt["text"] if evt["type"] == "delta"
      end
    end
  end
end
reply = JSON.parse(out)
$out = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($input),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . $TOKEN,
        "Content-Type: application/json",
        "Idempotency-Key: " . $key,
        "Accept: text/event-stream",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out) {
        foreach (explode("\n", $chunk) as $line) {
            if (str_starts_with($line, "data: ")) {
                $evt = json_decode(substr($line, 6), true);
                if (($evt["type"] ?? "") === "delta") { $out .= $evt["text"]; }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$reply = json_decode($out, true);
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", LottieShip.Token);
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
stream.Content = new StringContent(json, Encoding.UTF8, "application/json");

using var http = new HttpClient();
using var res = await http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

var sb = new StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
    if (!line.StartsWith("data: ")) continue;
    using var evt = JsonDocument.Parse(line[6..]);
    if (evt.RootElement.GetProperty("type").GetString() == "delta")
        sb.Append(evt.RootElement.GetProperty("text").GetString());
}

The reply — one envelope for all three lanes

The model returns a single JSON object. The outer shape never changes; only the lane-specific extras do. That is what lets one parser handle all three lanes.

FieldTypeNotes
taskstringEchoes the requested lane.
titlestringNames the document and the lane.
verdictstringship, ship-with-changes or do-not-ship. This is the field to gate a build on.
summarystringOne paragraph: the finding that matters most.
sections[]array{heading, body, items[]}; each item is {label, detail, sev, fix} with sev in block/warn/info. Always non-empty.
checklist[]string[]Ordered, verifiable actions. Under 10 entries.
facts_used[]string[]The probe facts the report rests on — use it to audit the answer.
notes[]string[]Caveats and anything the probe could not measure.
targets[]arraycompat only. {renderer, level, headline, actions[]}, one per requested runtime, level in clean/caveats/blocked.
savings[]arrayslim only. {action, est_bytes, risk, detail}, ordered by value for risk.
projected_gzip_bytesnumberslim only. Size after every step.
code[]arrayembed only. {filename, lang, body}, complete runnable files.

The app reconciles every reply against the probe before displaying it: a report more optimistic than the support table, a savings plan whose steps exceed the file size, or embed code that omits the asset path a file with external images needs are all flagged. If you are consuming this API directly, do the same — the probe facts you sent are the cheapest possible check.

Step 6 — Use it

Reading verdict and the block-severity items is enough to gate a deploy. The rest is for humans.

# reply.json is the envelope described below.
jq -r '.verdict, .summary' reply.json
jq -r '.targets[] | "\(.renderer): \(.level) — \(.headline)"' reply.json
jq -r '.checklist[] | "[ ] " + .' reply.json
print(reply["verdict"], "-", reply["title"])
for t in reply.get("targets", []):
    print(f'{t["renderer"]}: {t["level"]} — {t["headline"]}')
    for a in t["actions"]:
        print("   -", a)

blockers = [i for s in reply["sections"] for i in s.get("items", []) if i["sev"] == "block"]
if blockers:
    raise SystemExit(f"{len(blockers)} blocking defect(s) — not shippable")
console.log(reply.verdict, "-", reply.title);
for (const t of reply.targets ?? []) {
  console.log(`${t.renderer}: ${t.level} — ${t.headline}`);
  t.actions.forEach((a) => console.log("   -", a));
}

const blockers = reply.sections.flatMap((s) => s.items ?? []).filter((i) => i.sev === "block");
if (blockers.length) process.exitCode = 1;   // fail the build
var reply struct {
	Verdict string `json:"verdict"`
	Title   string `json:"title"`
	Targets []struct {
		Renderer string   `json:"renderer"`
		Level    string   `json:"level"`
		Headline string   `json:"headline"`
		Actions  []string `json:"actions"`
	} `json:"targets"`
}
json.Unmarshal([]byte(out.String()), &reply)

fmt.Println(reply.Verdict, "-", reply.Title)
for _, t := range reply.Targets {
	fmt.Printf("%s: %s - %s\n", t.Renderer, t.Level, t.Headline)
}
// Map the envelope onto a record and fail the build on a blocking verdict.
record Target(String renderer, String level, String headline, List<String> actions) {}
record Reply(String task, String title, String verdict, String summary,
             List<Target> targets, List<String> checklist) {}

Reply reply = parse(out.toString(), Reply.class);   // your JSON library
if ("do-not-ship".equals(reply.verdict())) {
  throw new IllegalStateException("Lottie is not shippable: " + reply.summary());
}
puts "#{reply["verdict"]} - #{reply["title"]}"
(reply["targets"] || []).each do |t|
  puts "#{t["renderer"]}: #{t["level"]} - #{t["headline"]}"
  t["actions"].each { |a| puts "   - #{a}" }
end

abort("not shippable") if reply["verdict"] == "do-not-ship"
echo $reply["verdict"], " - ", $reply["title"], PHP_EOL;
foreach ($reply["targets"] ?? [] as $t) {
    echo "{$t["renderer"]}: {$t["level"]} - {$t["headline"]}", PHP_EOL;
    foreach ($t["actions"] as $a) { echo "   - $a", PHP_EOL; }
}

if ($reply["verdict"] === "do-not-ship") { exit(1); }
using var doc = JsonDocument.Parse(sb.ToString());
var reply = doc.RootElement;

Console.WriteLine($"{reply.GetProperty("verdict").GetString()} - {reply.GetProperty("title").GetString()}");
foreach (var t in reply.GetProperty("targets").EnumerateArray())
    Console.WriteLine($"{t.GetProperty("renderer").GetString()}: {t.GetProperty("level").GetString()}");

if (reply.GetProperty("verdict").GetString() == "do-not-ship")
    Environment.Exit(1);

Worked example per lane

The only differences between lanes are the extra input fields. Same client, same envelope, same parser.

# compat — will my targets draw it?
jq -n --argjson probe "$(cat probe.json)" '{
  task: "compat",
  probe: $probe,
  targets: [ {id:"web-svg", label:"lottie-web (SVG)"},
             {id:"ios",     label:"lottie-ios"} ],
  context: "same file has to run on the marketing site and in the iOS app"
}' > input.json

# slim — how do I hit 50 KB?
jq -n --argjson probe "$(cat probe.json)" '{
  task: "slim",
  probe: $probe,
  budget_kb_gzipped: 50,
  must_be_smooth_on: "mid-range-mobile",
  fidelity_tradeoff: "imperceptible"
}' > input.json

# embed — what is the React code?
jq -n --argjson probe "$(cat probe.json)" '{
  task: "embed",
  probe: $probe,
  stack: "react",
  playback: "on-view",
  loading: "lazy",
  context: "strict CSP, no inline scripts"
}' > input.json
COMPAT = {"task": "compat", "probe": probe,
          "targets": [{"id": "web-svg", "label": "lottie-web (SVG)"},
                      {"id": "ios", "label": "lottie-ios"}],
          "context": "same file runs on the marketing site and in the iOS app"}

SLIM = {"task": "slim", "probe": probe,
        "budget_kb_gzipped": 50,
        "must_be_smooth_on": "mid-range-mobile",     # or modern-desktop, low-end-mobile, smart-tv
        "fidelity_tradeoff": "imperceptible"}        # or lossless, aggressive

EMBED = {"task": "embed", "probe": probe,
         "stack": "react",          # next, vue, svelte, vanilla, webcomponent, ios, android
         "playback": "on-view",     # autoplay-loop, autoplay-once, on-hover, scroll-scrub
         "loading": "lazy",         # bundled, cdn
         "context": "strict CSP, no inline scripts"}

for name, payload in (("compat", COMPAT), ("slim", SLIM), ("embed", EMBED)):
    est = api("POST", "/estimate", payload)
    print(name, est["hold_credits"], "credits reserved")
const compat = { task: "compat", probe,
  targets: [{ id: "web-svg", label: "lottie-web (SVG)" }, { id: "ios", label: "lottie-ios" }],
  context: "same file runs on the marketing site and in the iOS app" };

const slim = { task: "slim", probe,
  budget_kb_gzipped: 50,
  must_be_smooth_on: "mid-range-mobile",   // modern-desktop | low-end-mobile | smart-tv
  fidelity_tradeoff: "imperceptible" };    // lossless | aggressive

const embed = { task: "embed", probe,
  stack: "react",         // next | vue | svelte | vanilla | webcomponent | ios | android
  playback: "on-view",    // autoplay-loop | autoplay-once | on-hover | scroll-scrub
  loading: "lazy",        // bundled | cdn
  context: "strict CSP, no inline scripts" };

for (const [name, payload] of Object.entries({ compat, slim, embed })) {
  const est = await api("POST", "/estimate", payload);
  console.log(name, est.hold_credits);
}
compat := map[string]any{
	"task":  "compat",
	"probe": probe,
	"targets": []map[string]string{
		{"id": "web-svg", "label": "lottie-web (SVG)"},
		{"id": "ios", "label": "lottie-ios"},
	},
	"context": "same file runs on the marketing site and in the iOS app",
}

slim := map[string]any{
	"task":              "slim",
	"probe":             probe,
	"budget_kb_gzipped": 50,
	"must_be_smooth_on": "mid-range-mobile", // modern-desktop | low-end-mobile | smart-tv
	"fidelity_tradeoff": "imperceptible",    // lossless | aggressive
}

embed := map[string]any{
	"task":     "embed",
	"probe":    probe,
	"stack":    "react",   // next | vue | svelte | vanilla | webcomponent | ios | android
	"playback": "on-view", // autoplay-loop | autoplay-once | on-hover | scroll-scrub
	"loading":  "lazy",    // bundled | cdn
	"context":  "strict CSP, no inline scripts",
}

for name, payload := range map[string]any{"compat": compat, "slim": slim, "embed": embed} {
	raw, err := call("POST", "/estimate", payload)
	if err != nil {
		panic(err)
	}
	fmt.Println(name, string(raw))
}
// The three lanes differ only in their extra fields; the client and the reply
// parser are shared, which is the whole point of the single envelope.
String compat = """
  { "task": "compat", "probe": %s,
    "targets": [ { "id": "web-svg", "label": "lottie-web (SVG)" },
                 { "id": "ios",     "label": "lottie-ios" } ],
    "context": "same file runs on the marketing site and in the iOS app" }
  """.formatted(probeJson);

String slim = """
  { "task": "slim", "probe": %s,
    "budget_kb_gzipped": 50,
    "must_be_smooth_on": "mid-range-mobile",
    "fidelity_tradeoff": "imperceptible" }
  """.formatted(probeJson);

String embed = """
  { "task": "embed", "probe": %s,
    "stack": "react", "playback": "on-view", "loading": "lazy",
    "context": "strict CSP, no inline scripts" }
  """.formatted(probeJson);

for (String payload : List.of(compat, slim, embed)) {
  System.out.println(call("POST", "/estimate", payload));
}
compat = {
  "task"    => "compat",
  "probe"   => probe,
  "targets" => [{ "id" => "web-svg", "label" => "lottie-web (SVG)" },
                { "id" => "ios",     "label" => "lottie-ios" }],
  "context" => "same file runs on the marketing site and in the iOS app",
}

slim = {
  "task"              => "slim",
  "probe"             => probe,
  "budget_kb_gzipped" => 50,
  "must_be_smooth_on" => "mid-range-mobile",  # modern-desktop | low-end-mobile | smart-tv
  "fidelity_tradeoff" => "imperceptible",     # lossless | aggressive
}

embed = {
  "task"     => "embed",
  "probe"    => probe,
  "stack"    => "react",    # next | vue | svelte | vanilla | webcomponent | ios | android
  "playback" => "on-view",  # autoplay-loop | autoplay-once | on-hover | scroll-scrub
  "loading"  => "lazy",     # bundled | cdn
  "context"  => "strict CSP, no inline scripts",
}

{ "compat" => compat, "slim" => slim, "embed" => embed }.each do |name, payload|
  est = api("POST", "/estimate", payload)
  puts "#{name} #{est["hold_credits"]} credits reserved"
end
$compat = [
    "task"    => "compat",
    "probe"   => $probe,
    "targets" => [["id" => "web-svg", "label" => "lottie-web (SVG)"],
                  ["id" => "ios",     "label" => "lottie-ios"]],
    "context" => "same file runs on the marketing site and in the iOS app",
];

$slim = [
    "task"              => "slim",
    "probe"             => $probe,
    "budget_kb_gzipped" => 50,
    "must_be_smooth_on" => "mid-range-mobile",  // modern-desktop | low-end-mobile | smart-tv
    "fidelity_tradeoff" => "imperceptible",     // lossless | aggressive
];

$embed = [
    "task"     => "embed",
    "probe"    => $probe,
    "stack"    => "react",    // next | vue | svelte | vanilla | webcomponent | ios | android
    "playback" => "on-view",  // autoplay-loop | autoplay-once | on-hover | scroll-scrub
    "loading"  => "lazy",     // bundled | cdn
    "context"  => "strict CSP, no inline scripts",
];

foreach (["compat" => $compat, "slim" => $slim, "embed" => $embed] as $name => $payload) {
    $est = api("POST", "/estimate", $payload);
    echo $name, " ", $est["hold_credits"], " credits reserved", PHP_EOL;
}
var compat = new {
    task = "compat",
    probe,
    targets = new[] {
        new { id = "web-svg", label = "lottie-web (SVG)" },
        new { id = "ios",     label = "lottie-ios" },
    },
    context = "same file runs on the marketing site and in the iOS app",
};

var slim = new {
    task = "slim",
    probe,
    budget_kb_gzipped = 50,
    must_be_smooth_on = "mid-range-mobile",   // modern-desktop | low-end-mobile | smart-tv
    fidelity_tradeoff = "imperceptible",      // lossless | aggressive
};

var embed = new {
    task = "embed",
    probe,
    stack = "react",       // next | vue | svelte | vanilla | webcomponent | ios | android
    playback = "on-view",  // autoplay-loop | autoplay-once | on-hover | scroll-scrub
    loading = "lazy",      // bundled | cdn
    context = "strict CSP, no inline scripts",
};

foreach (var (name, payload) in new (string, object)[] { ("compat", compat), ("slim", slim), ("embed", embed) })
{
    var est = await LottieShip.CallAsync(HttpMethod.Post, "/estimate", payload);
    Console.WriteLine($"{name} {est.GetProperty("hold_credits").GetInt32()}");
}