This is the abridged developer documentation for LIGR Developer Docs
# LIGR Developer Platform
> Build on the live graphics platform broadcasters already run on. REST, webhooks, code graphics, and native Rive.
LIGR Developer Platform # Build on the live graphics platform broadcasters already run on. Create matches, feed scores, fire graphics, react to events, and ship code or native Rive graphics. One API key, and a reference that matches what is deployed. [Make your first request](/get-started/first-request/)[API reference](/rest/) [REST APIMatches, facts, teams, overlays, streams and themes. JSON in and out. Scoped keys with expiry.Reference](/rest/)[WebhooksSigned POST requests for match, fact, team, competition and summary changes, per competition.Events](/webhooks/events/)[Graphics SDK BetaWrite a graphic in HTML and JavaScript. Talk `ligr.gfx.v1` to the overlay. Push and publish it into a theme LIGR creates for you.Start building](/graphics-sdk/)[Rive graphics BetaImport native Rive runtime or source projects. Bind live sports data, publish exact versions, and create operator presets.Import Rive](/rive-graphics/)[GuidesDrive a broadcast, push a spreadsheet, automate a stream, publish a theme. Step by step, with curl.All guides](/guides/) ## Your first request [Section titled “Your first request”](#your-first-request) Create an API key in the dashboard under **Developers → API keys**. Give it `matches:read`. Send it in the `Authorization` header as a Bearer token. Terminal
```bash
curl 'https://api.ligr.live/rest/v2/matches?limit=1' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
200 OK
```json
{
"matches": [
{
"id": 1188213,
"name": "Sydney FC v Melbourne City",
"sport": "football",
"date": "2026-09-12T09:30:00.000Z",
"competitionId": 2311,
"competitorsType": "teams",
"liveStatus": "pregame",
"finishedStatus": "default"
}
],
"total": 412,
"returned": 1
}
```
## Where to go next [Section titled “Where to go next”](#where-to-go-next) [Authentication](/get-started/authentication/)API keys, per-resource scopes, expiry, and how LIGR isolates organizations. [Concepts](/get-started/concepts/)The competition tree, the theme tree, and the ids that join them. [Verify webhook signatures](/webhooks/verify-signatures/)HMAC-SHA256 over v0:timestamp:body. [For AI agents](/ai-agents/)llms.txt, Markdown twins of every page, and the rules an agent must follow. ## Latest in the changelog [Section titled “Latest in the changelog”](#latest-in-the-changelog) **2026-09-07** — Themes and code graphics REST endpoints (`/v2/themes`). `UserInputError` now returns 400 instead of 500. Lock conflicts return 409 with the holder. [All changes](/changelog/)
# Authentication
> API keys, per-resource scopes, expiry, legacy keys, organization isolation, and how to rotate a key.
Every REST request carries an API key in a header. A key belongs to one organization. A key has one or more scopes, and an optional expiry date. ## API keys [Section titled “API keys”](#api-keys) Create a key in the dashboard. Open the account menu at the bottom of the sidebar and select **Developers**. On the **API keys** tab, select **Create API key**. Pick the access for each resource, pick an expiry, and select **Create key**. LIGR shows the full key once. After that it shows only the first four and the last four characters. Only an organization admin or owner sees **Developers**. If the menu does not show it, ask an admin or owner to create the key. A key starts with `ligr_`. LIGR stores only a hash of the key. If you lose a key, create a new one. ## Scopes [Section titled “Scopes”](#scopes) A scope is `:read` or `:write`. `write` on a resource includes `read` on that resource. The dashboard groups resources by product area. Each resource has a **Read** box and a **Write** box. Select **Write** to also select **Read**. A group header box sets every resource in that group. The key list shows the scopes of each key. | Boxes selected | Scope | | -------------- | ------------------ | | None | none | | Read | `:read` | | Read and Write | `:write` | Each endpoint in the [REST reference](/rest/) names the scope it needs. | Resource | Endpoints | | -------------- | --------------------------------------------------------------------------- | | `matches` | `/v1/matches`, `/v2/matches`, facts, lineups and match summaries | | `competitions` | `/v1/competitions` | | `teams` | `/v1/teams`, team rosters and adding players to a team | | `players` | `/v1/players`, `/v1/officials` | | `venues` | `/v1/venues` | | `overlays` | `/v1/overlays`, `/v2/overlays`, graphics commands and control room graphics | | `streams` | `/v1/streams`, go-live, overlay show and hide | | `data-sources` | `/v2/data-sources` | | `themes` | `/v2/themes`, code graphics, `/v2/schemas`, `/v2/scenarios` | The resource is the first path segment after the version, except `/v1/officials`, which uses `players`. `read` allows every `GET` under that segment. `write` allows every request under that segment. No endpoint requires `players:write` or `data-sources:read` yet. The dashboard disables these two boxes. To create a player, use `POST /v1/teams/{teamId}/players` with `teams:write`. A key that lacks the scope for a route returns **403** with `code: "INSUFFICIENT_SCOPE"`. The message names the missing scope. LIGR checks the scopes of the key, not the role of the user who created it. Grant the smallest set of scopes that works. A score feed needs `matches:write`. A results website needs `matches:read` and `competitions:read`. ## Expiry [Section titled “Expiry”](#expiry) Pick an expiry when you create the key: 7, 30, 60, 90 days, one year, or a custom date. The default is 30 days. An expired key returns **401** with `code: "API_KEY_EXPIRED"`. Create a new key before the old one expires. See [Rotate a key](#rotate-a-key). You can also choose **No expiration**. The dashboard warns you first. A key with no expiry stays valid until you delete it. Choose it only when the integration cannot rotate keys. ## Legacy keys [Section titled “Legacy keys”](#legacy-keys) Keys created before scopes existed are legacy keys. The dashboard labels them **Legacy**. * A legacy `read` key has `read` on every resource. * A legacy `write` key has `write` on every resource. * A legacy key never expires. * A legacy key keeps working. You cannot create a new one. Move each integration to a scoped key, then delete the legacy key. ## Send the key [Section titled “Send the key”](#send-the-key) Use `Authorization: Bearer ` for new integrations. Both scoped and legacy keys work with this header. Terminal
```bash
curl https://api.ligr.live/rest/v1/competitions \
-H 'Authorization: Bearer YOUR_API_KEY'
```
Existing integrations can keep using the legacy `x-ligr-api-key` header:
```http
x-ligr-api-key: YOUR_API_KEY
```
Both headers use the same key validation, scopes, and expiry checks. Changing the header does not require a new key. Send one authentication header per request. If both headers are present, they must contain the same key. Different keys return **401**. An invalid `Authorization` format also returns **401**, even when `x-ligr-api-key` contains a valid key. REST routes ignore keys in the query string. Caution Send an API key as the Bearer token. REST routes do not accept dashboard or overlay JWTs. ## Who can create keys [Section titled “Who can create keys”](#who-can-create-keys) Organization **owners** and **admins** can create and delete a key. A key acts for the whole organization, within its scopes. It sees every competition and match that the organization owns, and nothing else. A request for a resource in another organization is always refused. The status is not uniform today: theme routes return **404**, and match, team, player, overlay, stream and data source routes return **403**. Do not read ownership from the status code. ## Rotate a key [Section titled “Rotate a key”](#rotate-a-key) There is no REST endpoint for keys today. Follow these steps. 1. Create the new key in the dashboard with the same scopes. 2. Deploy the new key to your service. 3. Delete the old key in the dashboard. Both keys work during the overlap. Deletion takes effect at once. ## Errors [Section titled “Errors”](#errors) | Status | When | Body | | ------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | 401 | The key is missing or unknown, the Authorization format is invalid, or the headers contain different keys | `{ "message": "…", "code": "UNAUTHENTICATED" }` | | 401 | The key has expired | `{ "message": "…", "code": "API_KEY_EXPIRED" }` | | 403 | The key is valid but lacks the scope for the route | `{ "message": "…", "code": "INSUFFICIENT_SCOPE" }` | | 403 | The resource belongs to another organization, on most routes | `{ "message": "…", "code": "FORBIDDEN" }` | | 404 | The resource does not exist, or a theme belongs to another organization | `{ "message": "Not Found" }` | | 429 | The rate limit is exceeded and enforcement is on | `{ "message": "Api key has made too many requests" }` | See [Errors](/get-started/errors/) for the full status map. See [Rate limits](/get-started/rate-limits/) for the headers and the 429 response.
# Concepts
> The competition tree, the theme tree, how an overlay joins them, and the ids you need.
Two object trees explain the whole API. The competition tree is what happens on the pitch. The theme tree is what appears on screen. An overlay joins them for one match. ## The competition tree [Section titled “The competition tree”](#the-competition-tree) REST v1 and v2 read and write this tree. | Object | What it is | | --------------- | ----------------------------------------------------------------------------- | | **Competition** | A league or a tournament. It owns teams, venues, matches and one control room | | **Match** | Sport, date, competitors, periods, clock, `liveStatus` and `finishedStatus` | | **Facts** | Time-stamped events under a match: a goal, a card, a point, a substitution | | **Summary** | The score, periods and statistics that LIGR computes from the facts | | **Lineup** | The match players of one match, as starters and bench | | **Overlay** | A per-match graphics session with a secret key | | **Stream** | Encode settings, inputs, destinations and stream state | A competition holds matches. A match holds facts, a summary and a lineup. A match also holds an overlay and a stream. ## The theme tree [Section titled “The theme tree”](#the-theme-tree) REST v2, the Rive Graphics Builder, and the Graphics SDK read and write this tree. | Object | What it is | | ----------------------------- | -------------------------------------------------------------------------------- | | **Theme** | A sport-scoped set of graphics. Its `activeVersion` is what overlays render | | **Graphic** | One graphic, either Rive or code. `graphicId` is stable. `id` changes on publish | | **Working version** | The mutable draft you push to | | **Published versions** | Immutable snapshots, numbered 1, 2, 3 and up | | **Assets** | The files of a graphic: code file, image, font or Rive file | | **Theme version** | A frozen list of graphic versions | | **Competition theme setting** | Binds a theme to a competition. It holds theme variable values and data sources | A publish picks the latest published version of each graphic, unless you name a version. ## Where the trees meet [Section titled “Where the trees meet”](#where-the-trees-meet) An overlay belongs to one match. It renders the active theme version of the competition of that match. Its `key` forms the browser-source URL. Browser source, 1920×1080
```http
https://overlay.ligr.live/production-3b2b1c0e-…
```
A control room is the button layout that an operator uses for that theme. Each button is a preset. You can fire a preset over REST. See [Overlays & control room](/control-room/). Fire a preset
```http
POST /rest/v2/overlays/2300003/control-room/graphics
```
## Ids that matter [Section titled “Ids that matter”](#ids-that-matter) | Id | Where you get it | Used by | | --------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `matchId` | Search matches, create a match, or the dashboard URL | Matches, facts, summary, overlays, webhooks | | `overlayId` | A match with `include=o`, or create an overlay | Graphics commands, control room graphics, stream overlay change | | `streamSettingsId` | A match with `include=s` | Stream state and stream commands | | `competitionThemeSettingId` | `GET /v2/competitions/{competitionId}/theme-profiles`, or the theme settings of the competition | [Data source push](/control-room/data-sources/) | | `themeId` | The dashboard theme URL, or get a theme | [Push and publish](/graphics-sdk/push-and-publish/) | | `graphicId` | A code or Rive creation response, or a theme read | Files, configuration, presets, and versions. It is stable across publishes | | `presetId` | `GET /v2/control-rooms/{roomId}/presets` | [Presets](/control-room/presets/) |
# Errors
> Every REST status code, the error codes you can branch on, and the response bodies.
Every error is JSON with a `message`. Some errors also carry a `code` you can branch on. Some carry `details`. ## Status codes [Section titled “Status codes”](#status-codes) | Status | Meaning | Body | | ------ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | 400 | Validation failed, or a user input error | `{ message: "Validation Failed", details: { "body.date": { message, value } } }` | | 401 | No key, an unknown key, or an expired key | `{ message, code: "UNAUTHENTICATED" }` or `{ message, code: "API_KEY_EXPIRED" }` | | 403 | A valid key without the scope the route needs | `{ message, code: "INSUFFICIENT_SCOPE" }` | | 403 | A resource owned by another organization, on most routes | `{ message, code: "FORBIDDEN" }` | | 404 | Not found, or a theme owned by another organization | `{ message }` | | 409 | A graphic is locked, a publish conflicts, or a stored graphic asset fails verification | `{ message, code }`; `LOCKED` also includes `holder.userName` | | 429 | The rate limit is exceeded while enforcement is on | `{ message }` | | 500 | Unexpected. Retry once, then contact support | `{ message }` | For a 500, send the `traceparent` header value you used to support. It lets LIGR find your request. ## Error codes [Section titled “Error codes”](#error-codes) | `code` | Status | When | Fix | | -------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `API_KEY_EXPIRED` | 401 | The key passed its expiry date | Create a new key in the dashboard | | `INSUFFICIENT_SCOPE` | 403 | The key lacks the scope the route needs. The message names the scope | Create a key with that scope | | `LOCKED` | 409 | Someone has the graphic open in the dashboard editor, or a stale lock has not expired. A lock expires after 60 seconds | Wait, then retry. `holder.userName` names the editor | | `CONFLICT` | 409 | Two publishes raced | Read the theme again, then publish again | | `GRAPHIC_ASSET_MISSING` | 409 | A graphic or theme publish references an S3 asset that is missing | Upload the missing asset, then publish again | | `GRAPHIC_ASSET_INVALID` | 409 | An asset cannot be read, or its stored size or SHA-256 differs from the recorded value | Replace the asset with the intended bytes, then publish again | | `CODE_GRAPHIC_INVALID` | 400 | An unknown control variable type, or a bundle that names a file you did not upload in the session it names and the graphic does not already hold | Read `details[]`. Each line is one problem. Upload the named file, then record the bundle with the `uploadSessionId` of that upload | | `BUNDLE_TOO_LARGE` | 400 | The bundle is over 10 MiB, or one file is over 5 MiB | Read `details[]`. It lists the files that are too large | | `THEME_LIMIT_REACHED` | 409 | Your organization already owns five themes | Delete a theme you no longer use, or ask your LIGR contact | | `THEME_IN_USE` | 409 | A competition still uses the theme you want to delete | Read `details[]`. Remove the theme instance from each competition, then delete again | | `GRAPHIC_TYPE_MISMATCH` | 400 | The id belongs to a Rive graphic, and the route serves code graphics | Use the [Rive graphics](/rive-graphics/) routes for a Rive graphic | | `INVALID_ASSET_PATH` | 400 | A file name holds `..`, an empty segment, a control character, or one of `?`, `#`, `%` | Rename the file | | `RUNTIME_HAS_NO_EDITABLE_SOURCE` | 400 | A raw `.riv` upload requested source retention | Send `retainSource: false`. The runtime revision preserves those bytes | | `INVALID_SELECTION` | 422 | The chosen artboard, state machine, or archive entry is unavailable | Read current import metadata, then send an exact returned index or entry | | `SCRIPT_SIGNING_UNSUPPORTED` | 422 | Source requires script signing, which this converter cannot perform | Export a signed `.riv` externally, then upload the runtime | | `IMPORT_UNAVAILABLE` | 503 | Source import is disabled or not qualified in this environment | Read the capability response and use an available path | ## Validation errors [Section titled “Validation errors”](#validation-errors) A 400 from schema validation names each bad field. 400 Bad Request
```json
{
"message": "Validation Failed",
"details": {
"body.date": {
"message": "invalid ISO 8601 date",
"value": "12-09-2026"
}
}
}
```
## Retries [Section titled “Retries”](#retries) | Status | Retry? | | ------------------ | ------------------------------------------------------------------------------------------------------------- | | 400, 401, 403, 404 | No. Fix the request | | 409 | For `LOCKED` or `CONFLICT`, retry after resolving the conflict. For `GRAPHIC_ASSET_*`, repair the asset first | | 429 | Yes, after the `RateLimit-Reset` seconds. See [Rate limits](/get-started/rate-limits/) | | 500 | Once. Then contact support | Make write requests safe to repeat. Read the entity back before you write it again.
# Your first request
> Create a key, list matches with curl, read the response, and choose what to do next.
This page takes about five minutes. You need an API key and a terminal. 1. **Create a read key.** Open the LIGR dashboard. Open the account menu at the bottom of the sidebar and select **Developers**. On the **API keys** tab, select **Create API key**. Set **Matches** to **Read**, keep the 30 day expiry, and select **Create key**. Copy the key. LIGR shows it once. 2. **List one match.** Replace `YOUR_READ_KEY` with the key you copied. Terminal
```bash
curl 'https://api.ligr.live/rest/v2/matches?limit=1' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
3. **Read the response.** 200 OK
```json
{
"matches": [
{
"id": 1188213,
"name": "Sydney FC v Melbourne City",
"sport": "football",
"date": "2026-09-12T09:30:00.000Z",
"competitionId": 2311,
"competitorsType": "teams",
"liveStatus": "pregame",
"finishedStatus": "default"
}
],
"total": 412,
"returned": 1
}
```
`total` is the number of matches your organization owns. `returned` is the number in this response. Keep the `id`. Most other endpoints need it. 4. **Fetch one match with its overlay.** Terminal
```bash
curl 'https://api.ligr.live/rest/v2/matches/1188213?include=o,cp' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
`include=o,cp` adds the overlay and the competitors to the response. See [Includes & pagination](/get-started/includes-and-pagination/). ## If the request fails [Section titled “If the request fails”](#if-the-request-fails) | Status | Cause | Fix | | ------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | 401 | The key is missing or invalid, or the authentication headers are invalid | Use `Authorization: Bearer `. See [Authentication](/get-started/authentication/#send-the-key) | | 403 | The key lacks the scope for the route | Create a key with the scope the message names | | 404 | The id belongs to another organization | Search the list endpoint first | ## What to do next [Section titled “What to do next”](#what-to-do-next) * Write data: post a fact to a match. See the [Facts](/rest/operations/tags/facts/) reference. * React to changes: subscribe a webhook instead of polling. See [Webhooks overview](/webhooks/overview/). * Understand the object model: read [Concepts](/get-started/concepts/).
# Includes & pagination
> Fetch related entities with include, and page through lists with limit and offset.
## Includes [Section titled “Includes”](#includes) Many `GET` endpoints accept the `include` query parameter. It populates the response with related entities, so you make fewer requests. | Value | Entity | | ----- | ------------ | | `m` | matches | | `o` | overlays | | `t` | teams | | `c` | competitions | | `v` | venues | | `p` | players | | `s` | streams | | `cp` | competitors | Send a comma-separated list. Each endpoint accepts a subset. The reference page for the endpoint lists the values. The v1 match endpoints accept `t` for teams. The v2 match endpoints accept `cp` for competitors instead. Terminal
```bash
curl 'https://api.ligr.live/rest/v1/matches/1188213?include=t,o,c' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
### Limits [Section titled “Limits”](#limits) * Includes apply one level deep. An included team does not carry its own includes. * Each endpoint has its own allow-list. The reference page of each endpoint names the values it accepts. * An unknown or unsupported value is ignored. The request succeeds and the response omits that object. The API returns no error. Check the response for the objects you asked for. ## Pagination [Section titled “Pagination”](#pagination) List endpoints take `limit` and `offset`. | Parameter | Meaning | Default | | --------- | ------------------------------------ | ------------------- | | `limit` | The number of items in this response | Set by the endpoint | | `offset` | The number of items to skip | `0` | The maximum `limit` is 100 on match lists. It is 500 on the other lists. ### Response shape [Section titled “Response shape”](#response-shape) Most list endpoints return the item array, a total and a returned count. 200 OK
```json
{
"matches": [ /* … */ ],
"total": 412,
"returned": 50
}
```
* `total` is the number of items that match the filter, across all pages. * `returned` is the number of items in this response. Some smaller lists return a `count` instead of `total` and `returned`. The reference page of each endpoint shows the exact shape. ### Page through a list [Section titled “Page through a list”](#page-through-a-list) Increase `offset` by `limit` until `offset` reaches `total`. Page two of fifty
```bash
curl 'https://api.ligr.live/rest/v2/matches?limit=50&offset=50' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
Caution Do not poll a list endpoint to detect changes. Use [webhooks](/webhooks/overview/). Polling counts against your [rate limit](/get-started/rate-limits/).
# Overview
> What you can build on LIGR, the three developer surfaces, and what you need before your first request.
LIGR runs live sports graphics and streams for broadcasters, leagues and federations. This site documents four surfaces you can build on. ## The four surfaces [Section titled “The four surfaces”](#the-four-surfaces) | Surface | What it does | Where to start | | ------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | | REST API | Read and write matches, facts, teams, overlays, streams and themes | [Your first request](/get-started/first-request/) | | Webhooks | Signed POST requests when a match, fact, team, competition or summary changes | [Webhooks overview](/webhooks/overview/) | | Graphics SDK | Write a graphic in HTML and JavaScript, then publish it into a theme | [Graphics SDK](/graphics-sdk/) | | Rive graphics | Import a native Rive runtime or source project, then bind live data | [Rive graphics](/rive-graphics/) | ## What you can build [Section titled “What you can build”](#what-you-can-build) * A scoring system that creates matches and posts facts as the match runs. * A broadcast automation that fires graphics on an overlay and drives the stream. * A data push from a spreadsheet or a feed into a theme data source. * Your own HTML graphics, published into a theme that operators use. * Native Rive graphics bound to live match data and control-room variables. ## Before you start [Section titled “Before you start”](#before-you-start) 1. Get an organization on LIGR. Ask your LIGR contact if you do not have one. 2. Ask an owner or an admin of that organization to create an API key. 3. Send that key on every request to `https://api.ligr.live/rest`. The examples in these docs use production: `api.ligr.live` for REST and `overlay.ligr.live` for overlays. If LIGR gave you a non-production environment, use its API and overlay origins instead. A key works only in the environment that issued it. ## The rest of this section [Section titled “The rest of this section”](#the-rest-of-this-section) | Page | What it answers | | -------------------------------------------------------------- | ---------------------------------------------------------------- | | [Authentication](/get-started/authentication/) | How keys work, which scopes exist, and how expiry works | | [Your first request](/get-started/first-request/) | A working `curl` call and the response it returns | | [Concepts](/get-started/concepts/) | The competition tree, the theme tree, and the ids that join them | | [Includes & pagination](/get-started/includes-and-pagination/) | How to fetch related entities and page through lists | | [Errors](/get-started/errors/) | Every status code, error code and response body | | [Rate limits](/get-started/rate-limits/) | The `RateLimit` headers, observe mode, and the enforcement dates | | [Versioning](/get-started/versioning/) | Why `/v1` and `/v2` run side by side, and the deprecation policy |
# Rate limits
> The RateLimit headers, observe mode, the 429 response, and how to stay inside the limits.
LIGR applies fair-use limits to the REST API. LIGR does not publish a fixed request budget. Unauthenticated requests are limited per IP address. Read the headers on every response, and slow down before they reach zero. ## Headers [Section titled “Headers”](#headers) Every response carries the IETF draft headers.
```http
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 41
RateLimit-Policy: 15;w=1, 300;w=60, 6000;w=3600
```
| Header | Meaning | | --------------------- | ----------------------------------------------------- | | `RateLimit-Limit` | The number of requests the current window allows | | `RateLimit-Remaining` | The number of requests left in the current window | | `RateLimit-Reset` | The number of seconds until the current window resets | | `RateLimit-Policy` | Every window in force, as `;w=` | More than one window can apply at the same time. `RateLimit-Policy` lists every window in force. ## Observe mode [Section titled “Observe mode”](#observe-mode) LIGR is phasing enforcement in. While enforcement is off, an over-limit request still succeeds. The response carries `x-ligr-rate-limit-observe: 1`. Caution Treat `x-ligr-rate-limit-observe: 1` as a 429 in your client. Back off when you see it. Your requests will fail once enforcement starts. ### Enforcement [Section titled “Enforcement”](#enforcement) Enforcement starts on a date to be announced in the [changelog](/changelog/). Until then, treat `x-ligr-rate-limit-observe: 1` as a 429. ## Over the limit [Section titled “Over the limit”](#over-the-limit) An enforced over-limit request returns **429**. 429 Too Many Requests
```json
{ "message": "Api key has made too many requests" }
```
Wait `RateLimit-Reset` seconds, then retry. ## Stay inside the limits [Section titled “Stay inside the limits”](#stay-inside-the-limits) * Use [webhooks](/webhooks/overview/) instead of polling. * Batch reads with [`include`](/get-started/includes-and-pagination/) so one request replaces four. * Cache `GET` responses on your side. * Contact support if your integration needs a higher limit.
# Versioning
> Why v1 and v2 run side by side, how LIGR ships breaking changes, and the deprecation policy.
LIGR versions each resource, not the whole API. The path holds the version.
```http
GET /rest/v1/matches/{matchId}
GET /rest/v2/matches/{matchId}
```
Both versions run at the same time. A resource moves to v2 when its shape changes in a way that would break v1 clients. Resources that have not changed stay on v1 only. A new resource starts at the current version. It has no older path. For example, `PUT /rest/v2/matches/{matchId}/lineup` and every `/rest/v2/themes` route exist only under v2. A v2 path does not mean that a v1 path exists. The [REST reference](/rest/) adds **(v1)** or **(v2)** to a label only when both versions of that operation exist. The two sit next to each other in the sidebar. Use the higher version for new work. A label without a version has one path only. ## What LIGR can change without a new version [Section titled “What LIGR can change without a new version”](#what-ligr-can-change-without-a-new-version) * A new optional field in a response. * A new optional query parameter or body field. * A new endpoint. * A new enum value in a field that already accepts several values. Write your client so that an unknown field does not break it. ## What needs a new path version [Section titled “What needs a new path version”](#what-needs-a-new-path-version) * A field that is removed or renamed. * A field whose type changes. * A required parameter that is added. * A change in the meaning of an existing field. ## Beta endpoints [Section titled “Beta endpoints”](#beta-endpoints) Some endpoints ship as beta before their shape is final. Today the Themes and Code graphics operations under `/v2/themes` are beta. See [Beta status](/graphics-sdk/#beta-status). A beta endpoint can change in ways that the rules above reserve for a new path version. Each change gets a changelog entry, and a removed or renamed field keeps working for at least **30 days** after that entry. A beta endpoint is marked in three places. | Where | Marker | | -------------------- | --------------------------------------------------------- | | The OpenAPI document | `x-beta: "true"` on the operation | | The reference page | A **Beta** line at the top of the operation | | Every response | `X-Ligr-Beta: true` and a `Link` header with `rel="help"` | ## Deprecation policy [Section titled “Deprecation policy”](#deprecation-policy) A deprecated endpoint keeps working for at least **12 months** after its changelog entry. While it is deprecated it returns two headers. | Header | Meaning | | ------------- | --------------------------------------- | | `Deprecation` | The date the endpoint became deprecated | | `Sunset` | The date the endpoint stops working | Watch the [changelog](/changelog/) for the entry, and read the `Sunset` header in your client logs. ## The specification version [Section titled “The specification version”](#the-specification-version) The OpenAPI document carries its own version in `info.version`. It is the version of the newest [changelog](/changelog/) entry, so the reference, the changelog and the API always show the same number. Read it at `https://api.ligr.live/rest/swagger.json`. [/openapi.json](/openapi.json) holds the same operations as that document, regrouped for this site. Generate a client from `swagger.json`. Read `/openapi.json` to see what this site shows. The [REST reference](/rest/) on this site is generated from that document, so it never drifts from what is deployed.
# For AI agents
> llms.txt, the Markdown twin of every page, the machine-readable endpoints, and the rules an agent must follow.
Every page on this site is also plain Markdown. Give an agent the index below and an API key. The agent can then complete any task on this site without a human. ## Start here [Section titled “Start here”](#start-here) Fetch [`/llms.txt`](/llms.txt). It names every machine-readable file this site serves. Terminal
```bash
curl https://docs.ligr.live/llms.txt
```
Then fetch the set you need. [`/llms-full.txt`](/llms-full.txt) is every written page in one file. [`/llms-small.txt`](/llms-small.txt) is the same set with the notes and the tips removed. To make Rive graphics from prompts with the Rive CLI, see [Build graphics with an AI agent](/rive-graphics/#build-graphics-with-an-ai-agent). ## Machine-readable endpoints [Section titled “Machine-readable endpoints”](#machine-readable-endpoints) | URL | What it is | | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | The index of the files below | | [`/llms-full.txt`](/llms-full.txt) | Every written page, concatenated, in sidebar order | | [`/llms-small.txt`](/llms-small.txt) | The same set, with the notes and the tips removed | | [`/_llms-txt/get-started.txt`](/_llms-txt/get-started.txt) | The Get started pages only | | [`/_llms-txt/webhooks.txt`](/_llms-txt/webhooks.txt) | The Webhooks pages only | | [`/_llms-txt/graphics-sdk.txt`](/_llms-txt/graphics-sdk.txt) | The Graphics SDK pages only. Beta | | [`/_llms-txt/rive-graphics.txt`](/_llms-txt/rive-graphics.txt) | The native Rive import and lifecycle pages only. Beta | | [`/_llms-txt/control-room.txt`](/_llms-txt/control-room.txt) | The Overlays & control room pages only | | [`/_llms-txt/guides.txt`](/_llms-txt/guides.txt) | The Guides only | | [`/_llms-txt/for-ai-agents.txt`](/_llms-txt/for-ai-agents.txt) | This page only | | [`/openapi.json`](/openapi.json) | The OpenAPI 3 specification. The same operations as `api.ligr.live/rest/swagger.json`, regrouped and renamed for this site. The v1 and v2 twins carry version-suffixed operation ids and summaries, for example `GetMatchV1` | | `/.md` | The Markdown source of that page, front matter included | | [`/changelog/rss.xml`](/changelog/rss.xml) | The changelog as an RSS feed | For example, [`/get-started/errors.md`](/get-started/errors.md) is the Markdown twin of the [Errors](/get-started/errors/) page. Every content page and every REST operation page has a Markdown twin. For example, [`/rest/operations/getmatchsummary.md`](/rest/operations/getmatchsummary.md) is the twin of the match summary endpoint page. Listing pages such as the changelog do not have a twin. Each page with a twin carries a **Copy page as Markdown** button. ## Rules for agents [Section titled “Rules for agents”](#rules-for-agents) 1. **Send every request to the REST base URL of your environment.** Production is `https://api.ligr.live/rest`. If the user gave you a non-production environment, use its API origin. Never send a key from one environment to another environment. 2. **Send an API key, never a JWT.** REST routes do not accept dashboard or overlay JWTs. Use `Authorization: Bearer `. 3. **Read before you write.** Fetch the match, the theme or the overlay first. An id from another organization returns 404. 4. **Respect `409 LOCKED`.** A person has the graphic open. Wait, then retry. Do not delete it and create it again. 5. **Stop on `x-ligr-rate-limit-observe`.** Treat that header as a 429, even though the request succeeded. 6. **Never publish a theme version with `activate: true` during a live match.** Do it only when a person asks for exactly that.
# Overview
> What an overlay is, the two endpoints that drive graphics on it, and where the ids come from.
An overlay is the per-match graphics session. It is the browser source that your vision mixer loads. Everything you fire over REST lands on an overlay. Browser source, 1920×1080
```http
https://overlay.ligr.live/production-3b2b1c0e-…
```
Two endpoints drive graphics on an overlay. Both need the `overlays:write` scope. | Endpoint | Use it when | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `POST /v2/overlays/{overlayId}/graphics/commands` | You address a graphic by name or UUID and you own every variable value. See [Graphics commands](/control-room/graphics-commands/). | | `POST /v2/overlays/{overlayId}/control-room/graphics` | You fire a control room preset by id, and the server fills in the values the operator configured. See [Presets](/control-room/presets/). | A third endpoint feeds data into the theme, not commands. See [External data sources](/control-room/data-sources/). ## What a control room is [Section titled “What a control room is”](#what-a-control-room-is) A control room is the button layout an operator uses during a match. Your organization owns it, and it uses one theme. Each button is a preset. A preset names one graphic and the values of the variables the operator can see. The dashboard groups presets into sections. A competition’s theme profile can assign a default control room. An overlay can select another control room for the same theme. Fire the same preset over REST, and an automation does what the operator does in manual mode. ## Set up a code graphic control room [Section titled “Set up a code graphic control room”](#set-up-a-code-graphic-control-room) Use [Set up through REST](/control-room/setup/) to prepare the full session before opening the dashboard. The sequence creates the competition, teams, venue, match, theme profile, room, sections, presets, and manual overlay. It returns a `controlRoomUrl` that opens the finished setup. Publish and activate your code graphic first through [REST or the CLI](/graphics-sdk/push-and-publish/). The same flow supports plain HTML and any browser rendering library. ## Manual mode [Section titled “Manual mode”](#manual-mode) Dashboard preset commands and both REST command endpoints update manual graphic state. The overlay must use manual mode to display that state. Sending a REST command does not switch the overlay into manual mode. The **MANUAL** switch is unavailable when no overlay is selected, the overlay follows a controller, or its ad type is **Free**. For **Free** overlays, change the ad type to **Brands** or **No Brands** in overlay settings before enabling manual mode. For linked overlays, select the controller and enable manual mode there. Theme activation is separate from manual mode. Reload existing overlay pages after activating a different theme version. ## Check what is on screen [Section titled “Check what is on screen”](#check-what-is-on-screen) No endpoint reads the live state of an overlay. `manualGraphicState` comes back only from the command you just sent. It proves the server accepted the command. It does not prove a graphic appeared. Open the monitoring view of the overlay to see the result. Read `monitoringUrl` from the overlay response. `GET /v2/overlays/{overlayId}` and `POST /v1/overlays` return it. `GET /v2/control-rooms/{roomId}` returns it for every overlay that uses the room when the key has `overlays:read`. Do not build the URL yourself. Monitoring view, safe to open at any time
```http
https://overlay.ligr.live/monitoring-{key}
```
The example shows the production host, `overlay.ligr.live`. Other environments use their own overlay host, and `monitoringUrl` always carries the correct one. Keep the monitoring tab visible and focused. Background tabs pause animation. A graphic’s element exists only while it is shown. Do not use DOM presence to check visibility. Use `monitoring-` for every check of your own. The `production-` URL of the same key belongs to the vision mixer. One production session can hold an overlay at a time, so opening that URL during a match can take the session from the mixer, or fail because the mixer already holds it. A production session also counts against ad metrics and the account balance. A monitoring view does neither. A command can answer `200` for a graphic that never appears. The graphic loads, and its own visibility never turns on. Check these in order. * The overlay uses manual mode. See [Manual mode](#manual-mode) above. * The theme version you activated holds the graphic version you published. * A Rive graphic binds its artboard visibility to the reserved `hide` variable. A graphic that binds visibility to a variable of its own accepts every command and never appears. See [How it works](/rive-graphics/how-it-works/). * A code graphic declares each variable you send in its manifest. See [Graphics commands](/control-room/graphics-commands/) for what happens to a value the graphic does not declare. ## Linked overlays [Section titled “Linked overlays”](#linked-overlays) An overlay can follow another overlay. The follower shows what the controller shows. Send commands to the controller. The API applies the state to the controller and to every follower in one write. A command sent to a follower fails. ## Where the ids come from [Section titled “Where the ids come from”](#where-the-ids-come-from) | Id | Where you get it | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `overlayId` | A match with `include=o`, or [create an overlay](/rest/operations/createoverlay/) | | `graphicUuid` and graphic `name` | `GET /v2/themes/{themeId}/code-graphics` or `GET /v2/themes/{themeId}/rive-graphics` returns `graphicId` and `name` | | `presetId` | `POST` or `GET /v2/control-rooms/{roomId}/presets` | | `competitionThemeSettingId` | `POST` or `GET /v2/competitions/{competitionId}/theme-profiles` | | Data source `alias` | [External data sources](/control-room/data-sources/) | To list every preset of a room with its UUID and configured values, call `GET /v2/control-rooms/{roomId}/presets`. Find the room with `GET /v2/control-rooms?themeId={themeId}`. Find the overlays of a match
```bash
curl 'https://api.ligr.live/rest/v2/matches/1188213?include=o' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
Find the overlays of every match in a competition
```bash
curl 'https://api.ligr.live/rest/v2/matches?competitionId=4821&include=o&page=1&pageSize=50' \
-H 'Authorization: Bearer YOUR_READ_KEY'
```
## The state you get back [Section titled “The state you get back”](#the-state-you-get-back) Every command returns the overlay id and the full `manualGraphicState`. The state is a map keyed by graphic UUID. Each entry holds the `variableValues` in force and the preset that set them. Response
```json
{
"overlayId": 2300003,
"manualGraphicState": {
"8efdf988-1f4d-43ac-873b-06b9b4f3e379": {
"_ligr_presetId": 1091,
"variableValues": { "CustomText": "Centre Court", "hide": false }
}
}
}
```
Keys that start with `_ligr_` are server bookkeeping. Read them if you want. Never send them.
# External data sources
> Push a table, a key-value list or one value into a theme data source, as JSON or as CSV, and every overlay of the competition updates.
A theme can declare external data sources: a standings table, a fact file, a sponsor line. Your graphics read them. You fill them over REST, from a script, a spreadsheet or an automation tool.
```http
POST /v2/data-sources/{competitionThemeSettingId}/{alias}
```
Needs the `data-sources:write` scope. One data source is one `alias` in one competition theme setting. Each push replaces the whole snapshot. Every overlay of that competition receives the new data at once. ## Where the ids come from [Section titled “Where the ids come from”](#where-the-ids-come-from) | Id | Where you get it | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `competitionThemeSettingId` | `GET /v2/competitions/{competitionId}/theme-profiles`: the `id` of a theme profile. Also under the competition, then the theme, in the dashboard | | `alias` | The data sources of the theme in the dashboard. Each data source shows its alias, its shape and its schema. To create a data source over REST, see [Data schemas](/rive-graphics/theme-resources/#data-schemas) | The dashboard offers a Google Apps Script per theme that pushes a sheet to this endpoint. Find it under the theme settings of the competition. ## Two payload formats [Section titled “Two payload formats”](#two-payload-formats) Send `data` or `csv`. A body with neither returns 400. ### Pre-formatted JSON [Section titled “Pre-formatted JSON”](#pre-formatted-json) Send the object that matches the schema of the data source. Use this format for a nested schema and for any integration that already has structured data. Push JSON
```bash
curl -X POST 'https://api.ligr.live/rest/v2/data-sources/8123/standings' \
-H 'Authorization: Bearer YOUR_WRITE_KEY' \
-H 'Content-Type: application/json' \
-d '{ "data": { "rows": [ { "team": "Eagles", "score": 42 }, { "team": "Hawks", "score": 38 } ] } }'
```
### CSV [Section titled “CSV”](#csv) Send the sheet as one string. The server parses it into the shape of the data source. Use this format for flat tables, for example from a spreadsheet export or a copy and paste. Push CSV
```bash
curl -X POST 'https://api.ligr.live/rest/v2/data-sources/8123/standings' \
-H 'Authorization: Bearer YOUR_WRITE_KEY' \
-H 'Content-Type: application/json' \
-d '{ "csv": "team,score\nEagles,42\nHawks,38" }'
```
CSV cannot express a nested object. If the schema nests objects, build the JSON yourself. ## How CSV is parsed [Section titled “How CSV is parsed”](#how-csv-is-parsed) * The delimiter is detected from the first line. Tab wins, then comma, then semicolon. * Quoted fields can hold the delimiter and line breaks. Two double quotes inside a quoted field are one quote. * Empty rows are dropped. * `true` and `false` become booleans. An empty cell and the word `null` become `null`. * A cell that is a number of 15 characters or fewer becomes a number. Longer digit strings stay strings, so phone numbers and long ids survive. * A cell that holds a JSON array of strings, numbers or booleans becomes that array, for example `["a","b"]`. ## Shapes [Section titled “Shapes”](#shapes) The shape of a data source is fixed in the theme. The parser turns the sheet into that shape. | Shape | Sheet | Result | | ------ | ----------------------------------------------------- | ------------------------------------------- | | `rows` | Row 1 holds the headers. Each later row is one record | `{ "rows": [ { "header": value, … }, … ] }` | | `kv` | Each row is `key,value` | `{ "key": value, … }` | | `cell` | One cell | `{ "value": value }` | Sheet
```text
name,score,tags,active
Alice,95,"[""sports"",""music""]",true
Bob,88,"[""art""]",false
```
Stored snapshot, shape rows
```json
{
"rows": [
{ "name": "Alice", "score": 95, "tags": ["sports", "music"], "active": true },
{ "name": "Bob", "score": 88, "tags": ["art"], "active": false }
]
}
```
### Parse hints [Section titled “Parse hints”](#parse-hints) Add `parse` next to `csv` when the sheet is not in the default layout. | Hint | Values | Meaning | | ------------- | ---------------------- | ------------------------------------------------------------------- | | `orientation` | `row` (default), `col` | `col` reads headers down the first column and one record per column | | `headerIndex` | `1` (default) | The 1-based row, or column, that holds the headers | Headers in the first column
```json
{ "csv": "name\tAlice\tBob\nscore\t95\t88", "parse": { "orientation": "col" } }
```
## Schema validation [Section titled “Schema validation”](#schema-validation) The server validates the result against the schema of the data source. A mismatch does not reject the push. The data is stored, the response carries a `warning`, and the dashboard shows the warning on the data source. Fix the data and push again to clear it. ## The response [Section titled “The response”](#the-response)
```json
{
"success": true,
"alias": "standings",
"competitionThemeSettingId": 8123,
"lastPushedAt": "2026-09-08T01:14:02.000Z",
"warning": "Schema validation warning: /rows/0/score: must be number"
}
```
`warning` is present only when validation failed. ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | ------------------------------------------------------------------------------------------------------ | | 400 | Neither `data` nor `csv`, an empty CSV string, or a CSV the parser cannot read | | 403 | The key lacks `data-sources:write`, or a competition theme setting that your organization does not own | | 404 | No competition theme setting with that id, or no data source with that alias in the theme |
# Graphics commands
> Show, update, hide and hideAll on one graphic of an overlay, by UUID or by name, with the variable values you choose.
`POST /v2/overlays/{overlayId}/graphics/commands` fires one command on one graphic. You name the graphic and you send the variable values. The server applies nothing else. Use [Presets](/control-room/presets/) instead when you want the values an operator configured. That endpoint is `POST /v2/overlays/{overlayId}/control-room/graphics`, and it takes `action` with `presetId` rather than `command` with `graphicUuid`. Both write the same state. The Rive import walkthrough uses the preset form; see [Import through REST](/rive-graphics/import-through-rest/). ## The request [Section titled “The request”](#the-request) Show a graphic with values
```bash
curl -X POST 'https://api.ligr.live/rest/v2/overlays/2300003/graphics/commands' \
-H 'Authorization: Bearer YOUR_WRITE_KEY' \
-H 'Content-Type: application/json' \
-d '{
"command": "show",
"graphicUuid": "8efdf988-1f4d-43ac-873b-06b9b4f3e379",
"variableValues": { "CustomText": "Centre Court" }
}'
```
| Field | Required | Meaning | | ---------------- | ---------- | --------------------------------------------------------------------------------------- | | `command` | yes | `show`, `update`, `hide` or `hideAll` | | `graphicUuid` | one of two | The UUID of the graphic. It must be a graphic of the theme of the overlay | | `name` | one of two | The name of the graphic in the theme of the overlay. The server resolves it to the UUID | | `variableValues` | no | A map of variable name to value. Values are strings, numbers, booleans or `null` | `hideAll` takes no target. Every other command needs `graphicUuid` or `name`. If you send both, `graphicUuid` wins. If you send neither, the response is 400. `name` is the human name of the graphic, as the theme shows it. Do not pass a UUID in `name`. ## What each command does [Section titled “What each command does”](#what-each-command-does) | Command | Effect on the graphic | | --------- | ------------------------------------------------------------------- | | `show` | The graphic is visible with the `variableValues` you send | | `update` | The same as `show`. The graphic is visible with the values you send | | `hide` | The graphic is hidden. Its last values stay in the state | | `hideAll` | Every graphic on the overlay is hidden. Media playback stops | `show` and `update` replace the variable values of the graphic. They do not merge with the values in force. Send the complete set on every call. A variable you omit falls back to its default in the graphic. Do not send `hide` in `variableValues`. The command sets it. Update the score on a visible scoreboard
```json
{ "command": "update", "name": "Scoreboard", "variableValues": { "scoreA": 1, "scoreB": 0 } }
```
Hide one graphic
```json
{ "command": "hide", "graphicUuid": "8efdf988-1f4d-43ac-873b-06b9b4f3e379" }
```
Clear the screen
```json
{ "command": "hideAll" }
```
## Variable values [Section titled “Variable values”](#variable-values) A graphic declares its control variables in the theme. Each variable has a name, a type and a default. `GET /v2/control-rooms/{roomId}/presets` lists them per graphic, with the configured value. Send the JSON type that matches the variable type: `boolean`, `number`, `string`, `enum`, and entity references such as `team`, `player` and `fact`. See [Control variable types](/graphics-sdk/manifest/#types) for the full catalogue. A value you send for a variable the graphic does not declare is stored and ignored. ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | ------------------------------------------------------------------------------------------------------------------------- | | 400 | No `graphicUuid` and no `name`, or the overlay has no theme so `name` cannot resolve | | 403 | The key lacks `overlays:write` | | 404 | The overlay belongs to another organization, or the theme of the overlay has no graphic with that `graphicUuid` or `name` | The command fails on an overlay that follows another overlay. Send it to the controller. See [Linked overlays](/control-room/#linked-overlays). ## The response [Section titled “The response”](#the-response) The response is the overlay id and the full `manualGraphicState`, keyed by graphic UUID. See [The state you get back](/control-room/#the-state-you-get-back). The dashboard and every open browser source receive the same state at the same moment.
# Presets
> Fire a control room preset by id. The server resolves the graphic and the values the operator configured, then applies your overrides.
`POST /v2/overlays/{overlayId}/control-room/graphics` fires one preset. A preset is one button in a control room. The server resolves the graphic the preset points at, applies the values the operator set on the preset, then applies your overrides on top. Use [Graphics commands](/control-room/graphics-commands/) instead when you own every value. ## The request [Section titled “The request”](#the-request) Show a preset
```bash
curl -X POST 'https://api.ligr.live/rest/v2/overlays/2300003/control-room/graphics' \
-H 'Authorization: Bearer YOUR_WRITE_KEY' \
-H 'Content-Type: application/json' \
-d '{ "action": "show", "presetId": 1091 }'
```
| Field | Required | Meaning | | ---------------- | -------- | --------------------------------------------------------- | | `action` | yes | `show`, `update` or `hide` | | `presetId` | yes | The numeric `id` returned by preset creation or listing | | `variableValues` | no | Overrides. A value here wins over the value on the preset | `update` sends new values to a preset graphic that is already on air. The live values become the preset values plus your `variableValues`, the same as a `show`. If the graphic is not on air, `update` returns 400 and changes nothing. Use `show` first. `update` writes the same command as an `update` through [Graphics commands](/control-room/graphics-commands/), and also records the preset id. For `hideAll`, use [Graphics commands](/control-room/graphics-commands/). Show a preset and override one value
```json
{ "action": "show", "presetId": 1091, "variableValues": { "CustomText": "Centre Court" } }
```
Update a preset that is on air
```json
{ "action": "update", "presetId": 1091, "variableValues": { "CustomText": "Court 2" } }
```
Hide a preset
```json
{ "action": "hide", "presetId": 1091 }
```
## How values are resolved [Section titled “How values are resolved”](#how-values-are-resolved) The values in force after `show` come from three layers. A later layer wins. 1. The defaults of the graphic in the theme. The server does not copy these into the state. The renderer reads them at draw time. 2. The values the operator set on the preset. Only variables the preset exposes carry a value. 3. Your `variableValues`. So a `show` with no `variableValues` shows exactly what the button shows. A `show` with overrides changes only the variables you name. Do not send `hide` in `variableValues`. The action sets it. ## Extensions [Section titled “Extensions”](#extensions) A control room can define a preset as an extension of a base preset. An example is a lower-third variant that adds a line to the base lower-third. The layering is a control room setting. Fire the extension preset by id and the server does the rest. * `show` on an extension shows it over the base and keeps the base values. * `hide` on an extension reverts the graphic to the base preset. * `hide` on a base preset hides the graphic, extension included. ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | ------------------------------------------------------------------------------------ | | 400 | `action` is not `show`, `update` or `hide`, or `presetId` is missing | | 400 | `update` targets a preset graphic that is not on air | | 403 | The key lacks `overlays:write` | | 404 | The overlay or the preset belongs to another organization, or the preset was deleted | The command fails on an overlay that follows another overlay. Send it to the controller. See [Linked overlays](/control-room/#linked-overlays). ## The response [Section titled “The response”](#the-response) The response is the overlay id and the full `manualGraphicState`. The entry for the graphic carries `_ligr_presetId` with the preset you fired. See [The state you get back](/control-room/#the-state-you-get-back). ## Where to find a preset id [Section titled “Where to find a preset id”](#where-to-find-a-preset-id) Call `GET /v2/control-rooms/{roomId}` with `themes:read` to read the room, its theme, and every preset with its graphic name and exposed variables. Call `GET /v2/control-rooms/{roomId}/presets` to list preset IDs, graphic IDs, sections, and configured values. Use `GET /v2/control-rooms?themeId={themeId}` to discover the room. Create a preset with `POST /v2/control-rooms/{roomId}/presets` and `themes:write`. Send `sectionId`, `graphicId`, `name`, optional `variableValues` keyed by manifest variable name, and optional `exposeVariables`. `exposeVariables` names fact, stat, team, player and match variables that the operator picks live. The API reads the active published graphic manifest and creates the preset’s internal structure. Change a preset with `PUT /v2/control-rooms/{roomId}/presets/{presetId}`: rename it, move it to another section, or replace its variable set. Every preset response lists `exposedVariables`. See [Set up through REST](/control-room/setup/) for the complete sequence. The control room in the dashboard shows the same presets as buttons.
# Set up through REST
> Prepare a code or Rive graphic, competition, match, presets, and manual overlay, then open the finished control room.
Create the resources through REST, then open the returned control-room URL. This flow supports code graphics and native Rive graphics. You need an organization and an [API key](/get-started/authentication/). The key needs `themes:write`, `competitions:write`, `teams:write`, `venues:write`, `matches:write`, and `overlays:write`. Each write scope also allows reads for that resource. The person opening the control room needs an authenticated dashboard session with access to the same organization. ## 1. Publish the graphic [Section titled “1. Publish the graphic”](#1-publish-the-graphic) Follow [Publish without the CLI](/graphics-sdk/push-and-publish/#without-the-cli) to create the theme, upload the bundle, and publish the graphic. For native Rive, follow [Import through REST](/rive-graphics/import-through-rest/) through theme activation. Publish a theme version, then activate that exact version through REST. Save the theme’s numeric `id` as `THEME_ID` and the graphic’s UUID as `GRAPHIC_ID`. | Operation | Route | | ----------------------------- | -------------------------------------------------------------- | | Create the theme | `POST /v2/themes` | | Create the graphic | `POST /v2/themes/{themeId}/code-graphics` | | Request signed upload URLs | `POST /v2/themes/{themeId}/code-graphics/{graphicId}/uploads` | | Upload files | `PUT` each returned upload URL with the file body | | Record the files and manifest | `PUT /v2/themes/{themeId}/code-graphics/{graphicId}/bundle` | | Publish the graphic | `POST /v2/themes/{themeId}/code-graphics/{graphicId}/versions` | | Publish the theme snapshot | `POST /v2/themes/{themeId}/versions` | | Activate that snapshot | `PUT /v2/themes/{themeId}/active-version` | The remaining examples use Bash, `curl`, and `jq`. Run them in one terminal. Set `LIGR_API_KEY`, `THEME_ID`, and `GRAPHIC_ID` from your authentication and publishing steps. Terminal
```bash
set -euo pipefail
export LIGR_API_URL="${LIGR_API_URL:-https://api.ligr.live/rest/v2}"
export LIGR_REST="${LIGR_API_URL%/v2}"
api() {
local method="$1" route="$2"
shift 2
curl --fail-with-body --silent --show-error \
-X "$method" "$LIGR_REST/$route" \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' "$@"
}
```
Set `LIGR_API_URL` to your target environment before continuing. The Rive tutorial and the CLI read the same variable. The default is `https://api.ligr.live/rest/v2`. This page calls `v1` and `v2` routes, so `LIGR_REST` removes the `/v2` suffix. ## 2. Create the room and preset [Section titled “2. Create the room and preset”](#2-create-the-room-and-preset) A room belongs to your organization and one theme. A section groups its preset buttons. Terminal
```bash
ROOM_ID=$(api POST v2/control-rooms --data "$(jq -n \
--argjson themeId "$THEME_ID" \
'{themeId: $themeId, name: "Match control room"}')" | jq -er '.id')
SECTION_ID=$(api POST "v2/control-rooms/$ROOM_ID/sections" \
--data '{"name":"Match graphics"}' | jq -er '.id')
PRESET_ID=$(api POST "v2/control-rooms/$ROOM_ID/presets" --data "$(jq -n \
--arg sectionId "$SECTION_ID" --arg graphicId "$GRAPHIC_ID" \
'{sectionId: $sectionId, graphicId: $graphicId, name: "Scorebug", variableValues: {}}')" \
| jq -er '.id')
```
The graphic must appear in the theme’s active published version. Preset creation reads that published manifest, including its variable types and defaults. Only variables named in `variableValues` or `exposeVariables` appear as editable fields on the preset. The API stores the internal preset structure for you. Set `variableValues` by manifest variable name, for example `{"CustomText":"Centre Court"}` when your graphic defines `CustomText`. `variableValues` accepts string, number, boolean and enum variables. Name fact, stat, team, player and match variables in `exposeVariables`; the preset shows them and the operator picks the value live. Do not send `hide`; the show and hide actions control visibility. Unknown names and invalid values fail before the API creates the preset. Expose an event picker and a statistic picker
```bash
api POST "v2/control-rooms/$ROOM_ID/presets" --data "$(jq -n \
--arg sectionId "$SECTION_ID" --arg graphicId "$GRAPHIC_ID" \
'{sectionId: $sectionId, graphicId: $graphicId, name: "Scorebug",
variableValues: {AddedTime: "0"}, exposeVariables: ["Event", "SingleStat"]}')" | jq
```
The response lists `exposedVariables`: every variable the operator can see, with or without a stored value. To change a preset later, send `PUT /v2/control-rooms/{roomId}/presets/{presetId}`. Send `name` or `sectionId` alone to rename or move it. Send `variableValues` and `exposeVariables` together to replace its variable set; the stored set becomes exactly what you send. To reuse resources, list rooms with `GET /v2/control-rooms?themeId={themeId}`. List their sections and presets with `GET /v2/control-rooms/{roomId}/sections` and `GET /v2/control-rooms/{roomId}/presets`. ## 3. Create the competition, venue, and teams [Section titled “3. Create the competition, venue, and teams”](#3-create-the-competition-venue-and-teams) Discover competitions through `GET /v1/competitions` and venues through `GET /v1/venues`. List a competition’s teams with `GET /v1/competitions/{competitionId}/teams`. Use the returned IDs to skip the corresponding creation calls. List grades through REST. Set `GRADE_ID` to the returned ID that matches your competition. The example uses adult mixed football; change the metadata for your competition. Terminal
```bash
api GET v1/competitions/grades | jq .
# Set GRADE_ID to the selected numeric id before continuing.
COMPETITION_ID=$(api POST v1/competitions --data "$(jq -n \
--argjson gradeId "$GRADE_ID" \
'{name: "My competition", sport: "football", age: "Adults", gender: "Mixed", gradeId: $gradeId}')" \
| jq -er '.id')
VENUE_ID=$(api POST v1/venues --data '{"name":"Main ground"}' | jq -er '.id')
create_team() {
api POST v1/teams --data "$(jq -n \
--arg name "$1" --arg abbreviation "$2" \
--argjson gradeId "$GRADE_ID" --argjson competitionId "$COMPETITION_ID" \
--argjson venueId "$VENUE_ID" \
'{name: $name, abbreviation: $abbreviation, sport: "football", age: "Adults", gender: "Mixed",
gradeId: $gradeId, competitionIds: [$competitionId], defaultVenueId: $venueId}')" | jq -er '.id'
}
HOME_TEAM_ID=$(create_team 'Home team' HOME)
AWAY_TEAM_ID=$(create_team 'Away team' AWAY)
```
### Set a team logo [Section titled “Set a team logo”](#set-a-team-logo) Graphics show the team logo as `logoUrl`. Upload the logo in two calls. The API does not fetch images from a URL. Use a PNG or JPEG file of 500 KB or less. Declare its type, size, and SHA-256 first. The response holds `uploadId`, a signed `url`, and the `headers` for the upload. Terminal
```bash
LOGO=home-crest.png
SESSION=$(api POST "v1/teams/$HOME_TEAM_ID/logo/uploads" --data "$(jq -n \
--argjson size "$(wc -c < "$LOGO" | tr -d ' ')" \
--arg sha256 "$(shasum -a 256 "$LOGO" | cut -d ' ' -f 1)" \
'{contentType: "image/png", size: $size, sha256: $sha256}')")
curl --fail-with-body --silent --show-error -X PUT "$(jq -er '.url' <<< "$SESSION")" \
-H "x-amz-checksum-sha256: $(jq -er '.headers["x-amz-checksum-sha256"]' <<< "$SESSION")" \
--data-binary "@$LOGO"
api PUT "v1/teams/$HOME_TEAM_ID/logo" --data "$(jq -n \
--argjson uploadId "$(jq -er '.uploadId' <<< "$SESSION")" '{uploadId: $uploadId}')" | jq '{id, logoUrl}'
```
Do the PUT before `expiresAt`. Send the `uploadId` within one hour. The API checks the size, the SHA-256, and the image type, then stores the logo. Each upload sets a logo one time. To change the logo, open a new upload. To remove the logo, send `DELETE /v1/teams/{teamId}/logo`. A team without a logo shows the club logo, if the club has one. ## 4. Create the match and theme profile [Section titled “4. Create the match and theme profile”](#4-create-the-match-and-theme-profile) Set `MATCH_DATE` to your kickoff time in ISO 8601 format, including its timezone. The example uses team competitors. Other sports can use player competitors. `competitorsType` is the match format: `singles`, `doubles`, `teams` or `group`. `entityType` is the kind of each row in `competitors`: `team` or `player`. For example, a doubles match has `competitorsType: doubles` and four `entityType: player` rows. A group match also has `player` rows. Terminal
```bash
MATCH_ID=$(api POST v2/matches --data "$(jq -n \
--arg date "$MATCH_DATE" --argjson competitionId "$COMPETITION_ID" \
--argjson venueId "$VENUE_ID" --argjson home "$HOME_TEAM_ID" --argjson away "$AWAY_TEAM_ID" \
'{competitionId: $competitionId, venueId: $venueId, date: $date,
competitorsType: "teams", competitors: [
{entityId: $home, entityType: "team", meta: {isHome: true}},
{entityId: $away, entityType: "team", meta: {isHome: false}}
]}')" | jq -er '.id')
PROFILE_ID=$(api POST "v2/competitions/$COMPETITION_ID/theme-profiles" --data "$(jq -n \
--argjson themeId "$THEME_ID" --argjson roomId "$ROOM_ID" \
'{themeId: $themeId, name: "Broadcast profile", defaultControlRoomId: $roomId}')" | jq -er '.id')
```
The theme must support the competition’s sport and have an active published version. The first profile becomes the competition default. This flow also assigns the profile and room explicitly to the overlay. To set competition-specific colors or labels, include `themeVariableValues` when creating the profile. Read `variables` from `GET /v2/themes/{themeId}/versions/{activeVersion}`. Use those variable IDs, for example `[{"variableId":"accent","value":"#0055ff"}]`. The response returns the saved values. Unknown IDs and invalid types fail before creation. To reuse a profile, call `GET /v2/competitions/{competitionId}/theme-profiles`. ## 5. Create the manual overlay [Section titled “5. Create the manual overlay”](#5-create-the-manual-overlay) Terminal
```bash
OVERLAY=$(api POST v1/overlays --data "$(jq -n \
--argjson matchId "$MATCH_ID" --argjson profileId "$PROFILE_ID" --argjson roomId "$ROOM_ID" \
'{name: "Broadcast overlay", matchId: $matchId, competitionThemeSettingId: $profileId,
controlRoomId: $roomId, autoMode: false, adType: "noBrands"}')")
OVERLAY_ID=$(jq -er '.id' <<< "$OVERLAY")
CONTROL_ROOM_URL=$(jq -er '.controlRoomUrl' <<< "$OVERLAY")
api GET "v1/overlays/$OVERLAY_ID" | jq '{id, autoMode, adType, competitionThemeSettingId, controlRoomId, controlRoomUrl}'
printf '%s\n' "$CONTROL_ROOM_URL"
```
The read response confirms the saved profile, room, and manual mode. `controlRoomUrl` includes the organization, match, overlay, and room IDs. It opens the saved setup without a theme-profile editor or overlay-settings visit. `autoMode: false` requires `adType: "noBrands"` or `"brands"`. The API rejects manual setup with `"free"`. An update preserves fields you omit, including the ad type. ## 6. Open the finished control room [Section titled “6. Open the finished control room”](#6-open-the-finished-control-room) Open `CONTROL_ROOM_URL` in your authenticated browser. Select **GFX In** on the preset and check the preview. If the preset exposes variables, edit them and select **Update GFX**. Select **GFX Out** to hide it. You can also fire the same prepared preset through REST: Terminal
```bash
api POST "v2/overlays/$OVERLAY_ID/control-room/graphics" --data "$(jq -n \
--argjson presetId "$PRESET_ID" '{action: "show", presetId: $presetId}')"
```
## 7. Check a prepared room without a dashboard login [Section titled “7. Check a prepared room without a dashboard login”](#7-check-a-prepared-room-without-a-dashboard-login) Use this procedure when you cannot open the dashboard, for example from an automation. The key needs `themes:read` to read the room, `overlays:read` to see its overlays, and `overlays:write` to fire presets. 1. Read the room. The response holds the theme, the sections, and every preset with its `graphicId`, `graphicName` and `exposedVariables`. Terminal
```bash
ROOM=$(api GET "v2/control-rooms/$ROOM_ID")
jq '{theme, presets: [.presets[] | {id, name, graphicName, exposedVariables}]}' <<< "$ROOM"
```
2. Open the monitoring view of the overlay. Read `monitoringUrl` from the room; do not build it. The room lists `overlays` only when the key has `overlays:read`. Terminal
```bash
jq -er --argjson id "$OVERLAY_ID" '.overlays[] | select(.id == $id) | .monitoringUrl' <<< "$ROOM"
```
3. Fire each preset and check the monitoring view after each command. Terminal
```bash
for PRESET in $(jq -r '.presets[].id' <<< "$ROOM"); do
api POST "v2/overlays/$OVERLAY_ID/control-room/graphics" --data "$(jq -n \
--argjson presetId "$PRESET" '{action: "show", presetId: $presetId}')" > /dev/null
read -r -p "Preset $PRESET on air? Press Enter to continue."
done
```
4. Update a preset that is on air. Send the values to change in `variableValues`. Terminal
```bash
api POST "v2/overlays/$OVERLAY_ID/control-room/graphics" --data "$(jq -n \
--argjson presetId "$PRESET_ID" '{action: "update", presetId: $presetId, variableValues: {CustomText: "Court 2"}}')"
```
5. Hide each preset. Terminal
```bash
api POST "v2/overlays/$OVERLAY_ID/control-room/graphics" --data "$(jq -n \
--argjson presetId "$PRESET_ID" '{action: "hide", presetId: $presetId}')"
```
See [Check what is on screen](/control-room/#check-what-is-on-screen) if a graphic does not appear. ## Theme versions [Section titled “Theme versions”](#theme-versions) Publishing another theme version does not change the active version unless you request activation. Use [version inspection, activation, and rollback](/guides/publish-a-theme-version/) through REST. Reload existing overlay pages after changing the active version.
# Graphics SDK
> Beta. Write a graphic in HTML, CSS and JavaScript, talk ligr.gfx.v1 to the overlay, and publish it into a theme over REST.
Beta The Graphics SDK, the `ligr-graphic` CLI and the theme and code graphics REST routes are in beta. You create the theme your graphics live in, up to five per organization. The protocol, the manifest and the REST shapes can change between minor versions. Report code graphics, SDK, and CLI bugs, concerns, or feature requests through [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues). For app, account, or page problems, use [LIGR support](https://help.ligr.live/en/). See [Issues and support](/graphics-sdk/issues-and-support/) for the scope of each channel. A code graphic is a graphic you write yourself in HTML, CSS and JavaScript. The LIGR overlay loads it in a transparent frame over the video, sends it live match data, and tells it when to show and when to hide. It runs beside the Rive graphics in the same theme, and operators drive it from the same control room. For native `.riv`, `.rev`, or RML project imports, use [Rive graphics](/rive-graphics/). Native Rive playback does not use this browser SDK or the `ligr.gfx.v1` protocol. LIGR supplies the match data. You build a static browser bundle, push it over REST, and publish it. The platform does not require a framework or the SDK. Plain HTML, Canvas, WebGL and browser libraries use the same protocol. ## Downloads and repository [Section titled “Downloads and repository”](#downloads-and-repository) The [public graphics repository](https://github.com/ligrsystems/graphics-packages) hosts SDK and CLI releases and scoped GitHub Issues. It is a distribution repository. The implementation source remains in a private repository. Download versioned package archives from [Releases](https://github.com/ligrsystems/graphics-packages/releases) without a GitHub or npm account. Use [Issues and support](/graphics-sdk/issues-and-support/) to choose where to report a problem or request a feature. ## How it fits together [Section titled “How it fits together”](#how-it-fits-together) 1. You write the graphic and keep a manifest next to it. See [The manifest](/graphics-sdk/manifest/). 2. Your graphic answers the overlay over `postMessage` with the [`ligr.gfx.v1` protocol](/graphics-sdk/protocol/). The overlay sends match data, control variable values, theme variables and external data. See [Data binding](/graphics-sdk/data/). 3. You create a theme, create the graphic in it, upload the files, and record the bundle. See [Push and publish](/graphics-sdk/push-and-publish/). 4. You publish a graphic version, then a theme version that holds it. Overlays render the active theme version. Operators show and hide a code graphic the same way as any other graphic. Automation and the [graphics commands](/control-room/graphics-commands/) endpoint drive it by its control variables. You write none of that. You only build the bundle. ## The runtime [Section titled “The runtime”](#the-runtime) The overlay is a GPU-accelerated Chromium browser. On a LIGR stream, LIGR runs that browser on the encoder and composites its output over the video. On a vision mixer, the browser source of the mixer renders the same page. Your graphic runs in an iframe inside that page. The iframe has the `width` and `height` of your manifest. The overlay scales the iframe to fit the output and keeps its aspect ratio. A 1920 x 1080 graphic fills a 16:9 output at any resolution. Everything the browser offers is available to your code: * WebGL 1 and 2, WebGPU, and Canvas 2D for GPU-driven rendering. * CSS animations and transforms, and the Web Animations API. * WebAssembly and Web Workers. * Any rendering library that runs in a browser: Three.js, PixiJS, Rive, Lottie, GSAP. The overlay renders at the frame rate of the stream. Keep every frame under 16 ms at 60 fps. A graphic that stalls the page stalls every other graphic on the overlay. A vision mixer browser source runs on the browser of that mixer. Check its Chromium version before you rely on WebGPU there. ## Pages [Section titled “Pages”](#pages) | Page | What it covers | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | [Quick start](/graphics-sdk/quick-start/) | From an empty folder to a graphic on an overlay | | [The `ligr.gfx.v1` protocol](/graphics-sdk/protocol/) | Every message the overlay sends and accepts | | [The manifest](/graphics-sdk/manifest/) | The runtime, the control variables and the validation rules | | [Data binding](/graphics-sdk/data/) | Match, control variable, theme, image and data source values inside a graphic | | [Expressions](/graphics-sdk/expressions/) | The expression language, the `$d`, `$v`, `$t`, `$u` and `$x` context, and the helpers | | [Bundle rules](/graphics-sdk/bundle/) | Files, paths, fonts, size limits and the file name rules | | [Push and publish](/graphics-sdk/push-and-publish/) | Create, upload, record, publish, and the theme version | | [Test and troubleshoot](/graphics-sdk/test/) | `ligr-graphic dev`, the local harness, the checklist before hand-over, and the symptoms table | | [Issues and support](/graphics-sdk/issues-and-support/) | Code graphics feedback, bug reports, feature requests, and general app support | ## Beta status [Section titled “Beta status”](#beta-status) Graphics creation is in beta. This is what beta means today: * You create the theme with `npx ligr-graphic theme create` or `POST /v2/themes`. Once activated, it appears on the Themes page of the dashboard and in the competition theme picker like any other theme. Your organization can own up to five themes. A plan-level cap and pricing are on the roadmap. * The dashboard cannot create, edit or activate an owned theme yet. Use the CLI or REST for theme versions, and the dashboard to assign it to a competition and to operate it. * The `ligr.gfx.v1` protocol, the manifest and the theme and code graphics REST routes can change between minor versions. The [changelog](/changelog/) lists every change. * Every response from a beta route carries `X-Ligr-Beta: true`, and the operation carries `x-beta: "true"` in the OpenAPI document. See [Beta endpoints](/get-started/versioning/#beta-endpoints). * The `@ligrsystems/graphics-sdk` and `@ligrsystems/graphics-cli` packages ship as `0.x` versions. * Test every graphic on the preview overlay of a test match before you use it on air. ## What you need [Section titled “What you need”](#what-you-need) * A theme your organization owns. Create it with `npx ligr-graphic theme create`. * A write API key. See [Authentication](/get-started/authentication/). * Node 22 or later and a browser. * Optionally, `@ligrsystems/graphics-sdk`: a browser implementation of `ligr.gfx.v1` with callbacks for data and visibility. * The `@ligrsystems/graphics-cli` package: the `ligr-graphic` command that scaffolds, runs, validates, pushes and publishes a graphic. ## Install [Section titled “Install”](#install) Scaffold a graphic with the CLI. The scaffold adds both packages with exact GitHub release URLs to its `package.json`. Public package downloads need no npm account or GitHub authentication. Keep your lockfile in Git. Terminal
```bash
npx --package=https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz ligr-graphic init my-graphic
cd my-graphic
npm install
npm run build
npm run dev
```
The default scaffold uses plain HTML, CSS and JavaScript. Framework templates are optional examples, not platform requirements. To add the optional runtime to an existing project, install the package and call `createGraphic`. Terminal
```bash
npm install https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-sdk-0.3.0.tgz
npm install --save-dev https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz
```
## Size limits [Section titled “Size limits”](#size-limits) | Limit | Value | | -------------------- | ------ | | One bundle | 10 MiB | | One file | 5 MiB | | One protocol message | 1 MB | A bundle over the limit is refused with `BUNDLE_TOO_LARGE`. The `details[]` array names the files. See [Errors](/get-started/errors/) for `CODE_GRAPHIC_INVALID`, `BUNDLE_TOO_LARGE` and `INVALID_ASSET_PATH`. The SDK function `createGraphic()` starts a code-graphic runtime listener. It does not create a stored Rive graphic through REST.
# Bundle rules
> The files a bundle holds, relative paths, fonts and images, the transparent body, the size limits and the file name rules.
A bundle is the folder of static files the overlay loads. LIGR serves it as static assets from its own CDN. Every rule on this page exists because a broadcast link is not a home connection, and because the overlay page renders your bundle over live video. ## Rules [Section titled “Rules”](#rules) * Ship plain browser files: HTML, CSS, JavaScript, fonts and images. * Name the entry file `index.html`, or name your entry file in the manifest. * Use relative paths only. If you use a build tool, set it to emit relative asset paths. * Do not run a server. LIGR serves your files as static assets. * Do not fetch JavaScript from anywhere at runtime. Bundle every module you need. * Ship your fonts and images inside the bundle. Do not link to an external font or image host. * Set the `` background to transparent. The video shows through it. * Make no network call at runtime. Every asset ships inside the bundle. Your graphic runs in a sandboxed frame with an opaque origin. It has no access to the overlay page, its cookies or its storage. Because the origin is opaque, every module script, font and file your graphic loads is a cross-origin request. LIGR serves the bundle with `Access-Control-Allow-Origin: *`, so files inside the bundle load. Ship only code you wrote or audited. Lay out your graphic at the `width` and `height` of the manifest. The iframe has that exact size. The overlay scales the iframe to fit the output and keeps its aspect ratio. Your layout never changes with the output resolution. If the aspect ratio of the manifest matches the output, the graphic fills the output. If the aspect ratios differ, the scaled graphic sits at the top-left corner and leaves an empty band at the right or at the bottom. ## Size limits [Section titled “Size limits”](#size-limits) | Limit | Value | | ------------------------ | ------------------------------------------------------------------------------------- | | One bundle | 10 MiB | | One file | 5 MiB | | Files per bundle request | 2000 | | Files per upload request | 200. Send `uploadSessionId` on the next request to add more files to the same session | `PUT …/bundle` checks the declared `size` of every file before it writes anything. It rejects an oversized bundle with `400 BUNDLE_TOO_LARGE` and lists every file over the limit in `details[]`. A large bundle loads slowly and can miss the show cue. Compress images. Subset fonts. Tree-shake your JavaScript. ## File names [Section titled “File names”](#file-names) A file name is a path inside the bundle, for example `assets/app.js`. It must stay inside the bundle folder. A name is refused with `400 INVALID_ASSET_PATH` when it holds: * `..` or an empty segment, * a control character, * one of `?`, `#` or `%`. Rename the file. A build tool that hashes file names produces valid names. ## Content types [Section titled “Content types”](#content-types) Send the content type of each file twice: as `contentType` when you request an upload URL, and as `mime` when you record the bundle. Send the same value in both places, and send the file body with that `Content-Type` header. Common values: | File | Content type | | -------- | ------------------------ | | `.html` | `text/html` | | `.js` | `application/javascript` | | `.css` | `text/css` | | `.woff2` | `font/woff2` | | `.png` | `image/png` | | `.svg` | `image/svg+xml` | | `.json` | `application/json` | ## Build tools [Section titled “Build tools”](#build-tools) You do not need a build tool. A bundle is plain browser files, so an `index.html` that loads your own `.js` and `.css` is a complete graphic. The default template works this way. If you do use one, it must write relative asset paths. Most bundlers default to absolute paths, which break when LIGR serves your bundle from a CDN sub-path. The React template uses Vite. Vite emits absolute paths unless you set the base: vite.config.ts
```ts
import { defineConfig } from 'vite'
export default defineConfig({ base: './' })
```
Run the build. It writes `dist/index.html` plus a `dist/assets/` folder holding your JavaScript, CSS, fonts and images. Ship the whole `dist/` folder.
# Data binding
> The surfaces LOAD_GRAPHIC and GRAPHIC_UPDATE carry, the shape of each, the fields per sport, and the JSON Schema you can validate against.
`LOAD_GRAPHIC` carries every surface. `GRAPHIC_UPDATE` carries the identity fields plus only the surfaces whose JSON changed. Merge an update over the state you hold. ## Identity fields [Section titled “Identity fields”](#identity-fields) | Field | Value | | ----------- | ------------------------------------------------------------------------------------------------------------- | | `graphicId` | The stable id of your graphic. Send it back in `HIDDEN` | | `show` | `true` while the graphic is on air. Informational. See [Show and hide](/graphics-sdk/protocol/#show-and-hide) | | `sport` | The sport key of the match, for example `football` | ## Surfaces [Section titled “Surfaces”](#surfaces) | Surface | Content | Keyed by | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | `sportData` | Live match data for the sport | Sport-specific field names | | `controlVariables` | The current value of each control variable in your manifest | Variable name | | `controlVariableData` | `{ value, data }` per control variable. `data` is the resolved fact, team or player object for an entity-typed variable | Variable name | | `themeVariables` | Theme variable values: colours, labels, sponsor names | Variable name | | `externalData` | The latest snapshot of each external data source of the theme | Data source alias | | `images` | Player, team and competition images | Entity arrays, see below | | `userExpressionValues` | The value of each user expression in your manifest, evaluated by the overlay. See [Expressions](/graphics-sdk/expressions/) | Expression name | | `assetOverrides` | Asset overrides such as ad images. Optional | Asset name | Read `controlVariableData..data` when you need the selected entity. Read `controlVariables.` when you need only the raw value. ## Images [Section titled “Images”](#images)
```json
{
"player": [{ "entityId": 501, "file": { "url": "https://…" } }],
"team": [{ "entityId": 1, "file": { "url": "https://…" } }],
"competition": [{ "entityId": 42, "file": { "url": "https://…" } }]
}
```
`file` can be `null`. Handle a missing image before you render one. ## `sportData` by sport [Section titled “sportData by sport”](#sportdata-by-sport) Pick your sport. The tabs stay on your choice across these docs. * Football These fields come from the LIGR football data model. | Field | Meaning | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `sportData['1']` | The home team. A team object | | `sportData['2']` | The away team. A team object | | `sportData.clock` | The match clock, for example `"43:30"` | | `sportData.lastClock` | The clock frozen at the end of the last live period | | `sportData.clockRunning` | `true` while the clock counts | | `sportData.periodShortName` | The current period, for example `"First Half"` | | Team `.score` | Goals scored | | Team `.abbreviation` | The short team code, for example `"MUN"` | | Team `.logoUrl` | The team logo URL | | Team `.kit.primaryColor` | The kit primary colour, a hex string. If the team has no kit, the team primary background colour. `""` if neither is set | | Team `.kit.secondaryColor` | The kit secondary colour. If the team has no kit, the team primary text colour. `""` if neither is set | | Team `.redCards` | The red card count | | Team `.squad` | The player objects | `sportData['1']` is always the home team and `sportData['2']` is always the away team. `sportData.fixtures` lists the matches of the same competition on the same calendar day. The list includes the current match. It leaves out cancelled matches and matches without team competitors. LIGR sorts the list by date, then start time, then home team name. In a Rive expression, read the list as `$d.fixtures`. | Fixture field | Meaning | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `id` | The match id | | `date` | The match date, for example `"2026-09-29"` | | `homeTeamName`, `awayTeamName` | The team names | | `homeTeam`, `awayTeam` | The team branding: `logoUrl`, background and text colours, and `kit`. `null` if the match has no team on that side | | `homeGoals`, `awayGoals` | The goals of each team. `null` before kick-off | | `startTime` | The kick-off time in 24-hour format, for example `"15:00"` | | `isLive` | `true` while the match is in progress | | `round` | The round key of the match | | `currentPeriod` | The current period object. `null` if the match has no current period | For a field not listed here, read the schema. * Tennis **Coming soon.** The Tennis field reference is not written yet. Until then, `GET /v2/schemas/sports/tennis` returns the full `sportData` shape, and `GET /v2/scenarios/tennis` returns sample data. * Basketball **Coming soon.** The Basketball field reference is not written yet. Until then, `GET /v2/schemas/sports/basketball` returns the full `sportData` shape, and `GET /v2/scenarios/basketball` returns sample data. * Australian Rules **Coming soon.** The Australian Rules field reference is not written yet. Until then, `GET /v2/schemas/sports/ausRules` returns the full `sportData` shape, and `GET /v2/scenarios/ausRules` returns sample data. * Rugby League **Coming soon.** The Rugby League field reference is not written yet. Until then, `GET /v2/schemas/sports/rugbyLeague` returns the full `sportData` shape, and `GET /v2/scenarios/rugbyLeague` returns sample data. * Rugby Union **Coming soon.** The Rugby Union field reference is not written yet. Until then, `GET /v2/schemas/sports/rugbyUnion` returns the full `sportData` shape, and `GET /v2/scenarios/rugbyUnion` returns sample data. * Cricket **Coming soon.** The Cricket field reference is not written yet. Until then, `GET /v2/schemas/sports/cricket` returns the full `sportData` shape, and `GET /v2/scenarios/cricket` returns sample data. * Netball **Coming soon.** The Netball field reference is not written yet. Until then, `GET /v2/schemas/sports/netball` returns the full `sportData` shape, and `GET /v2/scenarios/netball` returns sample data. * More sports **Coming soon.** The field reference for these sports is not written yet. The sport key is in brackets. * Baseball (`baseball`) * Field hockey (`fieldHockey`) * Futsal (`futsal`) * American Football (`gridiron`) * Handball (`handBall`) * Ice hockey (`iceHockey`) * Lacrosse (`lacrosse`) * Rugby Sevens (`rugbySevens`) * Touch football (`touchFootball`) * Volleyball (`volleyball`) * Water polo (`waterPolo`) `GET /v2/schemas/sports/{sport}` returns the full `sportData` shape of each sport. ## Schemas [Section titled “Schemas”](#schemas) The overlay sends no schemas at runtime. Get each shape during development. | Surface | Where the schema is | | ------------------------------------------------ | ------------------------------------------------------------------- | | `sportData` | `GET /v2/schemas/sports/{sport}` | | Entities: `team`, `player`, `playerPair`, `fact` | `GET /v2/schemas/control-variables` | | `controlVariables` | The `controlVariables` of your manifest | | `themeVariables` | The theme variables of the theme | | `externalData` | The data schemas of the theme: `ligr-graphic rive data-schema list` | Validate your test payloads against these schemas during development. A wrong field name shows up before you ship. `GET /v2/scenarios/{sport}` returns sample `sportData` for a sport. See [Schemas & scenarios](/rest/operations/tags/schemas--scenarios/). ## External data [Section titled “External data”](#external-data) A theme can declare external data sources: a standings table, a sponsor line, a fact file. Your graphic reads them from `externalData` by alias. Anyone with a write key fills them over REST. See [External data sources](/control-room/data-sources/). A large data source is the usual cause of a message over the 1 MB limit. Keep the snapshot to what the graphic renders.
# Expressions
> The expression language, the evaluation context, the $d, $v, $t, $u and $x references, the helper functions, and the rules for missing data and errors.
An expression is a line of JavaScript the overlay evaluates for you against the live match data. Rive graphics use expressions for every data binding. A code graphic uses them in the `userExpressions` array of its [manifest](/graphics-sdk/manifest/#userexpressions), and receives the results in `userExpressionValues`. The overlay evaluates every expression again on every data change. A user expression
```js
$d.1.score > $d.2.score ? $d.1.name : $d.2.name
```
## The language [Section titled “The language”](#the-language) An expression is JavaScript. The overlay evaluates it in a sandbox with the references and helpers on this page, and nothing else. * **One expression or many statements.** A single expression returns its value. A script of several statements returns the value of the last statement, with no `return`. `var`, `if`, `for`, `while` and function expressions all work. * **No browser globals.** `window`, `document`, `fetch`, `Date`, `RegExp`, `Promise`, `Symbol`, `Intl`, `Error` and timers are not in scope. Each one resolves to `undefined`. A call such as `new Date()` throws, and the binding falls back to its default. There is no clock in an expression. Send a time value through the match data or a theme variable instead. * **Regular expression literals work.** The `RegExp` constructor is absent, but `/live/i.test(x)` and `x.match(/\d+/)` both work, because a literal is syntax and not a global. * **The `NaN` and `Infinity` globals are absent.** Use `Number.NaN` and `Number.POSITIVE_INFINITY`, or test with `isNaN(x)`. * **No logical assignment.** `||=`, `&&=` and `??=` do not work on match data. The left side is read before the missing-data rules apply, so the assignment never happens. Write `x = x || 'fallback'` instead. * **No side effects.** An expression cannot change the match data, the variables or the graphic. * **Numeric keys with dot notation.** `$d.1.score` is rewritten to `$d['1'].score` before evaluation. Bracket notation works too. ## The context [Section titled “The context”](#the-context) | Reference | Holds | Keyed by | | --------- | ------------------------------------------------------------------------------------------------------- | ----------------- | | `$d` | The live match data for the sport. The same shape as `sportData` in [Data binding](/graphics-sdk/data/) | Sport field names | | `$v` | The control variables of the graphic. Each one is `{ value, data }` | Variable name | | `$t` | The theme variables: colours, labels, sponsor names | Variable name | | `$u` | The other user expressions of the graphic, by name, already evaluated | Expression name | | `$x` | The latest snapshot of each external data source of the theme | Data source alias | ### `$d`: match data [Section titled “$d: match data”](#d-match-data) `$d` is the sport data of the match. `$d.1` is the home team and `$d.2` is the away team in every team sport. Read the schema for the field list: `GET /v2/schemas/sports/{sport}` over REST.
```js
$d.1.abbreviation // "MUN"
$d.clock // "43:30"
$d.1.startingLineup[0].lastName // The first starter of the home team
$d.1.scorers?.[0]?.player.lastName ?? '' // Optional chaining works
```
### `$v`: control variables [Section titled “$v: control variables”](#v-control-variables) A control variable is `{ value, data }`. `value` is what the operator set. `data` is the resolved entity for an entity-typed variable: the team, the player, the fact or the statistic.
```js
$v.Title.value // "LINEUP"
!$v.hide.value // true while the graphic is on air
$v.Team.value // 1, the team id
$v.Team.data.name // "Manchester United"
$v.StatOne.data.names.plural // "Shots"
$v.StatOne.data[1] // The home team value of the statistic
Number($v.Rows.value) >= 3 // An enum value is a string
```
### `$t`: theme variables [Section titled “$t: theme variables”](#t-theme-variables) A theme variable resolves to its value. A per-graphic override wins over the theme value, and the theme value wins over the default.
```js
$t.primaryColor // "#0000ff"
$d.isLive ? $t.liveLabel : $t.idleLabel
```
### `$u`: user expressions [Section titled “$u: user expressions”](#u-user-expressions) A user expression can read another user expression by name. The overlay evaluates them in dependency order. An expression in a cycle, or one that throws, resolves to `null`.
```js
$u.leader + ' leads by ' + $u.margin
```
### `$x`: external data [Section titled “$x: external data”](#x-external-data) An external data source is a JSON or CSV snapshot the theme declares and a write key fills over REST. Read it by alias. See [External data sources](/control-room/data-sources/).
```js
$x.results.rows[0].Home ?? ''
$x.results.rows.length
```
### List context [Section titled “List context”](#list-context) Inside a list binding of a Rive graphic, two more names are in scope. | Name | Value | | -------- | ---------------------------------------- | | `index` | The position of the current item, from 0 | | `length` | The number of items in the list | ## Helpers [Section titled “Helpers”](#helpers) | Helper | Returns | | ---------------------------- | ------------------------------------------------------------------------- | | `startsWith(str, prefix)` | `true` when `str` starts with `prefix`. `false` for a missing string | | `endsWith(str, suffix)` | `true` when `str` ends with `suffix`. `false` for a missing string | | `contains(strOrArray, item)` | `true` when the string or array holds `item`. `false` for a missing value | | `find(array, fn)` | The first item for which `fn` returns `true` | | `findIndex(array, fn)` | The index of that item, or `-1` | | `filter(array, fn)` | The items for which `fn` returns `true` | | `map(array, fn)` | A new array of `fn(item)` | | `reduce(array, fn, initial)` | The folded value | These JavaScript built-ins are in scope: `Math`, `Number`, `String`, `Boolean`, `Array`, `Object`, `JSON`, `parseInt`, `parseFloat`, `isNaN` and `isFinite`. String and array methods work on any value, so `$d.1.name.toUpperCase()` and `$d.1.squad.map(p => p.lastName)` both work. ## Missing data [Section titled “Missing data”](#missing-data) A missing value adapts to how you use it: blank as text, `0` in arithmetic. The same path works in both places, with no guard.
```js
$d.1.aggregateScore // Blank in a text binding
$d.1.aggregateScore + $d.1.score // Adds as 0
`agg ${$d.1.aggregateScore}` // "agg "
```
Missing means null, or a field this match does not carry. `0`, `false` and `''` are real values. They are never treated as missing. The members of the field’s type work as well, so you do not have to guard before you call a method. A chain never throws, however deep.
```js
$d.1.coachName.toUpperCase() // ''
$d.1.coachName.length // 0
$d.1.squad.map(p => p.lastName) // []
$d.1.squad.length // 0
$d.1.a.b.c // '' — no error
```
### A path the schema does not know [Section titled “A path the schema does not know”](#a-path-the-schema-does-not-know) A path outside the schema has no type to adapt to. It reads as an object you can keep reading into, and a method call on it throws, so the binding falls back to its default.
```js
$d.1.coachName.toUpperCase() // '' — the schema knows coachName is a string
$d.1.notInTheSchema.toUpperCase() // Throws — check the field name against the schema
```
Read the schema before you write a path: `GET /v2/schemas/sports/{sport}`. ### Checking for a missing value [Section titled “Checking for a missing value”](#checking-for-a-missing-value) | Check | Matches | | ----------- | --------------------------------------------------- | | `x == null` | null and absent. `0`, `false` and `''` do not match | | `!x` | null and absent, and also `0`, `false` and `''` | Use `== null` when a zero is a real value you must keep on screen. Both `||` and `??` fall back on a missing value. A dash only when there is no aggregate score
```js
$d.1.aggregateScore == null ? '-' : $d.1.aggregateScore
```
A zero is a real score
```js
$d.1.score == null // false — the team has 0, which is a value
!$d.1.score // true — 0 is falsy
$d.1.coachName || 'TBC'
```
## What the graphic receives [Section titled “What the graphic receives”](#what-the-graphic-receives) The value your expression returns is not always the value the graphic sees. ### Rive graphics [Section titled “Rive graphics”](#rive-graphics) Every binding coerces the value to the type of its view model property. This is why a missing number still shows `0` on a number property, even though the expression returned `''`. | Property type | Coercion | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `string` | `String(value)` | | `number` | `Number(value)`, and `0` when that is not a number | | `boolean` | Truthy, except `''`, `'0'`, `'false'` and `0`, which are all false | | `color` | A `#rgb`, `#rrggbb` or `#rrggbbaa` string, or a finite number read as ARGB (`0xFFFF0000` is opaque red). Any other value, such as `''` or `'red'`, is ignored and the graphic keeps its current color | | `image` | A URL string | An expression that throws never reaches the binding. The binding falls back to the default for its type. ### Code graphics [Section titled “Code graphics”](#code-graphics) A code graphic has no bindings, so nothing coerces the value. Each result arrives in `userExpressionValues` exactly as the expression returned it, including `''` for a missing number. Handle the type in your own code. In a code graphic
```js
const agg = Number(values.aggregateScore) || 0
```
An expression that throws, or one in a dependency cycle, arrives as `null`. ## Errors [Section titled “Errors”](#errors) The overlay logs every expression error. Test each expression against a rehearsal scenario before you publish: `GET /v2/scenarios/{sport}` returns sample `sportData` for the sport. Run it against a scenario with missing data as well as a full one. Most expression bugs only appear when a field is absent. ## Examples [Section titled “Examples”](#examples) Score line
```js
$d.1.abbreviation + ' ' + $d.1.score + ' - ' + $d.2.score + ' ' + $d.2.abbreviation
```
Show a row only when it has data
```js
$d.1.scorers?.[index] ? true : false
```
Goal minute with penalty and own goal markers
```js
var event = $d.1.scorers?.[0]?.scoreEvents?.[0]
var marker = event?._fact === 'OWN_GOAL' ? ' (OG)' : event?.isPenaltyGoal ? ' (P)' : ''
event ? event.minute + "'" + marker : ''
```
Red card count for both teams
```js
var cards = $d.1.redCards + $d.2.redCards
cards === 0 ? '' : cards === 1 ? '1 red card' : cards + ' red cards'
```
Aggregate score, hidden when there is no first leg
```js
$d.1.aggregateScore == null ? '' : '(' + $d.1.aggregateScore + ')'
```
Squad count that survives an absent squad
```js
($d.1.squad || []).length
```
# Issues and support
> Where to report code graphics, SDK, and CLI issues, request features, or get help with the LIGR app.
## What the public repository contains [Section titled “What the public repository contains”](#what-the-public-repository-contains) The [graphics-packages repository](https://github.com/ligrsystems/graphics-packages) provides public SDK and CLI downloads and a scoped issue tracker. The implementation source remains in a private repository. [Releases](https://github.com/ligrsystems/graphics-packages/releases) contain versioned SDK and CLI archives, release notes, and checksums. You do not need a GitHub or npm account to download them. A GitHub account is required to open an issue. LIGR API authentication is separate. ## Choose the right channel [Section titled “Choose the right channel”](#choose-the-right-channel) | Topic | Where to go | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Code graphics authoring, runtime, protocol, or theme publishing bugs | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | SDK or CLI installation, commands, validation, or package bugs | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Code graphics, SDK, or CLI questions, concerns, and feature requests | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Incorrect code graphics documentation or examples | [GitHub Issues](https://github.com/ligrsystems/graphics-packages/issues) | | Broken app or documentation pages, login, account access, billing, or general platform problems | [LIGR support](https://help.ligr.live/en/) or in-app support | GitHub Issues covers the code graphics platform and its developer tools. This includes plain HTML, CSS, JavaScript, and any browser rendering library. ## Open a useful issue [Section titled “Open a useful issue”](#open-a-useful-issue) 1. Search [existing issues](https://github.com/ligrsystems/graphics-packages/issues) for the same problem or request. 2. Open a [new issue](https://github.com/ligrsystems/graphics-packages/issues/new/choose) and select the matching form. 3. For a bug, include package versions, browser or Node version, reproduction steps, and expected and actual results. 4. For a feature request, explain the code graphics use case and the behavior you need. 5. For a documentation correction, link the relevant example or instruction and describe the error. Issues are public. Remove API keys, credentials, private URLs, and customer data from examples and logs before posting. Use LIGR support for reports that require private account information. ## Automated issue checks [Section titled “Automated issue checks”](#automated-issue-checks) The issue bot checks the required form fields and declared topic when you open, edit, or reopen an issue. It can close incomplete reports or reports explicitly marked as general app or account problems. Its reply lists each failed requirement and explains how to correct the report or contact support. These checks help developers spend time investigating and fixing actionable code graphics problems. They are not a penalty for reporting a problem. We value your reports and apologize for the problems you encounter. If the bot closes your code graphics issue, edit it to address the listed requirements. The bot rechecks the report and reopens it when the requirements are met. Uncertain scope stays open for review, and maintainers can override the automated checks.
# The manifest
> The stateMachine of a code graphic. The runtime block, the control variables, the user expressions, and every rule the API checks on a write.
The manifest describes your graphic to LIGR: the entry file, the frame size, the hide deadline, and the control variables an operator can set. Over REST it is the `stateMachine` field of a code graphic. Keep it in a file named `graphic.json` next to your source, and send it as `stateMachine` when you [record the bundle](/graphics-sdk/push-and-publish/#record-the-bundle). graphic.json
```json
{
"schemaVersion": 1,
"runtime": {
"engine": "iframe-html",
"entryFile": "index.html",
"width": 1920,
"height": 1080,
"exitDurationMs": 800
},
"controlVariables": [
{ "id": "showAggregate", "name": "showAggregate", "type": "boolean", "defaultValue": false },
{ "id": "accent", "name": "accent", "type": "enum", "defaultValue": "home", "options": ["home", "away", "neutral"] }
],
"userExpressions": []
}
```
A graphic you create without a `stateMachine` gets this default: `index.html`, 1920 by 1080, a 1500 ms hide deadline, and no control variables. ## `runtime` [Section titled “runtime”](#runtime) | Field | Value | Rule | | ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `engine` | `iframe-html` | Required. The only engine today | | `entryFile` | The HTML file the overlay loads, as a path inside the bundle | Required. It must name an uploaded file at update and at publish | | `width` | Frame width in pixels | A positive integer. 1920 for a full-frame graphic | | `height` | Frame height in pixels | A positive integer. 1080 for a full-frame graphic | | `exitDurationMs` | How long the overlay waits for `HIDDEN` after `GRAPHIC_HIDE` before it hides the frame | Optional. A positive integer. Default 1500 | ## `controlVariables` [Section titled “controlVariables”](#controlvariables) A control variable is a value an operator sets in the control room, an automation sets from a rule, or a REST client sends with a [graphics command](/control-room/graphics-commands/). Your graphic receives the current values in `controlVariables` and, for entity types, the resolved object in `controlVariableData`. See [Data binding](/graphics-sdk/data/). You choose the variables. There is no platform list of variable names. Declare any name you need, pick a type from the catalogue below, and the control room renders the picker for it. The only fixed parts are the type catalogue and the reserved `hide` name. | Field | Value | | -------------- | ------------------------------------------------- | | `id` | The identifier. Use the same string as `name` | | `name` | The name. Unique within the graphic | | `type` | One of the types below | | `defaultValue` | The value in force when nothing sets the variable | | `options` | For `enum` only. The allowed values. At least one | ### Types [Section titled “Types”](#types) | Type | Value the graphic receives | Picker in the control room | | --------------------------------- | ----------------------------------------------------- | ------------------------------------- | | `string` | A string | Text field | | `number` | A number | Number field | | `boolean` | `true` or `false` | Switch | | `enum` | One of `options` | Drop-down | | `team` | A team id. `controlVariableData` carries the team | Team picker | | `player` | A player id. `controlVariableData` carries the player | Player picker | | `match` | A match id | Match picker | | `fact` | A fact id. `controlVariableData` carries the fact | Fact picker | | `teamStat` | A team statistic | Statistic picker | | `stat` | A statistic | Statistic picker | | `set`, `round`, `court`, `period` | A set, round, court or period selector | Selector for tennis and period sports | ### `hide` is reserved [Section titled “hide is reserved”](#hide-is-reserved) LIGR adds a boolean control variable named `hide` to every code graphic and sets it when the overlay shows or hides your graphic. Never declare it. A manifest that declares `hide` with any type other than `boolean` is refused. Your graphic still hides on `GRAPHIC_HIDE`, not on the value of `hide`. See [Show and hide](/graphics-sdk/protocol/#show-and-hide). ## `userExpressions` [Section titled “userExpressions”](#userexpressions) A user expression is a value the overlay computes for you from the match data, with the same expression language the Rive graphics use. The result arrives in `userExpressionValues`, keyed by expression name. Most code graphics leave this array empty and compute in JavaScript instead.
```json
{ "id": "leader", "name": "leader", "expression": "$d.1.score > $d.2.score ? $d.1.name : $d.2.name" }
```
| Field | Value | | ------------- | ---------------------------------------------------------------------------------------------- | | `id` | The identifier. Use the same string as `name` | | `name` | The key in `userExpressionValues`. Unique within the graphic | | `expression` | The expression. See [Expressions](/graphics-sdk/expressions/) for the language and the context | | `description` | Optional. A note for the editor | ## Validation [Section titled “Validation”](#validation) The API checks the manifest on every write: create, record the bundle, and publish. A failed check returns `400` with `code: "CODE_GRAPHIC_INVALID"` and one line per broken rule in `details[]`. | Rule | Message in `details[]` | | --------------------------------------------------------- | ------------------------------------------------------------------- | | `runtime` is an object | `runtime must be an object` | | `runtime.engine` is `iframe-html` | `runtime.engine must be "iframe-html"` | | `runtime.entryFile` is a path | `runtime.entryFile must be a file path` | | `runtime.entryFile` names an uploaded file | `runtime.entryFile "index.html" is not an uploaded code-file asset` | | `width`, `height`, `exitDurationMs` are positive integers | `runtime.width must be a positive integer` | | `controlVariables` is an array | `controlVariables must be an array` | | Every variable has a name | `controlVariable is missing a name` | | Names are unique | `controlVariables has duplicate name "title"` | | Every type is known | `controlVariable "title" has unknown type "text"` | | An enum has options | `controlVariable "accent" enum needs at least one option` | | An enum default is one of its options | `controlVariable "accent" default "blue" is not one of its options` | | A declared `hide` is boolean | `controlVariables.hide is reserved by LIGR and must be boolean` | | `userExpressions` is an array | `userExpressions must be an array` | The entry file rule runs at publish and when you send a `stateMachine` with the bundle. Record the files and the manifest in the same bundle request, so the check sees both.
# The ligr.gfx.v1 protocol
> The postMessage envelope, the handshake, every message the overlay sends and accepts, the hide rules and the message size limit.
Your graphic and the LIGR overlay page talk over `postMessage`. The overlay is the parent window. Your graphic runs in an iframe. Every message in both directions uses one envelope and the protocol name `ligr.gfx.v1`. ## The envelope [Section titled “The envelope”](#the-envelope)
```json
{ "protocol": "ligr.gfx.v1", "sessionId": "cg_…", "seq": 12, "type": "GRAPHIC_UPDATE", "payload": {} }
```
| Field | Value | | ----------- | -------------------------------------------------------------------------------- | | `protocol` | Always `ligr.gfx.v1` | | `sessionId` | The session the overlay opened for this load of your frame. Copy it from `HELLO` | | `seq` | A counter. Each side keeps its own and increases it by 1 per message | | `type` | The message type. See the two tables below | | `payload` | The body of the message. Its shape depends on `type` | Two rules apply to every message you send. * **Echo the `sessionId`.** Copy the exact string from the `HELLO` message into every message you send. The overlay drops a message with a different `sessionId`. * **Increase `seq`.** Start your counter at 1 and add 1 on every message. The overlay does not refuse a gap, but it logs a warning for one. The overlay refuses a message over 1 MB in either direction. It reports the oversize message as a `GRAPHIC_ERROR` in its own log. Keep `externalData` and your own payloads small. ## The handshake [Section titled “The handshake”](#the-handshake) The overlay sends `HELLO` when your frame fires its `load` event. It repeats `HELLO` every 250 ms until you answer `READY`, up to 20 times. A graphic that answers no `HELLO` within 5 seconds is logged as an error and never loads. Answer the first `HELLO` you receive. You can also answer every `HELLO`. The overlay ignores every `READY` after the first one of a session. | Step | Direction | Type | Payload | | ---- | ----------------- | -------------- | ------------------------------------------ | | 1 | Overlay → graphic | `HELLO` | `{ width, height }`, the frame size | | 2 | Graphic → overlay | `READY` | `{ version }`, the version of your graphic | | 3 | Overlay → graphic | `LOAD_GRAPHIC` | The full data payload | From then on, every change arrives as a `GRAPHIC_UPDATE`. ## Overlay to graphic [Section titled “Overlay to graphic”](#overlay-to-graphic) | Type | When | Payload | | ---------------- | -------------------------------------------------------------- | ----------------------------------------------------------------- | | `HELLO` | On frame load, repeated until `READY` | `{ width, height }` | | `LOAD_GRAPHIC` | After `READY` | Every surface. See [Data binding](/graphics-sdk/data/) | | `GRAPHIC_UPDATE` | When any surface changes | `graphicId`, `show`, `sport`, plus only the surfaces that changed | | `GRAPHIC_HIDE` | An operator, an automation or a REST command hides the graphic | `{ graphicId }` | | `PING` | Every 10 seconds | `{}` | A `GRAPHIC_UPDATE` carries only the surfaces whose JSON changed since the previous message. Merge it over the state you hold. A surface that is absent did not change. If the overlay fails to deliver a `LOAD_GRAPHIC`, it sends the full load again on the next data change. If it fails to deliver a `GRAPHIC_UPDATE`, it drops its diff baseline and sends a full `LOAD_GRAPHIC` on the next data change. Your graphic must render correctly from a `LOAD_GRAPHIC` at any time, not only the first one. ## Graphic to overlay [Section titled “Graphic to overlay”](#graphic-to-overlay) | Type | When | Payload | | --------------- | --------------------------------- | ------------------------------------------------------ | | `READY` | You answer `HELLO` | `{ version }` | | `HIDDEN` | Your out-animation ends | `{ graphicId }` | | `PONG` | You answer `PING` | `{}` | | `ACK` | Optional, after any command | `{ ackSeq }`, the `seq` of the message you acknowledge | | `GRAPHIC_ERROR` | Your graphic hits a runtime error | `{ message, stack?, graphicId? }` | | `METRICS` | Optional | `{ graphicId, renderTimeMs, frameCount? }` | Answer every `PING` with a `PONG`. The overlay logs a warning when no `PONG` arrives within 5 seconds. ## Show and hide [Section titled “Show and hide”](#show-and-hide) 1. The overlay sends `GRAPHIC_HIDE`. 2. Your graphic plays its out-animation and sends `HIDDEN`. 3. The overlay hides the frame when `HIDDEN` arrives, or after `exitDurationMs` from the manifest. The default is 1500 ms. Two rules govern show and hide. * **`GRAPHIC_HIDE` is the only hide trigger.** Hide your graphic only when you receive it. Do not hide on any other message. * **`show` inside `GRAPHIC_UPDATE` is informational.** There is no separate show message. Treat `show: true` in a `GRAPHIC_UPDATE` that arrives after a hide as the signal to show your graphic again. Always send `HIDDEN` when your out-animation ends. If you never send it, the overlay hides the frame at the deadline and your animation looks cut off. If your animation is longer than the default deadline, raise `exitDurationMs` in the [manifest](/graphics-sdk/manifest/). ## Origin [Section titled “Origin”](#origin) Post messages to `window.parent`. Capture `event.origin` from the first `HELLO` you receive and use it as the target origin for every later message. Before you know the origin, `'*'` is accepted. Your graphic runs in a sandboxed frame with an opaque origin. It cannot read the overlay page. The overlay accepts messages only from your frame. See [Bundle rules](/graphics-sdk/bundle/). ## A complete listener [Section titled “A complete listener”](#a-complete-listener) `createGraphic` from the `@ligrsystems/graphics-sdk` package implements this listener, with the origin check, the merge of every `GRAPHIC_UPDATE`, and `HIDDEN` after your out-animation. Use the listener below when you cannot add a dependency. protocol.js
```js
const PROTOCOL = 'ligr.gfx.v1'
let sessionId = null
let parentOrigin = '*'
let seq = 0
export function send(type, payload) {
window.parent.postMessage({ protocol: PROTOCOL, sessionId, seq: ++seq, type, payload }, parentOrigin)
}
export function listen(handlers) {
window.addEventListener('message', (event) => {
const msg = event.data
if (!msg || msg.protocol !== PROTOCOL) return
if (msg.type === 'HELLO') {
sessionId = msg.sessionId
parentOrigin = event.origin || '*'
send('READY', { version: '1.0.0' })
return
}
if (msg.sessionId !== sessionId) return
if (msg.type === 'PING') {
send('PONG', {})
return
}
handlers[msg.type]?.(msg.payload)
})
}
```
`handlers` maps `LOAD_GRAPHIC`, `GRAPHIC_UPDATE` and `GRAPHIC_HIDE` to your own functions. The [Quick start](/graphics-sdk/quick-start/) has a full graphic that uses this shape.
# Push and publish
> Create a code graphic in a theme, push the bundle, publish a graphic version, and publish the theme version that holds it. With the CLI in three commands, or over REST with a write key.
Beta Graphics creation is in beta. See [Beta status](/graphics-sdk/#beta-status). Every write on this page needs a write key. Set `LIGR_API_KEY` in your shell and replace `203` with your theme id. The CLI sends requests to production by default. For another environment, set `LIGR_API_URL` to its full REST base, including `/rest/v2`, for example `https:///rest/v2`. You can also pass `--base-url`. The `curl` tutorials in [Import through REST](/rive-graphics/import-through-rest/) and [Set up through REST](/control-room/setup/) read the same `LIGR_API_URL`. `npx ligr-graphic theme list` or `GET /v2/themes` prints the themes your organization owns. ## With the CLI [Section titled “With the CLI”](#with-the-cli) `ligr-graphic` runs the calls below for you. See [Quick start](/graphics-sdk/quick-start/) for the scaffold. 1. **Create the theme.** Once per theme. The command prints the theme id. In a folder that holds `graphic.json`, it writes the theme id into `ligr.json`. `--variables ` adds theme variables from a JSON array of `{ id, name, type, defaultValue }`. Terminal
```bash
npx ligr-graphic theme create --name "My Theme" --sports football
```
`npx ligr-graphic theme delete --theme 203 --yes` removes a theme you own and every graphic in it. A theme a competition still uses is refused. Remove the theme instance from the competition first. 2. **Create the graphic.** Once per graphic. The command writes `ligr.json` with the theme id and the graphic id. Terminal
```bash
npx ligr-graphic create --theme 203 --name "Scorebug" --sports football
```
3. **Push the bundle.** The command builds, validates, uploads only the files whose hash changed, and records the bundle with `graphic.json` as the manifest. Terminal
```bash
npx ligr-graphic push
```
`npx ligr-graphic validate` runs the same checks without a push. `--skip-build` pushes the folder as it is. 4. **Publish the graphic, then the theme.** The command publishes a graphic version. With `--theme-version` it also publishes a theme version pinned to it. Terminal
```bash
npx ligr-graphic publish --theme-version
```
Each publish creates a new graphic version, even when nothing changed since the last one. `npx ligr-graphic status` prints the working version, the published versions, the assets and the lock. 5. **Inspect and activate the published theme version.** Replace `12` with the version printed above. Terminal
```bash
npx ligr-graphic theme inspect --theme 203 --version 12
npx ligr-graphic theme activate --theme 203 --version 12
```
These commands select an existing snapshot. They do not publish another version or change its graphic selections. 6. **Publish one theme version after several graphics.** Publish each graphic without `--theme-version`. Then publish one theme version that pins the latest published version of every graphic. Terminal
```bash
npx ligr-graphic theme publish --theme 203 --dry-run
npx ligr-graphic theme publish --theme 203
```
`--dry-run` prints the version number and the graphic versions, and writes nothing. The command prints the new version, each pinned graphic version, and whether the version is active. Add `--activate` to set it active. A graphic with no published version is not in the theme version. Caution Publish without `--activate`, then activate the reviewed version when the operator is ready. Reload the preview overlay to verify it. Existing broadcast sources keep their loaded version until reloaded. ## Without the CLI [Section titled “Without the CLI”](#without-the-cli) Send the key in `Authorization: Bearer `. The endpoints are under [Code graphics](/rest/operations/tags/code-graphics/) and [Themes](/rest/operations/tags/themes/) in the REST reference. Create the theme and graphic once, then upload and publish each revision. 1. **Create the theme.** Once per theme. Keep the `id` from the answer. `GET /v2/themes` lists the themes you own, and `DELETE /v2/themes/{themeId}` removes one that no competition uses. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/themes' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "My Theme", "sports": ["football"],
"variables": [{ "id": "accent", "name": "accent", "type": "string", "defaultValue": "#ff0000" }] }'
```
201 Created
```json
{ "id": 203, "name": "My Theme", "activeVersion": null, "versions": [], "graphics": [] }
```
The sixth theme returns `409 THEME_LIMIT_REACHED`. 2. **Create the graphic.** Once per graphic. Keep the `graphicId` from the answer. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/themes/203/code-graphics' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "Scorebug", "sports": ["football"] }'
```
201 Created
```json
{
"graphicId": "e5515527-238e-44cb-9567-b65902218d47",
"id": 1761,
"name": "Scorebug",
"type": "code",
"version": 0,
"sports": ["football"],
"publishedVersions": [],
"lock": null
}
```
The answer holds two identifiers. Every later call in this guide takes `graphicId`, the UUID, in the path. `id` is the internal row number; it never goes in a path. A path with `id` in it answers `404`. `sports` defaults to the sports of the theme. Leave out `stateMachine` to get the default manifest. You send the real one with the bundle. 3. **Request an upload URL for each file.** One call opens one upload session. Terminal
```bash
curl -X POST "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/uploads" \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "files": [
{ "name": "index.html", "contentType": "text/html" },
{ "name": "assets/app.js", "contentType": "application/javascript" }
] }'
```
200 OK
```json
{
"uploadSessionId": "823d14c6-ef4e-4d72-932e-20d8ed422e3c",
"uploads": [
{ "name": "index.html", "url": "https://…?X-Amz-Signature=…", "method": "PUT", "expiresInSeconds": 600 },
{ "name": "assets/app.js", "url": "https://…?X-Amz-Signature=…", "method": "PUT", "expiresInSeconds": 600 }
]
}
```
Keep the `uploadSessionId`. Each URL is valid for 10 minutes. One request signs at most 200 files. For more, send the same `uploadSessionId` in the next request. The answer keeps that id, and every URL writes into the same session. The CLI does this for you. 4. **Send each file to its URL.** Use the content type you declared. Terminal
```bash
curl -X PUT "$UPLOAD_URL" \
-H 'Content-Type: text/html' \
--data-binary @dist/index.html
```
5. **Record the bundle.** Name every file the graphic keeps, with the manifest. Terminal
```bash
curl -X PUT "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/bundle" \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"uploadSessionId": "823d14c6-ef4e-4d72-932e-20d8ed422e3c",
"files": [
{ "name": "index.html", "mime": "text/html", "size": 1240, "hash": "sha256:6f1c…" },
{ "name": "assets/app.js", "mime": "application/javascript", "size": 8300, "hash": "sha256:9b02…" }
],
"stateMachine": { "schemaVersion": 1, "runtime": { "engine": "iframe-html", "entryFile": "index.html", "width": 1920, "height": 1080, "exitDurationMs": 800 }, "controlVariables": [], "userExpressions": [] }
}'
```
The answer is the working copy of the graphic, with its `assets` and its `publishedVersions`. 6. **Publish the graphic version.** Terminal
```bash
curl -X POST "https://api.ligr.live/rest/v2/themes/203/code-graphics/$GRAPHIC_ID/versions" \
-H "Authorization: Bearer $LIGR_API_KEY"
```
201 Created
```json
{ "version": 3 }
```
7. **Publish a theme version that holds it.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/themes/203/versions' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "activate": false }'
```
201 Created
```json
{ "version": 12, "activeVersion": 11 }
```
Inspect `GET /v2/themes/203/versions/12`, then select that exact snapshot: Terminal
```bash
curl -X PUT 'https://api.ligr.live/rest/v2/themes/203/active-version' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "version": 12 }'
```
Reload the preview overlay to load the selected version. See [Publish a theme version](/guides/publish-a-theme-version/) for pinning and rollback. Caution Activation selects the shared theme version for every competition using that theme. The customer dashboard has no activation action. Use the CLI or REST, then reload broadcast sources when the operator is ready. ## Upload sessions [Section titled “Upload sessions”](#upload-sessions) The first `POST …/uploads` call opens an upload session. Further batches can extend that session by sending its `uploadSessionId`. Every URL writes into that session, never directly into the graphic. A completed session cannot be reused. `PUT …/bundle` moves the files of the one session you name into the graphic. It applies one rule per file in `files`: | The file is | Result | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | In the named session | The request copies it into the working copy | | Not in the session, but the graphic already holds it | The request keeps it. Skip an unchanged file: compare its `hash` to the one in `GET …/code-graphics/{graphicId}` | | In neither place | The request fails with `CODE_GRAPHIC_INVALID` and names the file in `details[]` | A file you leave out of `files` is deleted. Leave out `uploadSessionId` only when every named file is already in the graphic. The bundle request clears the session it names when it succeeds. It leaves every other session alone. A publish never touches an upload session, so an upload URL you hold stays valid across a publish. ## Record the bundle [Section titled “Record the bundle”](#record-the-bundle) `files` is the complete file set: `name`, `mime`, `size` in bytes and `hash` for every file the graphic keeps. The API checks every `size` against the limits before it writes anything. The `hash` is yours: the API stores it and returns it, so a later push can compare and skip an unchanged file. `sha256:` is the usual form. `stateMachine` is optional. Leave it out to keep the stored manifest. When you send it, the API validates it against the file set of this request, so `runtime.entryFile` must be in `files`. See [The manifest](/graphics-sdk/manifest/). ## Versions [Section titled “Versions”](#versions) A publish turns the working copy into an immutable version and opens the next working copy. The `version` in the answer is the number a theme version pins. `GET …/code-graphics/{graphicId}` lists them in `publishedVersions`. A theme version is a frozen list of graphic versions. `POST /v2/themes/{themeId}/versions` pins every graphic you list at the version you give, and every graphic you do not list at its latest published version. A graphic with no published version is left out. Overlays load the active theme version when their page loads. Use `GET …/versions/{version}` to inspect a frozen selection and `PUT …/active-version` to activate or roll back to it. ## The lock [Section titled “The lock”](#the-lock) The dashboard editor takes a lock on a graphic while a person edits it. The lock stays for up to 60 seconds after the editor closes. | Request | Lock behaviour | | ----------------- | -------------------------------------------------------------------------------------------------------------------- | | `POST …/uploads` | Refuses to sign while another editor holds the lock. Does not take the lock, so a slow upload never blocks an editor | | `PUT …/bundle` | Holds the lock for the request | | `POST …/versions` | Holds the lock for the request | A request that meets a lock answers `409` with `code: "LOCKED"` and `holder.userName`. Wait, then retry. Do not delete the graphic and create it again. The API takes the lock as `API key `, and the same key can retake its own stale lock. ## Errors [Section titled “Errors”](#errors) | Status | Code | Cause | | ------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | | 400 | `CODE_GRAPHIC_INVALID` | The manifest breaks a rule, or a file in `files` is in neither the session nor the graphic. `details[]` names each problem | | 400 | `BUNDLE_TOO_LARGE` | A file is over 5 MiB, or the bundle is over 10 MiB. `details[]` names the files | | 400 | `INVALID_ASSET_PATH` | A file name holds `..`, an empty segment, a control character, or one of `?`, `#`, `%` | | 400 | `GRAPHIC_TYPE_MISMATCH` | The graphic is a Rive graphic, not a code graphic | | 400 | — | The body fails schema validation. The answer has `details` per field | | 403 | `INSUFFICIENT_SCOPE` | A read key on a write route | | 404 | — | The theme or the graphic does not exist, or your organization does not own it | | 409 | `LOCKED` | Another editor holds the lock | | 409 | `GRAPHIC_ASSET_MISSING` | A published asset or thumbnail is absent from storage. Upload the missing file, then publish again | | 409 | `GRAPHIC_ASSET_INVALID` | An asset cannot be read, or its size or SHA-256 differs. Replace the file, then publish again | See [Errors](/get-started/errors/) for the full map. ## Request count [Section titled “Request count”](#request-count) An upload batch is one request: `POST …/uploads` signs every file in one call. A full push of a graphic is four requests. See [Rate limits](/get-started/rate-limits/).
# Quick start
> From an empty folder to a scorebug on an overlay. Scaffold a graphic, run it in the local harness, then create, push and publish it with the CLI.
Beta Graphics creation is in beta. See [Beta status](/graphics-sdk/#beta-status). Use Node 22 or later. Any package manager works; the examples use npm. You need a write API key, a browser and a terminal. Set `LIGR_API_KEY` in your shell. This example uses plain HTML, CSS and JavaScript. Any browser rendering library can use the same protocol and publishing tools. 1. **Scaffold the graphic.** Terminal
```bash
npx --package=https://github.com/ligrsystems/graphics-packages/releases/download/v0.3.0/ligrsystems-graphics-cli-0.3.0.tgz ligr-graphic init my-graphic
cd my-graphic
npm install
```
Both packages use exact public GitHub release URLs. Package downloads need no npm account or GitHub authentication. Keep the generated lockfile in Git. The folder holds a scorebug, the manifest `graphic.json`, and two files for coding agents: `AGENTS.md` and `.claude/skills/ligr-graphic/SKILL.md`. Delete them if you do not use an agent. The default template uses one HTML file and a script that copies its files into `dist/`. Edit `index.html`. Its `createGraphic` callbacks receive match data and handle visibility. The hide callback resolves after the animation; the SDK then sends `HIDDEN`. The SDK is optional: any implementation of [the protocol](/graphics-sdk/protocol/) can run on LIGR. 2. **Run it in the local harness.** Terminal
```bash
npm run build
npm run dev
```
Open . The local viewer needs no dashboard login. It sends the same message order as the overlay. Open **Data**, choose a scenario, and press **Next** or **Play**. Edit declared variables under **Controls**, inspect **Files**, then press **Hide**. The log must show `READY` and `HIDDEN` from your graphic, and a `PONG` after the first `PING` at 10 seconds. See [Test and troubleshoot](/graphics-sdk/test/). Keep the harness running. Open a second terminal in the same project folder for the remaining commands. 3. **Create a theme.** Once per theme. Skip this step if your organization already has one: `npx ligr-graphic theme list` prints the themes you own. Terminal
```bash
npx ligr-graphic theme create --name "My Theme" --sports football
```
The command prints the theme id and writes it into `ligr.json`, because the folder holds `graphic.json`. Your organization can own up to five themes. 4. **Create the graphic in the theme.** Terminal
```bash
npx ligr-graphic create --name "Scorebug" --sports football
```
The command writes the graphic id into `ligr.json`. Pass `--theme ` to use a theme that is not in `ligr.json`. 5. **Push the bundle.** Terminal
```bash
npx ligr-graphic push
```
The command builds, checks the manifest and the files with the API rules, uploads the changed files, and records the bundle. Run `npx ligr-graphic validate` to check without a push. 6. **Publish the graphic, then the theme.** Terminal
```bash
npx ligr-graphic publish --theme-version
```
The command prints the published theme version. Inspect and activate that exact version when the broadcast allows it. Terminal — replace 1 with the published theme version
```bash
npx ligr-graphic theme inspect --version 1
npx ligr-graphic theme activate --version 1
```
Activation does not publish another version. Existing overlay pages need a reload to load the selected version. See [Push and publish](/graphics-sdk/push-and-publish/). 7. **Prepare the control room through REST.** Follow [Set up through REST](/control-room/setup/) with the theme and graphic IDs from the publishing steps. Create or reuse the competition, teams, venue, and match through REST. Create the theme profile, room, section, and graphic preset. Create an overlay with that profile and room, `autoMode: false`, and `adType: "noBrands"`. The API returns `controlRoomUrl`. Open that URL when setup is complete. Your browser needs a dashboard session with access to the same organization. 8. **Operate the prepared graphic.** Select **GFX In** on the preset. Verify the graphic on the preview overlay. After changing its variables, select **Update GFX** to apply the values while it is shown. Select **GFX Out** to hide it. REST commands also require manual mode; they do not enable it. See [Presets](/control-room/presets/) for preset commands, or address the graphic directly over REST: Set `OVERLAY_ID` to your match overlay’s numeric id. Terminal
```bash
curl -X POST "https://api.ligr.live/rest/v2/overlays/$OVERLAY_ID/graphics/commands" \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "command": "show", "name": "Scorebug" }'
```
## What to do next [Section titled “What to do next”](#what-to-do-next) * Add control variables so an operator can change what the graphic shows. See [The manifest](/graphics-sdk/manifest/). * Read the other surfaces: theme variables, images and external data. See [Data binding](/graphics-sdk/data/). * Read the rules the bundle must follow. See [Bundle rules](/graphics-sdk/bundle/). * Write your own listener without the package. See [The protocol](/graphics-sdk/protocol/). * Push without the CLI, one REST call at a time. See [Push and publish](/graphics-sdk/push-and-publish/#without-the-cli).
# Test and troubleshoot
> npx ligr-graphic dev, a local harness that drives your graphic with the same message order as the overlay, the checklist before you hand over, and the symptoms table.
Test your graphic before you push it. `npx ligr-graphic dev` runs a local harness that needs no API key. The viewer frames your graphic, sends the same message order as the LIGR overlay, and logs every message. It needs no dashboard login. Preview controls change local test state; they do not publish or update a saved graphic. ## The local harness [Section titled “The local harness”](#the-local-harness) Terminal
```bash
npm run dev
```
`npm run dev` runs `npx ligr-graphic dev` in a scaffolded graphic. Open . The harness sends `HELLO` on frame load, waits for `READY`, then sends `LOAD_GRAPHIC`, and a `PING` every 10 seconds after that. That is the order and the cadence the overlay uses. The panel next to the frame holds: | Control | What it sends | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | **Data** → scenario picker | A `GRAPHIC_UPDATE` with the first step of the scenario | | **Previous**, **Next** | A `GRAPHIC_UPDATE` with the sport data of that step. Step `n` is steps `0..n` merged | | **Hide** | `GRAPHIC_HIDE`, then a `GRAPHIC_UPDATE` with `show: false`. The log reports when `HIDDEN` arrives against `exitDurationMs` | | **Show** | A `GRAPHIC_UPDATE` with `show: true` | | **Play**, **Pause** | Advance scenario steps once per second, or stop playback | | **Controls** | Edit declared control variables and send their current values | | **Data** | Inspect the current sport data and scenario step | | **Files** | Inspect the available local file names and sizes | | **Reset** | Restore the first step, default controls, and visible state, then reload the frame | | The log | Every message in both directions, with a warning for a `seq` gap and for a missing `PONG` | With `LIGR_API_KEY` set, the harness loads the scenarios of the sport you pass with `--sport` and the real JSON Schema of every surface from LIGR. Without a key it uses one bundled football scenario. The scenarios are the ones the LIGR graphics builder uses. See [Schemas & scenarios](/rest/operations/tags/schemas--scenarios/). With a `start` script in `package.json`, the harness runs it on the next free port and frames that server. Any development server can use this path; it must serve runnable browser files on the supplied port. Without a `start` script, the harness serves `dist/` and reloads the frame when a file under `dist/` changes. Terminal
```bash
npx ligr-graphic dev --port 8080 --sport football
```
### Without the CLI [Section titled “Without the CLI”](#without-the-cli) Save this file as `harness.html` next to your `dist/` folder. Serve the folder from any static file server, then open the harness in a browser. It sends the same first messages as `npx ligr-graphic dev`, with one sample payload and no scenarios. Terminal
```bash
python3 -m http.server 8080
open http://localhost:8080/harness.html
```
harness.html
```html
```
Empty schema objects are fine for a local test. The overlay sends full JSON Schema. For real sample data, fetch `GET /v2/scenarios/{sport}` and paste one scenario into `samplePayload`. ## Dashboard code viewer [Section titled “Dashboard code viewer”](#dashboard-code-viewer) Open a code graphic from your theme to inspect it in the dashboard viewer. The dashboard viewer requires a session with access to the owning organization. It provides **Controls**, **Data**, and **Files**, plus scenario playback and **Show**, **Hide**, and **Reset**. A working version uses the current saved draft. A numbered version loads that published snapshot and its pinned files. Preview controls only change the current preview. They do not edit the published version. Use **Open working version** when you need the draft after inspecting a published version. ## Preview on an overlay [Section titled “Preview on an overlay”](#preview-on-an-overlay) After you [push and publish](/graphics-sdk/push-and-publish/), open a control room of a competition that uses the theme. Your graphic appears in the graphic list with its control variables. Show it on the preview overlay of a test match before you use it on air. ## Inspect the payload on an overlay [Section titled “Inspect the payload on an overlay”](#inspect-the-payload-on-an-overlay) The overlay frames your graphic in a sandbox, so the browser cannot inspect the frame from the page. Add `?debugCodeGraphics=1` to an overlay URL to see each data message that your graphic receives. Use one of these overlay URLs from the control room of a test match: | URL | Where to get it | | ---------------------------------------- | ------------------------------------------- | | `…/monitoring-{key}?debugCodeGraphics=1` | **Copy monitoring link** | | `…/preview-{key}?debugCodeGraphics=1` | **Preview Link** in the control room header | 1. Open the overlay URL with `?debugCodeGraphics=1` in a desktop browser. 2. Open the browser console and show messages at the **Verbose** level. 3. Find the `[CodeGraphicIframe] payload` line with `type: 'LOAD_GRAPHIC'`. It holds the full payload of the load. 4. Paste this listener to print each later message as an object: Browser console
```js
addEventListener('ligr:code-graphic-payload', e => console.log(e.detail))
```
Each event has `graphicId`, `type`, `seq`, and `payload`. `type` is `LOAD_GRAPHIC` or `GRAPHIC_UPDATE`. A `GRAPHIC_UPDATE` payload holds only the changed surfaces. `seq` is the same value that your graphic receives. The overlay reads the flag when it loads a graphic. If you add the flag to an open overlay, reload the page. The flag changes no graphic output, and it sends no network request. ## Checklist [Section titled “Checklist”](#checklist) Check every item before you push a version for a broadcast. * The handshake, `HELLO` through `LOAD_GRAPHIC`, completes within 2 seconds. * The graphic renders correctly from `LOAD_GRAPHIC` alone, with no earlier `GRAPHIC_UPDATE`. * The graphic updates correctly on a partial `GRAPHIC_UPDATE`, for example a score change with no clock change. * The graphic sends `HIDDEN` within `exitDurationMs` of `GRAPHIC_HIDE`. * The browser console shows no error across a full show and hide cycle. * The graphic makes no network call at runtime. * The built `dist/` folder is under 10 MiB, and no file is over 5 MiB. * The `` background stays transparent in every state. * The animation holds 60 frames per second during show and hide. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) | Symptom | Likely cause | Fix | | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | The frame stays blank | The entry file never sends `READY`, or a script error stopped it | Open the browser console. Check the harness log for a missing `READY` | | The graphic never receives data | You echoed the wrong `sessionId`, or you never sent `READY` | Copy `sessionId` from `HELLO` exactly. Send `READY` before you expect `LOAD_GRAPHIC` | | The graphic never hides | You never send `HIDDEN`, or `exitDurationMs` is shorter than your animation | Send `HIDDEN` when the out-animation ends. Raise `exitDurationMs` in the manifest | | A message fails with a 1 MB error | A payload, usually `externalData`, is too large | Trim the data source. Load large assets from the bundle instead of the payload | | Fonts do not load | A font path is absolute, or the font is missing from the bundle | Use relative paths. Ship the font file inside `dist/` | | The wrong team shows on the left | The home and away fields are swapped | `sportData['1']` is the home team. `sportData['2']` is the away team | | `CODE_GRAPHIC_INVALID` on push or publish | The manifest breaks a rule, or a file was not uploaded in the session you named | Read `details[]`. Each line is one problem. See [The manifest](/graphics-sdk/manifest/#validation) | | `409 LOCKED` on push or publish | A person has the graphic open in the dashboard editor, or a stale lock has not expired | Read `holder.userName`. Wait up to 60 seconds, then retry | | `404` on a theme you expect to reach | The API key belongs to another organization | Use a key of the organization that owns the theme | | The frame is blank on the overlay but fine in the harness | The bundle did not upload, or the entry file is missing | `GET …/code-graphics/{graphicId}` lists `assets`. Check the entry file is there | ## Report a problem [Section titled “Report a problem”](#report-a-problem) Use [Issues and support](/graphics-sdk/issues-and-support/) for code graphics, SDK, and CLI reports. General app problems, including broken pages, login, and billing, go to LIGR support.
# Guides
> Task-based walkthroughs for scoring, streams, webhooks, theme versions, code graphics, and native Rive graphics.
A guide takes you from an empty terminal to a working result, with `curl` at every step. The [REST reference](/rest/) documents every endpoint. A guide tells you which ones to call, in which order, and what to keep from each answer. | Guide | What it covers | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | [Score a match from your system](/guides/score-a-match/) | Create a match, post the facts as the match runs, and close it | | [Automate a stream](/guides/automate-a-stream/) | Preview, go live, switch the overlay, pause and stop | | [Configure a stream](/guides/configure-a-stream/) | Create an input, save a destination, create the stream of a match and link them | | [Receive webhooks](/guides/receive-webhooks/) | Stand up an endpoint, verify the signature, answer fast, and read the delivery log | | [Publish a theme version](/guides/publish-a-theme-version/) | Read the theme, pin graphic versions, publish, and activate. Beta | | [Build a code graphic](/graphics-sdk/quick-start/) | From one HTML file to a graphic on an overlay. In the Graphics SDK section. Beta | | [Import a Rive graphic](/rive-graphics/import-through-rest/) | From a source project to a native preset on an overlay. Beta | Every guide uses a write key and `https://api.ligr.live/rest`. Set `LIGR_API_KEY` in your shell before you start. See [Your first request](/get-started/first-request/) if you have no key yet. Two shorter tasks have their own reference pages. * Fire a graphic on an overlay: [Graphics commands](/control-room/graphics-commands/) and [Presets](/control-room/presets/). * Push a spreadsheet or JSON into a theme: [External data sources](/control-room/data-sources/).
# Automate a stream
> Find the stream of a match, go live with or without destinations, switch the overlay, stop, and read the state at every step.
A stream has an input, destinations and an overlay. Configure it in the dashboard, or over REST with [Configure a stream](/guides/configure-a-stream/). This guide drives a configured stream for one match. It needs a write key and a match that already has a stream. The endpoints are under [Streams](/rest/operations/tags/streams/) in the REST reference. 1. **Find the stream of the match.** Terminal
```bash
curl 'https://api.ligr.live/rest/v2/matches/1188213?include=s,o' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
200 OK (excerpt)
```json
{
"id": 1188213,
"streams": [{ "id": 90412, "name": "Main feed", "destinations": [{ "id": 3301, "name": "YouTube", "type": "youtube" }] }],
"overlays": [{ "id": 2300003, "name": "Broadcast", "key": "…" }]
}
```
`streams[].id` is the `streamSettingsId` every stream route takes. `overlays[].id` is the overlay you can switch to in step 4. 2. **Read the state.** Terminal
```bash
curl 'https://api.ligr.live/rest/v1/streams/90412/state' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
200 OK
```json
{ "streamSettingsId": 90412, "state": "IDLE", "matchId": 1188213 }
```
An idle stream is ready for `GoLive`. 3. **Go live.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/90412/manage' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "command": "GoLive" }'
```
200 OK
```json
{ "streamSettingsId": 90412, "state": "CREATING", "matchId": 1188213, "transitioning": true }
```
`GoLive` starts the stream. Without `startDestinationsImmediately` it publishes nowhere: the picture runs, and an operator starts the destinations from the dashboard when it is right. That is the preview. To publish at once, list the destination ids to start: Go live and publish to one destination at once
```json
{ "command": "GoLive", "startDestinationsImmediately": [3301] }
```
Poll `GET …/state` every 5 seconds until `state` reads `ONLIVE`. The transition takes tens of seconds. `ERROR` means the start failed. Open the stream in the dashboard to read the cause. 4. **Switch the overlay.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/90412/overlay/change' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "overlayId": 2300003 }'
```
Send `"overlayId": null` to unlink the overlay. `…/overlay/show` and `…/overlay/hide` toggle the linked overlay without unlinking it. `…/overlay/refresh` restarts it. 5. **Stop.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/90412/manage' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "command": "StopStream" }'
```
200 OK
```json
{ "streamSettingsId": 90412, "state": "TRANSIT_TO_STOPPED", "matchId": 1188213, "transitioning": true }
```
Poll `GET …/state` until `state` reads `IDLE`. The stream is then ready for the next `GoLive`. ## States [Section titled “States”](#states) `GET …/state` returns one of these values. | `state` | Meaning | | -------------------- | -------------------------------------------------------------- | | `IDLE` | No stream is running. `GoLive` is allowed | | `CREATING` | `GoLive` was accepted and the infrastructure is starting | | `ONLIVE` | The stream is live. `StopStream` is allowed | | `TRANSIT_TO_STOPPED` | `StopStream` was accepted and the infrastructure is stopping | | `ERROR` | The start or the stop failed. Open the stream in the dashboard | The manage answer adds `transitioning`. It is `true` for `CREATING` and `TRANSIT_TO_STOPPED`. A command sent while the stream is transitioning is refused with 400. The state endpoint does not carry `transitioning`. Poll on `state`. ## Commands [Section titled “Commands”](#commands) | `command` | Effect | | ------------ | ----------------------------------------------------------------------------------------------------------------- | | `GoLive` | Start the stream. Allowed from `IDLE`. `startDestinationsImmediately` picks the destinations that publish at once | | `StopStream` | Stop the stream. Allowed from `ONLIVE` | There is no pause. Stop the stream and go live again. There is no separate preview command. `GoLive` without destinations is the preview. ## Polling [Section titled “Polling”](#polling) Poll `GET …/state` every 5 seconds while a transition is in progress. Stop polling when the stream reaches the state you wait for. See [Rate limits](/get-started/rate-limits/). ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | ---------------------------------------------------------------------------------- | | 400 | The command is not allowed from the current state, or the stream is transitioning | | 403 | A read key on `…/manage` or an overlay route. Use a write key | | 404 | The `streamSettingsId` belongs to another organization, or the match has no stream | See [Streams](/rest/operations/tags/streams/) in the REST reference.
# Configure a stream
> Create an input, save a destination, create the stream of a match with both linked, read it back, and change it. Everything the stream settings page does, over REST.
A stream needs three things: an input LIGR reads the picture from, one or more destinations it publishes to, and the match it belongs to. This guide creates all three over REST and links them. It needs a write key with the `streams` scope, and a match id. The endpoints are under [Streams](/rest/operations/tags/streams/) in the REST reference. 1. **Create an input.** LIGR generates the host, the application name and the stream key. Send your picture to `rtmpUrl` with `streamKey` as the stream key, or to `srtUrl`. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/inputs' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "OB van 1", "latencyMs": 1000 }'
```
201 Created
```json
{
"id": 412,
"name": "OB van 1",
"type": "default",
"host": "a1b2c3d4.stream.ligr.live",
"applicationName": "en123",
"streamKey": "e5f6a7b8c9d0e1f2",
"rtmpUrl": "rtmp://a1b2c3d4.stream.ligr.live/en123",
"srtUrl": "srt://a1b2c3d4.stream.ligr.live:10080?streamid=#!::r=en123/e5f6a7b8c9d0e1f2,m=publish",
"sourceUrl": null,
"latencyMs": 1000,
"createdAt": "2026-09-12T08:00:00.000Z",
"updatedAt": "2026-09-12T08:00:00.000Z"
}
```
`latencyMs` is the receive latency of the input. It absorbs network jitter and adds the same delay to the picture. It is one of 20, 50, 100, 150, 250, 350, 500, 1000, 1500, 2000, 2500 or 3000. For a source LIGR pulls instead, send `"type": "pull"` and `sourceUrl`. An input is reusable. Create one per camera feed, not one per match. 2. **Save a destination.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/destinations' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "YouTube main",
"type": "youtube",
"url": "rtmp://a.rtmp.youtube.com/live2",
"key": "abcd-efgh-ijkl-mnop"
}'
```
201 Created
```json
{
"id": 3301,
"name": "YouTube main",
"type": "youtube",
"transportDirection": "push",
"transportProtocol": "rtmp",
"feedType": "program",
"url": "rtmp://a.rtmp.youtube.com/live2",
"key": "abcd-efgh-ijkl-mnop",
"pullUrl": null,
"singleUse": false,
"createdAt": "2026-09-12T08:01:00.000Z",
"updatedAt": "2026-09-12T08:01:00.000Z"
}
```
`type` is one of `youtube`, `facebook`, `twitter`, `linkedin`, `tiktok`, `twitch`, `custom` and the partner platforms. `custom` takes any RTMP or SRT server. An `srt://` url makes the protocol SRT. `feedType` is `program` with graphics, or `clean` without. A pull destination has no url and key. Send `"transportDirection": "pull"` and LIGR answers with `pullUrl`, the URL a player or a relay pulls from. A destination is reusable too. `GET /v1/streams/destinations` lists what the organization has, one-off destinations included. 3. **Create the stream.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"matchId": 1188213,
"name": "Main feed",
"resolution": "1920 * 1080",
"fps": "50",
"region": "ap-southeast-2",
"inputId": 412,
"destinationIds": [3301],
"overlayId": 2300003
}'
```
201 Created (excerpt)
```json
{
"id": 90412,
"name": "Main feed",
"matchId": 1188213,
"competitionId": 2311,
"overlayId": 2300003,
"resolution": "1920 * 1080",
"fps": "50",
"region": "ap-southeast-2",
"size": "regular",
"inputId": 412,
"inputSelection": "specific",
"destinationSelection": "specific",
"destinations": [{ "id": 3301, "name": "YouTube main", "type": "youtube", "url": "rtmp://a.rtmp.youtube.com/live2", "key": "abcd-efgh-ijkl-mnop" }],
"hideOverlay": false,
"autoStream": false,
"generateHighlights": false,
"archiveFullFeed": false
}
```
The stream takes its competition from the match. `matchId`, `resolution`, `fps` and `region` are required. Everything else has a default. `region` is the AWS region the stream runs in. Pick the one nearest the input, for example `ap-southeast-2` for Sydney or `eu-west-2` for London. `overlayId` is an overlay of the same match, from `GET /v2/matches/{id}?include=o`. The `id` in the answer is the `streamSettingsId` every lifecycle route takes. Start the stream with `POST /v1/streams/90412/start`. See [Automate a stream](/guides/automate-a-stream/). 4. **Read it back, and change it.** Terminal
```bash
curl 'https://api.ligr.live/rest/v1/streams?matchId=1188213' -H "Authorization: Bearer $LIGR_API_KEY"
curl 'https://api.ligr.live/rest/v1/streams/90412' -H "Authorization: Bearer $LIGR_API_KEY"
```
An update sends only the fields that change. Add a second destination and turn on highlights: Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/streams/90412' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "destinationIds": [3301, 3302], "generateHighlights": true, "archiveFullFeed": true }'
```
While the stream runs, only these fields can change: `autoStream`, `autoStreamConfig`, the three times, `generateHighlights`, `publishHighlights` and `destinationIds`. Turning `generateHighlights` on while the stream runs needs `archiveFullFeed` already on. Any other change answers 400. Stop the stream first. 5. **Delete what you no longer need.** Terminal
```bash
curl -X DELETE 'https://api.ligr.live/rest/v1/streams/90412' -H "Authorization: Bearer $LIGR_API_KEY"
```
A running stream, an input in use by a running stream, and a destination linked to a running stream all refuse to delete. Stop the stream first. ## Automatic input and destinations [Section titled “Automatic input and destinations”](#automatic-input-and-destinations) A stream can pick its input and destinations from the match instead of fixed ids. Send `"inputSelection": "home"` to use the input assigned to the home team, or `"venue"` for the one assigned to the venue of the match. `destinationSelection` works the same way. Assign inputs and destinations to teams and venues in the dashboard. A stream created with fixed ids uses `specific`, and an update that sends `inputId` or `destinationIds` moves it back to `specific`. ## Automation [Section titled “Automation”](#automation) | Field | Meaning | | ------------------------ | ------------------------------------------------------------------------------- | | `autoStream` | Start and stop at the automation times of the match | | `autoStreamConfig` | Per trigger, `preview`, `goLive` and `stop`, a `mode` of `schedule` or `venues` | | `streamPreviewStartTime` | Minutes relative to the scheduled start of the match | | `streamLiveStartTime` | Minutes relative to the scheduled start of the match | | `streamAutoStopTime` | Minutes relative to the time the match stopped | ## Recording and highlights [Section titled “Recording and highlights”](#recording-and-highlights) | Field | Meaning | | ---------------------- | -------------------------------------------------------------- | | `archiveFullFeed` | Keep the recording of the program feed | | `archiveCleanFeed` | Keep the recording of the clean feed | | `autoConvertRecording` | Convert the recording after the match. Needs `archiveFullFeed` | | `generateHighlights` | Cut highlights from the facts of the match | | `publishHighlights` | Publish the highlights as they are cut | ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | A change the running stream does not allow, a push destination without url and key, a pull input without `sourceUrl`, a latency outside the list, or a delete of something in use | | 403 | A key without the `streams:write` scope | | 404 | The match, stream, input or destination belongs to another organization |
# Publish a theme version
> Read the theme, pick the graphic versions the new theme version pins, publish it, and activate it at the right moment.
Beta Graphics creation is in beta. See [Beta status](/graphics-sdk/#beta-status). A theme version is a frozen list of graphic versions. An overlay loads the active theme version when its page loads. This guide publishes a theme version, inspects its contents, and activates that exact version. It needs a write key and a theme id. 1. **Read the theme.** Terminal
```bash
curl 'https://api.ligr.live/rest/v2/themes/203' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
200 OK
```json
{
"id": 203,
"name": "Match Day",
"activeVersion": 11,
"versions": [9, 10, 11],
"graphics": [
{ "graphicId": "8efdf988-1f4d-43ac-873b-06b9b4f3e379", "name": "Scorebug", "type": "code", "workingVersion": 4, "publishedVersions": [1, 2, 3] },
{ "graphicId": "c1e2a9d0-5b7e-4f60-9c3a-2d1f0e8b7a64", "name": "Lower third", "type": "rive", "workingVersion": 7, "publishedVersions": [5, 6] }
]
}
```
`activeVersion` is what an overlay loads on its next page load. Each graphic lists its `publishedVersions`. The `workingVersion` is the editable copy. A theme version can pin published versions only. 2. **Decide what the new version pins.** The request pins every graphic you list at the version you give. Every graphic you do not list is pinned at its latest published version. A graphic with no published version is left out. Publish a graphic version first if the change you want is still in a working copy. For a code graphic, see [Push and publish](/graphics-sdk/push-and-publish/). For a Rive graphic, use [Import through REST](/rive-graphics/import-through-rest/#6-publish-snapshot-and-activate). 3. **Publish the theme version.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/themes/203/versions' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"graphics": [
{ "graphicId": "c1e2a9d0-5b7e-4f60-9c3a-2d1f0e8b7a64", "version": 5 }
]
}'
```
201 Created
```json
{ "version": 12, "activeVersion": 11 }
```
This pins the lower third at version 5 and the scorebug at its latest, version 3. The active version is still 11. Nothing changed on air. With the CLI, run `npx ligr-graphic theme publish --theme 203`. It sends no `graphics` list, so every graphic takes its latest published version. Add `--dry-run` to print the selection first, and `--activate` to set the new version active. 4. **Inspect the published version.** Terminal
```bash
curl 'https://api.ligr.live/rest/v2/themes/203/versions/12' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
The response lists the frozen graphic version selections. It includes the lower third at v5, regardless of later graphic publications. With the CLI, run `npx ligr-graphic theme inspect --theme 203 --version 12`. 5. **Activate that version.** Select the existing version when the broadcast allows it. This request does not publish a new version or change its contents. Terminal
```bash
curl -X PUT 'https://api.ligr.live/rest/v2/themes/203/active-version' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "version": 12 }'
```
200 OK
```json
{ "activeVersion": 12 }
```
With the CLI, run `npx ligr-graphic theme activate --theme 203 --version 12`. All competitions using this theme share its active version. Existing overlay pages keep their loaded version until reloaded. Reload the preview overlay and check the result before reloading a broadcast browser source. Caution Activate versions and reload broadcast sources only when the operator is ready for the change. The customer dashboard does not expose theme version activation; use the CLI or REST. ## Roll back [Section titled “Roll back”](#roll-back) Read the theme’s `versions` list, then inspect the old version with `GET /v2/themes/203/versions/11`. Activate that existing snapshot to restore its exact graphic selection, including the absence of graphics added later. Terminal
```bash
npx ligr-graphic theme inspect --theme 203 --version 11
npx ligr-graphic theme activate --theme 203 --version 11
```
Reload the preview overlay to verify the rollback. Reload broadcast sources when the operator is ready. Presets for graphics absent from the selected snapshot are hidden. They return if you activate a snapshot containing those graphics. ## Errors [Section titled “Errors”](#errors) | Status | Cause | | ------ | ---------------------------------------------------------------------------------------- | | 400 | A `version` is not a published version of that graphic, or `version` is under 1 | | 403 | A read key. Use a write key | | 404 | The theme belongs to another organization, or the requested theme version does not exist | | 409 | A graphic in the theme is locked. `holder.userName` names the editor. Wait, then retry | See [Themes](/rest/operations/tags/themes/) in the REST reference.
# Receive webhooks
> Stand up an endpoint, verify every delivery, answer within the timeout, process after you answer, and handle retries.
This guide builds a webhook receiver in Node and connects it to a competition. It needs a public HTTPS URL, the signing secret of your organization, and about half an hour. The contract in one line: LIGR sends one signed POST per change, waits 10 seconds, and retries a failed delivery with up to a minute of retry delays. Your endpoint must verify, answer `2xx` at once, and process afterwards. 1. **Get the secret.** Open **Developers → Webhooks** in the dashboard and copy the signing secret. Your organization has one secret, and every webhook of the organization uses it. Put it in an environment variable on your server. Never put it in a browser. 2. **Write the receiver.** Read the raw body before anything parses it. The signature covers the bytes LIGR sent. server.ts
```ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'
const SECRET = process.env.LIGR_WEBHOOK_SECRET!
const FIVE_MINUTES = 5 * 60_000
const seen = new Set()
function verify(rawBody: string, ts: string | undefined, sig: string | undefined): boolean {
if (!ts || !sig) return false
if (Math.abs(Date.now() - Number(ts)) > FIVE_MINUTES) return false
const expected = createHmac('sha256', SECRET).update(`v0:${ts}:${rawBody}`).digest('hex')
if (sig.length !== expected.length) return false
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
const app = express()
app.post('/ligr', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
const ts = req.header('x-ligr-webhook-timestamp')
const sig = req.header('x-ligr-webhook-sig')
if (!verify(rawBody, ts, sig)) return res.status(401).end()
const event = JSON.parse(rawBody)
const key = `${event.entity}:${event.data.id}:${event.date}`
if (seen.has(key)) return res.status(200).end()
seen.add(key)
res.status(200).end()
setImmediate(() => handle(event))
})
function handle(event: { type: string; entity: string; competitionId: number; date: string; data: any }) {
console.log(event.entity, event.type, event.data.id)
}
app.listen(3000)
```
The handler answers before it does any work. A slow database write inside the request is the usual cause of a timeout. 3. **Expose it over HTTPS and register it.** Deploy the receiver, or tunnel it for a test. Then in **Developers → Webhooks**, add the URL, select the competitions and the events you want, and save. Subscribe only to the events you handle. See [Events](/webhooks/events/). 4. **Trigger a delivery.** Change something in a subscribed competition. A fact is the quickest: post one to a test match with `POST /v2/matches/{matchId}/facts`, then delete it. See [Score a match](/guides/score-a-match/). Your receiver logs `fact create`, then `fact delete`, and a `summary update` between them. 5. **Read the log.** Open **Developers → Webhooks**, open the webhook, and select **Event Logs**. The log keeps the newest 50 attempts per webhook, with the status, the response body and the payload. A `0` status means your endpoint sent no answer. A `401` from the receiver above means the signature failed. See [Delivery health & logs](/webhooks/delivery-health/). ## Retries [Section titled “Retries”](#retries) LIGR retries a delivery that gets no answer, or a `5xx`, `408` or `429` answer. A fact or summary event retries at once, then after 2, 4, 8 and 16 seconds. Any other event retries after 15 and 45 seconds. A restart or a deploy on your side is covered. An outage longer than a minute loses the events of that window. Read from the [REST API](/rest/) once, then continue from the webhooks, when you start late or lost events: | Situation | Read once | | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Your receiver comes up mid-match, or the webhook was enabled after kick-off | `GET /v2/matches/{matchId}` and `GET /v2/matches/{matchId}/summary` | | Your endpoint was down for longer than a minute | The matches and summaries of every live competition | The `date` on every event and the `updatedAt` on every entity tell you which side is newer. Delivery is at least once. A retried delivery keeps the `date` of the event, so the `seen` set in the receiver above drops a duplicate. It carries the entity as it is at retry time, and it can arrive after a newer event for the same entity. Keep the delivery with the newest `date`. ## Signature failures [Section titled “Signature failures”](#signature-failures) | Symptom | Cause | Fix | | ----------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | | Every delivery fails verification | The body was parsed and re-serialized before the check | Read the raw body. Mount the raw parser before any JSON parser | | Every delivery fails after a deploy | The secret changed, or the environment variable is missing | Copy the secret from the dashboard again | | Some deliveries fail | Clock skew over five minutes | Sync the server clock. The timestamp is Unix time in milliseconds | The full verification contract, with a Python version, is on [Verify signatures](/webhooks/verify-signatures/).
# Score a match from your system
> Create a match, set the lineup, start the periods, post goals and cards as they happen, correct a mistake, and close the match. Football, with curl at every step.
This guide connects a scoring system to LIGR for one football match. Your system creates the match, posts a fact for every event, and LIGR computes the score, the clock, the statistics and the graphics from those facts. It needs a write key and about half an hour. The endpoints are under [Matches](/rest/operations/tags/matches/) and [Facts](/rest/operations/tags/facts/) in the REST reference. 1. **Find the ids you need.** A match belongs to a competition, takes place at a venue, and has two teams. Read them once and keep the ids. Terminal
```bash
curl 'https://api.ligr.live/rest/v1/competitions' -H "Authorization: Bearer $LIGR_API_KEY"
curl 'https://api.ligr.live/rest/v1/competitions/2311/teams' -H "Authorization: Bearer $LIGR_API_KEY"
curl 'https://api.ligr.live/rest/v1/venues' -H "Authorization: Bearer $LIGR_API_KEY"
```
2. **Create the match.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Sydney FC v Melbourne City",
"competitionId": 2311,
"venueId": 764,
"date": "2026-09-12T09:30:00.000Z",
"competitorsType": "teams",
"competitors": [
{ "entityId": 1, "entityType": "team", "meta": { "isHome": true } },
{ "entityId": 2, "entityType": "team", "meta": { "isHome": false } }
]
}'
```
201 Created
```json
{ "id": 1188213, "overlays": [], "streams": [] }
```
`competitorsType` is the match format: `singles`, `doubles`, `teams` or `group`. `entityType` is the kind of each row in `competitors`: `team` or `player`. For example, a doubles match has `competitorsType: doubles` and four `entityType: player` rows. A group match also has `player` rows. Keep the `id`. To add an overlay or a stream to the match, use `POST /v1/overlays` or `POST /v1/streams`. 3. **Set the lineup.** Send the starting players and the substitutes of each team. The graphics use the lineup to show the teams, list the scorers and fill the player variables. Without a lineup, these stay empty. Each lineup player must be on the roster of the team. Read the roster with `GET /v1/teams/{teamId}/players`. If a player is not on the roster, add the player first. This call needs the `teams:write` scope. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v1/teams/1/players' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "firstName": "Adam", "lastName": "Le Fondre", "number": "9", "position": "Forward", "positionType": "FW" }'
```
201 Created
```json
{ "id": 501, "firstName": "Adam", "lastName": "Le Fondre", "gfxFirstName": null, "gfxLastName": null, "position": "Forward", "positionType": "FW", "number": "9", "captain": false, "starter": false, "bench": false }
```
Keep the `id`. It is the `playerId` in the lineup. Do not use a player of another team as a substitute for a missing player. Add the missing player to the roster. If a name is too long for the graphics, set shorter graphics names on the roster entry. Use `PATCH /v1/teams/{teamId}/players/{playerId}` with the `teams:write` scope. The same call changes the squad number, the position and the default starter flags. Terminal
```bash
curl -X PATCH 'https://api.ligr.live/rest/v1/teams/1/players/501' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "gfxFirstName": "Bul", "gfxLastName": "Juach" }'
```
If a data provider supplies the player, the next sync replaces the names, the number and the position. The sync does not change `gfxFirstName` and `gfxLastName`. Then send the lineup. Terminal
```bash
curl -X PUT 'https://api.ligr.live/rest/v2/matches/1188213/lineup' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"teams": [
{
"teamId": 1,
"starting": [{ "playerId": 501, "captain": true }, { "playerId": 503, "number": "14" }],
"bench": [{ "playerId": 502 }]
},
{ "teamId": 2, "starting": [{ "playerId": 617 }], "bench": [] }
]
}'
```
200 OK
```json
{
"lineup": [
{ "id": 501, "teamId": 1, "firstName": "Adam", "lastName": "Le Fondre", "number": "9", "position": "Forward", "positionType": "FW", "starter": true, "captain": true, "formationNumber": null, "lineupPosition": null }
],
"count": 4
}
```
The example answer shows one of the four players. A field you leave out, such as `number`, takes the roster value. The call replaces the full lineup of each team you send. LIGR removes a player you leave out. A team you leave out keeps its lineup. If you send the same body again, nothing changes. Open overlays show the new lineup immediately. 4. **Start the first half.** A football match has these periods. Period `0` exists before kick-off. | `periodNumber` | Period | | -------------- | ----------- | | `0` | Pre-match | | `1` | First half | | `2` | Half time | | `3` | Second half | | `4` | Full time | Post a `PERIOD_STARTED` fact with the number of the period that starts. The match goes live and the clock starts. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "PERIOD_STARTED", "periodNumber": 1 }'
```
201 Created
```json
{ "id": 56148731, "matchId": 1188213, "name": "PERIOD_STARTED", "periodNumber": 1, "createdAt": "2026-09-12T09:30:02.114Z" }
```
Periods start in order. A `PERIOD_STARTED` for a period that is not the next one returns 400. 5. **Post the events.** Every event is one fact. `name` is the event, `periodNumber` is the period it happened in, `teamId` and `playerId` name who did it, and `data` carries the sport-specific detail. A goal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "GOAL", "periodNumber": 1, "teamId": 1, "playerId": 501, "data": { "shotOnTarget": true } }'
```
201 Created
```json
{ "id": 56148902, "matchId": 1188213, "name": "GOAL", "periodNumber": 1, "createdAt": "2026-09-12T09:52:17.640Z" }
```
Keep the `id` of every fact you post. You need it to correct or delete the fact later. A yellow card
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "YELLOW_CARD", "periodNumber": 1, "teamId": 2, "playerId": 617 }'
```
`date` defaults to the time LIGR receives the fact. Send it when your system timestamps the event, so the match minute is right. LIGR derives the minute from `date` and the period start. 6. **End the half, start the second.** Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "PERIOD_FINISHED", "periodNumber": 1 }'
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "PERIOD_STARTED", "periodNumber": 3 }'
```
`PERIOD_FINISHED` takes the number of the period that ends. It stops the clock and moves the match into half time, period `2`. `PERIOD_STARTED` with `3` starts the second half. 7. **Correct a mistake.** Update a fact to change who scored or when. Delete a fact that never happened. LIGR recomputes the score and the statistics. Use the `id` from the answer of step 5, here the goal. The goal was scored by another player
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts/56148902' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "playerId": 502 }'
```
The goal was disallowed
```bash
curl -X DELETE 'https://api.ligr.live/rest/v2/matches/1188213/facts/56148902' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
An update changes only the fields you send. A period fact can be deleted only while it is the most recent one. 8. **Close the match.** Post `PERIOD_FINISHED` for the last period. The match moves to full time. To end a match early, call `POST /v2/matches/{matchId}/finish`. To cancel or abandon it, call `…/cancel` or `…/abandon`. Terminal
```bash
curl -X POST 'https://api.ligr.live/rest/v2/matches/1188213/facts' \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "PERIOD_FINISHED", "periodNumber": 3 }'
```
9. **Read back the result.** Terminal
```bash
curl 'https://api.ligr.live/rest/v2/matches/1188213/summary' \
-H "Authorization: Bearer $LIGR_API_KEY"
```
The summary is the score, the periods, the clock and the statistics that LIGR folded from your facts. Compare it to your own state after every match. ## Fact names by sport [Section titled “Fact names by sport”](#fact-names-by-sport) Pick your sport. The tabs stay on your choice across these docs. * Football | `name` | Event | Who | `data` | | --------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------- | | `PERIOD_STARTED` | A period starts | — | — | | `PERIOD_FINISHED` | A period ends | — | — | | `GOAL` | A goal | `teamId`, `playerId` | `{ "shotOnTarget": true }` | | `OWN_GOAL` | An own goal. Counts for the other team | `teamId`, `playerId` | — | | `PENALTY_SCORED` | A penalty scored | `teamId`, `playerId` | — | | `PENALTY_MISS` | A penalty missed | `teamId`, `playerId` | `{ "shotOnTarget": false }` | | `YELLOW_CARD` | A yellow card | `teamId`, `playerId` | — | | `SECOND_YELLOW_CARD` | A second yellow card | `teamId`, `playerId` | — | | `RED_CARD` | A red card | `teamId`, `playerId` | — | | `SUBSTITUTION` | A substitution. Counts in `substitutions` | `teamId`, `playerId` | Optional `{ "off": 501, "on": 502 }`, player ids. Swaps the players in the lineup | | `PARTIAL_SUBSTITUTION` | Swaps two players in the lineup. Does not count in `substitutions` | `teamId` | `{ "off": 501, "on": 502 }`, player ids | | `CORNER_WON` | A corner | `teamId` | — | | `OFFSIDE` | An offside | `teamId`, `playerId` | — | | `FREE_KICK_CONCEDED` | A foul that gives a free kick. Counts in `fouls` | `teamId`, `playerId` | — | | `PENALTY_CONCEDED` | A foul that gives a penalty. Counts in `fouls` | `teamId`, `playerId` | — | | `FOUL` | A foul. Does not change `fouls`: send `FREE_KICK_CONCEDED` or `PENALTY_CONCEDED` | `teamId`, `playerId` | — | | `SAVE` | A save | `teamId`, `playerId` | — | | `SHOT_ON_TARGET`, `SHOT_OFF_TARGET`, `SHOT_BLOCKED` | A shot | `teamId`, `playerId` | — | | `CLOCK_STOPPED`, `CLOCK_STARTED` | Stop or start the clock inside a period | — | — | | `SHOOTOUT_GOAL`, `SHOOTOUT_MISS` | A penalty shootout attempt | `teamId`, `playerId` | — | `teamId` is the id of the team from the competition. `playerId` is the id of the player in that team. See [Teams](/rest/operations/tags/teams/) and [Players](/rest/operations/tags/players/). `fouls` is the sum of free kicks conceded and penalties conceded. Possession, passes, tackles and other Opta-only statistics come only from a data provider feed. No fact sets them. The endpoint does not check `name` against this list. A name LIGR does not know is stored and drives nothing: no score, no statistic, no graphic. Check your spelling against the table. * Tennis **Coming soon.** The Tennis fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Tennis. A name LIGR does not know is stored and drives nothing. * Basketball **Coming soon.** The Basketball fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Basketball. A name LIGR does not know is stored and drives nothing. * Australian Rules **Coming soon.** The Australian Rules fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Australian Rules. A name LIGR does not know is stored and drives nothing. * Rugby League **Coming soon.** The Rugby League fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Rugby League. A name LIGR does not know is stored and drives nothing. * Rugby Union **Coming soon.** The Rugby Union fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Rugby Union. A name LIGR does not know is stored and drives nothing. * Cricket **Coming soon.** The Cricket fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Cricket. A name LIGR does not know is stored and drives nothing. * Netball **Coming soon.** The Netball fact name reference is not written yet. Until then, ask your LIGR contact for the fact names of Netball. A name LIGR does not know is stored and drives nothing. * More sports **Coming soon.** The fact name reference for these sports is not written yet. The sport key is in brackets. * Baseball (`baseball`) * Field hockey (`fieldHockey`) * Futsal (`futsal`) * American Football (`gridiron`) * Handball (`handBall`) * Ice hockey (`iceHockey`) * Lacrosse (`lacrosse`) * Rugby Sevens (`rugbySevens`) * Touch football (`touchFootball`) * Volleyball (`volleyball`) * Water polo (`waterPolo`) Ask your LIGR contact for the fact names of these sports. ## Errors [Section titled “Errors”](#errors) | Status | Message | Cause | | ------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | 400 | `Could not find period 'N'` | `periodNumber` is not a period of this match | | 400 | `Period has already started` | A second `PERIOD_STARTED` for the current period | | 400 | `Could not start period too far in the future. Next period is 'N'` | A `PERIOD_STARTED` that skips a period | | 400 | `Invalid lineup` | A lineup player is not on the team roster, or a team does not play in the match. `details` names each bad value | | 403 | — | A read key. Use a write key | | 404 | — | The match belongs to another organization | | 404 | `No fact found with id 'N'` | The fact does not belong to the match in the path | ## Keep LIGR in sync [Section titled “Keep LIGR in sync”](#keep-ligr-in-sync) * Post every fact once. LIGR does not deduplicate facts. * Post facts in the order they happened. The summary is folded in fact order. * Subscribe a webhook to `fact` and `summary` events to confirm what LIGR computed. See [Receive webhooks](/guides/receive-webhooks/).
# Rive graphics
> Beta. Import a native Rive runtime or source project, bind live sports data, and publish it through REST.
Beta Native Rive creation and its REST routes are in beta. Check `GET /v2/themes/{themeId}/rive-graphics/capabilities` before each import. A native Rive graphic stores exact `.riv` runtime bytes, one artboard, one state machine, and LIGR configuration. LIGR renders it through the native Rive runtime inside an isolated browser frame. You can start in the dashboard Rive Graphics Builder or use the REST API. Both paths use the same backend preparation and create the same working graphic. Create, drop, and replace actions share this preparation and publication workflow. | Input | What LIGR does | Source archive | | -------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------- | | `.riv` | Inspects and stores the supplied runtime unchanged | Unavailable. Runtime revisions already preserve these bytes | | `.rev` | Extracts the project and compiles a runtime | Exact uploaded file, with explicit retention | | ZIP with `.riv` and assets | Inspects the unchanged runtime and stores supplied playback images | Complete original archive, with explicit retention | | ZIP with `.rev` | Selects the editor file and compiles a runtime | Complete original archive, with explicit retention | | Complete RML project ZIP | Validates `rive.yaml`, dependencies, and output, then compiles | Complete original project archive, with explicit retention | RML means Rive Markup Language. A lone `.rml` file is not a general project interchange format. A complete project contains `rive.yaml` and every referenced dependency. ZIP sibling files must use the exact `uniqueFilename` reported by Rive. Duplicate matching filenames remain unresolved. LIGR does not guess a match from the display name. Supplied playback images remain available when source retention is off. A runtime with missing external images can become a draft. Its metadata marks those images as unresolved. Resolve them through the builder’s image uploads or bindings before using the graphic on air. Known default image dimensions remain available even when the external image is missing. Use these pages: | Page | What it covers | | ---------------------------------------------------------- | ------------------------------------------------------------------------------- | | [How it works](/rive-graphics/how-it-works/) | Runtime isolation, bindings, lifecycle, versions, and control-room updates | | [Import through REST](/rive-graphics/import-through-rest/) | Executable upload, polling, selection, creation, publication, and setup calls | | [Source files](/rive-graphics/source-files/) | Consent, temporary processing, cleanup, and exact original downloads | | [Local RML project](/rive-graphics/local-project/) | The licensed scorebug project, pinned compiler, and verified LIGR configuration | ## Rive and the Rive CLI [Section titled “Rive and the Rive CLI”](#rive-and-the-rive-cli) [Rive](https://rive.app) is a tool for interactive, real-time graphics. Designers build a graphic in the Rive editor. Rive exports it as a `.riv` runtime file or saves it as a `.rev` editor file. The [Rive CLI](https://rive.app/docs/cli/overview) builds the same files in a terminal, without the editor. It reads RML, a text format for Rive scenes. It compiles RML to a `.riv` file and shows a live preview while you edit. [Local RML project](/rive-graphics/local-project/) names the CLI version that LIGR tests with. ## Build graphics with an AI agent [Section titled “Build graphics with an AI agent”](#build-graphics-with-an-ai-agent) The Rive CLI works with a general AI agent from Anthropic (Claude), OpenAI (ChatGPT or Codex), or another provider. The agent writes the RML text. You make the graphic with prompts, not code. 1. Run `rive create` to start a project. It writes `AGENTS.md` and `CLAUDE.md` with instructions for the agent. 2. Describe the graphic to the agent, or give it a design image. The agent writes the scene in RML. 3. Watch the live preview. Tell the agent what to change. 4. Import the project into a theme. See [Import through REST](/rive-graphics/import-through-rest/). 5. Bind the graphic to live match data and control variables. See [How it works](/rive-graphics/how-it-works/). The result is a live overlay graphic. The score, the clock and the lineups update from the match. Operators show, hide and change the graphic from the control room. Give the agent this documentation too. [For AI agents](/ai-agents/) lists the machine-readable pages and the rules an agent must follow. ## Rive graphics and the Graphics SDK [Section titled “Rive graphics and the Graphics SDK”](#rive-graphics-and-the-graphics-sdk) Native Rive graphics do not implement the code-graphics `ligr.gfx.v1` protocol. They do not use `@ligrsystems/graphics-sdk` or its browser `createGraphic()` function. Browser SDK `createGraphic()` starts a code-graphic runtime listener in a web page. REST graphic creation stores a native Rive candidate in a theme. Use the [Graphics SDK](/graphics-sdk/) for HTML, CSS, and JavaScript graphics. The SDK installation guides identify the matching package release. Native Rive imports do not require those packages. ## Security boundaries [Section titled “Security boundaries”](#security-boundaries) Browser expression functions and native runtime handles stay inside the isolated renderer. The application supplies selected runtime bytes and fetches permitted public resources without cookies or referrers. Public image URLs can disclose renderer data through request URLs. The resource bridge executes in the browser and is not a server-side fetch proxy. Theme-writing API keys are trusted authoring credentials. Static expression validation complements browser isolation and does not replace careful authoring. Code graphics retain browser network access. Their module and font assets require anonymous CORS.
# Command line
> Import, edit, pull, push and publish a Rive graphic with the ligr-graphic CLI.
`ligr-graphic` sends every change as one REST operation. It needs no browser and no dashboard session. Install it with the commands in [Code graphics](/graphics-sdk/). Use a write API key with `themes:read` and `themes:write`. Keep it in `LIGR_API_KEY`. Never print it. The CLI sends requests to production by default. For another environment, set `LIGR_API_URL` to its full REST base, including `/rest/v2`, or pass `--base-url`. For `rive` commands, `apiUrl` in `rive.json` comes first. ## Import a file [Section titled “Import a file”](#import-a-file) Terminal
```bash
export LIGR_API_KEY=""
npx ligr-graphic rive import ./continental-scorebug.zip \
--theme 203 --name "Continental scorebug"
```
The command uploads the file, starts preparation, waits for the result, and creates the graphic. It writes `rive.json` with the theme id and the graphic id. Later commands read that file. Add `--retain-source` to keep the editable source. Read [Source files and privacy](/rive-graphics/source-files/) first. An archive with more than one project answers with a list of entries. Pass the exact entry: Terminal
```bash
npx ligr-graphic rive import ./bundle.zip --theme 203 --name "Bug" --select scorebug/rive.yaml
```
A file with more than one artboard pauses the import. The command prints each choice. Finish it: Terminal
```bash
npx ligr-graphic rive settings select --artboard 0 --state-machine 0
```
## Edit one value at a time [Section titled “Edit one value at a time”](#edit-one-value-at-a-time) Terminal
```bash
npx ligr-graphic rive control add stage --type enum --options "GROUP A,FINAL" --default "GROUP A"
npx ligr-graphic rive bind set Main.homeScore '$d.1.score'
npx ligr-graphic rive bind expose Main.badge --name "Home badge" --entity team --expr '$d.1.id' --required
npx ligr-graphic rive input set on '!$v.hide.value'
npx ligr-graphic rive asset set crest.png --expr '$d.1.logo.url'
npx ligr-graphic rive expr add leader '$d.1.score > $d.2.score ? 1 : 2'
npx ligr-graphic rive schema add tennisRankings
npx ligr-graphic rive settings set --renderer canvas --scale 0.5
```
Bind the visibility of the graphic to the reserved `hide` variable. The server sets `hide` on every show command and every hide command. See [How it works](/rive-graphics/how-it-works/). Each command reads the draft, then sends the change with the timestamp it read. A draft that changed in the meantime answers `409 TARGET_CHANGED`. The CLI reads it again and retries once. Opening a graphic in the dashboard Graphics Builder takes its edit lock. While that builder tab is open, every CLI write answers `409 LOCKED`. The message names the editor. Close the builder tab, then run the command again. ## Edit many values at once [Section titled “Edit many values at once”](#edit-many-values-at-once) Terminal
```bash
npx ligr-graphic rive pull
# edit the "config" block of rive.json
npx ligr-graphic rive push --dry-run
npx ligr-graphic rive push
```
`rive pull` writes the working configuration into the `config` block of `rive.json`. `rive push` compares that block with the draft. It sends one operation for each change. The server never receives the whole configuration. `--dry-run` prints the operations and sends none. A push is not atomic. Each operation stands alone. If one operation fails, the operations before it stay applied. The command prints the count it applied and the operation that failed. Run `rive pull`, then push again. A push removes a control variable or a user expression only when nothing refers to it. A referenced value answers `422 UNKNOWN_REFERENCE`. Add `--force` to remove it. `--force` blanks every expression that refers to the removed value. `pull` and `push` do not manage manual list elements. Use `rive bind list-add`, `rive bind list-rm` and `rive bind list-order` for those. ## Publish [Section titled “Publish”](#publish) Terminal
```bash
npx ligr-graphic rive publish --theme-version
```
`--theme-version` publishes a theme version that pins the new graphic version. The command prints the theme version. Replace `203` with your theme ID and `12` with that version. Inspect the version, then activate it when no match is live: Terminal
```bash
npx ligr-graphic theme inspect --theme 203 --version 12
npx ligr-graphic theme activate --theme 203 --version 12
```
Reload overlay pages to load the active version. Caution `--activate` sets the new theme version active in the same command. A person decides when to activate. Coding agents never add `--activate`. See rule 6 in [Rules for agents](/ai-agents/#rules-for-agents). ## Scripts [Section titled “Scripts”](#scripts) Add `--json` to any command. The command prints one JSON document and no human lines. Terminal
```bash
npx ligr-graphic rive list --theme 203 --json | jq -r '.[] | "\(.graphicId) \(.name)"'
```
## Files [Section titled “Files”](#files) | File | Purpose | | ----------- | ------------------------------------------------------------------------------------ | | `rive.json` | `themeId`, `graphicId`, optional `apiUrl`, and the `config` block that `pull` writes | Pass `--theme` and `--graphic` to work without `rive.json`.
# Edit through REST
> Change control variables, bindings, inputs, assets, user expressions, schemas and settings of a working Rive graphic.
Every change the graphics builder makes to a working Rive graphic is one REST operation. Each operation locks the graphic for the request, validates the whole configuration, saves, and publishes a live update to open builder tabs. All routes sit under `/rest/v2/themes/{themeId}/rive-graphics/{graphicId}`. Write routes need `themes:write`. Each write returns `updatedAt`, `entityId`, and the full `stateMachine`. ## Optimistic concurrency [Section titled “Optimistic concurrency”](#optimistic-concurrency) Send `ifUnchangedSince` with the `updatedAt` you last read. A changed draft answers `409 TARGET_CHANGED`. Opening a graphic in the dashboard Graphics Builder takes its edit lock. While that builder tab is open, every REST write answers `409 LOCKED`. Close the builder tab, then retry. The `ligr-graphic` CLI sends `ifUnchangedSince` for you and retries once. See [Command line](/rive-graphics/command-line/). ## Errors [Section titled “Errors”](#errors) An error body has `message` and `code`. A `4xx` answer can also have `details`, an array with one entry for each problem. | Field | Content | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `code` | The error code, for example `INVALID_CONFIGURATION`, `UNKNOWN_REFERENCE` or `INVALID_EXPRESSION`. | | `details[].field` | The path of the value that failed. The path points into the request body or into the graphic configuration, for example `controlVariables[stage].defaultValue` or `dataBindings[Main.homeScore].expression`. | | `details[].reason` | What is wrong with the value, for example `must be a JSON number`. | 422 response
```json
{
"message": "INVALID_CONFIGURATION",
"code": "INVALID_CONFIGURATION",
"details": [{ "field": "controlVariables[stage].defaultValue", "reason": "must be a JSON number" }]
}
```
A `500` answer has only `message`. The `ligr-graphic` CLI prints each detail on its own line as `field: reason`. ## Control variables [Section titled “Control variables”](#control-variables) Terminal
```bash
api POST "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/control-variables" \
--data '{"name":"stage","type":"enum","options":["GROUP A","FINAL"],"defaultValue":"GROUP A"}'
api PATCH "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/control-variables/$CONTROL_ID" --data '{"defaultValue":"FINAL"}'
api DELETE "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/control-variables/$CONTROL_ID?force=true"
```
Types: `string`, `number`, `boolean`, `enum`, and the entity types `team`, `player`, `match`, `fact`, `stat`, `teamStat`, `period`, `set`, `round`, `court`. Entity types carry a server-derived `dataSchema` in the response. `hide` is reserved. A control variable default must have the JSON type of the variable. Send `0`, not `"0"`. Removing a variable that an expression references answers `422 UNKNOWN_REFERENCE`; `force=true` blanks those expressions. ## Data bindings [Section titled “Data bindings”](#data-bindings) Binding ids contain dots, so URL-encode them. Terminal
```bash
B="themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/data-bindings"
api PATCH "$B/Main.homeScore" --data '{"expression":"$d.1.score"}'
api PUT "$B/Main.badge/exposure" --data '{"name":"Home badge","expression":"$d.1.id","required":true,"entityType":"team"}'
api PUT "$B/Main.badge/image-transform" --data '{"fit":"cover","outWidth":256,"outHeight":256}'
api PUT "$B/Main.players%5B%5D/source-array" --data '{"arrayPath":"1.lineup"}'
api POST "$B/Main.players%5B%5D/elements"
api DELETE "$B/Main.players%5B%5D/elements/0"
api PUT "$B/Main.players%5B%5D/elements/order" --data '{"order":[1,0]}'
```
Exposure and image transforms apply to image bindings only. A source array applies to one list binding per graphic. A source array is a path into the match data without the `$d` prefix, for example `1.lineup` for team one’s lineup. ## Inputs, assets, user expressions, schemas, settings [Section titled “Inputs, assets, user expressions, schemas, settings”](#inputs-assets-user-expressions-schemas-settings) Terminal
```bash
G="themes/$THEME_ID/rive-graphics/$GRAPHIC_ID"
api PATCH "$G/inputs/$INPUT_ID" --data '{"expression":"!$v.hide.value"}'
api PATCH "$G/assets/crest.png" --data '{"expression":"$d.1.logo.url"}'
api PUT "$G/assets/crest.png/exposure" --data '{"name":"Crest","required":true,"entityType":"team"}'
api POST "$G/user-expressions" --data '{"name":"leader","expression":"$d.1.score > $d.2.score ? 1 : 2"}'
api PUT "$G/schemas/tennisRankings"
api PATCH "$G/settings" --data '{"renderer":"canvas","scale":0.5,"name":"Scorebug","sports":["football"]}'
```
## Graphic lifecycle [Section titled “Graphic lifecycle”](#graphic-lifecycle) Terminal
```bash
api GET "themes/$THEME_ID/rive-graphics"
api POST "$G/duplicate" --data '{"name":"Scorebug copy"}'
api DELETE "$G"
```
Delete is refused with `409 TARGET_NOT_EMPTY` while the active theme version pins a published version of the graphic. Publish, theme snapshots and activation are unchanged; read [Import through REST](/rive-graphics/import-through-rest/) step 6.
# How Rive imports work
> Follow source bytes through preparation, bindings, exact graphic versions, theme snapshots, and the control room.
LIGR separates source preparation, native runtime playback, sporting expressions, and publication. Each boundary preserves one clear artifact. ## Lifecycle [Section titled “Lifecycle”](#lifecycle) Rive source-to-control-room lifecycle Upload → backend preparation ↙ or ↘ **.riv or runtime ZIP**Inspect unchanged **Source project**Compile ↘ rejoin ↙ Ready candidate + metadataResolve any missing images ↓ Select + configure ↓ Working graphic ↓ Saved revision ↙ contains ↘ **Runtime + supplied playback images**Stored independently of source retention **Original archive**Only when retained ↓ publish runtime Graphic version ↓ Theme snapshot → Activate → Preset + room + overlay Upload a .riv file for unchanged inspection, or upload a supported source project for compilation. Both paths use backend preparation and create a ready candidate with metadata. Resolve any missing external images. Select and configure the candidate to create a working graphic. A saved revision contains runtime bytes and supplied playback images. It contains the private original only when retention was selected. Publish a graphic version and theme snapshot, activate the snapshot, then add the graphic to a control room. A `.riv` file enters inspection without recompilation. LIGR preserves its bytes exactly. Supported `.rev` and RML projects compile before inspection. All inputs use the same backend preparation. The dashboard and REST API consume its validated candidate metadata. LIGR stores supplied playback images with the runtime, independently of optional source retention. Preparation runs asynchronously. It returns a candidate only after validating the result and inspecting its native structure. An ambiguous ZIP returns `selection-required`; send one exact candidate `entry` to continue. Choose one artboard index and one state-machine index from the returned metadata. LIGR never guesses between multiple valid choices. A ready candidate can still contain unresolved external images. Read its asset metadata and resolve those images in the builder. ## Properties, expressions, and controls [Section titled “Properties, expressions, and controls”](#properties-expressions-and-controls) Rive property bindings describe authored runtime properties such as `Main.homeScore`. They do not know football rules or LIGR’s live match shape. A LIGR expression connects a property to live data. For football, `$d.1.score` means displayed team one’s score. Configured team reversal can make displayed team one differ from the match home team. Control variables use `$v..value`. They hold operator choices such as a tournament stage. The reserved `hide` variable controls native visibility and cannot appear in a preset. | Rive property | LIGR expression | Live value | | ---------------- | ------------------- | ---------------------------- | | `Main.homeCode` | `$d.1.abbreviation` | Displayed team-one code | | `Main.awayCode` | `$d.2.abbreviation` | Displayed team-two code | | `Main.homeScore` | `$d.1.score` | Displayed team-one score | | `Main.awayScore` | `$d.2.score` | Displayed team-two score | | `Main.stage` | `$v.stage.value` | Preset or show-command value | | `Main.on` | `!$v.hide.value` | Runtime visibility | Expressions execute inside LIGR’s isolated native renderer. They use the existing expression compiler and never expose native handles to the parent application. ## Show and hide timing [Section titled “Show and hide timing”](#show-and-hide-timing) A hide command sets `hide` to `true`. Your state machine then plays its out animation. The overlay keeps the graphic on screen for 2 seconds after the hide command. Then the overlay fades the graphic out over 0.4 seconds and removes it. Keep each out animation shorter than 2 seconds. The fade cuts off a longer out animation. A new value can arrive while the out animation plays. For example, the next preset changes `stage`. By default, the graphic shows the new value at once. To keep the old value during the exit, set `exitMode: "deferred"` on the control variable. The overlay then holds the old value for 3 seconds before it applies the new value. `hide` never defers. A graphic can load while it is already visible. The state machine can then skip its in animation. To play the in animation on the first show, set `deferInitialShow: true` in the graphic settings. The renderer then applies `hide: true` for one frame before it applies the real value. Terminal
```bash
npx ligr-graphic rive control add stage --type string --default FINAL --exit-mode deferred
npx ligr-graphic rive control set "$CONTROL_ID" --exit-mode deferred
npx ligr-graphic rive settings set --defer-initial-show
```
The same changes through REST: Terminal
```bash
G="themes/$THEME_ID/rive-graphics/$GRAPHIC_ID"
api PATCH "$G/control-variables/$CONTROL_ID" --data '{"exitMode":"deferred"}'
api PATCH "$G/settings" --data '{"deferInitialShow":true}'
```
The `api` helper comes from [Import through REST](/rive-graphics/import-through-rest/). ## Default image sizes [Section titled “Default image sizes”](#default-image-sizes) Image bindings can include `imageDimensions: { width, height }` in pixels. These dimensions describe the image selected by the runtime’s default instance, including nested instances and the first inspected list item. They describe the image itself, not the artboard or the displayed bounds after animation and layout. The runtime’s default can differ from an instance marked as default in the editor. LIGR uses authored asset dimensions when available, or supported embedded image metadata. It does not download external images during preparation. An empty image, missing dimensions, or an unresolved reference leaves `imageDimensions` absent. Use `pathSegments` to identify nested properties when names contain dots. The builder fills **Width (px)** and **Height (px)** when you first expose an image binding. You can edit or clear either value. Reopening the form preserves those choices. A compatible replacement refreshes intrinsic metadata while preserving your configured exposure sizes. ## Worked scorebug [Section titled “Worked scorebug”](#worked-scorebug) The [local project](/rive-graphics/local-project/) compiles artboard `Main` and state machine `Broadcast`. The REST attachment stores the table’s bindings and a `stage` variable with default `FINAL`. The graphic publication freezes those runtime bytes and that configuration as graphic version 1. A theme publication then pins graphic version 1 in a new theme snapshot. Activation remains a separate request. A preset stores `stage: "GROUP A"`. A show command can override it with `stage: "ROUND OF 16"`. Live football data remains authoritative under `$d`, so a new fact changes `Main.homeScore` from 2 to 3. ## Version behavior [Section titled “Version behavior”](#version-behavior) A working graphic is mutable. Edit it through the operation routes described in [Edit through REST](/rive-graphics/edit-through-rest/). Each graphic publication creates an immutable positive version. Exact published runtime downloads never rebuild or follow a later working copy. Published playback images and configuration remain pinned to that graphic version. A theme snapshot freezes its selected graphic versions. Publishing a theme snapshot does not activate it unless you request activation. Existing overlay pages keep their loaded snapshot until reload. Read [Publish a theme version](/guides/publish-a-theme-version/) for inspection, activation, and rollback.
# Images, preview and evaluate
> Supply a missing image, play a working draft, and check every data binding.
A Rive runtime can reference an image that is not inside the file. LIGR marks that image `unresolved`. The graphic renders without it. Supply the image to make the graphic complete. ## Supply a missing image [Section titled “Supply a missing image”](#supply-a-missing-image) Find the unresolved assets first. Terminal
```bash
api GET "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID" | jq '.stateMachine.assets[] | select(.unresolved)'
```
Create an upload session. Send the exact byte count and the lowercase SHA-256 of the file. Terminal
```bash
api POST "themes/$THEME_ID/rive-graphics/images" \
--data '{"filename":"crowd.png","size":48213,"sha256":"6c1b…","contentType":"image/png"}'
```
The response holds `fileId`, `url`, `expiresAt` and `headers`. Upload the exact bytes with one PUT. Send each header from `headers` unchanged. `x-amz-checksum-sha256` is the base64 form of the SHA-256 digest. It is not the hex value you sent. Terminal
```bash
curl -X PUT "$URL" -H "content-type: image/png" -H "x-amz-checksum-sha256: $CHECKSUM" --data-binary @crowd.png
```
Then supply the file id to the asset. Terminal
```bash
api PUT "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/assets/crowd.png/file" --data '{"fileId":901}'
```
The asset stops being unresolved. The response holds the full `stateMachine`. The same `fileId` works as `defaultFileId` on an asset exposure. Supported types are PNG, JPEG and WebP. The limit is 25 MB. The upload session belongs to the theme. Use the same theme that owns the graphic. The builder shows the same control. Open the Assets tab and select **Upload image** on a row marked **Missing image**. ## Embed a placeholder for every bound image [Section titled “Embed a placeholder for every bound image”](#embed-a-placeholder-for-every-bound-image) Every Image that a view-model property binds needs an embedded placeholder asset in the Rive file. An Image with no asset can blank the whole artboard in the web runtime. LIGR replaces the embedded asset at runtime, so any small image works as the placeholder. ## Give every replaceable image a fixed box [Section titled “Give every replaceable image a fixed box”](#give-every-replaceable-image-a-fixed-box) LIGR sends a bound or exposed image to the Rive runtime at its own pixel size. LIGR does not resize it to the placeholder. The Rive runtime draws an Image outside a layout at the pixel size of the image it holds. The scale of the Image node then multiplies that size. The placeholder size has no effect after LIGR replaces the image. For example, an Image node with scale 2 draws a 256×256 crest at 512×512. The same node draws a 1000×1000 crest at 2000×2000. Crests from different sources then draw at different sizes in the same slot. Put each replaceable Image inside a layout of the slot size, and set `fit` on the Image. The runtime then scales every image into the layout box. A 200×200 crest and a 1000×1000 crest draw at the same size. scene.rml
```xml
```
Use `contain` to show the full image, or `cover` to fill the box and crop the edges. Bind the view-model image property to `Home crest` as usual, or expose the `crest` asset. If the Image cannot sit in a layout, keep the node scale at 1. Then set an image transform on the data binding with a fixed output size: Terminal
```bash
api PUT "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/data-bindings/Main.homeCrest/image-transform" \
--data '{"fit":"contain","outWidth":96,"outHeight":96}'
```
LIGR resizes each image to 96×96 before the runtime draws it. An image transform applies to data bindings only. An exposed asset without a binding needs the layout. ## Exposed images [Section titled “Exposed images”](#exposed-images) An exposed image makes one image slot on each entity of its type. For example, an image exposed with `entityType: "team"` adds a slot to every team. The binding expression returns the id of the entity, for example `$d.1.id` for team one. LIGR then shows the image that the operator uploaded for that entity. An operator uploads the image in the dashboard. Open the team page and select the **Customize** tab. Player pages and competition pages have the same control. If the entity has no upload, the graphic shows the default file of the exposure. Set the default file with `defaultFileId` on the exposure. ## Preview the working draft [Section titled “Preview the working draft”](#preview-the-working-draft) Terminal
```bash
api GET "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/preview?scenario=Ace%20on%20First%20Serve"
```
The response holds one signed URL for the runtime file and one for each stored image. Every URL expires after five minutes. `scenario` returns the demonstration data sequence of that name. Omit `scenario` to receive `null`. List the scenario names of a sport with `GET /v2/scenarios/{sport}`. ## Evaluate the bindings [Section titled “Evaluate the bindings”](#evaluate-the-bindings) Terminal
```bash
api POST "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/evaluate" \
--data '{"scenario":"Ace on First Serve","variables":{"stage":"FINAL"}}'
```
The response holds one row per data binding in `bindings` and one row per user expression in `userExpressions`, plus `unresolved`, the count of rows that failed. `mode` says what the server did. `full` means the server produced a value for each binding. `static` means the server checked syntax and references only. A static response holds no `value`. Today this route runs in `static` mode only. It checks that each binding and user expression parses, and that every `$v` and `$u` reference names a control variable or user expression that exists. It never runs the expression, so it never reports a value. To see the computed values, open the graphic in the dashboard Graphics Builder and select a match. The builder preview renders the graphic with the data of that match. Evaluate is a `POST`, so it needs `themes:write`. ## Command line [Section titled “Command line”](#command-line) Terminal
```bash
ligr-graphic rive asset file crowd.png ./crowd.png
ligr-graphic rive asset expose crowd.png --name "Crowd" --entity team --default-file ./crowd.png
ligr-graphic rive preview --scenario "Ace on First Serve" --open
ligr-graphic rive eval --scenario "Ace on First Serve" --var stage=FINAL
ligr-graphic rive push --watch
```
`rive eval` exits 0 only when every binding and user expression resolves. Use it in a pipeline. `rive push --watch` applies `rive.json` again after each save. Press Ctrl+C to stop.
# Import through REST
> Upload, prepare, inspect, select, configure, publish, activate, and connect a native Rive graphic to a control room.
This tutorial uses a dedicated example theme. It does not reuse or activate an unrelated production theme. It creates a theme, graphic, graphic version, theme snapshot, control room, section, and preset. Overlay setup also needs your test match and overlay IDs. Use a write API key with `themes:read` and `themes:write`. Keep it in `LIGR_API_KEY`; never print it. ## 1. Prepare the terminal [Section titled “1. Prepare the terminal”](#1-prepare-the-terminal) Terminal
```bash
export LIGR_API_URL="${LIGR_API_URL:-https://api.ligr.live/rest/v2}"
api() {
method=$1
path=$2
shift 2
curl --fail-with-body --silent --show-error \
-X "$method" "$LIGR_API_URL/$path" \
-H "Authorization: Bearer $LIGR_API_KEY" \
-H 'Content-Type: application/json' "$@"
}
```
Set `LIGR_API_URL` once. The CLI reads the same variable. The default REST base URL is `https://api.ligr.live/rest/v2`. Each `api` call on these pages takes a path relative to that base, for example `themes`. The API key and theme must belong to the same organization. Check capability before uploading source: Terminal
```bash
THEME=$(api POST themes --data '{"name":"Rive REST tutorial","sports":["football"]}')
THEME_ID=$(jq -er '.id' <<< "$THEME")
api GET "themes/$THEME_ID/rive-graphics/capabilities" | jq
```
If `sourceImports` is false, source preparation is unavailable in this environment. Compile externally and upload `.riv` when runtime inspection remains available. ## 2. Load the downloaded example [Section titled “2. Load the downloaded example”](#2-load-the-downloaded-example) Complete [Local RML scorebug project](/rive-graphics/local-project/) in the same terminal first. That guide downloads the public ZIP and configuration, then exports their absolute paths. Terminal
```bash
: "${PROJECT:?Complete the local RML project guide first}"
: "${UPLOAD:?Complete the local RML project guide first}"
: "${CONFIG:?Complete the local RML project guide first}"
test -f "$PROJECT/rive.yaml"
test -f "$UPLOAD"
test -f "$CONFIG"
SIZE=$(wc -c < "$UPLOAD" | tr -d ' ')
SHA256=$(shasum -a 256 "$UPLOAD" | awk '{print $1}')
```
## 3. Create and complete the upload [Section titled “3. Create and complete the upload”](#3-create-and-complete-the-upload) Choose source retention now. This example sends explicit `false`. Supplied playback images are preserved even when the original archive is not retained. Terminal
```bash
UPLOAD_SESSION=$(api POST "themes/$THEME_ID/rive-graphics/uploads" --data "$(jq -n \
--arg filename continental-scorebug.zip \
--argjson size "$SIZE" \
--arg sha256 "$SHA256" \
'{filename: $filename, size: $size, sha256: $sha256, retainSource: false}')")
JOB_ID=$(jq -er '.jobId' <<< "$UPLOAD_SESSION")
CANDIDATE_ID=$(jq -er '.candidateId' <<< "$UPLOAD_SESSION")
UPLOAD_URL=$(jq -er '.url' <<< "$UPLOAD_SESSION")
UPLOAD_CHECKSUM=$(jq -er '.headers["x-amz-checksum-sha256"]' <<< "$UPLOAD_SESSION")
UPLOAD_HEADERS=$(mktemp)
curl --fail-with-body --silent --show-error -D "$UPLOAD_HEADERS" -o /dev/null \
-X PUT "$UPLOAD_URL" \
-H "Content-Length: $SIZE" \
-H "x-amz-checksum-sha256: $UPLOAD_CHECKSUM" \
--data-binary "@$UPLOAD"
VERSION_ID=$(awk 'BEGIN{IGNORECASE=1} /^x-amz-version-id:/{gsub("\\r", "", $2); print $2}' "$UPLOAD_HEADERS")
test -n "$VERSION_ID"
rm "$UPLOAD_HEADERS"
```
Send `Content-Length` and every header returned in the upload-session `headers` map. The signature requires the checksum header, and storage rejects a body that does not match it. Send the opaque `x-amz-version-id` from the completed upload to preparation. Terminal
```bash
api POST "themes/$THEME_ID/rive-graphics/imports/$JOB_ID/prepare" \
--data "$(jq -n --arg versionId "$VERSION_ID" '{versionId: $versionId}')" | jq
```
## 4. Poll and select [Section titled “4. Poll and select”](#4-poll-and-select) Terminal
```bash
while :; do
STATUS=$(api GET "themes/$THEME_ID/rive-graphics/imports/$JOB_ID")
STATE=$(jq -er '.status' <<< "$STATUS")
printf '%s\n' "$STATE"
case "$STATE" in
ready) break ;;
selection-required)
jq '.candidates' <<< "$STATUS"
: "${SELECTED_ENTRY:?Set SELECTED_ENTRY to an exact entry returned in candidates}"
jq -e --arg entry "$SELECTED_ENTRY" '.candidates | any(.entry == $entry)' <<< "$STATUS" >/dev/null
api POST "themes/$THEME_ID/rive-graphics/imports/$JOB_ID/selection" \
--data "$(jq -n --arg entry "$SELECTED_ENTRY" '{entry: $entry}')" >/dev/null
;;
failed|cancelled|expired) jq '.error' <<< "$STATUS"; exit 1 ;;
esac
sleep 2
done
jq '{metadata, runtime, preview}' <<< "$STATUS"
```
Use an `entry` exactly as returned in `candidates`. This archive has one project. For another archive, select its returned entry explicitly before continuing. A `ready` result means preparation completed. It does not mean every external image has been supplied. Read unresolved asset status and optional default image dimensions before attachment: Terminal
```bash
jq '.metadata.assets[] | select(.unresolved)' <<< "$STATUS"
jq '.metadata.viewModels[].defaultInstanceBindings[]? |
select(.type == "image") | {path, pathSegments, imageDimensions}' <<< "$STATUS"
```
A draft can contain unresolved external images. Resolve them through image uploads or bindings in the builder. Preparation does not fetch external image URLs. Missing dimensions remain absent; do not substitute the artboard size. ## 5. Configure and create the working graphic [Section titled “5. Configure and create the working graphic”](#5-configure-and-create-the-working-graphic) The inspected fixture uses artboard index 0 and state-machine index 0. Its separate configuration binds live football data and the `stage` control variable. [The local RML project](/rive-graphics/local-project/) holds the full `selection` and `configuration` schema: `artboardIndex`, `stateMachineIndex`, `controlVariables` and `bindings`. Bind the visibility of the graphic to the reserved `hide` variable, as the example does with `{ "id": "Main.on", "expression": "!$v.hide.value" }`. The server sets `hide` on every show and hide command. The server adds `hide` to `controlVariables` and lists it there. Do not change or delete it. A graphic that binds visibility to some other variable still accepts those commands and answers `200`. That graphic never appears or disappears. See [How it works](/rive-graphics/how-it-works/). Terminal
```bash
CREATE_BODY=$(jq -n --slurpfile config "$CONFIG" \
'{name: "Continental scorebug", selection: $config[0].selection,
configuration: $config[0].configuration}')
GRAPHIC=$(api POST "themes/$THEME_ID/rive-graphics/candidates/$CANDIDATE_ID/graphics" \
--data "$CREATE_BODY")
GRAPHIC_ID=$(jq -er '.graphicId' <<< "$GRAPHIC")
ASSET_ID=$(jq -er '.assetId' <<< "$GRAPHIC")
api GET "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID" | jq
api GET "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/files/metadata?version=working&assetId=$ASSET_ID" | jq
```
### Replace an existing Rive asset [Section titled “Replace an existing Rive asset”](#replace-an-existing-rive-asset) Use the same preparation and attachment endpoints for replacement. Set `GRAPHIC_ID` to your existing graphic and `UPLOAD` to the new `.rev`, `.riv`, or project ZIP. Read the working draft before creating the upload session. Terminal
```bash
WORKING=$(api GET "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID")
OLD_ASSET_ID=$(jq -er '[.assets[] | select(.type == "rive")] |
if length == 1 then .[0].id else error("Select one Rive asset ID explicitly") end' <<< "$WORKING")
UPDATED_AT=$(jq -er '.updatedAt' <<< "$WORKING")
SIZE=$(wc -c < "$UPLOAD" | tr -d ' ')
SHA256=$(shasum -a 256 "$UPLOAD" | awk '{print $1}')
UPLOAD_SESSION=$(api POST "themes/$THEME_ID/rive-graphics/uploads" --data "$(jq -n \
--arg filename "$(basename "$UPLOAD")" --argjson size "$SIZE" --arg sha256 "$SHA256" \
--arg graphicId "$GRAPHIC_ID" --arg assetId "$OLD_ASSET_ID" --arg updatedAt "$UPDATED_AT" \
'{filename: $filename, size: $size, sha256: $sha256, retainSource: false,
target: {graphicId: $graphicId, assetId: $assetId, updatedAt: $updatedAt}}')")
```
Continue step 3 from the `JOB_ID` assignment, then complete step 4. The API acquires its own temporary editing lock. Do not send a dashboard lock token. Conversion does not hold that lock; attachment acquires it again and verifies the original target timestamp and asset. Inspect the prepared metadata and review your selection and binding overrides before attachment. Use the step 5 attachment endpoint and record its returned `assetId`; replacement creates a new runtime asset ID. Send only `bindings`, `inputs` and `assets` on a replacement. Control variables, user expressions and layout are edited through the operation routes; a replacement that includes them is refused with `422 INVALID_CONFIGURATION`. See [Edit through REST](/rive-graphics/edit-through-rest/). Submit reviewed `bindings`, `inputs`, and `assets` overrides for the incoming metadata instead. Compatible bindings retain their configured exposure sizes, including values that you explicitly cleared. The incoming runtime refreshes intrinsic `imageDimensions`; those dimensions do not overwrite your exposure settings. `LOCKED` means another editor currently owns the graphic. Retry after that editor releases it. `TARGET_CHANGED` means the saved draft changed during preparation. Read the new draft and start a new replacement import. If the attachment response is lost, retry the same candidate with the identical request body. An already completed attachment returns the same graphic and asset IDs. Replacement changes only the working draft. Earlier published versions retain their exact runtime, playback images, configuration, and any retained source files. Publish and activate separately when the replacement is ready. ## 6. Publish, snapshot, and activate [Section titled “6. Publish, snapshot, and activate”](#6-publish-snapshot-and-activate) Terminal
```bash
GRAPHIC_VERSION=$(api POST "themes/$THEME_ID/rive-graphics/$GRAPHIC_ID/versions" | jq -er '.version')
THEME_VERSION=$(api POST "themes/$THEME_ID/versions" --data "$(jq -n \
--arg graphicId "$GRAPHIC_ID" --argjson version "$GRAPHIC_VERSION" \
'{graphics: [{graphicId: $graphicId, version: $version}], activate: false}')" | jq -er '.version')
api PUT "themes/$THEME_ID/active-version" \
--data "$(jq -n --argjson version "$THEME_VERSION" '{version: $version}')" | jq
```
These are three separate mutations. Activation changes what new overlay page loads will render. The theme version also pins every graphic you do not list in `graphics`. Each of those graphics is pinned at its latest published version. ## 7. Create the control-room preset [Section titled “7. Create the control-room preset”](#7-create-the-control-room-preset) Terminal
```bash
ROOM=$(api POST control-rooms --data "$(jq -n --argjson themeId "$THEME_ID" \
'{themeId: $themeId, name: "Continental scorebug room"}')")
ROOM_ID=$(jq -er '.id' <<< "$ROOM")
SECTION=$(api POST "control-rooms/$ROOM_ID/sections" --data '{"name":"Native graphics"}')
SECTION_ID=$(jq -er '.id' <<< "$SECTION")
PRESET=$(api POST "control-rooms/$ROOM_ID/presets" --data "$(jq -n \
--arg sectionId "$SECTION_ID" --arg graphicId "$GRAPHIC_ID" \
'{sectionId: $sectionId, graphicId: $graphicId, name: "Continental scorebug",
variableValues: {stage: "GROUP A"}}')")
PRESET_ID=$(jq -er '.id' <<< "$PRESET")
```
Create a test match, theme profile, and manual overlay through [Set up through REST](/control-room/setup/). Use this theme and room when that guide requests `themeId` and `defaultControlRoomId`. Terminal
```bash
api POST "overlays/$OVERLAY_ID/control-room/graphics" --data "$(jq -n \
--argjson presetId "$PRESET_ID" \
'{action: "show", presetId: $presetId, variableValues: {stage: "ROUND OF 16"}}')" | jq
```
The show override changes `Main.stage`. Match facts remain authoritative for bindings under `$d`. This call fires a preset, so it carries the values an operator configured on that preset. To fire the graphic without a preset, and send every value yourself, use [Graphics commands](/control-room/graphics-commands/): `POST /v2/overlays/{overlayId}/graphics/commands` takes `command` with `graphicUuid` or `name` instead of `action` with `presetId`. Both endpoints write the same state. Pick the preset endpoint when an operator owns the values, and the command endpoint when your own system does. Use `POST /imports/{jobId}/cancel` for an unattached import you will not use. Read [Source files and privacy](/rive-graphics/source-files/) before retaining or downloading originals.
# Local RML scorebug project
> Compile the licensed Continental scorebug, inspect its native contract, and keep LIGR configuration separate.
The [complete authored project ZIP](/examples/rive-scorebug/continental-scorebug.zip) contains `rive.yaml`, `scene.rml`, the Barlow Semibold font, and their license. The 229-line `scene.rml` contains one scorebar, eight bound text fields, and one visibility layer. It starts hidden on a transparent 1920×1080 artboard. Setting `Main.on` to `true` fades and slides the scorebar into view. Setting it to `false` hides the scorebar. The project contains no scripts or shaders. ## Download the project [Section titled “Download the project”](#download-the-project) Keep one terminal open through this guide and the linked REST tutorial. The commands export absolute paths for every later step. Terminal
```bash
export DOCS_ORIGIN="${DOCS_ORIGIN:-https://docs.ligr.live}"
export TUTORIAL_DIR="${TUTORIAL_DIR:-$HOME/ligr-rive-scorebug}"
mkdir -p "$TUTORIAL_DIR/project"
export TUTORIAL_DIR="$(cd "$TUTORIAL_DIR" && pwd)"
export PROJECT="$TUTORIAL_DIR/project"
export UPLOAD="$TUTORIAL_DIR/continental-scorebug.zip"
export CONFIG="$TUTORIAL_DIR/ligr-configuration.json"
curl --fail-with-body --location \
"$DOCS_ORIGIN/examples/rive-scorebug/continental-scorebug.zip" \
--output "$UPLOAD"
curl --fail-with-body --location \
"$DOCS_ORIGIN/examples/rive-scorebug/ligr-configuration.json" \
--output "$CONFIG"
unzip -q -o "$UPLOAD" -d "$PROJECT"
```
`PROJECT`, `UPLOAD`, and `CONFIG` now contain absolute customer workspace paths. ## Compile and inspect [Section titled “Compile and inspect”](#compile-and-inspect) LIGR tests this project with Rive CLI 1.2.0. Install that version with the Rive installer on macOS or Linux: Terminal
```bash
curl -fsSL https://releases.rive.app/cli/install.sh | RIVE_VERSION=1.2.0 sh
```
For Windows and Homebrew, see [Rive CLI: Getting started](https://rive.app/docs/cli/getting-started). A Homebrew install is not under `~/.rive`. Set `RIVE_BIN` to its path. Then run the pinned compiler directly against the extracted project. Terminal
```bash
export RIVE_BIN="${RIVE_BIN:-$HOME/.rive/bin/rive}"
test "$("$RIVE_BIN" --version)" = "rive 1.2.0"
(
cd "$PROJECT"
"$RIVE_BIN" . --verify --format=json
"$RIVE_BIN" inspect . --json
"$RIVE_BIN" . --once --format=json
)
RUNTIME="$PROJECT/build/continental-scorebug.riv"
test "$(shasum -a 256 "$RUNTIME" | awk '{print $1}')" = \
99ff8f04e54e36863cbbd717ba611f38207120174740f5713027050f339889c8
```
The build produces `build/continental-scorebug.riv` with SHA-256 `99ff8f04e54e36863cbbd717ba611f38207120174740f5713027050f339889c8`. Inspection must report artboard `Main`, state machine `Broadcast`, view model `Main`, and default instance `Default`, with no problems. A different Rive CLI version can produce a different SHA-256 from the same project. The pinned browser runtime for the verified tutorial is 2.41.0. ## Learn RML and preview without a browser [Section titled “Learn RML and preview without a browser”](#learn-rml-and-preview-without-a-browser) Rive documents RML and the CLI in the [Rive CLI docs](https://rive.app/docs/cli/overview). To print the RML properties of one object type, run `rive schema `, for example `rive schema Text`. Render one frame to a PNG file to check a change without a browser: Terminal
```bash
(
cd "$PROJECT"
"$RIVE_BIN" . --screenshot=out.png --data=on=true --data=homeScore=3 --advance=2s
)
```
`--data` sets one view-model property by path. Repeat `--data` for each property. The scorebug starts hidden, so the example sets `on` to `true`. `--advance` runs the state machine for that time before the screenshot. Change the RML, run the command again, and open `out.png`. `--data` cannot set an image property. To preview an image slot, embed a sample image in the project as the placeholder. See [Images, preview and evaluate](/rive-graphics/images-and-preview/#embed-a-placeholder-for-every-bound-image). ## Keep LIGR configuration separate [Section titled “Keep LIGR configuration separate”](#keep-ligr-configuration-separate) RML defines authored properties and animation behavior. The separate [LIGR configuration](/examples/rive-scorebug/ligr-configuration.json) connects those properties to LIGR data. `hide` is bound but never declared in `controlVariables`. It is reserved: the platform injects it on every show and hide command, so your own list never carries it. Bind the visibility of the artboard to it, or show and hide answer `200` and change nothing. LIGR configuration
```json
{
"selection": { "artboardIndex": 0, "stateMachineIndex": 0 },
"configuration": {
"renderer": "webgl2",
"controlVariables": [{ "id": "stage", "name": "stage", "type": "string", "defaultValue": "FINAL" }],
"bindings": [
{ "id": "Main.on", "expression": "!$v.hide.value" },
{ "id": "Main.homeCode", "expression": "$d.1.abbreviation" },
{ "id": "Main.awayCode", "expression": "$d.2.abbreviation" },
{ "id": "Main.homeScore", "expression": "$d.1.score" },
{ "id": "Main.awayScore", "expression": "$d.2.score" },
{ "id": "Main.clock", "expression": "$d.clock" },
{ "id": "Main.period", "expression": "$d.periodAbbrv" },
{ "id": "Main.competition", "expression": "$d.competitionName" },
{ "id": "Main.stage", "expression": "$v.stage.value" }
]
}
}
```
Do not put LIGR sporting expressions into RML property definitions. REST validates each configured binding against the selected inspected runtime. The [REST tutorial](/rive-graphics/import-through-rest/) runs the upload-to-control-room sequence in your selected environment. It creates a dedicated theme and carries returned identifiers through every later request.
# Source files and privacy
> Choose source retention before upload, understand temporary processing, and download exact originals by version.
Source retention is optional for every import. It defaults off and never affects publication eligibility. For `.rev`, choose **Keep editable source in LIGR** before creating the upload. For ZIP, choose **Keep original archive in LIGR**. REST clients must send `retainSource: false` when the user leaves it unchecked. Omitting, sending `false`, or sending `null` disables retention. Raw `.riv` uploads reject `retainSource: true` with `RUNTIME_HAS_NO_EDITABLE_SOURCE`. Their immutable runtime revisions already preserve the uploaded bytes. ## Playback images and source retention [Section titled “Playback images and source retention”](#playback-images-and-source-retention) `retainSource: false` disables storage of the original editor file or complete upload archive after processing. It does not remove supplied images required for playback. LIGR stores those images with the runtime revision. Required playback files remain available after save, reload, and publication, independently of the retention choice. Missing external images remain explicitly unresolved in a draft. Known dimensions do not mean that image bytes are available. Use the builder’s image uploads or bindings to supply the missing images. ## Temporary processing [Section titled “Temporary processing”](#temporary-processing) LIGR receives editor source temporarily when it must compile that source. Users who do not want source uploaded can compile externally and upload only `.riv`. An import expires after 24 hours. Processing attempts have a 15-minute deadline. Abandoned cleanup targets approximately 24 hours, plus manager scheduling and queue delivery time. Cleanup retries after storage failures. Late upload events reopen cleanup after a previous pass. Cancelled and unattached candidates enter cleanup even when retention was selected. Do not treat the upload URL expiry as deletion proof. The upload URL lasts 15 minutes, while cleanup follows the job lifecycle above. ## Converter limits [Section titled “Converter limits”](#converter-limits) The converter accepts script-free sources and uses bundled dependencies. It does not transmit source to Rive for signing. Scripted source conversion returns `SCRIPT_SIGNING_UNSUPPORTED`. Import configuration permits up to 20,000 mapped bindings and 4 MiB of serialized data, including expanded list rows. Existing signed `.riv` runtimes remain supported. Check the selected environment’s import capabilities before uploading source. ## Retained originals [Section titled “Retained originals”](#retained-originals) Retention stores the exact uploaded `.rev` file or complete ZIP archive. LIGR never regenerates an editor file for an original download. It does not promise a byte-identical `.rev` to RML to `.rev` round trip. A retained runtime-and-assets ZIP is stored as an original archive. It is not editable source. Request metadata before requesting a file:
```http
GET /rest/v2/themes/{themeId}/rive-graphics/{graphicId}/files/metadata?version=1&assetId={assetId}
```
Use `version=working` for the current draft. Use a positive number for one exact published version. Send `assetId` when the graphic has multiple matching assets. `originalAvailable: false` means that exact revision has no retained original. LIGR never falls back to another version’s source. The builder settings menu offers **Download runtime file (.riv)** and a separate original download when that original is available. A runtime download returns the exact `.riv` bytes for the selected version. It does not embed separately hosted images or reconstruct a ZIP. Keep the version’s configuration and playback images when moving that runtime to another renderer. To tell two versions apart, compare the `sha256` field of each download. Two versions with the same `.riv` bytes have the same `sha256`.
```http
GET /rest/v2/themes/{themeId}/rive-graphics/{graphicId}/files/original?version=1&assetId={assetId}
```
The response signs the exact owner-authorized object for five minutes. The complete original ZIP is the download; there is no archive-member extraction endpoint. ## Themes that require editable source [Section titled “Themes that require editable source”](#themes-that-require-editable-source) Source stays optional by default. A theme owner can turn on **Require editable source for Rive graphics** in the theme settings. On such a theme, every import must carry editable source: a `.rev` file or a complete project ZIP. Runtime-only uploads (`.riv`, or a ZIP whose selected entry is a runtime) are refused with `SOURCE_REQUIRED`. The source is always kept, so `retainSource` is treated as `true` and the builder locks the retention choice on. `GET /v2/themes/{themeId}/rive-graphics/capabilities` reports `sourceRequired: true` for these themes. Turning the setting off later changes nothing already stored; imports made under it keep their source.
# Theme variables and data schemas
> Create theme variables and data source templates over REST, and select a data schema on a graphic.
A theme carries two resources every graphic in it can use. A theme variable holds one value an operator sets per competition, such as a colour or a sponsor name. A data source template names a data schema, such as a ladder, that an operator pushes rows into per competition. All routes sit under `/rest/v2/themes/{themeId}`. Write routes need `themes:write`. Read routes need `themes:read`. A theme another organization owns answers `404`, the same answer as a theme that does not exist. ## Theme variables [Section titled “Theme variables”](#theme-variables) Terminal
```bash
V="themes/$THEME_ID/variables"
api GET "$V"
api POST "$V" --data '{"name":"accent","type":"string","defaultValue":"#0055ff","isStyle":true}'
api POST "$V" --data '{"name":"stage","type":"enum","enumOptions":["GROUP","FINAL"],"defaultValue":"GROUP"}'
api PATCH "$V/stage" --data '{"defaultValue":"FINAL"}'
api DELETE "$V/stage"
```
Types are `string`, `number`, `boolean` and `enum`. An `enum` variable must list at least one option in `enumOptions`, and its default must be one of those options. A `number` default must parse as a number. A `boolean` default must be `"true"` or `"false"`. The id takes the value of the name, so `PATCH` and `DELETE` address the variable by its name. The name never changes. To rename a variable, delete it and create it again. Set `isStyle` for a colour, a font or another style value; the dashboard groups style variables together. `GET` answers `{ "updatedAt": "...", "variables": [...] }`. `updatedAt` is the theme’s own `updatedAt`, not one variable’s. `POST` and `PATCH` answer the variable with `updatedAt` alongside it. `DELETE` answers `200` with `{ "updatedAt": "..." }`. Send that `updatedAt` back as `ifUnchangedSince` on the next write; read [Optimistic concurrency](#optimistic-concurrency) below. A published theme version freezes the variables. Publish a new theme version to release a change to the overlays. ## Data schemas [Section titled “Data schemas”](#data-schemas) A data source template describes the rows an operator pushes. Send a CSV sample as `sample`, or send XLSX bytes as `sampleBase64`. The server parses the sample, infers the JSON schema, and stores the first rows as a preview. Terminal
```bash
D="themes/$THEME_ID/data-source-templates"
api GET "$D"
api POST "$D" --data '{"alias":"standings","sourceType":"csv","shape":"rows","sample":"team,points\nEagles,42\nHawks,38\n"}'
api PATCH "$D/$TEMPLATE_ID" --data '{"description":"League ladder"}'
api DELETE "$D/$TEMPLATE_ID"
```
The alias uses lowercase letters, digits and underscores, and it starts with a letter. It is unique in the theme. Shapes are `cell` for one value, `kv` for a key and value list, and `rows` for a table. `config` carries parse options, for example `{"headerRow":1,"dataStartRow":2}` for a `rows` sheet. Select the schema on a graphic with `PUT /v2/themes/{themeId}/rive-graphics/{graphicId}/schemas/{alias}`. Read [Edit through REST](/rive-graphics/edit-through-rest/) for the graphic operations. Delete is refused with `409 DATA_SCHEMA_IN_USE` while a graphic selects the alias. The `details` list names each graphic. Deselect the schema on each graphic, then delete the template. ## Pushing data [Section titled “Pushing data”](#pushing-data) An operator pushes rows per competition, not per theme: Terminal
```bash
api POST "data-sources/$COMPETITION_THEME_SETTING_ID/standings" \
--data '{"csv":"team,points\nEagles,42\nHawks,38\n"}'
```
That route needs `data-sources:write`. Read [External data sources](/control-room/data-sources/) for the push formats. ## Optimistic concurrency [Section titled “Optimistic concurrency”](#optimistic-concurrency) Send `ifUnchangedSince` with the `updatedAt` you last read. A theme or a template that changed since then answers `409 TARGET_CHANGED`. Read the resource again, apply your change to the new state, and send the request again. ## Command line [Section titled “Command line”](#command-line) The `graphics-cli` package wraps both resources so you can script them without curl: Terminal
```bash
npx ligr-graphic rive var list --theme $THEME_ID
npx ligr-graphic rive var add accent --type string --default '#0055ff' --style
npx ligr-graphic rive var set stage --default FINAL
npx ligr-graphic rive var rm stage
npx ligr-graphic rive data-schema list --theme $THEME_ID
npx ligr-graphic rive data-schema create standings --type csv --shape rows --sample standings.csv
npx ligr-graphic rive data-schema set $TEMPLATE_ID --description 'League ladder'
npx ligr-graphic rive data-schema rm $TEMPLATE_ID
```
Pass `--theme `, or run the command from a project folder that holds a `rive.json` with a `themeId`. Neither group needs `--graphic` or takes a lock. Add `--json` to print the raw resource instead of a summary line.
# Delivery health & logs
> The delivery contract, the webhook logs in the dashboard, and the health states of an endpoint.
## Delivery contract [Section titled “Delivery contract”](#delivery-contract) | Behaviour | Today | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Timeout | 10 seconds. Answer `2xx` first, then process | | Redirects | Public HTTP/HTTPS destinations are supported, up to 21 redirects. Every destination is validated | | Automatic suspension | Disabled during the compatibility rollout. Failed deliveries remain visible in logs | | Retries | `match.*`, `fact.*` and `summary.*`: up to 6 attempts with 30 seconds of retry delays. Other events: up to 3 attempts with 1 minute of retry delays | | Ordering | `match.*`, `fact.*` and `summary.*` for one match are sent in order, one at a time. A retried delivery can arrive after a newer event. Across matches there is no guarantee | | Delivery | At least once. A duplicate is normal, a lost event needs every attempt to fail | | Idempotency | Use `(entity, data.id, date)` as the deduplication key | Redirects must remain on public HTTP/HTTPS destinations. Private-network destinations are rejected. Use `307` or `308` to preserve the POST method and body. Cross-host redirects do not retain Basic Auth credentials. ## Retries [Section titled “Retries”](#retries) LIGR retries a delivery when your endpoint sends no answer, or answers `408`, `429` or any `5xx`. LIGR does not retry a `2xx`, or any other `4xx`. A `4xx` means the request itself is wrong for your endpoint, and a repeat carries the same bytes. The schedule depends on the event. `match.*`, `fact.*` and `summary.*` events are delivered in order per match, one at a time, so a failing endpoint must not hold a match for long. Other events, `team.*` and `competition.*`, have no order to keep and wait longer. | Attempt | Match events: wait before it | Other events: wait before it | | ------- | ---------------------------- | ---------------------------- | | 1 | None | None | | 2 | None | 15 seconds | | 3 | 2 seconds | 45 seconds | | 4 | 4 seconds | — | | 5 | 8 seconds | — | | 6 | 16 seconds | — | The waits add up to 30 seconds for a match event and 1 minute for any other event. Each attempt also waits up to 10 seconds for your answer. An event that fails every attempt is lost. Retries cover a restart or a deploy on your side, not an outage. After a longer outage, read the entities you track once from the [REST API](/rest/) and continue from the webhooks. Five rules follow from retries. * **Delivery is at least once.** A retry after a lost answer, or a worker that restarts mid-delivery, sends an event you already processed. Answer `2xx` and skip it. * **A retry is the same event.** `date` is the same on every attempt of one event, so `(entity, data.id, date)` still deduplicates it. * **A retry carries the entity as it is at that moment.** The payload is read again for every attempt. Treat every delivery as the latest state of the entity, not as a diff. * **A retry can arrive out of order.** A retry goes to the back of the match’s queue, so events for the same match keep flowing while it waits. Compare `date`, or `updatedAt` on the entity, and ignore an older delivery. * **One event counts once towards health.** Each attempt is one row in the log. Only the final failed attempt of an event adds one consecutive failure. One lost event cannot move an endpoint to `failing` on its own. ## Logs [Section titled “Logs”](#logs) The dashboard keeps the newest 50 attempts per webhook. Open **Developers → Webhooks**, open the webhook, and select **Event Logs**. Each row is one attempt. It shows the status, the response body, the payload and the recorded health. | Status in the log | Meaning | | ----------------- | ---------------------------------------------------------------------------------- | | `200`–`299` | Your endpoint accepted the delivery | | `300`–`599` | The request ended without a successful `2xx` response | | `0` | Your endpoint sent no response. It timed out, refused the connection or failed DNS | | `-500` | LIGR failed before it sent the request | ## Health states [Section titled “Health states”](#health-states) LIGR records the result of every delivery and shows one health state per webhook. A broken endpoint remains visible as `degraded` or `failing`; failures do not automatically suspend delivery during this rollout. LIGR will email affected customers with an effective date before enabling stricter delivery enforcement. | State | Meaning | What to do | | ----------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `unknown` | No delivery attempted yet | Change something in a subscribed competition | | `healthy` | The last delivery succeeded | Nothing | | `degraded` | 1 to 4 consecutive failures, or the last result was a failure | Read your endpoint logs. Deliveries continue | | `failing` | 5 or more consecutive failures | Investigate the failures. Deliveries continue | | `suspended` | Delivery was already suspended | Fix the endpoint, then select **Re-enable** in the dashboard. LIGR keeps the history | ## Keep your endpoint healthy [Section titled “Keep your endpoint healthy”](#keep-your-endpoint-healthy) 1. Answer `2xx` in under 10 seconds. Queue the work. 2. Return `2xx` for an event you already processed. A duplicate is not an error. 3. Alert on your own 5xx rate. LIGR does not alert you.
# Events
> The webhook events, when each one fires, and how they are ordered.
LIGR sends one POST per change to every webhook that subscribes to the competition. Each event names one entity and one verb. ## Event matrix [Section titled “Event matrix”](#event-matrix) Set subscriptions per webhook and per competition in the dashboard. Select the events you want. LIGR filters everything else before it leaves the queue. | Entity | create | update | delete | Fires when | | ------------- | ------ | ------ | ------ | ---------------------------------------------------------------------------------- | | `match` | ✓ | ✓ | — | A match is created, or its details, status, score, clock or lineups change | | `fact` | ✓ | ✓ | ✓ | A fact is created, edited, undone or deleted | | `summary` | — | ✓ | ✓ | The match summary is recomputed. A delete carries ids only | | `team` | ✓ | ✓ | — | A team in the competition changes | | `competition` | — | ✓ | — | The competition itself changes | | `overlay` | ✓ | ✓ | ✓ | An overlay of a match is created, its settings or its key change, or it is deleted | A fact is a goal, a card, a point, a substitution or any other time-stamped match event. A match is never deleted. A match that leaves the schedule is cancelled, abandoned or finished early. That arrives as a `fact.create` named `MATCH_CANCELLED`, `MATCH_ABANDONED` or `MATCH_FINISHED_EARLY`, then a `summary.update`, then a `match.update` with `finishedStatus` set. Read the status from `match.update`. An `overlay` event fires for the overlay record: name, key, graphics delay, Auto Graphics, ad type, theme setting and control room. A graphic shown or hidden on the overlay is not an overlay event. An overlay that belongs to no match fires nothing, because there is no competition to route it to. Caution `summary.create` and `competition.create` do not exist. A summary starts to exist with its first `update`. A competition is created in the dashboard, not through an event. `match.delete`, `team.delete` and `competition.delete` do not exist. ## Order and volume [Section titled “Order and volume”](#order-and-volume) * `match.*`, `fact.*` and `summary.*` for one match arrive in order, one delivery at a time, in the order the change happened. A retried delivery is the exception: it can arrive after a newer event. Compare `date`. * Events across matches have no order guarantee. * A live football match produces tens of events. A `summary.update` follows most fact changes. Subscribe only to the events you handle. Every extra event costs your endpoint time, and a slow endpoint fails the 10-second timeout. See [Delivery health & logs](/webhooks/delivery-health/).
# Overview
> What LIGR webhooks do, how to create one, and what you must build to receive them.
A webhook is an HTTP POST that LIGR sends to your URL when something changes. Use webhooks instead of polling the REST API. ## What you get [Section titled “What you get”](#what-you-get) * One POST per change, for the entities you subscribe to. * A signed request, so you can prove that LIGR sent it. * Per-competition subscriptions, so one endpoint can serve many competitions. * At-least-once delivery. An event can arrive more than once. It never arrives at most once. See [Events](/webhooks/events/) for the full matrix. ## Create a webhook [Section titled “Create a webhook”](#create-a-webhook) Webhooks are a dashboard feature. There is no REST endpoint for them. 1. Ask LIGR to enable the `f-webhooks` feature flag for your organization. 2. Open **Developers → Webhooks** in the dashboard. 3. Add the URL of your endpoint. 4. Select the competitions and the events you want. 5. Copy the signing secret from **Developers → Webhooks**. Your organization has one signing secret. Every webhook of the organization uses it. Caution A webhook with no signing secret is skipped without an error. Set the secret before you rely on deliveries. ## What your endpoint does [Section titled “What your endpoint does”](#what-your-endpoint-does) 1. Read the raw request body. Do not parse it before you verify the signature. 2. Verify `x-ligr-webhook-sig`. See [Verify signatures](/webhooks/verify-signatures/). 3. Answer `2xx` at once. Process the event after you answer. 4. Deduplicate on `(entity, data.id, date)`. Delivery is at least once, so a duplicate is normal. Your endpoint is your own code, so LIGR cannot check any of these steps. Step 2 is a recommendation. LIGR signs every delivery; verifying that signature is your choice. Steps 3 and 4 change what you receive: a slow answer causes a retry, and a retry arrives whether or not you deduplicate. LIGR waits 10 seconds for your answer. A delivery with no answer, or with a `5xx`, `408` or `429` answer, is retried: `match.*`, `fact.*` and `summary.*` up to 5 more times with 30 seconds of retry delays, other events up to 2 more times with 1 minute of retry delays. Each attempt can take 10 seconds. See [Delivery health & logs](/webhooks/delivery-health/). ## Fill a gap from REST [Section titled “Fill a gap from REST”](#fill-a-gap-from-rest) Webhooks carry changes. The [REST API](/rest/) holds current state. Read from REST in two cases: * **You start late.** A receiver that comes up mid-match, or a webhook enabled after kick-off, has not seen the earlier events. Read the match and its summary once, then apply events as they arrive. * **You lost events.** An event that fails every attempt is lost. After an outage on your side longer than a minute, read the entities you track once and continue from the webhooks. Do not poll REST on a timer as a substitute for webhooks.
# Payloads
> The webhook envelope, and the data shape for match, fact, summary, team and competition.
## Envelope [Section titled “Envelope”](#envelope) Every event has the same five top-level keys.
```json
{
"type": "update",
"entity": "fact",
"competitionId": 2311,
"date": "2026-09-12T10:04:31.118Z",
"data": {}
}
```
| Key | Type | Meaning | | --------------- | --------------------------------------------------------- | ------------------------------------- | | `type` | `create` \| `update` \| `delete` | The verb | | `entity` | `match` \| `team` \| `fact` \| `competition` \| `summary` | The entity | | `competitionId` | number | The competition the change belongs to | | `date` | ISO 8601 string | When LIGR queued the event | | `data` | object | The entity, in the webhook shape | Use `(entity, data.id, date)` as your deduplication key. Delivery is at least once: the same event can arrive twice with the same key. ## match [Section titled “match”](#match) `data.homeTeam` and `data.awayTeam` follow `competitorsType`. A `teams` match carries a team object. A `singles` match carries a player. A `doubles` match carries a pair. `lineups` is present after a lineup import.
```json
{
"type": "update",
"entity": "match",
"competitionId": 2311,
"date": "2026-09-12T10:04:31.118Z",
"data": {
"id": 1188213,
"name": "Sydney FC v Melbourne City",
"competitorsType": "teams",
"homeTeam": { "id": 5021, "name": "Sydney FC", "gfxName": "SYD" },
"awayTeam": { "id": 5030, "name": "Melbourne City", "gfxName": "MCY" },
"score": { "home": 1, "away": 0 },
"clockData": { "clock": 1712, "running": true },
"liveStatus": "live",
"finishedStatus": "default",
"currentPeriod": { "number": 1, "name": "1st half" },
"coverage": "full",
"lineups": {
"home": { "starters": [], "bench": [] },
"away": { "starters": [], "bench": [] }
}
}
}
```
## fact [Section titled “fact”](#fact) A fact carries its match, its type, its clock position and the competitors it names. An undone fact arrives as a `delete`. ## summary [Section titled “summary”](#summary) A `summary` event carries the computed score, the periods and the statistics of one match. LIGR sends it after it folds a fact change into the summary. A `summary` delete carries ids only. ## overlay [Section titled “overlay”](#overlay) An `overlay` event carries the overlay record: the key a browser source uses, the match it belongs to, and the switches an operator or the REST API changes. A new key arrives as an `update`.
```json
{
"type": "update",
"entity": "overlay",
"competitionId": 2311,
"date": "2026-09-12T10:04:31.118Z",
"data": {
"id": 2300003,
"key": "3f2a9c1e-4b6d-4e8f-9a0b-1c2d3e4f5a6b",
"name": "Broadcast",
"matchId": 1188213,
"competitionThemeSettingId": 8802,
"controlRoomId": 91,
"controllerId": null,
"isPrimary": true,
"isActive": true,
"autoMode": false,
"encodingDelay": 6,
"adType": "free",
"lng": null,
"createdAt": "2026-09-01T02:14:09.000Z",
"updatedAt": "2026-09-12T10:04:31.118Z",
"deletedAt": null
}
}
```
## team and competition [Section titled “team and competition”](#team-and-competition) A `team` event carries the team as the competition holds it, including `gfxName`. A `competition` event carries the competition record. ## Read the exact fields [Section titled “Read the exact fields”](#read-the-exact-fields) The webhook payload of an entity tracks the entity itself. To see every field your competition produces, read one live delivery in the log. Open **Developers → Webhooks**, open the webhook, and select **Event Logs**. The log shows the payload LIGR sent.
# Verify signatures
> Compute the HMAC-SHA256 signature over v0:timestamp:body and compare it in constant time.
Every delivery is signed with the signing secret of your organization. LIGR recommends that you verify the signature before you trust the body. LIGR cannot enforce this: the check runs in your code, on your endpoint. The rules below describe the check LIGR recommends you write. ## Headers [Section titled “Headers”](#headers) | Header | Value | | -------------------------- | ------------------------------------------------------ | | `content-type` | `application/json` | | `x-ligr-webhook-timestamp` | Unix time in milliseconds when LIGR signed the request | | `x-ligr-webhook-sig` | The hex HMAC-SHA256 of `v0:{timestamp}:{body}` | ## The signed string [Section titled “The signed string”](#the-signed-string) LIGR joins three parts with colons.
```text
v0:1757671471118:{"type":"update","entity":"fact",…}
```
* `v0` is the signature scheme. * The timestamp is the value of `x-ligr-webhook-timestamp`. * The body is the raw request body, byte for byte. Caution Sign the raw body. A body that your framework parsed and serialized again produces a different signature. ## Verify in Node [Section titled “Verify in Node”](#verify-in-node) verify.ts
```ts
import { createHmac, timingSafeEqual } from 'node:crypto'
const FIVE_MINUTES = 5 * 60_000
export function verify(rawBody: string, headers: Record, secret: string): boolean {
const ts = headers['x-ligr-webhook-timestamp']
const sig = headers['x-ligr-webhook-sig']
if (!ts || !sig) return false
if (Math.abs(Date.now() - Number(ts)) > FIVE_MINUTES) return false
const expected = createHmac('sha256', secret).update(`v0:${ts}:${rawBody}`).digest('hex')
if (sig.length !== expected.length) return false
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
```
## Verify in Python [Section titled “Verify in Python”](#verify-in-python) verify.py
```python
import hmac
import time
from hashlib import sha256
FIVE_MINUTES_MS = 5 * 60 * 1000
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("x-ligr-webhook-timestamp")
sig = headers.get("x-ligr-webhook-sig")
if not ts or not sig:
return False
if abs(time.time() * 1000 - int(ts)) > FIVE_MINUTES_MS:
return False
signed = f"v0:{ts}:".encode() + raw_body
expected = hmac.new(secret.encode(), signed, sha256).hexdigest()
return hmac.compare_digest(sig, expected)
```
## Rules [Section titled “Rules”](#rules) 1. Compare in constant time. `timingSafeEqual` and `compare_digest` do this. 2. Reject a request older than 5 minutes. This stops a replay. 3. Reject a request with no signature header. 4. Never log the secret.