Skip to content
FrameCast

Developers · API v1

FrameCast Storage API

Manage FrameCast Files from your own code. Make folders, upload and download files of any size, rename, move, search, and keep a copy in sync, in My Files or any team you're in. Everything you do through the API shows up straight away on the web, Mac, and iPhone.

Base URL https://getframecast.com/api/v1

Quickstart

  1. Make a key. Sign in and open Account › API keys. Choose Read and write, then copy the key. It starts with fcsk_ and is shown only once. Keep it in an environment variable:
    Shell
    export FRAMECAST_API_KEY="fcsk_…"
  2. Check it works by listing your drives:
    Request
    curl https://getframecast.com/api/v1/drives \
      -H "Authorization: Bearer $FRAMECAST_API_KEY"
  3. Make a folder and upload a file, using the id of a drive from step 2:
    Make a folder
    curl -X POST https://getframecast.com/api/v1/folders \
      -H "Authorization: Bearer $FRAMECAST_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"driveId": "DRIVE_ID", "path": "From the API"}'
    Upload into it
    curl -X POST "https://getframecast.com/api/v1/uploads/direct?parentId=FOLDER_ID&name=hello.txt" \
      -H "Authorization: Bearer $FRAMECAST_API_KEY" \
      -H "Content-Type: text/plain" \
      --data-binary "Hello from the FrameCast API"

    Open Files and it's there.

Authentication

Send your key with every request, in the Authorization header:

Header
Authorization: Bearer fcsk_…

X-API-Key: fcsk_… works too. Requests without a working key get 401 unauthorized.

A key acts as you. It can never do more than your account can. In a team, your role, restricted folders, and the team's plan all still apply, so a key made by a team viewer can only read. Scopes and drive limits narrow a key further; they never widen it.

Keep keys secret and on servers. Don't put a key in a web page, a browser extension, a mobile app, or a public repository. The API doesn't allow cross-origin browser requests. If a key might have leaked, revoke or rotate it under Account › API keys; it stops working on its next request.

Keys are fcsk_ followed by 46 letters and digits, the last 6 being a checksum. FrameCast keeps only a one-way fingerprint of each key, so nobody at FrameCast can see or recover yours.

Scopes

Scopes decide what a key can do. You choose them when you make the key, and you can change them at any time without replacing it. Some scopes include others, as shown below.

ScopeAllowsIncludes
files:readList drives and folders, search, read file details, download, and follow the change feed.—
files:writeUpload files and new versions, make folders, rename, and move.files:read
files:deleteMove items to Trash and bring them back.files:read
files:purgePermanently delete items in Trash. This can't be undone.files:readfiles:delete
access:manageRestrict team folders and choose who sees them. Only works in teams where you're an owner or admin.files:read

Presets: Read only (files:read); Read and write (files:read, files:write); Full access (files:read, files:write, files:delete, files:purge, access:manage). Each endpoint below lists the scope it needs. Without it, the answer is 403 insufficient_scope.

Managing keys

  • Drive limits. Limit a key to My Files, to particular teams, or let it reach every drive, including teams you join later. Anything outside its drives answers 404, so a key never learns what exists elsewhere.
  • Expiry. Keys last 7, 30, 90, or 365 days, or never expire. The default is 90 days.
  • Rotation. Rotating issues a new key with the same scopes and drives. The old key stops at once, or keeps working for up to 7 days while you deploy the new one.
  • Revoking stops a key on its next request.
  • Last used. Each key shows when and from which address it was last used.
  • You can have 25 working keys. Keys can only be made and changed by someone signed in, never with another key.

Errors

Errors use standard HTTP status codes, with a stable code to check in your code and a message written for people:

Response · 403
{
  "error": {
    "code": "insufficient_scope",
    "message": "This key doesn't have the files:write scope. Add it to the key under Account › API keys."
  }
}
StatusCodeMeaning
400invalid_requestSomething in the request is missing or wrong. The message says what.
401unauthorizedNo key, or the key is malformed, unknown, revoked, or expired.
402plan_requiredThe team's plan has lapsed, so its drive is read-only, or there's no storage room left.
403insufficient_scopeThe key doesn't have the scope this endpoint needs. The message and WWW-Authenticate header name it.
403forbiddenYour role doesn't allow it, such as a contributor making folders, or a member emptying a team's Trash.
404not_foundIt doesn't exist, you can't see it, or the key is limited to other drives.
409conflictA name is already taken, or a file changed since the baseRev you sent.
413too_largeThe file or request body is too big.
415unsupported_media_typeThe request body needs to be JSON.
429rate_limitedToo many requests. Wait the number of seconds in Retry-After.
500internal_errorSomething went wrong on our side. Retry, and quote the X-Request-Id if it keeps happening.

