Projects
Manage the lifecycle of your vibe coding projects.
Projects are the top-level unit of the Totalum App Builder API. Each project has its own database, source code, dev server, and (optionally) a deployment and custom domain. All endpoints require the api-key header and return the standard { "errors": ..., "data": ... } envelope.
Start with Launch Project — it creates a project and starts building it in one call, and it is the default for anything new. Everything else on this page manages a project that already exists.
#Launch Project
/api/v1/vcaas/projects/launchUses creditsCreate a project and start building it in one call.
Getting from POST /projects to "the agent is building my app" takes four to six more calls, in an order that is not obvious and that is wrong in three different ways if you guess it. This endpoint is that sequence, done in the order that works.
The only required fields are projectId and prompt. Everything else is optional — send nothing else and it behaves exactly like a create followed by Run AI Agent.
Use Create Project instead — but it is not recommended for anything you intend to build. It gives you a project and nothing else: no prompt, no build, nothing running, and four to six further calls (in a specific order) before the agent can start.
It is the right call in exactly three cases: you are about to import an existing project into it, you want to connect GitHub before any code exists, or you intend to write the files yourself. Otherwise, launch.
The order is load-bearing, and each of these is a real failure people hit doing it by hand:
- Secrets are written into the sandbox
.envwhen the build starts. A secret created afteragent/startis not in the file the first build reads — so your app reports the key as missing even though you sent it. - Figma must be connected before the run, or a design link in the prompt is read by an agent that cannot reach Figma.
- Attachments uploaded after
agent/startare not attached to anything — that run already has its file list. - Credit limits set afterwards do not cover the first run, which is the most expensive thing the project will ever do.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string | Yes | Your preferred ID. Same format rules as Create Project. If it is taken, it is not an error — see the callout below |
prompt | string | Yes | What to build. Run as the project's first prompt, exactly as if sent to POST /agent/start |
description | string | No | Project description, max 500 characters |
label | string | No | Human display name, max 80 characters |
groupId | string | No | File the project under an existing project group |
files | array | No | Attachments for the first prompt. Max 10. Each entry needs exactly one of url or content |
files[].name | string | Yes | File name, e.g. mockup.png |
files[].description | string | No | What the file shows — the agent reads this. imageDescription is accepted as an alias |
files[].url | string | No | Public http(s) URL of an already-hosted file. Passed to the agent untouched; nothing is downloaded by Totalum |
files[].content | string | No | Base64 file contents. A data:image/png;base64,… prefix is accepted and stripped. Uploaded for you, and costs UPLOAD_FILE per file |
creditLimits | object | No | Monthly per-project caps, applied at creation so they cover the first run |
creditLimits.maxDevelopmentCreditsPerMonth | number | null | No | Positive number, or null for no cap. Default: no cap |
creditLimits.maxInfrastructureCreditsPerMonth | number | null | No | Positive number, or null for no cap. Default: 250 |
secrets | array | No | Project secrets (.env values), stored before the agent starts. Max 50, names must be unique |
secrets[].secretName | string | Yes | Environment variable name |
secrets[].secretValue | string | Yes | The value. Never returned by any endpoint afterwards |
secrets[].environment | string | No | "development" | "production" | "both" (default "both") |
figma | object | No | Connect a Figma account so the agent can read designs linked in the prompt |
figma.token | string | Yes | A Figma personal access token. Validated before anything is created, so a bad token costs you nothing |
Unlike Create Project, this endpoint never answers PROJECT_ALREADY_EXISTS. Project ids are globally unique, so the short obvious names a prompt produces collide often — and a 409 on a call that also starts an agent run costs you the whole round trip and forces you to reimplement the retry.
Instead it tries the name you asked for, then adds 2 random characters, then 3, 4, 5, 6, and creates the first free one. When that happens the response carries requestedProjectId with the name you sent.
Always use the returned projectId for every later call. That is already true in every environment — outside production the API appends its own suffix — but here it can differ for this reason too.
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | The project ID that was actually created. Use this for every subsequent call |
data.requestedProjectId | string | undefined | The projectId you asked for. Present only when it was taken and a suffixed name was created instead |
data.description | string | Project description |
data.plan | string | Always "api" for projects created through the API |
data.createdAt | string | ISO 8601 creation date |
data.label | string | undefined | The display name, when one was given |
data.groupId | string | undefined | The group the project was filed under, when any |
data.creditLimits | object | The limits actually stored on the project, defaults included |
data.creditLimits.maxDevelopmentCreditsPerMonth | number | null | null = no cap |
data.creditLimits.maxInfrastructureCreditsPerMonth | number | null | null = no cap |
data.files | array | The attachments as the agent received them. Empty when none were sent |
data.files[].name | string | File name |
data.files[].imageDescription | string | The description you gave, or "" |
data.files[].url | string | The URL the agent was given — yours for a url entry, a signed Totalum URL for an uploaded one |
data.secrets | array | The secrets that were stored. Values are never echoed |
data.secrets[]._id | string | Secret ID (use for deletion) |
data.secrets[].secretName | string | Environment variable name |
data.secrets[].environment | string | "development" | "production" | "both" |
data.figma | object | undefined | Present only when the request carried figma and the connection succeeded |
data.figma.connected | boolean | true |
data.figma.account | object | The linked Figma account: { id, handle, email, imgUrl }. The token is never returned |
data.agent | object | Whether the first prompt actually started |
data.agent.started | boolean | false means the project exists but the run did not start — see warnings |
data.agent.status | string | undefined | "init" when the run started; absent otherwise |
data.agent.message | string | What to do next, in words |
data.warnings | array | Empty on the happy path. Each entry is a step that did not happen |
data.warnings[].step | string | "creditLimits" | "secrets" | "figma" | "files" | "agent" |
data.warnings[].errorCode | string | Why it failed |
data.warnings[].errorMessage | string | The reason, plus the endpoint that retries just that step |
A 200 means the project exists — not that everything you asked for happened.
Everything judged before the project is created (the body, your balance, the Figma token, your plan's project limit, the name) returns a 4xx and leaves nothing behind. But from the moment the project exists you have been charged and have taken a name in a global namespace, so a later failure cannot answer 4xx: that would tell you nothing happened, and your retry would collide with the project this very call just created.
So a secret that would not store, a Figma connect that failed, an upload that broke — or the prompt itself not starting — are reported in warnings, and each message names the endpoint that retries only that step. If agent.started is false, call POST /projects/{projectId}/agent/start with the same prompt.
1 credit for the project (CREATE_PROJECT) + 0.5 per inline attachment (UPLOAD_FILE; a url attachment costs nothing), then the agent run itself — typically 10 to 40 credits over 10 to 30 minutes.
The whole up-front bill is checked before anything is created, so you are never left with a project whose attachments were dropped for want of credits.
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"projectId": "barber-bookings",
"prompt": "Build a booking system for a barbershop, with Stripe payments and email confirmations",
"label": "Barber bookings",
"files": [
{ "name": "brand.png", "description": "our brand colours", "url": "https://cdn.example.com/brand.png" }
],
"creditLimits": { "maxDevelopmentCreditsPerMonth": 500 },
"secrets": [
{ "secretName": "STRIPE_SECRET_KEY", "secretValue": "sk_live_xxx", "environment": "production" }
]
}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects/launch{
"errors": null,
"data": {
"projectId": "barber-bookings",
"description": "",
"plan": "api",
"createdAt": "2026-08-24T10:30:00.000Z",
"label": "Barber bookings",
"creditLimits": {
"maxDevelopmentCreditsPerMonth": 500,
"maxInfrastructureCreditsPerMonth": 250
},
"files": [
{
"name": "brand.png",
"imageDescription": "our brand colours",
"url": "https://cdn.example.com/brand.png"
}
],
"secrets": [
{
"_id": "65f1a2b3c4d5e6f7a8b9c0d1",
"secretName": "STRIPE_SECRET_KEY",
"environment": "production"
}
],
"agent": {
"started": true,
"status": "init",
"message": "Process started, can take from 4 to 40 minutes."
},
"warnings": []
}
}{
"errors": null,
"data": {
"projectId": "barber-bookings-k7q",
"requestedProjectId": "barber-bookings",
"description": "",
"plan": "api",
"createdAt": "2026-08-24T10:30:00.000Z",
"creditLimits": {
"maxDevelopmentCreditsPerMonth": null,
"maxInfrastructureCreditsPerMonth": 250
},
"files": [],
"secrets": [],
"agent": {
"started": false,
"message": "The project was created but the first prompt did not start. Retry with POST /api/v1/vcaas/projects/barber-bookings-k7q/agent/start."
},
"warnings": [
{
"step": "agent",
"errorCode": "AGENT_START_FAILED",
"errorMessage": "An agent process is already running."
}
]
}
}Error codes
Nothing is created and nothing is charged for any of these.
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROMPT | 400 | prompt is required and must be a non-empty string |
INVALID_FILES | 400 | files is not an array, or an entry is not an object |
TOO_MANY_FILES | 400 | More than 10 attachments |
INVALID_FILE_NAME | 400 | An attachment has no name |
INVALID_FILE_SOURCE | 400 | An attachment has neither url nor content, or has both |
INVALID_FILE_URL | 400 | files[].url is not a public http(s) URL |
INVALID_FILE_CONTENT | 400 | files[].content is not valid base64 |
INVALID_SECRETS | 400 | secrets is not an array, or an entry is not an object |
TOO_MANY_SECRETS | 400 | More than 50 secrets |
MISSING_SECRET_FIELDS | 400 | A secret is missing secretName or secretValue |
DUPLICATE_SECRET_NAME | 400 | The same secretName appears twice |
INVALID_SECRET_ENVIRONMENT | 400 | Not one of development, production, both |
INVALID_CREDIT_LIMITS | 400 | creditLimits is not an object |
INVALID_LIMIT | 400 | A credit limit is not a positive number (or null) |
INVALID_FIGMA | 400 | figma is not an object |
MISSING_FIGMA_TOKEN | 400 | figma.token is missing or empty |
FIGMA_TOKEN_MALFORMED | 400 | That does not look like a Figma token — see Check a Figma Token |
FIGMA_TOKEN_INVALID | 400 | Figma rejected the token |
FIGMA_TOKEN_FORBIDDEN | 400 | Valid token, missing the scopes Totalum needs |
FIGMA_RATE_LIMITED | 400 | Figma is rate-limiting the check — retry shortly |
FIGMA_UNREACHABLE | 400 | Figma could not be reached to check the token |
RATE_LIMITED | 429 | Too many Figma token checks (10/minute per account) |
MISSING_PROJECT_ID | 400 | projectId is required |
INVALID_PROJECT_NAME | 400 | Invalid format. Lowercase letters, numbers and hyphens; must start with a letter |
INVALID_PROJECT_NAME_LENGTH | 400 | Project name must be between 4 and 35 characters |
INVALID_PROJECT_GROUP | 400 | The groupId does not exist, is not yours, or the group is full |
INSUFFICIENT_CREDITS | 402 | Not enough credits for the project, its attachments, or to start an agent run |
MAX_PROJECTS_REACHED | 403 | Your plan's project limit is already in use — see Create Project |
RATE_LIMIT_EXCEEDED | 429 | You are creating projects faster than your plan allows |
PROJECT_ALREADY_EXISTS | 409 | Only when the name and six suffixed candidates were all taken |
LAUNCH_PROJECT_ERROR | 400 | The project could not be created — the message carries the reason |
#List Projects
/api/v1/vcaas/projectsFreeGet your projects, most recently created first.
A single call returns up to 100 projects. If your account has more, the response is only the first page — use skip to page through the rest, or narrow the result with search. Check the X-Has-More response header to know whether more pages exist.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | number | No | Projects per page, 1-100 (default: 100) |
skip | number | No | Projects to skip, for paging (default: 0) |
search | string | No | Case-insensitive match on project ID and description |
sortField | string | No | date (default) orders by creation date; lastModified orders by when the project last changed. Any other value falls back to date |
sortDirection | string | No | desc (default) newest first, or asc for oldest first |
groupId | string | No | Return only the projects in one group. The literal value none returns only ungrouped projects |
createdFrom | string | No | Only projects created at or after this date. Inclusive. A full instant (2026-03-01T00:00:00.000Z) is used verbatim; a bare day (2026-03-01) is read as UTC |
createdTo | string | No | Only projects created at or before this date. Inclusive — a bare day is widened to that day's last millisecond, so createdFrom=X&createdTo=X returns that whole day |
This endpoint does not accept a plan filter. Each project's plan is still returned in data[].plan, but the list cannot be sliced by it — filter client-side if you need that.
A group id that is malformed, deleted, or belongs to another account is passed through and simply matches nothing. That is deliberate: the failure mode of a mistyped filter is "no projects", never "all of your projects".
Response headers
| Header | Type | Description |
|---|---|---|
X-Total-Count | number | Total projects matching the filters, across all pages |
X-Limit | number | Page size actually applied |
X-Skip | number | Offset actually applied |
X-Has-More | boolean | true when more projects exist beyond this page |
Response fields
| Field | Type | Description |
|---|---|---|
data | array | Array of project objects |
data[].projectId | string | The project ID |
data[].description | string | Project description |
data[].plan | string | Always "api" for projects created through the API |
data[].createdAt | string | ISO 8601 creation date |
data[].label | string | undefined | The display name. Absent when none is set — fall back to projectId |
data[].groupId | string | undefined | The group the project is filed under. Absent when ungrouped |
data[].previewImageUrl | string | undefined | Screenshot of the project's home page, refreshed whenever a prompt finishes. Absent until the first prompt of a project completes |
data[].lastModifiedAt | string | ISO 8601 date of the last change to the project — a prompt, a deploy, a rename, a file edit. Never null on any project, however old: it falls back through last finished run → screenshot time → updatedAt → createdAt, so you can render and compare it without a null branch. "Same as createdAt" is the truthful answer for a project nobody has touched since making it |
data[].productionProjectUrl | string | The project's production address, {projectId}.totalum-project.com |
On the list it is always the canonical {projectId}.totalum-project.com host. Get Project Details is the one that prefers an active custom domain — resolving that costs two extra calls per project, which on a page of 100 would turn the cheapest endpoint on this API into its most expensive.
That is safe to link to: a custom hostname is additive, so the canonical host keeps serving alongside it. But if you are showing the owner their own domain, read it from the detail endpoint for the one project you are displaying.
It is also not proof of a deployment. Nothing records whether a project has ever been published, so this is the address the project would serve from, and it 404s until the first successful deploy — exactly as the same field behaves on the detail endpoint. Use Get Deployment Status for the real answer.
previewImageUrl is returned on every list item on purpose, so a dashboard can draw its cards from this one call instead of a GET /projects/:projectId per tile.
Example request
curl -H "api-key: tlm_sk_your_key" \
https://api-accounts.totalum.app/api/v1/vcaas/projectsSecond page, 50 at a time:
curl -H "api-key: tlm_sk_your_key" \
"https://api-accounts.totalum.app/api/v1/vcaas/projects?limit=50&skip=50"Find a project by name instead of paging:
curl -H "api-key: tlm_sk_your_key" \
"https://api-accounts.totalum.app/api/v1/vcaas/projects?search=landing"Only the projects in one group, or only the ungrouped ones:
curl -H "api-key: tlm_sk_your_key" \
"https://api-accounts.totalum.app/api/v1/vcaas/projects?groupId=65f1a2b3c4d5e6f7a8b9c0d1"
curl -H "api-key: tlm_sk_your_key" \
"https://api-accounts.totalum.app/api/v1/vcaas/projects?groupId=none"{
"errors": null,
"data": [
{
"projectId": "my-app",
"description": "A SaaS landing page",
"plan": "api",
"createdAt": "2026-03-11T10:30:00.000Z",
"label": "Acme storefront",
"groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
"previewImageUrl": "https://storage.totalum.app/previews/my-app.png"
}
]
}Keep increasing skip by your limit while X-Has-More is true:
skip=0
while :; do
page=$(curl -s -D /tmp/h -H "api-key: tlm_sk_your_key" \
"https://api-accounts.totalum.app/api/v1/vcaas/projects?limit=100&skip=$skip")
echo "$page"
grep -qi '^x-has-more: true' /tmp/h || break
skip=$((skip + 100))
done{
"errors": {
"errorCode": "LIST_PROJECTS_ERROR",
"errorMessage": "Internal error listing projects"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
LIST_PROJECTS_ERROR | 400 | Internal error listing projects |
#Get Project Details
/api/v1/vcaas/projects/:projectIdFreeRetrieve full project info including status, deployment, secrets, and URLs. This is the main polling endpoint for project state.
Path parameters
| Parameter | Type | Description |
|---|---|---|
:projectId | string | The project ID |
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | The project ID |
data.label | string | undefined | The display name. Absent when none is set — fall back to projectId. Returned here as well as on the list so a single-project screen can show and edit the name without paging every project to find its own row |
data.groupId | string | undefined | The group the project is filed under. Absent when ungrouped |
data.previewImageUrl | string | undefined | Screenshot of the project's home page, refreshed whenever a prompt finishes. Absent until the first prompt completes |
data.description | string | Project description |
data.plan | string | Always "api" for projects created through the API |
data.agentProcessStatus | string | undefined | "init" (running) | "done" (finished) | "idle" (not started) |
data.agentServerStatus | string | undefined | "Active" | "Creating" | "Starting" | "Archived" | "Unarchiving" | "Archiving" |
data.createdAt | string | ISO 8601 creation date |
data.deployment | object | null | Latest deployment info, null if never deployed |
data.deployment.status | string | "deploying" | "success" | "error" |
data.deployment.createdAt | string | ISO 8601 deployment date |
data.deployment.versionId | string | undefined | Version ID that was deployed |
data.versionRecovery | object | null | Set while a recoverVersion call is running, otherwise null. This is the canonical signal for "is a version recovery in progress" — do NOT poll agentProcessStatus for recovery (the agent is not involved). |
data.versionRecovery.status | string | "recovering" (in progress) | "error" (last recovery failed) |
data.versionRecovery.versionId | string | The version ID currently being / last attempted being recovered |
data.versionRecovery.startedAt | string | ISO 8601 start time |
data.versionRecovery.errorMessage | string | undefined | Present when status="error" — surface this text to the user |
data.importInProgress | object | null | Set while an importProject call is restoring and rebuilding, otherwise null. This is the canonical signal for "is a project import in progress" — poll this until it is null rather than guessing from agentServerStatus, which looks identical on a cold sandbox. Reported as null once the import ends (success or failure) and also once the lock is older than 30 minutes, so it can never stay set for a job that died. |
data.importInProgress.startedAt | string | ISO 8601 start time, from the server that started the import — not the caller's clock |
data.importInProgress.errorMessage | string | undefined | Present when the import ended in failure |
data.secrets | array | List of secret names (values never returned) |
data.secrets[]._id | string | Secret ID (use for deletion) |
data.secrets[].secretName | string | Environment variable name |
data.secrets[].environment | string | "development" | "production" | "both" |
data.customDomain | object | null | Custom domain info, null if none configured |
data.customDomain.hostname | string | The custom domain hostname |
data.customDomain.status | string | "pending_validation" | "pending_deployment" | "active" | "blocked" |
data.customDomain.sslStatus | string | SSL certificate status |
data.customDomain.dnsRecordsToAdd | array | undefined | DNS records to configure: [{ type: "CNAME"|"TXT", name, value }] |
data.customDomain._id | string | The custom domain record ID |
data.customDomain.projectId | string | The project the domain belongs to |
data.customDomain.deploymentId | string | The deployment the domain is attached to |
data.customDomain.createdAt | string | ISO 8601 date the domain was added |
data.customDomain.updatedAt | string | ISO 8601 date the domain record last changed |
data.temporalDevelopmentProjectUrl | string | null | undefined | Live development preview URL (from the running dev server). May be null/undefined if no server has started yet. |
data.cachedDevelopmentUrl | string | null | undefined | Cached development preview URL (static snapshot, available when server is not active). May be null/undefined if the project has never been archived. |
data.developmentUrlFieldToUse | string | null | undefined | Which field to use for the development preview right now: "temporalDevelopmentProjectUrl" or "cachedDevelopmentUrl". If this field is null or undefined, default to temporalDevelopmentProjectUrl. |
data.productionProjectUrl | string | undefined | Production URL — custom domain if connected, otherwise {projectId}.totalum-project.com |
data.totalCreditsSpent | number | Total credits spent on this project |
data.creditLimits | object | Currently configured monthly credit limits for this project |
data.creditLimits.maxDevelopmentCreditsPerMonth | number | null | Max development credits/month (null = no limit) |
data.creditLimits.maxInfrastructureCreditsPerMonth | number | null | Max infrastructure credits/month (null = no limit) |
data.multiPrompt | object | null | Present only when a multi-prompt batch was started via POST /agent/start with multiPrompt. Same shape as on GET /agent/status. |
Use data.developmentUrlFieldToUse to decide which development URL to display. It returns the name of the response field containing the best URL for the current state. If it returns "cachedDevelopmentUrl", use data.cachedDevelopmentUrl; if it returns "temporalDevelopmentProjectUrl", use data.temporalDevelopmentProjectUrl. If developmentUrlFieldToUse is null or undefined, always fall back to data.temporalDevelopmentProjectUrl. The cached URL is a static snapshot available when the dev server is down (e.g. archived). Once the server is active and a prompt completes, it switches back to the live URL.
You MUST call GET /projects/:projectId and re-read the preview URL fields on these events: (1) when the user navigates to the project page, (2) when the user manually refreshes the page, and (3) every time a prompt finishes (agent status becomes "done"). The preview URL can change between these events. Always re-read developmentUrlFieldToUse after fetching and use it to pick the correct URL. Never cache the preview URL permanently. If you embed a dev preview URL or the production URL in an iframe, always provide an "Open in new tab" button next to it.
Example request
curl -H "api-key: tlm_sk_your_key" \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-app{
"errors": null,
"data": {
"projectId": "my-app",
"label": "Acme storefront",
"groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
"previewImageUrl": "https://storage.totalum.app/previews/my-app.png",
"description": "A SaaS landing page",
"plan": "api",
"agentProcessStatus": "done",
"agentServerStatus": "Active",
"createdAt": "2026-03-11T10:30:00.000Z",
"deployment": {
"status": "success",
"createdAt": "2026-03-11T11:00:00.000Z",
"versionId": "v_abc123"
},
"versionRecovery": null,
"importInProgress": null,
"secrets": [
{
"_id": "s1",
"secretName": "STRIPE_KEY",
"environment": "both"
}
],
"customDomain": {
"hostname": "app.mysite.com",
"status": "active",
"sslStatus": "active",
"dnsRecordsToAdd": [
{ "type": "CNAME", "name": "app", "value": "my-app.totalum-project.com" },
{ "type": "TXT", "name": "_cf-custom-hostname.app", "value": "verification-token" }
]
},
"temporalDevelopmentProjectUrl": "https://dev-my-app.totalum.app",
"cachedDevelopmentUrl": "https://my-app-dev-a1b2c3d4.totalum-project.com",
"developmentUrlFieldToUse": "temporalDevelopmentProjectUrl",
"productionProjectUrl": "app.mysite.com",
"totalCreditsSpent": 12.4,
"creditLimits": {
"maxDevelopmentCreditsPerMonth": 100,
"maxInfrastructureCreditsPerMonth": null
},
"multiPrompt": null
}
}{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project does not exist or you don't own it"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
#Update Project
/api/v1/vcaas/projects/:projectIdFreeChange a project's display label, its description, or the group it is filed under.
The project ID is the organization id and the production hostname, and there is no rename anywhere in the stack — which is exactly why label exists. Sending projectId in the body does nothing.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
label | string | null | No | Display name, trimmed and capped at 80 characters. null or an empty string clears it, and clients fall back to projectId |
description | string | null | No | Trimmed and capped at 500 characters. null or an empty string clears it |
groupId | string | null | No | An existing project group to file it under. null removes the project from its group |
These are different requests and behave differently. {} is rejected as a no-op, { "label": "New name" } touches nothing but the label, and { "label": null } removes it. A PATCH is never a full replacement here — updating one field can't blank another by omission.
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | Unchanged — the id is immutable |
data.label | string | undefined | The label after the update. Absent when cleared or never set |
data.description | string | The description after the update. Empty string when none is set |
data.groupId | string | undefined | The group after the update. Absent when the project is ungrouped |
Example request
curl -X PATCH \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"label":"Acme storefront","groupId":"65f1a2b3c4d5e6f7a8b9c0d1"}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-appRemove the label and take the project out of its group:
curl -X PATCH \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"label":null,"groupId":null}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-app{
"errors": null,
"data": {
"projectId": "my-app",
"label": "Acme storefront",
"description": "A SaaS landing page",
"groupId": "65f1a2b3c4d5e6f7a8b9c0d1"
}
}{
"errors": {
"errorCode": "NOTHING_TO_UPDATE",
"errorMessage": "Provide at least one of label, description or groupId"
},
"data": null
}GET /projects/:projectId serves a snapshot cached for a few seconds to absorb dashboard polling, and that snapshot carries label and groupId. A successful PATCH drops it, so an editing UI that saves a name and refetches straight away reads the new value, not the old one.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
NOTHING_TO_UPDATE | 400 | The body contained none of label, description or groupId |
INVALID_PROJECT_GROUP | 400 | The group does not exist, is not yours, or already holds the maximum of 100,000 projects |
UPDATE_PROJECT_ERROR | 400 | Internal error updating the project |
#Delete Project
/api/v1/vcaas/projects/:projectIdFreePermanently delete a project and all its data.
Path parameters
| Parameter | Type | Description |
|---|---|---|
:projectId | string | The project ID to delete |
Response fields
| Field | Type | Description |
|---|---|---|
data.success | boolean | true on successful deletion |
Example request
curl -X DELETE -H "api-key: tlm_sk_your_key" \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-app{ "errors": null, "data": { "success": true } }{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project does not exist or you don't own it"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
PLAN_NOT_API | 400 | Only API plan projects can be deleted from this API |
#Update Credit Limits
/api/v1/vcaas/projects/:projectId/credit-limitsFreeSet monthly spending caps per project for development and/or infrastructure credits. Set a field to null to remove that limit.
Defaults, on every project created through this API — whether by Launch Project or Create Project, they are the same:
| Category | Default | Meaning |
|---|---|---|
maxDevelopmentCreditsPerMonth | null | No cap. Agent runs, deploys and file writes are limited only by your account balance |
maxInfrastructureCreditsPerMonth | 250 | Capped. The app's own usage — emails, PDFs, AI calls, image generation — stops at 250 credits a month until you raise or remove it |
launch lets you override both in the same call that creates the project, so the caps cover the first run. This endpoint changes them afterwards.
Path parameters
| Parameter | Type | Description |
|---|---|---|
:projectId | string | The project ID |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
maxDevelopmentCreditsPerMonth | number or null | No | Max development credits per month (null to remove) |
maxInfrastructureCreditsPerMonth | number or null | No | Max infrastructure credits per month (null to remove) |
Response fields
| Field | Type | Description |
|---|---|---|
data.creditLimits.maxDevelopmentCreditsPerMonth | number or null | Current development limit |
data.creditLimits.maxInfrastructureCreditsPerMonth | number or null | Current infrastructure limit |
Example request
curl -X PATCH \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"maxDevelopmentCreditsPerMonth":500,"maxInfrastructureCreditsPerMonth":100}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-app/credit-limitsTo remove a limit, set it to null:
curl -X PATCH \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"maxDevelopmentCreditsPerMonth":null}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects/my-app/credit-limits{
"errors": null,
"data": {
"creditLimits": {
"maxDevelopmentCreditsPerMonth": 500,
"maxInfrastructureCreditsPerMonth": 100
}
}
}{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project doesn't exist or you don't own it"
},
"data": null
}Development spending is uncapped by default and limited only by your account balance; infrastructure is capped at 250 credits a month on every project created through this API (see the defaults table above). When a project reaches a limit, operations in that category return a 403 PROJECT_CREDIT_LIMIT_REACHED error. Limits reset automatically on the 1st of each month.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_LIMIT_FIELDS | 400 | At least one of the two limit fields is required |
INVALID_LIMIT | 400 | Amount must be a positive number |
PROJECT_NOT_FOUND | 404 | Project doesn't exist or you don't own it |
#Create Project (without building)
/api/v1/vcaas/projectsUses creditsCreate an empty project: no prompt, no build, nothing running.
This endpoint is not deprecated and is fully supported, but it is not the one to reach for. An empty project does nothing until you start a build, and getting from here to a running app takes four to six more calls in an order that is wrong three different ways when assembled by hand — Launch Project is that sequence in one call, and it is the default.
Use this one only when you genuinely want a project with no development started: to import an existing project into it, to connect GitHub before any code exists, or to write the files yourself.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string | Yes | 4-35 chars, lowercase letters + numbers + hyphens, must start with letter. Permanent — it is the organization id and the production hostname, and cannot be renamed later |
description | string | No | Project description, max 500 characters |
label | string | No | Human display name, max 80 characters. This is the part you can change later — see Update Project. Omitted → clients show the projectId |
groupId | string | No | File the project under an existing project group. Omitted → ungrouped |
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | The project ID |
data.description | string | Project description |
data.plan | string | Always "api" for projects created through the API |
data.createdAt | string | ISO 8601 creation date |
data.label | string | undefined | The display name, when one was given. Absent when not set |
data.groupId | string | undefined | The group the project was filed under. Absent when ungrouped |
The group is resolved before the project is created and before any credits are spent, so an unknown, malformed, someone else's or a full group returns 400 INVALID_PROJECT_GROUP and nothing is created. You are never left with a charged project that silently landed outside the folder you asked for.
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"projectId":"my-app","description":"A SaaS landing page","label":"Acme storefront"}' \
https://api-accounts.totalum.app/api/v1/vcaas/projects{
"errors": null,
"data": {
"projectId": "my-app",
"description": "A SaaS landing page",
"plan": "api",
"createdAt": "2026-03-11T10:30:00.000Z",
"label": "Acme storefront"
}
}{
"errors": {
"errorCode": "PROJECT_ALREADY_EXISTS",
"errorMessage": "A project with this name already exists"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
INVALID_PROJECT_NAME | 400 | Invalid format. Use lowercase letters, numbers, and hyphens. Must start with a letter |
INVALID_PROJECT_NAME_LENGTH | 400 | Project name must be between 4 and 35 characters |
PROJECT_ALREADY_EXISTS | 409 | A project with this name already exists |
INVALID_PROJECT_GROUP | 400 | The groupId does not exist, is not yours, or the group is full |
INSUFFICIENT_CREDITS | 402 | Not enough credits for this operation |
MAX_PROJECTS_REACHED | 403 | Your plan's project limit is already in use — see below |
RATE_LIMIT_EXCEEDED | 429 | You are creating projects faster than your plan allows |
Your account may hold a maximum number of projects at once, and may create them at a maximum rate. Both come from the plan on the account:
| Plan | Projects | New projects |
|---|---|---|
| Free | 2 | 1 every 5 min · 12/hour |
| Starter | 10 | 5/min · 60/hour |
| Basic | 50 | 10/min · 120/hour |
| Professional | 300 | 30/min · 360/hour |
| Enterprise | unlimited | 100/min · 1 200/hour |
The two refusals need different handling, which is why they are different codes:
403 MAX_PROJECTS_REACHED— retrying never succeeds. Delete a project (the slot frees immediately) or move up a plan. The response carrieserrorDetailswithmaxProjects,projectsUsed,planandupgradePlanso a client can act without parsing the message.429 RATE_LIMIT_EXCEEDED— clears on its own. Back off and retry.
A create that fails validation or hits a name collision does not consume your rate allowance.
