Projects
A project is the ownership and billing boundary for collaborative work. One user owns it and pays for everything done inside it; contributors join by email invite and spend the owner's credits, with every charge attributed to whoever triggered it.
Every account has an auto-created default project that can't be deleted.
Work with no projectId lands there and is self-billed, which is why most API
callers never touch this resource.
Reach for it when you want pooled billing — one payer, many actors, per-actor spend reporting.
The project object
| Field | Type | Description |
|---|---|---|
id | string | Project id (UUID). |
name | string | Display name. |
description | string | null | Free text, up to 2,000 characters. |
avatarUrl | string | null | Signed read URL for the avatar image. Expires. |
role | enum | Your role on this project: owner or contributor. |
owner | object | { email, name } of the paying account. |
createdAt · updatedAt | string | ISO 8601. |
Roles
| Action | Owner | Contributor |
|---|---|---|
| Read the project, its media, workflows, and runs | ✅ | ✅ |
| Run generations and workflows (billed to the owner) | ✅ | ✅ |
| Link media into the project | ✅ | ✅ |
| Invite and remove members | ✅ | ❌ |
| Rename, update, or delete the project | ✅ | ❌ |
| Cancel another member's job | ✅ | ❌ |
| See all members' ledger entries | ✅ | own only |
| Leave the project | ❌ (delete instead) | ✅ |
Projects are capped at 20 members and pending invites combined.
POST /v1/projects
Create a project. You become its owner.
The id is client-supplied, not server-generated — pass a UUID you mint.
Reusing an existing id returns conflict (409).
Body
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | A UUID you generate. |
name | string | yes | 1–200 characters. |
Returns { project, members, invites? } — invites is present only for the
owner, which on creation is always you.
curl -s -X POST https://api.corte.so/v1/projects \
-H "Authorization: Bearer corte_sk_…" \
-H "Content-Type: application/json" \
-d '{ "id": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4", "name": "Autumn Campaign" }'
{
"project": {
"id": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4",
"name": "Autumn Campaign",
"description": null,
"avatarUrl": null,
"role": "owner",
"owner": { "email": "you@example.com", "name": "You" },
"createdAt": "2026-07-28T19:00:01.220Z",
"updatedAt": "2026-07-28T19:00:01.220Z"
},
"members": [
{ "userId": "3f8c1a92-…", "email": "you@example.com", "name": "You", "role": "owner", "joinedAt": "2026-07-28T19:00:01.244Z" }
],
"invites": []
}
GET /v1/projects
List every project you belong to, most recently updated first, plus invites awaiting your response.
Returns
| Field | Type | Description |
|---|---|---|
projects | object[] | Projects you own or contribute to. |
pendingInvites | object[] | Invites addressed to your email, redeemable now. |
defaultProjectId | string | Your undeletable default project. |
curl -s https://api.corte.so/v1/projects \
-H "Authorization: Bearer corte_sk_…"
{
"projects": [
{
"id": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4",
"name": "Autumn Campaign",
"description": "Everything for the September drop",
"avatarUrl": null,
"role": "owner",
"owner": { "email": "you@example.com", "name": "You" },
"createdAt": "2026-07-28T19:00:01.220Z",
"updatedAt": "2026-07-28T19:14:52.881Z"
}
],
"pendingInvites": [
{
"id": "0a5e2c71-…",
"projectId": "88b3d049-…",
"projectName": "Client — Northwind",
"email": "you@example.com",
"invitedBy": { "email": "lead@agency.com", "name": "Sam" },
"createdAt": "2026-07-26T10:22:14.003Z"
}
],
"defaultProjectId": "9d47f2b8-1e60-4c3a-a5f9-70b8e14c2a6d"
}
GET /v1/projects/:id
Fetch one project with its member list. Requires membership.
Returns { project, members, invites? }. invites appears only when you're
the owner.
curl -s https://api.corte.so/v1/projects/1b6d4f90-… \
-H "Authorization: Bearer corte_sk_…"
{
"project": { "id": "1b6d4f90-…", "name": "Autumn Campaign", "role": "owner", "…": "…" },
"members": [
{ "userId": "3f8c1a92-…", "email": "you@example.com", "name": "You", "role": "owner", "joinedAt": "2026-07-28T19:00:01.244Z" },
{ "userId": "b2e91d07-…", "email": "editor@agency.com", "name": "Jo", "role": "contributor", "joinedAt": "2026-07-28T19:31:07.660Z" }
],
"invites": [
{ "id": "4c7a0e93-…", "projectId": "1b6d4f90-…", "projectName": "Autumn Campaign", "email": "new@agency.com", "invitedBy": { "email": "you@example.com", "name": "You" }, "createdAt": "2026-07-28T19:40:11.512Z" }
]
}
Members are sorted owner-first, then by join time.
PATCH /v1/projects/:id
Update a project. Owner only. Omitted fields are unchanged; null clears a
nullable field.
Body
| Name | Type | Description |
|---|---|---|
name | string | 1–200 characters. |
description | string | null | Up to 2,000 characters. null clears it. |
avatarStorageId | string | null | A committed image object you can read. null clears the avatar. |
Returns { project, members, invites? }.
curl -s -X PATCH https://api.corte.so/v1/projects/1b6d4f90-… \
-H "Authorization: Bearer corte_sk_…" \
-H "Content-Type: application/json" \
-d '{ "description": "Everything for the September drop" }'
DELETE /v1/projects/:id
Delete a project. Owner only. Your default project can't be deleted
(forbidden).
This is destructive and cascading. It:
- cancels every in-flight generation billed to the project,
- deletes its cuts and their content,
- unlinks its shared media (the underlying objects survive, owned by whoever uploaded them),
- deletes members and pending invites,
- moves each workflow to its creator's default project rather than deleting it — workflows survive, runs follow them.
Returns { "ok": true }.
Invites & members
POST /v1/projects/:id/invites
Invite by email. Owner only. Sends an invite email and returns the pending invite.
Body — { "email": "…" }. Lowercased and validated.
Returns the invite object.
curl -s -X POST https://api.corte.so/v1/projects/1b6d4f90-…/invites \
-H "Authorization: Bearer corte_sk_…" \
-H "Content-Type: application/json" \
-d '{ "email": "editor@agency.com" }'
{
"id": "4c7a0e93-2b18-4f56-90ad-6e1c85f0d372",
"projectId": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4",
"projectName": "Autumn Campaign",
"email": "editor@agency.com",
"invitedBy": { "email": "you@example.com", "name": "You" },
"createdAt": "2026-07-28T19:40:11.512Z"
}
invalid_params covers inviting yourself, an existing member, a duplicate
invite, and exceeding the 20-member cap.
DELETE /v1/projects/:id/invites/:inviteId
Revoke a pending invite. Owner only. Returns { "ok": true }.
POST /v1/project-invites/:inviteId/accept
Accept an invite addressed to your email. You join as a contributor, and the
invite is consumed.
Returns the project object.
{
"id": "88b3d049-…",
"name": "Client — Northwind",
"description": null,
"avatarUrl": null,
"role": "contributor",
"owner": { "email": "lead@agency.com", "name": "Sam" },
"createdAt": "2026-07-20T08:14:00.117Z",
"updatedAt": "2026-07-26T10:22:14.003Z"
}
POST /v1/project-invites/:inviteId/decline
Decline an invite addressed to your email. Returns { "ok": true }.
DELETE /v1/projects/:id/members/:userId
Remove a member, or remove yourself to leave. The owner may remove anyone;
a contributor may only remove themselves. The owner can't leave — delete the
project instead (forbidden).
Returns { "ok": true }.
Shared media
Uploads are private to the uploader. Linking an object into a project makes it
readable by every member — that's what lets a contributor pass your storageId
to a generation.
POST /v1/projects/:id/storage
Link committed objects into the project. Any member may call it. Idempotent: already-linked ids are skipped.
Body
| Name | Type | Description |
|---|---|---|
storageIds | string[] | 1–200 committed object ids. Each must be yours, or already linked to this project. |
Returns { "ok": true }.
curl -s -X POST https://api.corte.so/v1/projects/1b6d4f90-…/storage \
-H "Authorization: Bearer corte_sk_…" \
-H "Content-Type: application/json" \
-d '{ "storageIds": ["b0e7c9a1-4d52-4f0e-8c31-77a2e5d9f6b8"] }'
GET /v1/projects/:id/storage
List the project's shared media, newest link first.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
limit | number | unset | Page size, 1–500. Omit for the full list (media pickers hydrate everything). |
offset | number | 0 | Only meaningful with limit. |
sort | string | by newest link | Pass size to sort largest-first. |
kind | string | unset | Keep only one media kind: image, video or audio. Matches the content type's major part. |
Returns { "objects": [...], "total": number } — the
storage object shape plus
addedByUserId. total counts the objects after kind filtering, so
paging through a filtered list is safe.
{
"objects": [
{
"storageId": "b0e7c9a1-4d52-4f0e-8c31-77a2e5d9f6b8",
"filename": "trail-shoe.jpg",
"contentType": "image/jpeg",
"sizeBytes": 482113,
"origin": "upload",
"url": "https://media.corte.so/…?X-Amz-Signature=…",
"createdAt": "2026-07-28T15:02:44.881Z",
"addedByUserId": "3f8c1a92-b5d7-4e60-9c14-8a72d0e5b391"
}
],
"total": 18
}
GET /v1/projects/:id/usage
The project's credit ledger, plus per-actor spend totals. Paginated.
Owners see every member's entries. Contributors see only their own — the
same filter applies to totalsByActor.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Max 200. |
offset | number | 0 |
Returns
| Field | Type | Description |
|---|---|---|
entries | object[] | Ledger rows, newest first. Same shape as GET /v1/usage, always with actorUserId and actorEmail. |
totalsByActor | object[] | { actorUserId, actorEmail, spentCredits } over the project's whole history, not just this page. |
total | number | Ledger rows visible to you. |
curl -s "https://api.corte.so/v1/projects/1b6d4f90-…/usage?limit=2" \
-H "Authorization: Bearer corte_sk_…"
{
"entries": [
{
"id": "aa41e0c7-…",
"kind": "generation",
"deltaCredits": -85,
"ref": "5f2b9c14-…",
"note": "kling-3",
"createdAt": "2026-07-28T14:04:47.019Z",
"projectId": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4",
"actorUserId": "b2e91d07-…",
"actorEmail": "editor@agency.com",
"previewUrl": "https://media.corte.so/…?X-Amz-Signature=…",
"previewKind": "video"
},
{
"id": "7d02b91f-…",
"kind": "generation",
"deltaCredits": -12,
"ref": "a91c2d7e-…",
"note": "nano-banana-2",
"createdAt": "2026-07-28T13:51:02.664Z",
"projectId": "1b6d4f90-77c2-4a5e-b3d8-2e9a0c15f7b4",
"actorUserId": "3f8c1a92-…",
"actorEmail": "you@example.com",
"previewUrl": null,
"previewKind": null
}
],
"totalsByActor": [
{ "actorUserId": "b2e91d07-…", "actorEmail": "editor@agency.com", "spentCredits": 1840 },
{ "actorUserId": "3f8c1a92-…", "actorEmail": "you@example.com", "spentCredits": 612 }
],
"total": 47
}
Billing to a project
projectId is accepted by
POST /v1/generations,
POST /v1/applications/:id/run,
POST /v1/transcriptions,
and
POST /v1/workflows/:id/execute.
Passing it means the owner pays and you're recorded as the actor. If the
owner's balance is short, the error is owner_insufficient_credits (402) — and
only the owner can resolve it.
Related
- Pooled billing — how shared spend works
- Projects & sharing — the in-app version