Every response has an X-Request-Id header. Include it if you contact support about a request.

Rate limits

Each key can make 600 requests a minute. Every response says where you stand:

Headers
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 41

X-RateLimit-Reset is the number of seconds until the count starts again. Over the limit, requests get 429 rate_limited with a Retry-After header; wait that many seconds before trying again. An address that sends 30 keys that don't work within a minute is blocked for the rest of that minute. Uploading parts to their signed URLs doesn't count against the limit.

Pagination

Lists come in pages. Pass the answer's nextOffset as offset to get the next page, and stop when it's null. Folder listings take a limit of up to 1000; the default is 100.

Uploading files

Every upload names its destination, with parentId (a folder) or driveId (the top of a drive), so nothing lands anywhere you didn't choose. Uploads count against the storage of the space they go into.

Up to 4 MB: one request

Send the file as the body of POST /uploads/direct, with the name in the query string. It answers with the new file.

Any size, up to 50 GB: in parts

  1. Start the upload with the file's name and exact size. The answer gives a partSize, a partCount, and signed URLs for the first 100 parts.
  2. Send part n, bytes (n − 1) × partSize up to n × partSize, as a PUT to its URL, without your API key. Keep the ETag header from each answer. Parts can go in parallel and be retried.
  3. Past part 100, or after a URL expires (they last 60 minutes), ask for more URLs.
  4. Complete the upload with every part's number and ETag. To give up instead, cancel it.

New versions

To replace a file's contents, start a multipart upload with replaces set to the file's id. The file keeps its id, name, and place, and its rev goes up. Send the rev you last saw as baseRev; if someone saved in the meantime, you get a 409 instead of overwriting their work.

Names and types

A file name already used in the folder gets a number, such as photo (2).jpg. If the content type is missing or generic, such as application/octet-stream or curl's default form type, it's worked out from the file's extension.

Avoiding duplicates

Give each upload a sourceKey: your own id for the file, 8 to 300 characters of letters, digits, and . _ : / + = @ -, such as camera-7:IMG_0001. If that key is already in the drive, the upload answers { "duplicate": true, "item": … } and stores nothing. That includes files moved to Trash, so a retried or re-run backup never stores the same file twice.

Keeping in sync

To keep a copy of a drive up to date, call the change feed without a cursor. It answers reset: true and a cursor. List the drive from its rootId, then call the feed with that cursor every so often. Each change is an upsert with the item as it now is, or a delete with an id. Download a file again only when its contentId changes. If the feed answers reset: true again, list the drive again.

The drive object

Fields

idstringThe drive's id.
kindstringpersonal (My Files) or workspace (a team).
namestringMy Files, or the team's name.
rootIdstringThe drive's top folder. List it with GET /items/{rootId}/children.
workspaceIdstring | nullThe team's id, or null for My Files.
rolestringYour role: owner, admin, member, contributor, or viewer.
canWritebooleanfalse when a team's plan has lapsed; its files stay readable.

The item object

Files, folders, and share links are all items.

Fields

