Developers

API reference

Manage your Stillhaven library from your own tools. Everything here is scoped to one account, and no scope grants billing, team management, branding, key management, or permanent deletion. Those stay in the console.

Getting started

Every request goes to https://console.stillhaven.io/api/v1 and carries an API key. Create one in the console under Settings, then API. JSON in, JSON out.

curl https://console.stillhaven.io/api/v1/me \
  -H "Authorization: Bearer shk_..."

Version 1.0.0. The full spec is published as openapi.yaml and openapi.json.

Authentication

An API key from Settings, API in the console, sent as Authorization: Bearer shk_.... Keys are shown once at creation and stored only as a hash, so a lost key is replaced rather than recovered. Keys are available on every paid plan.

A key acts as your account, so treat it like a password. If one leaks, revoke it in the console and create another. Nothing you can do with a key is permanent: there is no delete in this API, and a trashed video comes back for 30 days.

Scopes

Each key carries the scopes you tick when you create it, and each endpoint below says which one it needs. Keys made before scopes existed carry all of them.

No scope grants billing, team management, branding, key management, or permanent deletion. Those stay in the console.

Pagination

Lists take ?limit= (up to 200, 50 by default) and an opaque ?cursor=. Every list response carries next_cursor, which is null on the last page. Read a whole library by following it:

GET /videos?limit=200
GET /videos?limit=200&cursor=eyJzIjoi...

Cursors point at an item, not at a position, so a video uploaded while you are paging cannot make you skip or repeat one. Do not build a cursor by hand: send back what you were given.

Errors

Every failure answers with the same shape: error is a stable machine code, message is a sentence for a person.

{
  "error": "validation_failed",
  "message": "Some values were not usable. See fields for which ones.",
  "fields": { "status": "Use one of uploading, scanning, processing, ready, held, blocked, failed." }
}
CodeStatusWhat it means
unauthorized401The key is missing, wrong, or revoked.
forbidden_scope403The key does not carry the scope this route needs.
plan_required403The account is not on a plan that includes this. plan names the lowest one that is.
not_found404No such thing on this account.
quota_exceeded402Out of room on the plan. Byte counts say by how much.
validation_failed422Some values were not usable. fields names them.
rate_limited429Over the limit. Retry-After says how long to wait.
not_implemented501The route exists but is not built yet.

Rate limits

600 requests a minute per account, across every key and every route. Starting an upload is limited separately, to 50 an hour. A refused request answers 429 with Retry-After.

Uploading a video

Uploading is three calls, because your file goes to storage in parts and those parts never pass through this API.

  1. Start. POST /videos with the filename and size. You get back a video id, how many parts to send, and how big each one is. The last part is whatever is left over.
  2. Send the parts. POST /videos/{id}/upload-parts with the part numbers you want URLs for, up to 1000 at a time. PUT each part's bytes to its URL and keep the ETag header from the response. URLs expire, so ask for more as you go rather than all at once.
  3. Finish. POST /videos/{id}/upload-complete with every part number and its ETag. What was actually uploaded is measured and checked against your plan before anything is saved, so an upload bigger than you declared is refused here and nothing is kept. Processing starts on success.

Poll GET /videos/{id} until status is ready. Until then there is nothing to play. duration_seconds is null until the transcode reports a length, and stays null on the occasional video where it never did, so read it as unknown rather than zero.

Account

Who this credential acts for, and what the plan allows.

GET /me

Read the account this credential acts for

Cheap, and the only route that needs no scope: a client calls it at connect time to find out what it may do, and requiring a scope to ask what your scopes are would be a loop. member is null for an API key, which belongs to an account rather than to a person. Needs no scope: any valid credential may call it.

Request

curl -X GET "https://console.stillhaven.io/api/v1/me" \
  -H "Authorization: Bearer shk_..."

Response

The account this credential acts for. member is null under an API key.

account_id
string
account_name
string
plan
string
plan_label
string
member
object
scopes
array

GET /usage

Read storage and bandwidth against the plan

The same numbers the Settings page shows. Bandwidth is metered per calendar month in UTC; period_start is inclusive and period_end exclusive. overage states what going over costs, in cents, so a client warning about a quota can say what it means. Scope: usage:read.

Request

