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.
#Create Project
/api/v1/vcaas/projectsUses creditsCreate a new vibe coding project.
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 |
RATE_LIMIT_EXCEEDED | 429 | Maximum 10 project creations per 60 seconds |
#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 |
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 |
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 |
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.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.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,
"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. By default, projects have no credit limits. Set a field to null to remove that limit.
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
}By default, projects have no credit limits — all operations are allowed as long as you have credits in your account. When a project reaches its monthly 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 |