idstringThe item's id.
driveIdstringThe drive it's in.
parentIdstring | nullThe folder it's in. null only for a drive's top folder.
kindstringfolder, file, or share (a recording's share link).
namestringIts name, unique within its folder regardless of case.
specialstring | nullSet on folders FrameCast manages, such as root, screenshots, or photos. These can't be renamed, moved, or deleted.
bytesintegerThe file's size. 0 for folders.
contentTypestring | nullThe file's type, such as image/jpeg.
createdByobject | null{ id, name } of whoever added it.
modifiedByobject | null{ id, name } of whoever last renamed, moved, or replaced it, when that's someone else.
restrictedbooleanOnly people granted this folder, and team admins, can see inside it.
capturedAtstring | nullWhen the photo or video was taken, if known.
createdAtstringWhen it was added.
updatedAtstringWhen it last changed.
deletedAtstring | nullWhen it was moved to Trash, or null.
revintegerGoes up on every change. Send it as baseRev when uploading a new version.
contentIdstring | nullChanges only when the file's bytes do, so sync clients know when to download it again.
hasThumbbooleanA thumbnail is ready (variant=thumb).
hasPreviewbooleanA preview is ready (variant=preview).
inlinebooleanBrowsers show it rather than download it.
width, height, durationSecondsnumber | nullSize and length of photos and videos, when known.
shareobject | nullFor share links: { id, status, views, url }.

Key

Check which key a request is using and what it can do.

Inspect the current key

GET/api/v1/keyAny key

The calling key's scopes, the drives it's limited to (null means every drive), its expiry, and the account it acts for. Any working key can call it, so it's a good first request to check a key works.

Request
curl https://getframecast.com/api/v1/key \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "key": {
    "id": "a41f0c9e-6b2d-4e57-8c13-7d9e2f0b6a85",
    "name": "Backup server",
    "scopes": [
      "files:read",
      "files:write"
    ],
    "driveIds": null,
    "expiresAt": "2027-01-29T09:00:00.000Z"
  },
  "user": {
    "id": "u_8f2k1",
    "email": "alex@example.com",
    "name": "Alex Rivera"
  }
}

Drives

Each account has My Files, plus one drive for each team it's in. A key only sees the drives it's limited to.

List drives

GET/api/v1/drivesScope files:read

My Files first, then each team drive the key can reach. Use a drive's rootId to list its top folder.

Request
curl https://getframecast.com/api/v1/drives \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "drives": [
    {
      "id": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
      "kind": "personal",
      "name": "My Files",
      "rootId": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
      "workspaceId": null,
      "role": "owner",
      "canWrite": true
    },
    {
      "id": "0b7c4e21-93aa-4f5e-8d16-2c7f0e9b5a44",
      "kind": "workspace",
      "name": "Acme Field Team",
      "rootId": "e2a94b17-5c08-4d6f-9b31-0f8c7e2d4a56",
      "workspaceId": "c2e24d68-58eb-4234-b3f1-1a009659cf5b",
      "role": "admin",
      "canWrite": true
    }
  ]
}

Get a drive and its storage

GET/api/v1/drives/{driveId}Scope files:read

One drive, with how much of its storage is used. My Files counts against your plan; a team drive counts against the team's.

Path

driveIdstringrequiredThe drive's id.
Request
curl https://getframecast.com/api/v1/drives/d9068a33-32df-4e72-b2ed-39ee457fd89e \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "drive": {
    "id": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "kind": "personal",
    "name": "My Files",
    "rootId": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
    "workspaceId": null,
    "role": "owner",
    "canWrite": true
  },
  "storage": {
    "usedBytes": 48213417984,
    "limitBytes": 1000000000000,
    "canWrite": true,
    "tier": "pro"
  }
}

Follow the change feed

GET/api/v1/drives/{driveId}/changesScope files:read

What changed in a drive since a cursor, for keeping a copy in sync. The first call, without a cursor, answers reset: true: list the drive from the top, then keep calling with the returned cursor. A reset later means the cursor was too old, so list again. If hasMore is true, call again straight away.

Path

driveIdstringrequiredThe drive to follow.

Query

cursorstringThe cursor from the previous answer.
Request
curl "https://getframecast.com/api/v1/drives/d9068a33-32df-4e72-b2ed-39ee457fd89e/changes?cursor=18422" \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "cursor": "18431",
  "reset": false,
  "hasMore": false,
  "changes": [
    {
      "op": "upsert",
      "item": {
        "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
        "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
        "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
        "kind": "file",
        "name": "Site photo.jpg",
        "special": null,
        "bytes": 2481920,
        "contentType": "image/jpeg",
        "createdBy": {
          "id": "u_8f2k1",
          "name": "Alex Rivera"
        },
        "modifiedBy": null,
        "sharedBy": null,
        "restricted": false,
        "device": null,
        "capturedAt": "2026-10-30T14:02:11.000Z",
        "createdAt": "2026-10-31T09:15:42.118Z",
        "updatedAt": "2026-10-31T09:15:42.118Z",
        "deletedAt": null,
        "rev": 1,
        "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
        "hasThumb": false,
        "hasPreview": false,
        "inline": true,
        "durationSeconds": null,
        "width": null,
        "height": null,
        "share": null,
        "…": "…"
      }
    },
    {
      "op": "delete",
      "id": "6e1d3b8f-2a47-4c90-b5f1-8d3e7a2c9b04"
    }
  ]
}