curl -X GET "https://console.stillhaven.io/api/v1/usage" \
  -H "Authorization: Bearer shk_..."

Response

Storage and bandwidth against the plan. Byte counts throughout, cents for rates.

plan
string
plan_label
string
storage
object
bandwidth
object
overage
object

Videos

Reading, uploading, editing, and trashing videos.

GET /videos

List videos, newest first

Trashed videos are not included. Filters compose, and a filter that cannot be used fails the request rather than being ignored: a silently dropped filter returns a longer list that looks like a correct answer. Scope: videos:read.

Query

limit
Up to 200. Defaults to 50.
cursor
From next_cursor on the previous page.
folder_id
A folder id. Send it empty to list unfiled videos.
channel_id
Only videos in this channel.
status
One of uploading, scanning, processing, ready, held, blocked, failed.
q
Case-insensitive match on the title.
updated_since
ISO 8601. Videos changed at or after this moment.

Request

curl -X GET "https://console.stillhaven.io/api/v1/videos" \
  -H "Authorization: Bearer shk_..."

Response

A page of results. next_cursor is null on the last page; send it as ?cursor= to get the next one.

data
array
next_cursor
string

POST /videos

Start an upload

Step one of three. Creates the video and an S3 multipart session, and returns how many parts to send. Your bytes go straight to S3 and never pass through this API. Limited to 50 upload starts an hour on top of the usual rate limit. Scope: videos:write.

Body

filename string
Required. The name of the file, used for its extension and as a default title.
size_bytes integer
Required. The size of the file, in bytes.
title string
Defaults to the filename without its extension.
folder_id string
An existing folder. Omit to leave the video unfiled.
orientation string
portrait or landscape. Defaults to landscape.
content_type string
The file MIME type.
transcript boolean
true to have a transcript generated.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "...",
    "size_bytes": 0,
    "title": "...",
    "folder_id": "...",
    "orientation": "...",
    "content_type": "...",
    "transcript": true
}'

Response

Split the file into parts_expected pieces of part_size_bytes, the last one shorter. upload_id is informational: later calls take the video id.

video_id
string
upload_id
string
part_size_bytes
integer
parts_expected
integer
expires_at
string

GET /videos/{id}

Read one video

A trashed video answers 404 here. Restore it first. poster_url carries a signature and stops working after about six hours, so fetch it again rather than storing it. Scope: videos:read.

Path

id
The id of the thing being addressed.

Request

curl -X GET "https://console.stillhaven.io/api/v1/videos/{id}" \
  -H "Authorization: Bearer shk_..."

Response

A video. duration_seconds is null when no duration was ever recorded, which is possible on a video that is otherwise ready: the pipeline writes it only when the transcode reports a positive length. Treat null as unknown, not as zero. share_url is empty when the share page is off. poster_url is signed and expires after about six hours. edits is null when the video plays as uploaded; otherwise trim_start, trim_end, fade_in and fade_out in seconds, measured from the untouched original, and duration_seconds is the edited length.

id
string
title
string
description_html
string
description_visible
boolean
folder_id
string
source
string
status
string
created_at
string
updated_at
string
duration_seconds
integer
edits
object
size_bytes
integer
orientation
string
has_captions
boolean
poster_url
string
share_url
string
embed_code
string
share_page
object
allowed_domains
object
player
object

PATCH /videos/{id}

Change a video

Only the fields you send are changed. A field you omit is left exactly as it was. description_html is sanitized, so tags outside a small safe set are removed. Scope: videos:write.

Path

id
The id of the thing being addressed.

Body

title string
Up to 200 characters.
description_html string
Markup shown beside the video on its share page.
description_visible boolean
Whether that description is shown.
folder_id string
An existing folder, or empty to unfile the video.

Request

curl -X PATCH "https://console.stillhaven.io/api/v1/videos/{id}" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "...",
    "description_html": "...",
    "description_visible": true,
    "folder_id": "..."
}'

Response

A video. duration_seconds is null when no duration was ever recorded, which is possible on a video that is otherwise ready: the pipeline writes it only when the transcode reports a positive length. Treat null as unknown, not as zero. share_url is empty when the share page is off. poster_url is signed and expires after about six hours. edits is null when the video plays as uploaded; otherwise trim_start, trim_end, fade_in and fade_out in seconds, measured from the untouched original, and duration_seconds is the edited length.

