Overview
Introduction
The Loreline Developer API lets your backend read and write Loreline content — Lorelines, Segments and Scenes — and upload media into Loreline's processing pipeline, on behalf of a Loreline account.
Loreline is a collaborative documentary platform: people upload footage, share perspectives and build living visual archives that evolve over time. Each Loreline plays back as a documentary whose scenes are ordered by their real capture date, mixing photos, video clips, audio and embedded media into a single timeline.
New here? Get a key first — see Getting an API key — then run the Quickstart: five
curlcalls from a fresh key to a Loreline with an uploaded photo in it.
What you can build
- Import pipelines — push an existing archive (a newsroom's footage, a museum collection, an event's photo dump) into Loreline as Segments and Scenes.
- Publishing tools — create a Loreline from your CMS, attach scenes and keep titles and descriptions in sync.
- Read integrations — list your content and any public, approved content, and pull view/save counts for your own Lorelines and Segments.
How it fits together
| Resource | What it is | Endpoints |
|---|---|---|
| Loreline | A curated documentary: an ordered selection of scenes | /v1/lorelines |
| Segment | A browsable library of scenes — the place scenes live | /v1/segments |
| Scene | One photo, video clip, audio recording, text card or embed | /v1/scenes |
| Media job | Background processing of an uploaded file | /v1/media-jobs |
Read Core concepts for how ownership, visibility and moderation work.
The essentials
- Base URL:
https://api.loreline.live— every endpoint lives under/v1. - Auth:
Authorization: Bearer lk_live_…on every request. See Authentication. - Format: JSON in, JSON out,
snake_casefields, UUID ids. See Conventions. - Server-side only: API keys are secrets and browsers on other origins are blocked by CORS. Call the API from your backend.
- Acts as an account: a key is issued to one Loreline account. Everything you create belongs to that account and shows up in its Loreline Studio.
What the API does not do (yet)
- Publishing. Lorelines and Segments created through the API start private (
is_public: false). Making them public — and the moderation review that comes with it — happens in Loreline Studio. - Other people's content. You can read public, approved content from anyone, but you can only change content owned by your key's account.
- Webhooks. Media processing is asynchronous; poll the media job for its result.
Overview
Quickstart
From a fresh API key to a Loreline with a video embed and an uploaded photo in five calls. You need a key with both the read and write scopes — see Getting an API key.
export LORELINE_API_KEY="lk_live_…"
export API="https://api.loreline.live"
1. Check the key
curl "$API/v1/me" -H "Authorization: Bearer $LORELINE_API_KEY"
{
"user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
"profile": { "display_name": "Harbour Archive", "username": "harbour" },
"scopes": ["read", "write"]
}
A 401 here means the key is wrong, revoked or paused — see Authentication.
2. Create a Segment
Segments are where scenes live.
curl -X POST "$API/v1/segments" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Harbour, 1990–2024", "description": "Footage from the old harbour." }'
The response is 201 Created with the new Segment. Keep its id:
export SEGMENT_ID="…"
3. Create a Loreline
A Loreline is the documentary people watch.
curl -X POST "$API/v1/lorelines" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "The Harbour", "category": "History" }'
export LORELINE_ID="…"
4. Add a scene from a URL
A video scene pointing at YouTube, TikTok, Instagram or X becomes an embed automatically. captured_at is the real-world date — it drives chronological ordering.
curl -X POST "$API/v1/scenes" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "video",
"title": "Last ferry leaves the harbour",
"file_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"captured_at": "2019-06-01T18:30:00Z",
"segment_id": "'"$SEGMENT_ID"'",
"loreline_id": "'"$LORELINE_ID"'"
}'
201 Created returns the Scene. Passing loreline_id also appends it to the Loreline.
5. Upload a photo
Uploads are multipart/form-data. Send the text fields first and the file part last — curl -F sends parts in the order you give them.
curl -X POST "$API/v1/scenes" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-F type=photo \
-F title="Crane at dawn" \
-F segment_id="$SEGMENT_ID" \
-F loreline_id="$LORELINE_ID" \
-F [email protected]
{
"job_id": "9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10",
"status": "waiting",
"status_url": "/v1/media-jobs/9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10"
}
Poll status_url until state is completed (or failed). The finished job's result.scene_id is the new scene; its capture date and location are read from the file's metadata when present.
curl "$API/v1/media-jobs/9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10" \
-H "Authorization: Bearer $LORELINE_API_KEY"
Read it back
curl "$API/v1/lorelines/$LORELINE_ID" -H "Authorization: Bearer $LORELINE_API_KEY"
The Loreline comes back with a scenes array in playback order. It is still private — publish it from Loreline Studio when it is ready.
Overview
Getting an API key
API keys are issued by the Loreline team to a Loreline account. There is no self-serve key page yet: you create an account, ask for access, and receive a key that acts as that account.
1. Create the account the key will act as
Sign up at loreline.live/login. Everything the key creates is owned by this account and appears in its Loreline Studio, so use a dedicated account for your organisation or integration rather than a personal one.
Optionally set a display name and username on the account's profile — GET /v1/me returns them, and they are what people see on your content.
2. Request access
Email [email protected] with:
- the email or username of the account from step 1,
- what you are building and roughly how much content you expect to send,
- the scopes you need —
read, orread+write, - a name for the key (e.g.
acme-import-production) so both sides can tell keys apart.
3. Receive and store the key
Keys look like this:
lk_live_Q4m9ZcT2pX7rVb1LkS8nWd3HfY6uJe0A
lk_live_ followed by 32 random letters and digits. The full key is shown exactly once. Loreline only stores a SHA-256 hash of it, so a lost key cannot be recovered — ask for a new one and the old one is revoked.
Store it like a password: in your secret manager or a server-side environment variable such as LORELINE_API_KEY.
- Never ship it in a browser bundle, mobile app, public repository or log line.
- Never send it anywhere except
https://api.loreline.live.
Scopes
| Scope | Grants |
|---|---|
read | Every GET endpoint: /v1/me, listing and fetching Lorelines, Segments and Scenes, analytics, media job status |
write | Every POST, PATCH and DELETE: creating, editing and deleting content, uploads, cancelling media jobs |
A key has one or both scopes. Calling an endpoint outside its scopes returns 403 with API key lacks the write scope (or read). GET /v1/me echoes the key's scopes.
One key per environment
Ask for separate keys for production, staging and each independent service. Rate limits are counted per key, so a runaway batch job on one key does not starve the others, and a leaked key can be revoked without taking everything down.
Rotation and revocation
To rotate, request a new key, deploy it, then ask for the old one to be revoked. Revocation takes effect immediately: the next request with the old key gets 401 Invalid API key.
A key also stops working — with the same 401 — while its account is suspended or the partner's API access is paused. If a key that used to work suddenly returns 401, contact [email protected].
Think a key leaked? Email us right away and we will revoke it.
Overview
Authentication
Every Developer API request carries the key as a bearer token in the Authorization header. There are no sessions, cookies or OAuth flows — one header, every request.
GET /v1/me HTTP/1.1
Host: api.loreline.live
Authorization: Bearer lk_live_Q4m9ZcT2pX7rVb1LkS8nWd3HfY6uJe0A
const res = await fetch('https://api.loreline.live/v1/me', {
headers: { Authorization: `Bearer ${process.env.LORELINE_API_KEY}` },
})
if (!res.ok) throw new Error(`Loreline API ${res.status}: ${(await res.json()).error.message}`)
const me = await res.json()
Call from your server server-side only
The API only accepts cross-origin browser requests from Loreline's own sites, so fetch from a page on your domain is blocked by CORS — by design. A key in front-end code would hand your account to anyone who opens dev tools. Put the call behind your own backend and expose only what your users need.
Who the key acts as
Requests run as the account the key was issued to, with that account's permissions and nothing more:
- Reads see the account's own content plus anything that is public and approved.
- Writes only touch content the account owns.
- Content you cannot see is reported as
404, never403, so the API does not reveal whether a private item exists.
Failures
| Status | error.code | Message | Meaning |
|---|---|---|---|
401 | unauthorized | Missing or malformed Authorization header | No header, or it does not start with Bearer |
401 | unauthorized | Invalid API key | Unknown key, revoked key, or the account/partner access is suspended |
403 | forbidden | API key lacks the write scope | The key does not have the scope this endpoint needs (see Scopes) |
429 | rate_limited | Rate limit exceeded | Too many requests for this key — see Rate limits |
503 | service_unavailable | Developer API key authentication is unavailable | Temporary outage; retry with backoff |
The full error format is described in Errors.
Overview
Core concepts
Three resources make up all Loreline content. Scenes are the atoms, Segments hold them, and Lorelines arrange a selection of them into something to watch.
Scene
A single piece of media with a real-world date: a photo, a video clip, an audio recording, a text card, or an embedded post from YouTube, TikTok, Instagram or X. A scene has a title, an optional description, a captured_at date and a file_url pointing at the media (text and placeholder scenes have none).
A scene belongs to at most one Segment (segment_id) and can appear in any number of Lorelines.
type | What it holds | Needs file_url |
|---|---|---|
photo | An image | yes |
video | A video file, or a YouTube / TikTok / Instagram / X link (stored as an embed) | yes |
audio | An audio recording | yes |
text | A text card — title and description only | no |
placeholder | A slot for media that is not available yet | no |
Two more types are accepted on create as shorthands: link is stored as a text scene with metadata.is_link: true, and embed is stored as a video scene with metadata.is_embed: true (the URL must be a supported provider).
Segment
An organised, browsable library of scenes — an archive around one place, event or theme. Scenes in a Segment are ordered chronologically by capture date. Public Segments are open archives other people can browse.
Loreline
A curated documentary. A Loreline is an ordered selection of scenes — usually from one or more Segments — that plays back as a single timeline. Lorelines have a title, description, category, tags, a banner_url, and optional chapters, subtitles and audio tracks edited in Studio.
Ownership and visibility
Every Loreline, Segment and Scene has an owner (user_id). With an API key you act as the key's account:
| You can… | …on content that is |
|---|---|
| read | yours (any visibility), or public and approved by moderation |
| create, update, delete | yours only |
Public visibility needs two things: is_public: true and moderation_status: "approved" (pending and rejected content stays hidden). Content created through the API starts private. Publishing is done in Loreline Studio, and public content passes moderation before other people can see it.
Reuse and forks
allow_reuse lets other people reuse your scenes in their own Lorelines, and allow_fork (Lorelines only) lets them fork a copy of the whole Loreline. Both default to false; set them on create or update.
REST API
Conventions
Everything below applies to every endpoint in the REST API section.
Base URL and versioning
https://api.loreline.live/v1
The version is part of the path. New endpoints, optional request fields and response fields can appear within v1 at any time, so build clients that tolerate them.
Requests
- JSON bodies need
Content-Type: application/jsonand may be at most 2 MB. Only media uploads accept larger or chunked bodies. - Unknown fields are rejected. Request bodies are validated strictly: a typo such as
"titel"returns422 validation_errorrather than being silently ignored. - IDs are UUIDs. A malformed id in the path returns
422. - Dates you send (
captured_at) must be parseable dates; send ISO 8601 with a timezone, e.g.2019-06-01T18:30:00Z.
Responses
- Field names are
snake_case. Timestamps are ISO 8601 strings with a timezone. - Successful creates return
201, accepted uploads202, deletes204with no body. - Every Developer API response is sent with
Cache-Control: private, no-store. - Ignore fields you do not recognise. Loreline and Segment responses include internal bookkeeping columns (moderation details, search data) beyond the fields documented here; treat undocumented fields as unstable.
theme_datais a deprecated alias that mirrorscontent_data. Readcontent_data.
Pagination
List endpoints take limit and offset query parameters and wrap results in the same envelope.
| Parameter | Default | Range |
|---|---|---|
limit | 50 | 1–100 |
offset | 0 | 0–1000000 |
{
"data": [ { "id": "…" } ],
"pagination": { "limit": 50, "offset": 0, "total": 132 }
}
total counts every matching item, so you are done once offset + data.length >= total.
async function* all(path: string) {
for (let offset = 0; ; offset += 100) {
const res = await fetch(`https://api.loreline.live${path}?limit=100&offset=${offset}`, {
headers: { Authorization: `Bearer ${process.env.LORELINE_API_KEY}` },
})
const page = await res.json()
yield* page.data
if (offset + page.data.length >= page.pagination.total) return
}
}
Scope filter
GET /v1/lorelines and GET /v1/segments also take scope:
scope | Returns |
|---|---|
mine | Content owned by the key's account, public or private |
public | Public, approved content from everyone |
all | Both of the above (default) |
Results are newest first.
REST API
Account
Identify the account a key acts as. Use it as a health check when you deploy a new key.
| Method | Path | Scope |
|---|---|---|
| GET | /v1/me | read |
GET /v1/me
Response 200 OK
{
"user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
"profile": {
"display_name": "Harbour Archive",
"username": "harbour"
},
"scopes": ["read", "write"]
}
| Field | Type | Description |
|---|---|---|
user_id | uuid | The account the key acts as — the user_id on everything you create |
profile | object | null | Public profile, or null if the account has not set one up |
profile.display_name | string | null | Name shown on the account's content |
profile.username | string | null | Handle used in profile URLs |
scopes | string[] | Scopes granted to this key: read, write |
REST API
Lorelines
Create and manage Lorelines — curated documentaries built from an ordered selection of scenes. You can read your own Lorelines and any public, approved one; you can only change your own.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/lorelines | read | List Lorelines |
| POST | /v1/lorelines | write | Create a Loreline |
| GET | /v1/lorelines/:id | read | Get a Loreline with all its scenes |
| PATCH | /v1/lorelines/:id | write | Update a Loreline |
| DELETE | /v1/lorelines/:id | write | Delete a Loreline |
| GET | /v1/lorelines/:id/scenes | read | Page through a Loreline's scenes |
| GET | /v1/lorelines/:id/analytics | read | View and save counts |
The Loreline object
{
"id": "0f3a8c52-7d6e-4b8a-9c1f-5e2d7b4a6c90",
"user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
"title": "The Harbour",
"description": "Thirty years of the old harbour, in the words of the people who worked it.",
"slug": null,
"category": "History",
"tags": [],
"banner_url": null,
"is_public": false,
"moderation_status": null,
"allow_reuse": false,
"allow_fork": false,
"forked_from": null,
"fork_count": 0,
"content_data": {},
"chapters": [],
"subtitles": [],
"audio_tracks": [],
"revision": 0,
"created_at": "2026-09-29T10:12:03.418Z",
"updated_at": "2026-09-29T10:12:03.418Z"
}
| Field | Type | Description |
|---|---|---|
id | uuid | Loreline id |
user_id | uuid | Owner |
title | string | Title, 1–300 characters |
description | string | null | Up to 5,000 characters |
slug | string | null | Public URL slug (loreline.live/loreline/<slug>) |
category | string | null | Topic, up to 100 characters |
tags | string[] | Tags |
banner_url | string | null | Banner image |
is_public | boolean | Whether the owner has published it |
moderation_status | string | null | pending, approved, rejected, or null |
allow_reuse | boolean | Others may reuse its scenes |
allow_fork | boolean | Others may fork a copy |
forked_from | uuid | null | The Loreline this one was forked from |
fork_count | integer | Times it has been forked |
content_data | object | Playback, presentation and provenance metadata |
chapters, subtitles, audio_tracks | array | Edited in Studio |
revision | integer | Edit revision counter |
created_at, updated_at | timestamp |
Responses carry a few more internal columns; see Conventions.
GET /v1/lorelines
List Lorelines, newest first. Takes scope (mine | public | all, default all), limit and offset — see Pagination.
curl "https://api.loreline.live/v1/lorelines?scope=mine&limit=20" \
-H "Authorization: Bearer $LORELINE_API_KEY"
Response 200 OK
{
"data": [ { "id": "0f3a8c52-…", "title": "The Harbour", "is_public": false } ],
"pagination": { "limit": 20, "offset": 0, "total": 1 }
}
POST /v1/lorelines
Create a Loreline owned by the key's account. It starts private.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | 1–300 characters after trimming |
description | string | no | Up to 5,000 characters |
category | string | no | Up to 100 characters |
allow_reuse | boolean | no | Default false |
allow_fork | boolean | no | Default false |
{
"title": "The Harbour",
"description": "Thirty years of the old harbour.",
"category": "History",
"allow_reuse": true
}
Response 201 Created
The new Loreline object.
GET /v1/lorelines/:id
Get one Loreline — yours, or any public, approved one — with every scene in a scenes array, in playback order. Each scene carries its position in the Loreline. For long Lorelines prefer the paginated /scenes endpoint below.
Response 200 OK
{
"id": "0f3a8c52-7d6e-4b8a-9c1f-5e2d7b4a6c90",
"title": "The Harbour",
"is_public": false,
"scenes": [
{
"id": "c7e1d2a4-58b3-4f6e-9a0d-1b2c3d4e5f60",
"type": "video",
"title": "Last ferry leaves the harbour",
"file_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"captured_at": "2019-06-01T18:30:00+00:00",
"segment_id": "3a9e5c1b-…",
"position": 0
}
]
}
Not found — or private and not yours — returns 404.
PATCH /v1/lorelines/:id
Update any of title, description, category, allow_reuse, allow_fork. Send at least one; omitted fields are unchanged. Returns the updated Loreline. Only the owner can update — anyone else gets 404.
{ "description": "Thirty years of the old harbour, 1990–2024." }
DELETE /v1/lorelines/:id
Delete one of your Lorelines. Returns 204 No Content. The scenes it contained are not deleted — they stay in their Segments.
GET /v1/lorelines/:id/scenes
The same scenes as GET /v1/lorelines/:id, paginated with limit and offset, in playback order. Each item is a scene record plus its position.
{
"data": [ { "id": "c7e1d2a4-…", "type": "video", "title": "Last ferry leaves the harbour", "position": 0 } ],
"pagination": { "limit": 50, "offset": 0, "total": 1 }
}
GET /v1/lorelines/:id/analytics
Engagement counts for your own Lorelines (others' return 404).
{ "loreline_id": "0f3a8c52-7d6e-4b8a-9c1f-5e2d7b4a6c90", "views": 1284, "saves": 37 }
REST API
Segments
Create and manage Segments — libraries of scenes around one place, event or theme. Segments work like Lorelines with three differences: they have no allow_fork, GET /v1/segments/:id does not embed scenes, and deleting a Segment deletes its scenes.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/segments | read | List Segments |
| POST | /v1/segments | write | Create a Segment |
| GET | /v1/segments/:id | read | Get a Segment |
| PATCH | /v1/segments/:id | write | Update a Segment |
| DELETE | /v1/segments/:id | write | Delete a Segment and its scenes |
| GET | /v1/segments/:id/scenes | read | Page through a Segment's scenes |
| GET | /v1/segments/:id/analytics | read | View and save counts |
The Segment object
{
"id": "3a9e5c1b-2d4f-4e6a-8b0c-9d1e2f3a4b5c",
"user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
"title": "Harbour, 1990–2024",
"description": "Footage from the old harbour.",
"slug": null,
"category": null,
"color": null,
"emoji": null,
"is_public": false,
"moderation_status": null,
"allow_reuse": false,
"content_data": {},
"created_at": "2026-09-29T10:11:40.102Z",
"updated_at": "2026-09-29T10:11:40.102Z"
}
| Field | Type | Description |
|---|---|---|
id | uuid | Segment id |
user_id | uuid | Owner |
title | string | Title, 1–300 characters |
description | string | null | Up to 5,000 characters |
slug | string | null | Public URL slug (loreline.live/segment/<slug>) |
category | string | null | Topic, up to 100 characters |
color, emoji | string | null | Presentation, set in Studio |
is_public | boolean | Whether the owner has published it |
moderation_status | string | null | pending, approved, rejected, or null |
allow_reuse | boolean | null | Others may reuse its scenes |
content_data | object | Presentation and provenance metadata |
created_at, updated_at | timestamp |
GET /v1/segments
List Segments, newest first. Takes scope (mine | public | all, default all), limit and offset — see Conventions. Returns { data, pagination }.
POST /v1/segments
Create a private Segment owned by the key's account.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | 1–300 characters after trimming |
description | string | no | Up to 5,000 characters |
category | string | no | Up to 100 characters |
allow_reuse | boolean | no | Default false |
Sending allow_fork returns 422 — Segments do not support allow_fork.
Response 201 Created
The new Segment object.
GET /v1/segments/:id
Get one Segment — yours, or any public, approved one. Scenes are not embedded; use /scenes below. Otherwise 404.
PATCH /v1/segments/:id
Update any of title, description, category, allow_reuse. Send at least one. Returns the updated Segment. Owner only — anyone else gets 404.
DELETE /v1/segments/:id
Delete one of your Segments and every scene in it. Those scenes also disappear from any Loreline that used them. Returns 204 No Content. To keep scenes, first move them to another Segment with PATCH /v1/scenes/:id.
GET /v1/segments/:id/scenes
The Segment's scenes, paginated with limit and offset, ordered chronologically by capture date.
{
"data": [ { "id": "c7e1d2a4-…", "type": "photo", "title": "Crane at dawn", "captured_at": "2021-03-14T06:02:11+00:00" } ],
"pagination": { "limit": 50, "offset": 0, "total": 1 }
}
GET /v1/segments/:id/analytics
Engagement counts for your own Segments (others' return 404).
{ "segment_id": "3a9e5c1b-2d4f-4e6a-8b0c-9d1e2f3a4b5c", "views": 412, "saves": 9 }
REST API
Scenes
Create, read, update and delete Scenes — single photos, videos, audio recordings, text cards and embeds. Create a scene from a URL with a JSON body, or upload the file in the same request (see Media uploads).
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/scenes | read | List your scenes |
| POST | /v1/scenes | write | Create a scene (JSON) or upload one (multipart) |
| GET | /v1/scenes/:id | read | Get a scene |
| PATCH | /v1/scenes/:id | write | Update or move a scene |
| DELETE | /v1/scenes/:id | write | Delete a scene |
The Scene object
{
"id": "c7e1d2a4-58b3-4f6e-9a0d-1b2c3d4e5f60",
"segment_id": "3a9e5c1b-2d4f-4e6a-8b0c-9d1e2f3a4b5c",
"user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
"type": "photo",
"title": "Crane at dawn",
"description": null,
"file_url": "https://media.loreline.live/…/crane.webp",
"thumbnail": "https://media.loreline.live/…/crane-thumb.webp",
"playback_url": null,
"duration": null,
"captured_at": "2021-03-14T06:02:11+00:00",
"location_data": { "latitude": 54.352, "longitude": 18.646 },
"metadata": {},
"credits": null,
"contributor_name": null,
"selected_for_lorelines": [],
"is_placeholder": false,
"is_descriptive": false,
"audio_deleted": false,
"storage_path": null,
"save_count": 0,
"reuse_count": 0,
"source_media_item_id": null,
"created_at": "2026-09-29T10:14:52.771Z"
}
| Field | Type | Description |
|---|---|---|
id | uuid | Scene id |
segment_id | uuid | null | The Segment it lives in |
user_id | uuid | Owner |
type | string | video, photo, audio, text or placeholder — see Core concepts |
title | string | 1–1,000 characters |
description | string | null | Up to 5,000 characters |
file_url | string | null | The media: an uploaded file's processed URL or the URL you supplied |
thumbnail | string | null | Preview image |
playback_url | string | null | Streaming URL for processed video/audio |
duration | string | null | e.g. "12.4 seconds" or "00:01:30" |
captured_at | string | null | Real-world capture date — drives chronological order |
location_data | object | null | Where it was captured |
metadata | object | Free-form JSON; embeds carry is_embed, embed_platform, embed_video_id, embed_url |
save_count, reuse_count | integer | null | Engagement counters |
source_media_item_id | uuid | null | Original scene when this one is a reuse |
created_at | timestamp |
GET /v1/scenes
List scenes owned by the key's account, newest first, with limit and offset. To browse public content, go through /v1/segments/:id/scenes or /v1/lorelines/:id/scenes.
{
"data": [ { "id": "c7e1d2a4-…", "type": "photo", "title": "Crane at dawn" } ],
"pagination": { "limit": 50, "offset": 0, "total": 1 }
}
POST /v1/scenes
Create a scene from a URL. For files you host yourself, prefer uploading: uploads are transcoded into web-ready variants with thumbnails, and their capture date and location are read from the file. A URL scene is referenced as-is, so it must be permanent.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | video, photo, audio, text, placeholder, link or embed |
title | string | yes | 1–1,000 characters after trimming |
description | string | no | Up to 5,000 characters |
file_url | url | for video, photo, audio, embed | http(s) URL of the media; the target URL for link |
thumbnail | url | no | Preview image |
playback_url | url | no | Streaming URL |
captured_at | string | no | Real-world date, e.g. 2019-06-01T18:30:00Z |
duration | string | no | "12.4s", "12.4 seconds", "1:30" or "0:01:30" |
segment_id | uuid | null | no | Put it in one of your Segments |
loreline_id | uuid | no | Also append it to one of your Lorelines |
metadata | object | no | Free-form JSON |
{
"type": "video",
"title": "Last ferry leaves the harbour",
"file_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"captured_at": "2019-06-01T18:30:00Z",
"segment_id": "3a9e5c1b-2d4f-4e6a-8b0c-9d1e2f3a4b5c",
"loreline_id": "0f3a8c52-7d6e-4b8a-9c1f-5e2d7b4a6c90"
}
Response 201 Created
The new Scene object.
Embeds
A video whose file_url is on YouTube, TikTok, Instagram or X is stored as an embed and plays in Loreline's player; metadata gains is_embed, embed_platform, embed_video_id and embed_url. type: "embed" with any other host returns 422 Unsupported embed URL.
Validation errors
| Message | Cause |
|---|---|
A media URL is required | video, photo, audio or embed without file_url |
Media must have a permanent HTTP URL | file_url is not http(s) |
Invalid captured date | captured_at cannot be parsed |
Invalid scene duration | duration is not in one of the formats above |
Target segment not found / Target loreline not found | The id does not exist or is not yours (404) |
GET /v1/scenes/:id
Get a scene you own, or one that sits in a public, approved Segment or Loreline. Otherwise 404.
PATCH /v1/scenes/:id
Update any of title, description, file_url, thumbnail, playback_url, captured_at, duration, metadata and segment_id. Omitted fields are unchanged. Returns the updated scene.
- Move a scene by sending another of your Segments'
segment_id, or detach it with"segment_id": null. typeandloreline_idcannot be changed — sending them returns422.- Loreline-managed provenance keys in
metadata(reused_from,source_owner_id,source_media_item_id,reused_at,original_creator_id) are ignored.
{ "title": "Crane at dawn, looking east", "segment_id": null }
DELETE /v1/scenes/:id
Delete one of your scenes. It is also removed from every Loreline that used it. Returns 204 No Content.
REST API
Media uploads
Upload photos, video and audio into Loreline's media pipeline. Files are validated, transcoded into web-ready variants with thumbnails, and their capture date and GPS location are read from the file's own metadata. Processing is asynchronous: every upload returns a media job you poll until it finishes.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/scenes | write | Upload a file and create its scene in one request (multipart) |
| POST | /v1/uploads | write | Upload a file without creating a scene (multipart) |
| POST | /v1/media/uploads | write | Start a direct multipart upload for large files |
| POST | /v1/media/uploads/:id/parts | write | Re-sign part URLs |
| POST | /v1/media/uploads/:id/complete | write | Finish a direct upload |
| DELETE | /v1/media/uploads/:id | write | Abort a direct upload |
| GET | /v1/media-jobs/:id | read | Poll a media job |
| DELETE | /v1/media-jobs/:id | write | Cancel a media job |
Accepted files
| Kind | Extensions | Max size |
|---|---|---|
| Photo | jpg jpeg png gif webp avif heic heif dng | 50 MB |
| Video | mp4 mov webm | 2 GB |
| Audio | mp3 wav ogg opus aac m4a | 2 GB |
The filename must carry one of these extensions and the bytes must actually be that kind of file — content is sniffed, so a renamed .exe is rejected with 422 File content does not match an allowed type. Too-large files get 413.
POST /v1/scenes (multipart)
The simplest path: one request uploads the file and creates the scene when processing finishes. Send multipart/form-data with the scene fields as text parts first and exactly one part named file last.
typemust bephoto,videooraudio, andtitleis required. The other scene fields are optional; sendmetadataas a JSON string.- Fields — including
segment_id/loreline_idownership — are validated before a single byte of the file is stored, so a bad request fails fast. - A field after the file returns
422 Upload form fields must precede the file.
curl -X POST "https://api.loreline.live/v1/scenes" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-F type=video \
-F title="Night shift at the docks" \
-F segment_id="$SEGMENT_ID" \
-F loreline_id="$LORELINE_ID" \
-F [email protected]
import { openAsBlob } from 'node:fs'
const form = new FormData()
form.append('type', 'photo')
form.append('title', 'Crane at dawn')
form.append('segment_id', segmentId)
form.append('file', await openAsBlob('crane.jpg'), 'crane.jpg') // file last
const res = await fetch('https://api.loreline.live/v1/scenes', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.LORELINE_API_KEY}` },
body: form,
})
const job = await res.json() // 202 { job_id, status, status_url }
Response 202 Accepted
{
"job_id": "9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10",
"status": "waiting",
"status_url": "/v1/media-jobs/9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10"
}
When the job completes, result.scene_id is the new scene.
Keep the upload moving. Each account can stream 2 uploads at a time (a third gets 429 with details.quota: "concurrent_uploads"), and the server aborts uploads that stall. For big files on slow or unreliable links, use a direct upload instead.
POST /v1/uploads
Same multipart rules, but only a file part and no scene is created. The finished job's result gives you file_url, thumbnail and playback_url to use in a JSON scene later.
Direct uploads for large files
Direct uploads send the bytes straight to Loreline's storage in parts, so a multi-gigabyte file never has to survive one long HTTP request, and a failed part can be retried on its own.
1. Start
curl -X POST "https://api.loreline.live/v1/media/uploads" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "docks.mp4", "size": 734003200 }'
{
"upload_id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"part_size": 16777216,
"parts": [
{ "part_number": 1, "url": "https://…signed-url…" },
{ "part_number": 2, "url": "https://…signed-url…" }
],
"expires_at": "2026-09-29T11:14:52.000Z",
"expires_in_seconds": 3600,
"complete_url": "/v1/media/uploads/f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b/complete"
}
2. Upload every part
PUT the raw bytes of each part to its url — no Authorization header, the URL is pre-signed. Part n covers bytes (n-1) × part_size up to n × part_size; the last part is whatever remains. Each signature is bound to the part's exact length.
dd if=docks.mp4 bs=16777216 skip=0 count=1 2>/dev/null | \
curl -X PUT --data-binary @- "https://…signed-url-for-part-1…"
If URLs expire before you are done, ask for fresh ones:
curl -X POST "https://api.loreline.live/v1/media/uploads/$UPLOAD_ID/parts" \
-H "Authorization: Bearer $LORELINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "part_numbers": [7, 8] }'
3. Complete
curl -X POST "https://api.loreline.live/v1/media/uploads/$UPLOAD_ID/complete" \
-H "Authorization: Bearer $LORELINE_API_KEY"
Loreline checks that every part arrived at the right size and returns 202 with a media job, exactly like the multipart endpoints. Then create the scene with the job's result.file_url, thumbnail, playback_url and captured_at via POST /v1/scenes.
Part URLs are valid for an hour; an upload that is not completed within 24 hours expires. To give up earlier, DELETE /v1/media/uploads/:id discards the parts (204). If direct uploads are not enabled on the server, these endpoints return 503.
GET /v1/media-jobs/:id
Poll a job every second or two until state is completed or failed. Only jobs started by your account are visible.
Response 200 OK
{
"id": "9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10",
"state": "completed",
"progress": 100,
"result": {
"file_url": "https://media.loreline.live/…/docks.mp4",
"storage_key": "…",
"original_url": "https://media.loreline.live/…",
"thumbnail": "https://media.loreline.live/…/docks.webp",
"playback_url": "https://media.loreline.live/…/docks.mp4",
"captured_at": "2023-11-02T22:41:07Z",
"location": { "latitude": 54.352, "longitude": 18.646 },
"detected_mime": "video/mp4",
"scene_id": "c7e1d2a4-58b3-4f6e-9a0d-1b2c3d4e5f60",
"duration_seconds": 94.2
},
"error": null,
"error_code": null
}
state | Meaning |
|---|---|
waiting | Queued |
delayed | Waiting to retry after a transient failure |
active | Processing now — progress is informational |
completed | Done — read result |
failed | Gave up — read error and error_code |
error_code | Meaning |
|---|---|
rejected | The file itself is invalid (corrupt, wrong type) — do not retry |
canceled | You cancelled it |
failed | Processing or infrastructure failure — safe to retry the upload |
result.scene_id is set for uploads made through POST /v1/scenes and null otherwise. location and captured_at are null when the file carries no such metadata.
DELETE /v1/media-jobs/:id
Cancel a job that has not finished. Returns 204 No Content; the job ends as failed with error_code: "canceled".
Reference
Errors
Every error has a meaningful HTTP status and the same JSON envelope. Branch on error.code; show or log error.message.
{
"error": {
"code": "validation_error",
"message": "Invalid request",
"details": {
"formErrors": ["Unrecognized key: \"titel\""],
"fieldErrors": { "title": ["Too small: expected string to have >=1 characters"] }
}
}
}
details is only present on some errors. For request validation failures it lists problems per field (fieldErrors) and for the body as a whole (formErrors, e.g. an unrecognised key).
Codes
| Status | error.code | When | Retry? |
|---|---|---|---|
401 | unauthorized | Missing, malformed, unknown or revoked API key | No — fix the key |
403 | forbidden | The key lacks the scope, or the operation is not permitted | No |
404 | not_found | The resource does not exist or is not visible to you | No |
408 | request_timeout | An upload stalled or took too long | Yes |
409 | conflict | The change conflicts with existing data | After re-reading |
411 | length_required | A chunked body was sent to an endpoint that needs Content-Length | No — send a length |
413 | payload_too_large | Body over 2 MB, or a file over its size limit | No |
422 | validation_error | Invalid body, query, id or file | No — fix the request |
429 | rate_limited | Rate limit or upload quota hit | Yes — see Rate limits |
500 | internal_error | Unexpected failure on our side | Yes, with backoff |
503 | service_unavailable | A dependency (database, storage, media processing) is temporarily unavailable | Yes, with backoff |
Retrying safely
GET,PATCHandDELETEare safe to retry.POSTcreates something new each time. If a create times out, list recent items (scope=mine) before retrying so you do not end up with duplicates.- Back off exponentially on
429,500and503— start around 1 second, double each attempt, add jitter, and give up after a handful of tries.
Common messages
| Message | Fix |
|---|---|
Missing or malformed Authorization header | Send Authorization: Bearer lk_live_… |
API key lacks the write scope | Ask for a key with write — see Scopes |
Content not found | Wrong id, or someone else's private Loreline/Segment |
No editable fields supplied | PATCH needs at least one field |
multipart/form-data with a "file" part is required | Upload endpoints need a multipart body with a file part |
Upload form fields must precede the file | Put text fields before the file part |
Invalid or missing file extension | Name the file with an accepted extension |
Too many uploads are in progress for this account | Wait for a running upload to finish |
Reference
Rate limits
Limits are counted per API key in fixed one-minute windows. Separate keys have separate budgets — see One key per environment.
| Bucket | Applies to | Limit |
|---|---|---|
| Read | Every GET on /v1/me, /v1/lorelines, /v1/segments, /v1/scenes | 120 / minute |
| Write | Every POST, PATCH, DELETE on those paths, including multipart POST /v1/scenes | 30 / minute |
| Job polling | GET /v1/media-jobs/:id | 600 / minute |
| Upload | POST /v1/uploads, POST /v1/media/uploads | 20 / minute |
| Part renewal | POST /v1/media/uploads/:id/parts | 120 / minute |
| Complete / abort / cancel | …/complete, DELETE /v1/media/uploads/:id, DELETE /v1/media-jobs/:id | 60 / minute each |
On top of the request limits, each account may stream at most 2 uploads at the same time.
Headers
Rate-limited responses — successful or not — carry the state of the bucket they used:
| Header | Meaning |
|---|---|
x-ratelimit-limit | Requests allowed in the window |
x-ratelimit-remaining | Requests left in the current window |
x-ratelimit-reset | When the window resets, as a Unix timestamp in milliseconds |
When you hit the limit
You get 429 with error.code: "rate_limited". Wait until x-ratelimit-reset, then continue.
async function call(url: string, init: RequestInit = {}): Promise<Response> {
for (;;) {
const res = await fetch(url, init)
if (res.status !== 429) return res
const reset = Number(res.headers.get('x-ratelimit-reset'))
await new Promise((r) => setTimeout(r, Math.max(1000, reset - Date.now())))
}
}
For bulk imports, pace writes to stay under 30 per minute per key rather than bursting and backing off. Need more throughput for a large migration? Email [email protected].