List Trash

GET/api/v1/drives/{driveId}/trashScope files:read

Items in the drive's Trash. They stay there for 30 days, then they're deleted for good.

Path

driveIdstringrequiredThe drive.
Request
curl https://getframecast.com/api/v1/drives/d9068a33-32df-4e72-b2ed-39ee457fd89e/trash \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "items": [
    {
      "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
      "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
      "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
      "kind": "file",
      "name": "Site photo.jpg",
      "special": null,
      "bytes": 2481920,
      "contentType": "image/jpeg",
      "createdBy": {
        "id": "u_8f2k1",
        "name": "Alex Rivera"
      },
      "modifiedBy": null,
      "sharedBy": null,
      "restricted": false,
      "device": null,
      "capturedAt": "2026-10-30T14:02:11.000Z",
      "createdAt": "2026-10-31T09:15:42.118Z",
      "updatedAt": "2026-10-31T09:15:42.118Z",
      "deletedAt": "2026-10-31T10:02:00.000Z",
      "rev": 1,
      "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
      "hasThumb": false,
      "hasPreview": false,
      "inline": true,
      "durationSeconds": null,
      "width": null,
      "height": null,
      "share": null,
      "…": "…"
    }
  ]
}

Delete from Trash for good

DELETE/api/v1/drives/{driveId}/trashScope files:purge

Deletes one item in Trash, and everything inside it, for good. Or, with all=1, empties the drive's whole Trash (in a team, admins only). all=1 is required so that a missing item can never empty everything. This can't be undone.

Path

driveIdstringrequiredThe drive.

Query

itemstringThe item in Trash to delete.
all1Send all=1 instead of item to empty the whole Trash.
Request
curl -X DELETE "https://getframecast.com/api/v1/drives/d9068a33-32df-4e72-b2ed-39ee457fd89e/trash?item=87f58e92-537b-4adf-8139-e15b9a50e829" \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "removed": 1
}

Items

Files, folders, and share links are all items, and use the same endpoints.

Get an item

GET/api/v1/items/{itemId}Scope files:read

One file, folder, or share link. See the item object for every field.

Path

itemIdstringrequiredThe item's id.
Request
curl https://getframecast.com/api/v1/items/87f58e92-537b-4adf-8139-e15b9a50e829 \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "item": {
    "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "kind": "file",
    "name": "Site photo.jpg",
    "special": null,
    "bytes": 2481920,
    "contentType": "image/jpeg",
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": "2026-10-30T14:02:11.000Z",
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
    "hasThumb": false,
    "hasPreview": false,
    "inline": true,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null
  }
}

List a folder

GET/api/v1/items/{folderId}/childrenScope files:read

What's in a folder: folders first, then files. Pass a drive's rootId to list the top of the drive. The answer also gives the path from the top and what you can do in the folder.

Path

folderIdstringrequiredA folder's id, or a drive's rootId.

Query

sortstringname (default), date (newest first), or size (largest first).
limitintegerHow many to return, 1–1000. Default 100.
offsetintegerWhere to continue from. Use the previous answer's nextOffset.
Request
curl "https://getframecast.com/api/v1/items/c9924781-b396-4532-93f1-dad0786e0a0d/children?sort=date&limit=50" \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "folder": {
    "id": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
    "kind": "folder",
    "name": "2026",
    "special": null,
    "bytes": 0,
    "contentType": null,
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": null,
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": null,
    "hasThumb": false,
    "hasPreview": false,
    "inline": false,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null,
    "…": "…"
  },
  "drive": {
    "id": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "kind": "personal",
    "name": "My Files",
    "rootId": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
    "workspaceId": null,
    "role": "owner",
    "canWrite": true
  },
  "path": [
    {
      "id": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
      "name": "My Files"
    },
    {
      "id": "9a3c5e71-4b2d-4f80-a6e9-1c7d3b5f2e08",
      "name": "Clients"
    },
    {
      "id": "c9924781-b396-4532-93f1-dad0786e0a0d",
      "name": "2026"
    }
  ],
  "permissions": {
    "level": "manage",
    "canCreateFolder": true,
    "canRestrict": false
  },
  "items": [
    {
      "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
      "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
      "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
      "kind": "file",
      "name": "Site photo.jpg",
      "special": null,
      "bytes": 2481920,
      "contentType": "image/jpeg",
      "createdBy": {
        "id": "u_8f2k1",
        "name": "Alex Rivera"
      },
      "modifiedBy": null,
      "sharedBy": null,
      "restricted": false,
      "device": null,
      "capturedAt": "2026-10-30T14:02:11.000Z",
      "createdAt": "2026-10-31T09:15:42.118Z",
      "updatedAt": "2026-10-31T09:15:42.118Z",
      "deletedAt": null,
      "rev": 1,
      "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
      "hasThumb": false,
      "hasPreview": false,
      "inline": true,
      "durationSeconds": null,
      "width": null,
      "height": null,
      "share": null,
      "…": "…"
    }
  ],
  "total": 1,
  "nextOffset": null
}