id
string
title
string
description_html
string
description_visible
boolean
folder_id
string
source
string
status
string
created_at
string
updated_at
string
duration_seconds
integer
edits
object
size_bytes
integer
orientation
string
has_captions
boolean
poster_url
string
share_url
string
embed_code
string
share_page
object
allowed_domains
object
player
object

GET /videos/{id}/analytics

Read play analytics for one video

Two different counts, kept apart rather than reconciled: a windowed count from the per-day source rows, and the lifetime counters carried on the video. Source rows are kept 90 days, so a 90d window is the whole of what is retained. There is no per-video bandwidth figure and no viewer count, because neither is measured anywhere in the product. Scope: usage:read.

Path

id
The id of the thing being addressed.

Query

period
One of 7d, 30d, or 90d. Defaults to 30d.

Request

curl -X GET "https://console.stillhaven.io/api/v1/videos/{id}/analytics" \
  -H "Authorization: Bearer shk_..."

Response

plays and loads cover the requested window; all_time carries the lifetime counters, including how far viewers got.

period
string
plays
integer
loads
integer
all_time
object

POST /videos/{id}/upload-parts

Get presigned URLs for a batch of parts

Step two. PUT each part to its URL with that part number's bytes and keep the ETag the response returns. Ask for up to 1000 at a time. URLs expire, so call this again for the rest of a long upload rather than requesting everything at the start. Scope: videos:write.

Path

id
The id of the thing being addressed.

Body

part_numbers array
Required. Part numbers, from 1 to parts_expected.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos/{id}/upload-parts" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "part_numbers": []
}'

Response

One entry per requested part, as {part_number, url}. PUT the part's bytes to its URL and keep the ETag header from the response.

urls
array
expires_at
string

POST /videos/{id}/upload-complete

Finish an upload

Step three. The parts S3 is holding are measured and checked against the plan before anything is finalised, so an upload larger than declared is refused here and nothing is saved. Processing starts on success. Scope: videos:write.

Path

id
The id of the thing being addressed.

Body

parts array
Required. Every part as {part_number, etag}.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos/{id}/upload-complete" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "parts": []
}'

Response

Processing has started. Poll the video until its status is ready.

video_id
string
status
string

POST /videos/{id}/import

Import a video from a URL

Not available yet. The route exists so its shape is settled, and it answers 501 in the meantime. Scope: videos:write.

Path

id
The id of the thing being addressed.

Body

source_url string
The video to fetch.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos/{id}/import" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "source_url": "..."
}'

Response

Not built yet. Import from URL is not available yet.

POST /videos/{id}/trash

Move a video to trash

Reversible for 30 days. There is no permanent delete in this API at all, so this is the most a key can do, and the next call undoes it. Scope: videos:write.

Path

id
The id of the thing being addressed.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos/{id}/trash" \
  -H "Authorization: Bearer shk_..."

Response

The video is in trash and can be restored for this many days.

id
string
trashed
boolean
restorable_days
integer

POST /videos/{id}/restore

Restore a video from trash

The one video route that addresses a trashed video, since that is the only kind it can act on. Scope: videos:write.

Path

id
The id of the thing being addressed.

Request

curl -X POST "https://console.stillhaven.io/api/v1/videos/{id}/restore" \
  -H "Authorization: Bearer shk_..."

Response

A video. duration_seconds is null when no duration was ever recorded, which is possible on a video that is otherwise ready: the pipeline writes it only when the transcode reports a positive length. Treat null as unknown, not as zero. share_url is empty when the share page is off. poster_url is signed and expires after about six hours. edits is null when the video plays as uploaded; otherwise trim_start, trim_end, fade_in and fade_out in seconds, measured from the untouched original, and duration_seconds is the edited length.

id
string
title
string
description_html
string
description_visible
boolean
folder_id
string
source
string
status
string
created_at
string
updated_at
string
duration_seconds
integer
edits
object
size_bytes
integer
orientation
string
has_captions
boolean
poster_url
string
share_url
string
embed_code
string
share_page
object
allowed_domains
object
player
object

PUT /videos/{id}/allowed-domains

Set where a video may be embedded

