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." }
}
| Code | Status | What it means |
|---|---|---|
unauthorized | 401 | The key is missing, wrong, or revoked. |
forbidden_scope | 403 | The key does not carry the scope this route needs. |
plan_required | 403 | The account is not on a plan that includes this. plan names the lowest one that is. |
not_found | 404 | No such thing on this account. |
quota_exceeded | 402 | Out of room on the plan. Byte counts say by how much. |
validation_failed | 422 | Some values were not usable. fields names them. |
rate_limited | 429 | Over the limit. Retry-After says how long to wait. |
not_implemented | 501 | The 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.
-
Start.
POST /videoswith 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. -
Send the parts.
POST /videos/{id}/upload-partswith the part numbers you want URLs for, up to 1000 at a time. PUT each part's bytes to its URL and keep theETagheader from the response. URLs expire, so ask for more as you go rather than all at once. -
Finish.
POST /videos/{id}/upload-completewith 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_cursoron 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
-
filenamestring - Required. The name of the file, used for its extension and as a default title.
-
size_bytesinteger - Required. The size of the file, in bytes.
-
titlestring - Defaults to the filename without its extension.
-
folder_idstring - An existing folder. Omit to leave the video unfiled.
-
orientationstring - portrait or landscape. Defaults to landscape.
-
content_typestring - The file MIME type.
-
transcriptboolean - 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
-
titlestring - Up to 200 characters.
-
description_htmlstring - Markup shown beside the video on its share page.
-
description_visibleboolean - Whether that description is shown.
-
folder_idstring - 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_numbersarray - 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
-
partsarray - 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_urlstring - 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
-
modestring - Required. inherit, locked, or open.
-
domainsarray - Up to 20 hostnames. Required when mode is locked.
-
unknown_originstring - 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
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_cursoron 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
-
namestring - Required. Up to 80 characters.
-
parent_idstring - 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
-
namestring - 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_cursoron 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
-
titlestring - Up to 120 characters.
-
description_htmlstring - Markup shown beside the video on the channel page.
-
visibilitystring - 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
-
titlestring - Up to 120 characters.
-
description_htmlstring - Markup shown beside the video on the channel page.
-
visibilitystring - public or private.
-
slugstring - 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_idsarray - 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_idstring - 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
-
emailstring - 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