Download a file

GET/api/v1/items/{fileId}/contentScope files:read

A signed link to the file's bytes, good for an hour. Fetch it without the API key. Add redirect=1 to get a 302 to the link instead, so curl -L -o saves the file in one step. A preview or thumbnail that hasn't been made yet answers 202 with { "pending": true }.

Path

fileIdstringrequiredThe file's id.

Query

variantstringoriginal (default), preview (up to 1600 px), or thumb (up to 480 px).
download1Save it under its name rather than open it in a browser.
redirect1Answer with a 302 to the link.
Request
curl -L -o "Site photo.jpg" \
  "https://getframecast.com/api/v1/items/87f58e92-537b-4adf-8139-e15b9a50e829/content?redirect=1&download=1" \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "url": "https://…r2.cloudflarestorage.com/f/…/original.jpg?X-Amz-Signature=…",
  "expiresInSeconds": 3600
}

Rename or move

PATCH/api/v1/items/{itemId}Scope files:write

Renames an item, moves it to another folder, or both. searchText sets extra words that search matches, such as text read from a photo (files only). The request is applied whole or not at all. Managed folders such as Screenshots can't be renamed or moved.

Path

itemIdstringrequiredThe item's id.

JSON body

namestringThe new name. A name already used in the folder is refused with 409.
parentIdstringThe folder to move it into.
searchTextstringWords search should find this file by, up to 20,000 characters. An empty string clears it.
Request
curl -X PATCH https://getframecast.com/api/v1/items/87f58e92-537b-4adf-8139-e15b9a50e829 \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Forklift 7 – north bay.jpg", "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d"}'
Response · 200
{
  "item": {
    "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "kind": "file",
    "name": "Forklift 7 – north bay.jpg",
    "special": null,
    "bytes": 2481920,
    "contentType": "image/jpeg",
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": "2026-10-30T14:02:11.000Z",
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 2,
    "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
    "hasThumb": false,
    "hasPreview": false,
    "inline": true,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null,
    "…": "…"
  }
}

Move to Trash

DELETE/api/v1/items/{itemId}Scope files:delete

Moves an item, and everything in it, to Trash. It can be restored for 30 days.

Path

itemIdstringrequiredThe item's id.
Request
curl -X DELETE https://getframecast.com/api/v1/items/87f58e92-537b-4adf-8139-e15b9a50e829 \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
  "trashed": true
}

Restore from Trash

POST/api/v1/items/{itemId}/restoreScope files:delete

Brings an item back to its old folder, if that still exists, or otherwise to the top of the drive.

Path

itemIdstringrequiredThe item's id.
Request
curl -X POST https://getframecast.com/api/v1/items/87f58e92-537b-4adf-8139-e15b9a50e829/restore \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "item": {
    "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "kind": "file",
    "name": "Site photo.jpg",
    "special": null,
    "bytes": 2481920,
    "contentType": "image/jpeg",
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": "2026-10-30T14:02:11.000Z",
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
    "hasThumb": false,
    "hasPreview": false,
    "inline": true,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null,
    "…": "…"
  }
}

Get folder access

GET/api/v1/items/{folderId}/accessScope access:manage

Whether a team folder is restricted, and who has been granted access. Only for team owners and admins.

Path

folderIdstringrequiredA team folder.
Request
curl https://getframecast.com/api/v1/items/c9924781-b396-4532-93f1-dad0786e0a0d/access \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "access": {
    "restricted": true,
    "grants": [
      {
        "userId": "u_3n9d2",
        "name": "Sam Lee",
        "email": "sam@example.com",
        "level": "edit",
        "role": "member"
      }
    ]
  }
}