inherit follows the account default, locked allows only the domains you list, open allows every site. Send bare hostnames: no scheme, no path. Scope: settings:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Body

mode string
Required. inherit, locked, or open.
domains array
Up to 20 hostnames. Required when mode is locked.
unknown_origin string
lenient or strict, for a request whose origin cannot be read.

Request

curl -X PUT "https://console.stillhaven.io/api/v1/videos/{id}/allowed-domains" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "...",
    "domains": [],
    "unknown_origin": "..."
}'

Response

A video. duration_seconds is null when no duration was ever recorded, which is possible on a video that is otherwise ready: the pipeline writes it only when the transcode reports a positive length. Treat null as unknown, not as zero. share_url is empty when the share page is off. poster_url is signed and expires after about six hours. edits is null when the video plays as uploaded; otherwise trim_start, trim_end, fade_in and fade_out in seconds, measured from the untouched original, and duration_seconds is the edited length.

id
string
title
string
description_html
string
description_visible
boolean
folder_id
string
source
string
status
string
created_at
string
updated_at
string
duration_seconds
integer
edits
object
size_bytes
integer
orientation
string
has_captions
boolean
poster_url
string
share_url
string
embed_code
string
share_page
object
allowed_domains
object
player
object

PUT /videos/{id}/share-page

Set the share page, its password, and downloads

Only the fields you send are changed. password is write-only and never comes back; the read side reports only whether one is set. Sending null clears it, which also signs out anyone currently holding an unlock. Scope: settings:write.

Path

id
The id of the thing being addressed.

Body

enabled boolean
Whether the video has a public share page.
password string
4 to 64 characters, or null to remove the password.
downloads_enabled boolean
Whether the share page offers a download.

Request

curl -X PUT "https://console.stillhaven.io/api/v1/videos/{id}/share-page" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "password": "...",
    "downloads_enabled": true
}'

Response

A video. duration_seconds is null when no duration was ever recorded, which is possible on a video that is otherwise ready: the pipeline writes it only when the transcode reports a positive length. Treat null as unknown, not as zero. share_url is empty when the share page is off. poster_url is signed and expires after about six hours. edits is null when the video plays as uploaded; otherwise trim_start, trim_end, fade_in and fade_out in seconds, measured from the untouched original, and duration_seconds is the edited length.

id
string
title
string
description_html
string
description_visible
boolean
folder_id
string
source
string
status
string
created_at
string
updated_at
string
duration_seconds
integer
edits
object
size_bytes
integer
orientation
string
has_captions
boolean
poster_url
string
share_url
string
embed_code
string
share_page
object
allowed_domains
object
player
object

Folders

The folder tree, up to three levels deep.

GET /folders

List folders, by name

Every folder and subfolder on the account. A folder with an empty parent_id is at the top level. Scope: videos:read.

Query

limit
Up to 200. Defaults to 50.
cursor
From next_cursor on the previous page.

Request

curl -X GET "https://console.stillhaven.io/api/v1/folders" \
  -H "Authorization: Bearer shk_..."

Response

A page of results. next_cursor is null on the last page; send it as ?cursor= to get the next one.

data
array
next_cursor
string

POST /folders

Create a folder

Folders go three levels deep at most. Scope: videos:write.

Body

name string
Required. Up to 80 characters.
parent_id string
An existing folder. Omit for a top-level folder.

Request

curl -X POST "https://console.stillhaven.io/api/v1/folders" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "...",
    "parent_id": "..."
}'

Response

A folder. An empty parent_id means top level.

id
string
name
string
parent_id
string
created_at
string

PATCH /folders/{id}

Rename a folder

There is no delete. Removing a folder stays in the console, because it decides what happens to what is inside it. Scope: videos:write.

Path

id
The id of the thing being addressed.

Body

name string
Required. Up to 80 characters.

Request

curl -X PATCH "https://console.stillhaven.io/api/v1/folders/{id}" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "..."
}'

Response

A folder. An empty parent_id means top level.

id
string
name
string
parent_id
string
created_at
string

Channels

Playlists with their own page, and who may watch them.

GET /channels

List channels, newest first

Summaries: video_count without the playlist. Read one channel for its order. Scope: channels:read. Plan: any paid plan.

Query

limit
Up to 200. Defaults to 50.
cursor
From next_cursor on the previous page.

