Loreline for developers

Developer API.

Read and write Lorelines, Segments and Scenes from your own server.

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 curl calls 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

ResourceWhat it isEndpoints
LorelineA curated documentary: an ordered selection of scenes/v1/lorelines
SegmentA browsable library of scenes — the place scenes live/v1/segments
SceneOne photo, video clip, audio recording, text card or embed/v1/scenes
Media jobBackground 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_case fields, 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.

Terminal
export LORELINE_API_KEY="lk_live_…"
export API="https://api.loreline.live"

1. Check the key

Terminal
curl "$API/v1/me" -H "Authorization: Bearer $LORELINE_API_KEY"
JSON
{
  "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.

Terminal
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:

Terminal
export SEGMENT_ID="…"

3. Create a Loreline

A Loreline is the documentary people watch.

Terminal
curl -X POST "$API/v1/lorelines" \
  -H "Authorization: Bearer $LORELINE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "The Harbour", "category": "History" }'
Terminal
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.

Terminal
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.

Terminal
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]
JSON
{
  "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.

Terminal
curl "$API/v1/media-jobs/9d7b6c1e-3a52-4c0f-8a1e-6e0c2b8f4d10" \
  -H "Authorization: Bearer $LORELINE_API_KEY"

Read it back

Terminal
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, or read + 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

ScopeGrants
readEvery GET endpoint: /v1/me, listing and fetching Lorelines, Segments and Scenes, analytics, media job status
writeEvery 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.

HTTP
GET /v1/me HTTP/1.1
Host: api.loreline.live
Authorization: Bearer lk_live_Q4m9ZcT2pX7rVb1LkS8nWd3HfY6uJe0A
tsx
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, never 403, so the API does not reveal whether a private item exists.

Failures

Statuserror.codeMessageMeaning
401unauthorizedMissing or malformed Authorization headerNo header, or it does not start with Bearer
401unauthorizedInvalid API keyUnknown key, revoked key, or the account/partner access is suspended
403forbiddenAPI key lacks the write scopeThe key does not have the scope this endpoint needs (see Scopes)
429rate_limitedRate limit exceededToo many requests for this key — see Rate limits
503service_unavailableDeveloper API key authentication is unavailableTemporary 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.

typeWhat it holdsNeeds file_url
photoAn imageyes
videoA video file, or a YouTube / TikTok / Instagram / X link (stored as an embed)yes
audioAn audio recordingyes
textA text card — title and description onlyno
placeholderA slot for media that is not available yetno

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
readyours (any visibility), or public and approved by moderation
create, update, deleteyours 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/json and 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" returns 422 validation_error rather 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 uploads 202, deletes 204 with 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_data is a deprecated alias that mirrors content_data. Read content_data.

Pagination

List endpoints take limit and offset query parameters and wrap results in the same envelope.

ParameterDefaultRange
limit501–100
offset00–1000000
JSON
{
  "data": [ { "id": "…" } ],
  "pagination": { "limit": 50, "offset": 0, "total": 132 }
}

total counts every matching item, so you are done once offset + data.length >= total.

tsx
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:

scopeReturns
mineContent owned by the key's account, public or private
publicPublic, approved content from everyone
allBoth 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.

MethodPathScope
GET/v1/meread

GET /v1/me

Response 200 OK

JSON
{
  "user_id": "5b0c6a3e-1f0e-4c1b-9d1a-2f4f5d1f7c11",
  "profile": {
    "display_name": "Harbour Archive",
    "username": "harbour"
  },
  "scopes": ["read", "write"]
}
FieldTypeDescription
user_iduuidThe account the key acts as — the user_id on everything you create
profileobject | nullPublic profile, or null if the account has not set one up
profile.display_namestring | nullName shown on the account's content
profile.usernamestring | nullHandle used in profile URLs
scopesstring[]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.

MethodPathScopeDescription
GET/v1/lorelinesreadList Lorelines
POST/v1/lorelineswriteCreate a Loreline
GET/v1/lorelines/:idreadGet a Loreline with all its scenes
PATCH/v1/lorelines/:idwriteUpdate a Loreline
DELETE/v1/lorelines/:idwriteDelete a Loreline
GET/v1/lorelines/:id/scenesreadPage through a Loreline's scenes
GET/v1/lorelines/:id/analyticsreadView and save counts

The Loreline object

JSON
{
  "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"
}
FieldTypeDescription
iduuidLoreline id
user_iduuidOwner
titlestringTitle, 1–300 characters
descriptionstring | nullUp to 5,000 characters
slugstring | nullPublic URL slug (loreline.live/loreline/<slug>)
categorystring | nullTopic, up to 100 characters
tagsstring[]Tags
banner_urlstring | nullBanner image
is_publicbooleanWhether the owner has published it
moderation_statusstring | nullpending, approved, rejected, or null
allow_reusebooleanOthers may reuse its scenes
allow_forkbooleanOthers may fork a copy
forked_fromuuid | nullThe Loreline this one was forked from
fork_countintegerTimes it has been forked
content_dataobjectPlayback, presentation and provenance metadata
chapters, subtitles, audio_tracksarrayEdited in Studio
revisionintegerEdit revision counter
created_at, updated_attimestamp

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.

Terminal
curl "https://api.loreline.live/v1/lorelines?scope=mine&limit=20" \
  -H "Authorization: Bearer $LORELINE_API_KEY"

Response 200 OK

JSON
{
  "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

FieldTypeRequiredDescription
titlestringyes1–300 characters after trimming
descriptionstringnoUp to 5,000 characters
categorystringnoUp to 100 characters
allow_reusebooleannoDefault false
allow_forkbooleannoDefault false
JSON
{
  "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

JSON
{
  "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.

JSON
{ "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.

JSON
{
  "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).

JSON
{ "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.

MethodPathScopeDescription
GET/v1/segmentsreadList Segments
POST/v1/segmentswriteCreate a Segment
GET/v1/segments/:idreadGet a Segment
PATCH/v1/segments/:idwriteUpdate a Segment
DELETE/v1/segments/:idwriteDelete a Segment and its scenes
GET/v1/segments/:id/scenesreadPage through a Segment's scenes
GET/v1/segments/:id/analyticsreadView and save counts

The Segment object

JSON
{
  "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"
}
FieldTypeDescription
iduuidSegment id
user_iduuidOwner
titlestringTitle, 1–300 characters
descriptionstring | nullUp to 5,000 characters
slugstring | nullPublic URL slug (loreline.live/segment/<slug>)
categorystring | nullTopic, up to 100 characters
color, emojistring | nullPresentation, set in Studio
is_publicbooleanWhether the owner has published it
moderation_statusstring | nullpending, approved, rejected, or null
allow_reuseboolean | nullOthers may reuse its scenes
content_dataobjectPresentation and provenance metadata
created_at, updated_attimestamp

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

FieldTypeRequiredDescription
titlestringyes1–300 characters after trimming
descriptionstringnoUp to 5,000 characters
categorystringnoUp to 100 characters
allow_reusebooleannoDefault 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.

JSON
{
  "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).

JSON
{ "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).

MethodPathScopeDescription
GET/v1/scenesreadList your scenes
POST/v1/sceneswriteCreate a scene (JSON) or upload one (multipart)
GET/v1/scenes/:idreadGet a scene
PATCH/v1/scenes/:idwriteUpdate or move a scene
DELETE/v1/scenes/:idwriteDelete a scene

The Scene object

JSON
{
  "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"
}
FieldTypeDescription
iduuidScene id
segment_iduuid | nullThe Segment it lives in
user_iduuidOwner
typestringvideo, photo, audio, text or placeholder — see Core concepts
titlestring1–1,000 characters
descriptionstring | nullUp to 5,000 characters
file_urlstring | nullThe media: an uploaded file's processed URL or the URL you supplied
thumbnailstring | nullPreview image
playback_urlstring | nullStreaming URL for processed video/audio
durationstring | nulle.g. "12.4 seconds" or "00:01:30"
captured_atstring | nullReal-world capture date — drives chronological order
location_dataobject | nullWhere it was captured
metadataobjectFree-form JSON; embeds carry is_embed, embed_platform, embed_video_id, embed_url
save_count, reuse_countinteger | nullEngagement counters
source_media_item_iduuid | nullOriginal scene when this one is a reuse
created_attimestamp

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.

JSON
{
  "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

FieldTypeRequiredDescription
typestringyesvideo, photo, audio, text, placeholder, link or embed
titlestringyes1–1,000 characters after trimming
descriptionstringnoUp to 5,000 characters
file_urlurlfor video, photo, audio, embedhttp(s) URL of the media; the target URL for link
thumbnailurlnoPreview image
playback_urlurlnoStreaming URL
captured_atstringnoReal-world date, e.g. 2019-06-01T18:30:00Z
durationstringno"12.4s", "12.4 seconds", "1:30" or "0:01:30"
segment_iduuid | nullnoPut it in one of your Segments
loreline_iduuidnoAlso append it to one of your Lorelines
metadataobjectnoFree-form JSON
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

MessageCause
A media URL is requiredvideo, photo, audio or embed without file_url
Media must have a permanent HTTP URLfile_url is not http(s)
Invalid captured datecaptured_at cannot be parsed
Invalid scene durationduration is not in one of the formats above
Target segment not found / Target loreline not foundThe 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.
  • type and loreline_id cannot be changed — sending them returns 422.
  • Loreline-managed provenance keys in metadata (reused_from, source_owner_id, source_media_item_id, reused_at, original_creator_id) are ignored.
JSON
{ "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.

MethodPathScopeDescription
POST/v1/sceneswriteUpload a file and create its scene in one request (multipart)
POST/v1/uploadswriteUpload a file without creating a scene (multipart)
POST/v1/media/uploadswriteStart a direct multipart upload for large files
POST/v1/media/uploads/:id/partswriteRe-sign part URLs
POST/v1/media/uploads/:id/completewriteFinish a direct upload
DELETE/v1/media/uploads/:idwriteAbort a direct upload
GET/v1/media-jobs/:idreadPoll a media job
DELETE/v1/media-jobs/:idwriteCancel a media job

Accepted files

KindExtensionsMax size
Photojpg jpeg png gif webp avif heic heif dng50 MB
Videomp4 mov webm2 GB
Audiomp3 wav ogg opus aac m4a2 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.

  • type must be photo, video or audio, and title is required. The other scene fields are optional; send metadata as a JSON string.
  • Fields — including segment_id / loreline_id ownership — 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.
Terminal
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]
tsx
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

JSON
{
  "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

Terminal
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 }'
JSON
{
  "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.

Terminal
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:

Terminal
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

Terminal
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

JSON
{
  "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
}
stateMeaning
waitingQueued
delayedWaiting to retry after a transient failure
activeProcessing now — progress is informational
completedDone — read result
failedGave up — read error and error_code
error_codeMeaning
rejectedThe file itself is invalid (corrupt, wrong type) — do not retry
canceledYou cancelled it
failedProcessing 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.

JSON
{
  "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

Statuserror.codeWhenRetry?
401unauthorizedMissing, malformed, unknown or revoked API keyNo — fix the key
403forbiddenThe key lacks the scope, or the operation is not permittedNo
404not_foundThe resource does not exist or is not visible to youNo
408request_timeoutAn upload stalled or took too longYes
409conflictThe change conflicts with existing dataAfter re-reading
411length_requiredA chunked body was sent to an endpoint that needs Content-LengthNo — send a length
413payload_too_largeBody over 2 MB, or a file over its size limitNo
422validation_errorInvalid body, query, id or fileNo — fix the request
429rate_limitedRate limit or upload quota hitYes — see Rate limits
500internal_errorUnexpected failure on our sideYes, with backoff
503service_unavailableA dependency (database, storage, media processing) is temporarily unavailableYes, with backoff

Retrying safely

  • GET, PATCH and DELETE are safe to retry.
  • POST creates 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, 500 and 503 — start around 1 second, double each attempt, add jitter, and give up after a handful of tries.

Common messages

MessageFix
Missing or malformed Authorization headerSend Authorization: Bearer lk_live_…
API key lacks the write scopeAsk for a key with write — see Scopes
Content not foundWrong id, or someone else's private Loreline/Segment
No editable fields suppliedPATCH needs at least one field
multipart/form-data with a "file" part is requiredUpload endpoints need a multipart body with a file part
Upload form fields must precede the filePut text fields before the file part
Invalid or missing file extensionName the file with an accepted extension
Too many uploads are in progress for this accountWait 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.

BucketApplies toLimit
ReadEvery GET on /v1/me, /v1/lorelines, /v1/segments, /v1/scenes120 / minute
WriteEvery POST, PATCH, DELETE on those paths, including multipart POST /v1/scenes30 / minute
Job pollingGET /v1/media-jobs/:id600 / minute
UploadPOST /v1/uploads, POST /v1/media/uploads20 / minute
Part renewalPOST /v1/media/uploads/:id/parts120 / minute
Complete / abort / cancel…/complete, DELETE /v1/media/uploads/:id, DELETE /v1/media-jobs/:id60 / 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:

HeaderMeaning
x-ratelimit-limitRequests allowed in the window
x-ratelimit-remainingRequests left in the current window
x-ratelimit-resetWhen 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.

tsx
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].