Change folder access

PATCH/api/v1/items/{folderId}/accessScope access:manage

Restricts a team folder to the people granted it, or opens it to the whole team. Grants a member view or edit access, or removes it. Owners and admins always see everything.

Path

folderIdstringrequiredA team folder.

JSON body

restrictedbooleantrue to restrict the folder, false to open it to the team.
userIdstringThe member to grant or remove.
levelstring | nullview or edit to grant; null to remove.
Request
curl -X PATCH https://getframecast.com/api/v1/items/c9924781-b396-4532-93f1-dad0786e0a0d/access \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"userId": "u_3n9d2", "level": "view"}'
Response · 200
{
  "access": {
    "restricted": true,
    "grants": [
      {
        "userId": "u_3n9d2",
        "name": "Sam Lee",
        "email": "sam@example.com",
        "level": "view",
        "role": "member"
      }
    ]
  }
}

Folders

Make one folder, or a whole path at once.

Create a folder

POST/api/v1/foldersScope files:write

Send name to make one folder. A name already used in that folder is refused with 409, unless you also send reuse: true, which returns the existing folder. Or send path to make every missing folder along it, like mkdir -p. That reuses folders that exist and returns the last one, so it's safe to repeat.

JSON body

parentIdstringThe folder it goes into. Send this or driveId.
driveIdstringThe top of this drive. Send this or parentId.
namestringThe folder's name. Send this or path.
pathstringFolder names separated by /, up to 32 deep. Send this or name.
reusebooleanWith name: return the existing folder rather than a 409.
Request
curl -X POST https://getframecast.com/api/v1/folders \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e", "path": "Clients/Acme/2026"}'
Response · 201
{
  "item": {
    "id": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "5b1e0c2a-7f3d-4c11-9a51-3f0e4c2d8a10",
    "kind": "folder",
    "name": "2026",
    "special": null,
    "bytes": 0,
    "contentType": null,
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": null,
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": null,
    "hasThumb": false,
    "hasPreview": false,
    "inline": false,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null,
    "…": "…"
  }
}

Uploads

Send files of up to 4 MB in one request. Larger files, and new versions of existing files, go in parts. See the upload guide.

Upload a small file

POST/api/v1/uploads/directScope files:write

Uploads a file of up to 4 MB in one request, with the file itself as the request body. A file name already taken in the folder gets a number, such as photo (2).jpg.

Query

parentIdstringThe folder it goes into. Send this or driveId.
driveIdstringThe top of this drive. Send this or parentId.
namestringrequiredThe file's name, with its extension.
sourceKeystringYour own unique id for the file, to prevent duplicates. See duplicates.
capturedAtdateWhen the photo or video was taken (ISO 8601). Search can filter by it.

Body

The raw file bytes. Set Content-Type to the file's type. If it's missing or generic, the type is worked out from the name.

Request
curl -X POST "https://getframecast.com/api/v1/uploads/direct?parentId=c9924781-b396-4532-93f1-dad0786e0a0d&name=Site%20photo.jpg" \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" \
  -H "Content-Type: image/jpeg" \
  --data-binary @"Site photo.jpg"
Response · 201
{
  "item": {
    "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "kind": "file",
    "name": "Site photo.jpg",
    "special": null,
    "bytes": 2481920,
    "contentType": "image/jpeg",
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": "2026-10-30T14:02:11.000Z",
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
    "hasThumb": false,
    "hasPreview": false,
    "inline": true,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null
  }
}

Start a multipart upload

POST/api/v1/uploadsScope files:write

Starts an upload of up to 50 GB. The answer includes signed URLs for the first 100 parts. PUT each part to its URL, then complete the upload. To upload a new version of a file, send replaces instead of a destination; it keeps the file's id and name. If the sourceKey is already in the drive, the answer is { "duplicate": true, "item" } and there's nothing to send.

JSON body