Request

curl -X GET "https://console.stillhaven.io/api/v1/channels" \
  -H "Authorization: Bearer shk_..."

Response

A page of results. next_cursor is null on the last page; send it as ?cursor= to get the next one.

data
array
next_cursor
string

POST /channels

Create a channel

A new channel has no videos and no public name. Set slug to give it a page. Scope: channels:write. Plan: any paid plan.

Body

title string
Up to 120 characters.
description_html string
Markup shown beside the video on the channel page.
visibility string
public or private. Defaults to public.

Request

curl -X POST "https://console.stillhaven.io/api/v1/channels" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "...",
    "description_html": "...",
    "visibility": "..."
}'

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

GET /channels/{id}

Read one channel, with its video order

share_url is empty until the channel has a slug, because a channel without one has no public page to link to. Scope: channels:read. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Request

curl -X GET "https://console.stillhaven.io/api/v1/channels/{id}" \
  -H "Authorization: Bearer shk_..."

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

PATCH /channels/{id}

Change a channel

Only the fields you send are changed. A private channel admits only invited viewers; inviting them needs the Studio plan, though marking a channel private does not. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Body

title string
Up to 120 characters.
description_html string
Markup shown beside the video on the channel page.
visibility string
public or private.
slug string
The channel's public name, 3 to 32 lowercase letters, numbers, and hyphens. Unique across Stillhaven. Send null to take the page down.

Request

curl -X PATCH "https://console.stillhaven.io/api/v1/channels/{id}" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "...",
    "description_html": "...",
    "visibility": "...",
    "slug": "..."
}'

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

PUT /channels/{id}/videos

Replace a channel's playlist and its order

Every id is checked before any of them is stored, so a list with one bad entry changes nothing. Send an empty list to clear the channel. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Body

video_ids array
Required. Video ids, in the order they should play.

Request

curl -X PUT "https://console.stillhaven.io/api/v1/channels/{id}/videos" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "video_ids": []
}'

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

POST /channels/{id}/videos

Add one video to a channel

Appends without disturbing the existing order. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Body

video_id string
Required.

Request

curl -X POST "https://console.stillhaven.io/api/v1/channels/{id}/videos" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "video_id": "..."
}'

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

DELETE /channels/{id}/videos/{videoId}

Remove one video from a channel

Removing a video that is not in the channel succeeds: that is the state you asked for. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.
videoId
The video to remove.

Request

curl -X DELETE "https://console.stillhaven.io/api/v1/channels/{id}/videos/{videoId}" \
  -H "Authorization: Bearer shk_..."

Response

A channel. video_ids appears when one channel is read, not in a list. share_url is empty until the channel has a slug.

id
string
title
string
description_html
string
visibility
string
slug
string
share_url
string
video_count
integer
created_at
string
updated_at
string
video_ids
array

GET /channels/{id}/users

List a private channel's viewers and what each has watched

One entry per invited viewer, most recently active first, with the channel's videos in order and how far the viewer got in each: 0 not started, 75 watched, 100 finished. The same rows the console's Progress card shows. Public channels are not tracked, so they return their viewers with nothing watched. Scope: channels:read. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Request

curl -X GET "https://console.stillhaven.io/api/v1/channels/{id}/users" \
  -H "Authorization: Bearer shk_..."

Response

A page of results. next_cursor is null on the last page; send it as ?cursor= to get the next one.

data
array
next_cursor
string

POST /channels/{id}/users

Invite a viewer to a private channel

The endpoint the CRM integrations use. The viewer is emailed a sign-in link. Needs the Studio plan, which is what carries a viewer allowance. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.

Body

email string
Required. The viewer to invite.

Request

curl -X POST "https://console.stillhaven.io/api/v1/channels/{id}/users" \
  -H "Authorization: Bearer shk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "..."
}'

Response

ok
boolean

DELETE /channels/{id}/users/{email}

Remove a viewer from a private channel

The viewer loses access immediately. Scope: channels:write. Plan: any paid plan.

Path

id
The id of the thing being addressed.
email
The viewer's email address, URL encoded.

Request

curl -X DELETE "https://console.stillhaven.io/api/v1/channels/{id}/users/{email}" \
  -H "Authorization: Bearer shk_..."

Response

ok
boolean