Install. Quickstart. Reference.
Get the CLI on your machine, the SDK in your project, and the API in your head, in that order.
Two paths in.
The CLI for daily uploads, scripting, and ingest. The SDK when you're writing code against the API.
Command-line client.
curl -fsSL https://khaosstorage.com/install | sh
Drops khaos-storage and the khs alias on your PATH. Run khs update any time to upgrade in place. Requires Node 20+.
TypeScript client.
npm i https://khaosstorage.com/sdk/latest.tgz
Same package we'll publish to npm later. Works in Node and any modern bundler.
Three steps to first asset.
Sign in, upload, share. The CLI version is one terminal pane; the Node version is six lines of code.
Authorize once, in the browser.
khs login
Pick files interactively.
khs upload
Publish into a space.
khs upload showreel.mov \ --space "Highlights"
Pass an API key.
import { KhaosClient } from
'@khaosstorage/sdk';
const client = new KhaosClient({
baseUrl: 'https://api.khaosstorage.com',
apiKey: process.env.KHAOS_API_KEY,
});
Multipart, hashed, retried.
import { sourceFromPath }
from '@khaosstorage/sdk/node';
const src = await sourceFromPath(
'./clip.mov');
const asset = await client.upload(
src);
Filter and read back.
const recent = await
client.assets.list({
type: 'video',
limit: 20,
});
The shape of the API.
Every endpoint takes Authorization: Bearer <key>, returns
{ data, meta?, error? }, and is gated by a scope on the calling key.
Errors carry a code, message, and (when applicable) a
fields array.
Base URL
https://api.khaosstorage.com
Scopes
read · write · admin. Higher scopes inherit lower.
Errors
HTTP status, plus error.code like insufficient_scope, validation_error, not_found.
Assets
Upload, list, get, update, delete, and resolve signed download URLs. The upload helpers in the SDK and CLI choreograph this resource end to end.
POST /v1/assets write
Create an asset and receive an upload descriptor (single PUT for ≤100 MiB, multipart above). The SDK's client.upload(...) wraps this plus the part PUT and complete calls.
khs upload clip.mov
await client.upload(
await sourceFromPath('clip.mov')
);curl -X POST https://api.khaosstorage.com/v1/assets \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"filename":"clip.mov",
"type":"video",
"content_type":"video/quicktime",
"size_bytes":3621090133,
"multipart":true
}'GET /v1/assets read
List assets. Supports limit, cursor, status, type, search, and modified_after.
Node
const assets = await client.assets.list({
type: 'video',
status: 'active',
limit: 50,
});
GET /v1/assets/{asset_id} read
Fetch one asset by id. The response carries any populated thumb_url and preview_url as 1-hour signed GETs.
await client.assets.get('ast_…');
PATCH /v1/assets/{asset_id} write
Update tags, filename, or attributes. Returns the full asset.
await client.assets.patch('ast_…', { tags: ['raw','drone'] });
DELETE /v1/assets/{asset_id} write
Hard-delete the asset and its R2 objects (original, thumb, preview). Idempotent.
await client.assets.delete('ast_…');
GET /v1/assets/{asset_id}/url read
Mint a signed download URL for the original. Default 1-hour TTL via expires_in.
await client.assets.url('ast_…', 3600);
POST /v1/assets/{asset_id}/parts write
Mint a per-attempt presigned PUT URL for one part of a multipart upload. Refresh per attempt; URLs expire in 15 minutes.
await client.assets.presignPart('ast_…', uploadId, partNumber);
POST /v1/assets/{asset_id}/complete write
Finalize the upload. Pass upload_id + parts for multipart; empty body for single PUT. Triggers the processor fan-out.
await client.assets.completeUpload('ast_…', uploadId, parts);
POST /v1/assets/{asset_id}/abort write
Abort an in-flight multipart upload. Cleans up R2 state. Called automatically by the SDK when an upload is cancelled.
await client.assets.abortUpload('ast_…', uploadId);
POST /v1/assets/{asset_id}/reprocess write
Re-run the image / video processor. Useful after a processor bug fix or to regenerate thumbnails.
await client.assets.reprocess('ast_…');
POST /v1/assets/bulk-reprocess write
Paginated bulk reprocess. Each call processes up to limit active assets (default 50, max 100) and returns a next_cursor to continue. Loop on has_more until exhausted. Skips non-active assets server-side. Useful after a XIFty / processor upgrade lands and existing assets need fresh metadata. Same idempotent fan-out as the per-asset endpoint.
// Walk every batch:
let cursor;
for (;;) {
const batch = await client.assets.bulkReprocess({ cursor });
if (!batch.has_more) break;
cursor = batch.next_cursor;
}
// Or from the CLI:
khs reprocess --all
khs reprocess --all --type image --limit 100
GET /v1/assets/{asset_id}/spaces read
List the spaces an asset is currently published into.
await client.assets.spaces('ast_…');
Spaces
A space groups assets. private (default) is owner-only — useful for organizing without sharing. public resolves for anyone with the share URL. protected resolves for anyone with the URL plus a password. Assets are always private; publishing into a public/protected space is what surfaces them externally.
POST /v1/spaces admin
Create a space. visibility defaults to private (owner-only grouping). Pass public for an open share URL, or protected with a password for a password-gated URL.
await client.spaces.create({
name: 'Highlights',
visibility: 'public',
});
GET /v1/spaces read
List your spaces.
await client.spaces.list();
GET /v1/spaces/{space_id} read
Get one space, including asset_count.
await client.spaces.get('spc_…');
PATCH /v1/spaces/{space_id} admin
Update name, description, visibility, or password. Pass password: null to clear.
await client.spaces.update('spc_…', { visibility: 'public', password: null });
DELETE /v1/spaces/{space_id} admin
Delete the space. Cascades publications. Underlying assets are not affected.
await client.spaces.delete('spc_…');
POST /v1/spaces/{space_id}/publications write
Publish one or more assets into the space. Returns per-asset success / error.
await client.spaces.publish('spc_…', ['ast_…','ast_…']);
GET /v1/spaces/{space_id}/publications read
List the assets published into a space, decorated with signed thumb/preview URLs.
await client.spaces.listPublications('spc_…');
DELETE /v1/spaces/{space_id}/publications/{asset_id} write
Unpublish one asset from a space. The asset itself stays.
await client.spaces.unpublish('spc_…','ast_…');
API keys
Long-lived bearer tokens for scripts, ingest agents, and integrations. Each key carries an array of scopes (read / write / admin); higher scopes inherit lower. New keys default to ['write']. The raw key is returned once at creation.
GET /v1/api-keys read
List your keys. Hashes only; never the raw value.
await client.apiKeys.list();
POST /v1/api-keys admin
Mint a new key. The response includes the raw key exactly once. Persist it now.
await client.apiKeys.create({
name: 'ingest-bot',
scopes: ['write'],
});
DELETE /v1/api-keys/{key_id} admin
Revoke. Idempotent. Subsequent requests bearing the revoked key fail at the authorizer.
await client.apiKeys.revoke('key_…');
Storage connections
Bring your own R2 or S3-compatible storage to extend capacity. Credentials live encrypted at rest under a per-account KMS key; nothing is logged or replayable.
GET /v1/storage-connections read
List configured connections.
POST /v1/storage-connections admin
Add a connection. The server runs HEAD / LIST / PUT / DELETE probes against the bucket before persisting.
POST /v1/storage-connections/preview write
Run the same probes without persisting. Useful for the onboarding wizard.
POST /v1/storage-connections/list-buckets write
Discovery: enumerate buckets visible to the credentials. Used during connection setup.
POST /v1/storage-connections/create-bucket admin
Discovery: create a new bucket using the credentials. Region-aware.
Account
The account record for the calling owner. Covers identity (name, email), the first-run welcome stamp, the avatar two-step upload, and the public profile fields that drive /u/<handle> share pages.
GET /v1/account read
Read the current account: display_name, email, plan, status, plus optional onboarded_at, avatar_url (1-hour signed), handle, bio, links, is_public. The first authenticated read after sign-up auto-derives a unique handle from the email local part.
await client.account.get();
PATCH /v1/account admin
Update display_name or email. Profile-shaped fields go through the dedicated profile route below.
await client.account.patch({ display_name: 'K' });
POST /v1/account/onboard admin
Stamp onboarded_at idempotently and (optionally) set display_name in one atomic write. Body is optional — empty body is "skip the welcome prompt, just mark me onboarded." Console calls this once after email verification.
await client.account.onboard({ display_name: 'Khaos Studio' });
POST /v1/account/avatar admin
Step 1 of avatar upload. Returns a presigned PUT URL plus a storage_key. Resize the image to 256×256 before sending the bytes — JPEG quality 85 is what the console emits. Allow-list: image/jpeg, image/png, image/webp; max 10 MB raw input.
const desc = await client.account.startAvatarUpload({
content_type: 'image/jpeg',
size_bytes: bytes.length,
});
await fetch(desc.url, { method: 'PUT', headers: desc.headers, body: bytes });
POST /v1/account/avatar/complete admin
Step 2. Confirms the bytes landed (server HEADs the storage_key) and atomically swaps avatar_storage_key on the account. The previous avatar object is best-effort deleted from R2. Returns the account with a fresh signed avatar_url.
await client.account.completeAvatarUpload(desc.storage_key);
DELETE /v1/account/avatar admin
Clear the custom avatar; UI falls back to initials. Idempotent.
await client.account.deleteAvatar();
PATCH /v1/account/profile admin
Update the public profile: handle, bio (≤280 chars), links (max 6, http(s) only), is_public. Renaming the handle atomically releases the old slug; surface a warning in your UI before submit. Conflicts return 409 handle_taken.
await client.account.updateProfile({
handle: 'kimi',
bio: 'photographer / filmmaker',
links: [{ label: 'site', url: 'https://example.com' }],
is_public: true,
});
Usage
Plan utilization. Bytes and counts, split by Khaos-managed vs BYOK.
GET /v1/usage read
Returns total_assets, total_bytes, and the same broken out by managed_* and byok_*.
Public spaces
Unauthenticated JSON endpoints used by the in-app viewer. For pasteable share URLs that render rich previews on Slack / iMessage / X, see Share host.
GET /v1/public/spaces/{short_id} none
If the space is public, returns the manifest with signed asset URLs. If protected, returns { requires_password: true }. Private spaces 404 (existence not leaked).
POST /v1/public/spaces/{short_id}/access none
Submit { password }; on match, returns the manifest. No tokens, no sessions; re-auth per request.
Public profiles
Unauthenticated portfolio endpoint. Returns 404 unless the owner has flipped is_public on. The 404 is intentionally indistinguishable from "handle doesn't exist" so unpublished profiles don't leak existence.
GET /v1/public/profiles/{handle} none
Returns display_name, bio, links, signed avatar_url, and the owner's public spaces (cover-decorated, sorted newest-first, capped at 24). Strictly less than the authenticated /v1/account projection — never leaks email, plan, status, or onboarded_at.
await client.publicProfiles.get('kimi');
The spec.
Every route below is described in openapi.yaml. Drop it into Postman, Bruno, Insomnia, or Stoplight and you have a request playground.
https://raw.githubusercontent.com/khaos-studio/khaosstorage/main/openapi.yaml
CI fails on drift between openapi.yaml and the SAM template, so the spec stays in lock-step with the deployed surface.
Get in touch.
Found a bug, want a feature, need access. We read everything.
Email k@khaos.studio.