parentIdstringThe folder it goes into. Send this or driveId.
driveIdstringThe top of this drive. Send this or parentId.
namestringThe file's name. Required unless replaces is sent.
bytesintegerrequiredThe file's exact size in bytes.
contentTypestringThe file's type. If it's missing or generic, it's worked out from the name.
replacesstringA file to upload a new version of, instead of parentId or driveId.
baseRevintegerWith replaces: the file's rev when you last read it. If someone saved since, you get a 409 instead of overwriting their change.
sourceKeystringYour own unique id for the file, to prevent duplicates.
capturedAtdateWhen the photo or video was taken (ISO 8601).
Request
curl -X POST https://getframecast.com/api/v1/uploads \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"parentId": "c9924781-b396-4532-93f1-dad0786e0a0d", "name": "Walkthrough.mp4", "bytes": 734003200, "contentType": "video/mp4"}'
Response · 201
{
  "upload": {
    "id": "3f6a9d2e-1b4c-4e8f-a7d0-5c2b9e1f4a63",
    "partSize": 16777216,
    "partCount": 44,
    "parts": [
      {
        "partNumber": 1,
        "url": "https://…r2.cloudflarestorage.com/f/…?partNumber=1&uploadId=…&X-Amz-Signature=…"
      },
      {
        "partNumber": 2,
        "url": "https://…"
      },
      {
        "…": "…"
      }
    ],
    "partUrlsExpireInSeconds": 3600
  }
}

Get more part URLs

POST/api/v1/uploads/{uploadId}/partsScope files:write

Signed URLs for parts after the first 100, or new URLs for parts whose URLs expired. Up to 100 parts at a time.

Path

uploadIdstringrequiredThe upload's id.

JSON body

partNumbersinteger[]requiredThe part numbers, from 1 to partCount.
Request
curl -X POST https://getframecast.com/api/v1/uploads/3f6a9d2e-1b4c-4e8f-a7d0-5c2b9e1f4a63/parts \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"partNumbers": [101, 102, 103]}'
Response · 200
{
  "parts": [
    {
      "partNumber": 101,
      "url": "https://…"
    },
    {
      "…": "…"
    }
  ],
  "partUrlsExpireInSeconds": 3600
}

Complete a multipart upload

POST/api/v1/uploads/{uploadId}/completeScope files:write

Finishes the upload and files it in its folder. List every part with the ETag header its PUT returned. If you uploaded a new version but someone else saved first, the answer is a conflicted copy saved beside the file.

Path

uploadIdstringrequiredThe upload's id.

JSON body

partsobject[]required{ partNumber, etag } for every part, from 1 to partCount.
Request
curl -X POST https://getframecast.com/api/v1/uploads/3f6a9d2e-1b4c-4e8f-a7d0-5c2b9e1f4a63/complete \
  -H "Authorization: Bearer $FRAMECAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"parts": [{"partNumber": 1, "etag": "\"9b2cf535f27731c974343645a3985328\""}, …]}'
Response · 200
{
  "item": {
    "id": "87f58e92-537b-4adf-8139-e15b9a50e829",
    "driveId": "d9068a33-32df-4e72-b2ed-39ee457fd89e",
    "parentId": "c9924781-b396-4532-93f1-dad0786e0a0d",
    "kind": "file",
    "name": "Walkthrough.mp4",
    "special": null,
    "bytes": 734003200,
    "contentType": "video/mp4",
    "createdBy": {
      "id": "u_8f2k1",
      "name": "Alex Rivera"
    },
    "modifiedBy": null,
    "sharedBy": null,
    "restricted": false,
    "device": null,
    "capturedAt": "2026-10-30T14:02:11.000Z",
    "createdAt": "2026-10-31T09:15:42.118Z",
    "updatedAt": "2026-10-31T09:15:42.118Z",
    "deletedAt": null,
    "rev": 1,
    "contentId": "4c2e8a71-0d5b-4f39-b6e2-91a7c3d05f18",
    "hasThumb": false,
    "hasPreview": false,
    "inline": true,
    "durationSeconds": null,
    "width": null,
    "height": null,
    "share": null,
    "…": "…"
  }
}

Cancel an upload

DELETE/api/v1/uploads/{uploadId}Scope files:write

Cancels an upload that hasn't finished, and frees the storage it was holding.

Path

uploadIdstringrequiredThe upload's id.
Request
curl -X DELETE https://getframecast.com/api/v1/uploads/3f6a9d2e-1b4c-4e8f-a7d0-5c2b9e1f4a63 \
  -H "Authorization: Bearer $FRAMECAST_API_KEY"
Response · 200
{
  "id": "3f6a9d2e-1b4c-4e8f-a7d0-5c2b9e1f4a63",
  "cancelled": true
}

Code samples

Complete scripts that run as they are, with your key in FRAMECAST_API_KEY.

Node.js · upload any file
// Node.js 18+ — uploads a file of any size into a folder path, then prints a download link.
import { open, stat } from "node:fs/promises";
import path from "node:path";

const API = "https://getframecast.com/api/v1";
const KEY = process.env.FRAMECAST_API_KEY;

async function api(method, route, body) {
  const response = await fetch(API + route, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, ...(body ? { "Content-Type": "application/json" } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await response.json();
  if (!response.ok) throw new Error(`${response.status} ${data.error.code}: ${data.error.message}`);
  return data;
}

async function upload(file, folderId) {
  const { size } = await stat(file);
  const started = await api("POST", "/uploads", { parentId: folderId, name: path.basename(file), bytes: size });
  if (started.duplicate) return started.item;
  const { id, partSize, partCount } = started.upload;
  let urls = started.upload.parts;
  const handle = await open(file);
  const parts = [];
  try {
    for (let n = 1; n <= partCount; n++) {
      if (n > urls.at(-1).partNumber) {
        const batch = Array.from({ length: Math.min(100, partCount - n + 1) }, (_, i) => n + i);
        urls = (await api("POST", `/uploads/${id}/parts`, { partNumbers: batch })).parts;
      }
      const url = urls.find((part) => part.partNumber === n).url;
      const length = Math.min(partSize, size - (n - 1) * partSize);
      const buffer = Buffer.alloc(length);
      await handle.read(buffer, 0, length, (n - 1) * partSize);
      const put = await fetch(url, { method: "PUT", body: buffer });
      if (!put.ok) throw new Error(`Part ${n} failed: ${put.status}`);
      parts.push({ partNumber: n, etag: put.headers.get("etag") });
    }
  } catch (error) {
    await api("DELETE", `/uploads/${id}`).catch(() => {});
    throw error;
  } finally {
    await handle.close();
  }
  return (await api("POST", `/uploads/${id}/complete`, { parts })).item;
}

const { drives } = await api("GET", "/drives");
const folder = (await api("POST", "/folders", { driveId: drives[0].id, path: "Backups/Videos" })).item;
const item = await upload(process.argv[2], folder.id);
const { url } = await api("GET", `/items/${item.id}/content?download=1`);
console.log(`Uploaded ${item.name} (${item.bytes} bytes). Download for the next hour: ${url}`);
Python · back up a folder of photos
# Python 3.9+ with requests — backs up a folder of photos, skipping ones already uploaded.
import os
import sys
from pathlib import Path

import requests

API = "https://getframecast.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FRAMECAST_API_KEY']}"


def api(method, route, **kwargs):
    response = session.request(method, API + route, **kwargs)
    data = response.json()
    if not response.ok:
        raise RuntimeError(f"{response.status_code} {data['error']['code']}: {data['error']['message']}")
    return data


drive = api("GET", "/drives")["drives"][0]
folder = api("POST", "/folders", json={"driveId": drive["id"], "path": "Backups/Site photos"})["item"]

for photo in sorted(Path(sys.argv[1]).glob("*.jpg")):
    if photo.stat().st_size > 4_000_000:
        print(f"skipped {photo.name}: over 4 MB, use a multipart upload")
        continue
    result = api(
        "POST",
        "/uploads/direct",
        params={"parentId": folder["id"], "name": photo.name, "sourceKey": f"site-backup:{photo.name}"},
        data=photo.read_bytes(),
        headers={"Content-Type": "image/jpeg"},
    )
    print(("already there: " if result.get("duplicate") else "uploaded: ") + result["item"]["name"])

Changelog

2026-10-31 · Version 1: drives, folders, items, uploads, downloads, Trash, search, the change feed, and folder access, with scoped API keys.

Changes within /api/v1 only add things: new endpoints, new optional parameters, and new fields. Anything that would break existing code will come as /api/v2.

Help us measure our ads?

With your permission, Meta and Trybe receive visits to our marketing pages and browser identifiers. Meta receives confirmed Pro or Team purchase amounts; Trybe receives paid checkout amounts to credit creators for sales. Recording and editing work either way. You can change your choice anytime. Privacy details

Global Privacy Control is honored automatically.