# Totalum App Builder API — Full reference

This file is the complete reference for this area. The short guide for agents is https://www.totalum.app/totalum-api.md.

- Base URL: `https://builder-api.totalum.app/api/v1/vcaas`
- Authentication: header `api-key: tlm_sk_…` on every request, from a server only.
- Response envelope: `{ "errors": null | { "errorCode", "errorMessage", "errorDetails?" }, "data": … }`.


---

# Totalum App Builder API

> Create real apps from a prompt, programmatically.

The **Totalum App Builder API** turns a prompt into a working, hosted, full-stack app — and lets you keep changing it. You send a prompt; Totalum's AI agent writes a real Next.js app with its own database, sign-in, file storage, AI and payments built in; Totalum hosts it, gives it a preview and publishes it when you say so. Use it to put an app builder inside your product, to let an AI agent ship apps, or to run your own white-label builder.

:::note Projects created before 8 October 2026
Projects created before 8 October 2026, or without the v2 engine, are **legacy projects**: they keep working, but some endpoints and features on this page do not apply to them. See [Legacy projects: what differs](https://www.totalum.app/docs/api/migration-v2).
:::

## Get your API key

1. Open **API** in your [Totalum account](https://platform.totalum.app/api). A key is created for you; reveal and copy it (`tlm_sk_…`). You can create more keys and restrict a key to some projects.
2. Send it in the `api-key` header of every request — **from your server only**, never from browser or mobile code.
3. Keep a credit balance: actions that spend need at least 1 credit. The API works on every plan, including the free one.

```bash
curl -H "api-key: tlm_sk_your_key" https://builder-api.totalum.app/api/v1/vcaas/account
```

The older host `https://api-accounts.totalum.app` answers the same paths, so existing integrations keep working.

## The development cycle

```text
launch ─▶ prompt ─▶ preview ─▶ prompt ─▶ preview … ─▶ publish ─▶ live site
 (dev database, preview URL)                          (live database, your domain)
```

1. **[Launch Project](https://www.totalum.app/docs/api/projects#launch-project)** creates the project and runs the first prompt.
2. Poll **[Get Agent Status](https://www.totalum.app/docs/api/agent#get-agent-status)** (every 10–15 s) until `status` is `"done"`; show [progress](https://www.totalum.app/docs/api/agent#get-agent-progress) meanwhile.
3. Open the **development preview**: `temporalDevelopmentProjectUrl` from [Get Project](https://www.totalum.app/docs/api/projects#get-project). Every prompt that changes code is saved as a [version](https://www.totalum.app/docs/api/versions) and deployed to the preview, on the project's **development database**.
4. Send more prompts with **[Run AI Agent](https://www.totalum.app/docs/api/agent#run-ai-agent)**.
5. **[Publish](https://www.totalum.app/docs/api/deployments#deploy-to-production)** when it is right: the version goes live at `<projectId>.totalum-project.com` (and your [custom domain](https://www.totalum.app/docs/api/domains)), and its database changes are applied to the **live database**. Poll [Get Deployment Status](https://www.totalum.app/docs/api/deployments#get-deployment-status).

Development and production are like two branches: prompts, file edits and restores only ever change **dev**; only a publish changes **live**. A publish that would delete live data asks for confirmation.

## What you can do

| Area | Endpoints |
|---|---|
| Build | [Projects](https://www.totalum.app/docs/api/projects) · [AI Agent](https://www.totalum.app/docs/api/agent) · [Publishing](https://www.totalum.app/docs/api/deployments) · [Versions](https://www.totalum.app/docs/api/versions) · [Project Files](https://www.totalum.app/docs/api/project-files) · [Visual Edits](https://www.totalum.app/docs/api/visual-edit) |
| Data | [Database](https://www.totalum.app/docs/api/database) · [Backups & Restore](https://www.totalum.app/docs/api/database-backups) · [Source Code & Uploads](https://www.totalum.app/docs/api/source-files) · [Export & Import](https://www.totalum.app/docs/api/transfer) |
| Run | [Logs](https://www.totalum.app/docs/api/server) · [Usage & Analytics](https://www.totalum.app/docs/api/analytics) · [Spending & Rate Limits](https://www.totalum.app/docs/api/limits) · [Scheduled Jobs](https://www.totalum.app/docs/api/cron-jobs) · [Secrets, Keys & .env](https://www.totalum.app/docs/api/secrets) |
| Ship | [Custom Domains](https://www.totalum.app/docs/api/domains) · [Buy a Domain](https://www.totalum.app/docs/api/domain-registration) · [Mobile Apps](https://www.totalum.app/docs/api/mobile) · [Payments](https://www.totalum.app/docs/api/payments) |
| Connect | [Webhooks](https://www.totalum.app/docs/api/webhooks) · [GitHub](https://www.totalum.app/docs/api/github) · [Figma](https://www.totalum.app/docs/api/figma) · [Project Groups](https://www.totalum.app/docs/api/project-groups) · [Account](https://www.totalum.app/docs/api/account) |
| Reference | [Errors & Limits](https://www.totalum.app/docs/api/errors) · [OpenAPI 3.1](https://www.totalum.app/openapi.json) · [Docs for AI agents](https://www.totalum.app/docs/ai-agents) · [MCP server](https://www.totalum.app/docs/mcp) |

## What every generated app includes

Each project is a standard **Next.js** app (App Router, React, Tailwind CSS) that runs on **Cloudflare Workers**, with its own SQL database (two of them: development and live), sign-in, file storage and the [Totalum SDK](https://www.totalum.app/docs/sdk) for e-mail, AI (text, images, video, speech), document scanning, web scraping, a cloud browser, PDFs, payments, real-time, scheduled jobs and vector search. Mention what you need in the prompt; no keys to set up. Read [For Developers](https://www.totalum.app/docs/developers) for the stack, and the code is yours: [download it](https://www.totalum.app/docs/api/source-files#download-source-code) or [sync it to GitHub](https://www.totalum.app/docs/api/github) any time.

## Response envelope

```json
{ "errors": null, "data": { "projectId": "barber-bookings" } }
```

On failure `data` is `null` and `errors` is `{ errorCode, errorMessage, errorDetails? }`. Branch on `errorCode`. All codes, limits and retry rules: [Errors & Limits](https://www.totalum.app/docs/api/errors).


---

# Errors & Limits

> What can go wrong, and the numbers to design for.

## The envelope

Every response is JSON in the same envelope. On success `errors` is `null`; on failure `data` is `null` and `errors` has a stable `errorCode` (branch on it), a human `errorMessage` (show it, never parse it) and sometimes `errorDetails`.

```json
{ "errors": { "errorCode": "AGENT_RUNNING", "errorMessage": "An agent run is already in progress", "errorDetails": { "runId": "run_01k6xc4n7d" } }, "data": null }
```

Legacy projects and the older host `api-accounts.totalum.app` keep two older shapes: `403 VCAAS_INSUFFICIENT_CREDITS` answers `{ "error", "code" }` and a secret that fails validation `{ "success": false, "message", "errors": [...] }` ([Legacy projects](https://www.totalum.app/docs/api/migration-v2#what-behaves-differently)).

## Errors any endpoint can answer

| Code | HTTP | Meaning | What to do |
|---|---|---|---|
| `invalid token` / `Unauthorized api key` | 401 | Missing or unknown `api-key` header | Send a valid `tlm_sk_` key |
| `VCAAS_INSUFFICIENT_CREDITS` | 403 | Your balance is below 1 credit (reads and deleting a project still work) | Add credits |
| `PROJECT_NOT_FOUND` | 404 | No project with this id in your account | Check the id ([List Projects](https://www.totalum.app/docs/api/projects#list-projects)) |
| `PROJECT_NOT_ALLOWED` | 403 | Your key is restricted to other projects | Use a key with access |
| `API_ACCESS_DISABLED` | 403 | API access is switched off for this project in your account settings | Switch it on |
| `PROJECT_NOT_READY` | 409 | The project is still being set up (its first seconds) | Retry after `Retry-After` seconds |
| `INSUFFICIENT_CREDITS` | 402 | The action costs more than your balance | Add credits |
| `PROJECT_CREDIT_LIMIT_REACHED` | 403 | The project's monthly cap for that category is reached | [Raise the cap](https://www.totalum.app/docs/api/limits#update-credit-limits) |
| `AGENT_RUNNING` | 409 | A prompt is running | Wait for `agent/status` to be `"done"` |
| `DEPLOYMENT_RUNNING` | 409 | A publish is running | Wait for `deployments/status` to leave `"deploying"` |
| `OPERATION_IN_PROGRESS` | 409 | Another long operation (restore, import, pause, database move…) holds the project | Retry when it ends ([Get Project State](https://www.totalum.app/docs/api/projects#get-project-state)) |
| `PROJECT_PAUSED_BY_USER` | 409 | The project is paused | [Resume](https://www.totalum.app/docs/api/projects#resume-project) it |
| `PROJECT_PAUSED_RUNAWAY` | 429 | The emergency stop paused the project after unusual usage (it lifts by itself after 24 hours) | Fix the cause, then [release it](https://www.totalum.app/docs/api/projects#release-runaway-pause) |
| `AI_MEDIA_PAUSED_THIS_HOUR` | 429 | The app used a lot of credits this hour, so its image, video and speech AI calls are paused until the hour ends (UTC); text AI and everything else keep working | Wait for `Retry-After` (the start of the next hour) |
| `RATE_LIMIT_EXCEEDED` / `RATE_LIMITED` | 429 | Too many requests | Back off; honour `Retry-After` |
| `VALIDATION_ERROR` | 400 | A field is missing or has the wrong type | Fix the request |
| `INVALID_JSON` | 400 | The body is not valid JSON | Fix the body |
| `PAYLOAD_TOO_LARGE` | 413 | The JSON body is over 1 MB | Upload files with [Upload File](https://www.totalum.app/docs/api/source-files#upload-file) |
| `ROUTE_NOT_FOUND` | 404 | No endpoint at this path | Check the path |
| `METHOD_NOT_ALLOWED` | 405 | The path exists for other methods (listed in the `Allow` header) | Use one of them |
| `ENGINE_UNAVAILABLE` | 503 | A temporary failure | Retry in a few seconds |
| `INTERNAL_ERROR` | 500 | An unexpected error | Retry; contact support if it persists |

**Retrying.** `4xx` means the request must change (except `409` and `429`, which clear by themselves). `503` and the `PROJECT_NOT_READY` `409` are temporary: retry after `Retry-After`. Every long operation (prompt, publish, restore, import) answers at once and is followed by polling; never retry the start call while the first one is still running.

## Limits

The API's own limits. Plan, project, database and built-in feature limits are all on [Limits](https://www.totalum.app/docs/limits).

| What | Limit |
|---|---|
| Authentication | One `api-key` header per request, kept on your server |
| JSON request body | 1 MB |
| File upload | 20 MB per file ([Upload File](https://www.totalum.app/docs/api/source-files#upload-file)); 300 uploads a minute |
| Prompt | 40,000 characters, up to 10 attachments |
| Multi-prompt batch | 50 prompts |
| One prompt's run | The agent works up to about 40 minutes, then wraps up and saves its work |
| Concurrency per project | One prompt, one publish, one restore or import at a time |
| Requests per API key | Free 10 a second, paid plans 100, Enterprise 1,000 (counted each second, Enterprise over 10 s); over it `429 RATE_LIMITED` with `Retry-After` |
| Prompts per account | All projects together: as many a minute as the plan's new projects a minute (a create counts as one), and twice that many running at once — see [Limits](https://www.totalum.app/docs/limits); over it `429 RATE_LIMITED` with `Retry-After` |
| Project creation | Per plan: Free 2 projects (1 a minute), Starter 10, Builder 50, Professional 300, Enterprise unlimited — see [Create Project](https://www.totalum.app/docs/api/projects#create-project) |
| Project id | 4–35 characters: lowercase letters, digits and single hyphens, starting with a letter |
| File read / write through the API | 1 MB read, 512 KB write; 300 writes a minute |
| Secrets | 500 per project, values up to 10,000 characters |
| Scheduled jobs | 2 (Free), 10 (Starter) or 20 per environment; every hour (Free) or every minute (paid) at most |
| Export | 5 an hour per project |
| Webhooks | One unfiltered subscription per event; deliveries time out after 10 s |
| The app's built-in features | 100 calls a second per project; 20 paid calls a minute per visitor (AI, web, browser) |
| Database (per environment) | 10 GB on the default database (move to Turso for more); a query runs up to 30 s; 100 bound parameters per query |
| Published site | 5,000 requests a minute per visitor by default ([rate limit](https://www.totalum.app/docs/api/limits#get-rate-limit)) |

## Plans and credits

Every plan can use the API, including the free one. Everything is paid in **credits**: plan credits renew each billing period and are spent first; purchased credits never expire.

- **Prompts** are metered by the work done: at least 3 credits, usually 3 to 50. A failed run that built nothing is refunded.
- **Fixed-price actions** (create, publish, restore, export…) are listed by [Get Credit Costs](https://www.totalum.app/docs/api/account#get-credit-costs).
- **The running app** (hosting, database, files, AI, e-mail, scraping…) is metered per use; a published project on a paid plan also pays a small monthly fee, and an active custom domain another.
- Cap each project with [credit limits](https://www.totalum.app/docs/api/limits) and follow spending with [Usage & Analytics](https://www.totalum.app/docs/api/analytics).


---

# Projects

> Create, inspect and manage your apps.

A project is one app: its source code, its preview, its published site, its own databases and files. **Start with [Launch Project](#launch-project)** — it creates the project and starts building it in one call. Every endpoint needs the `api-key` header and answers the `{ "errors", "data" }` envelope.

## Launch Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/launch
cost: 2 credits + the first prompt
```

Create a project **and start building it** with its first prompt, in one call. Secrets, attachments, credit limits and a Figma token sent here are in place before the first build starts, which is the order the build needs.

:::tip Control your costs
Every project has two monthly caps: **development** (what the AI agent and project actions spend) and **infrastructure** (what the running app spends on hosting, database, AI, e-mail…). Projects created through the API start at 5,000 and 300 credits a month. Set your own in `creditLimits` here or in [Create Project](#create-project), and change them any time with [Update Credit Limits](https://www.totalum.app/docs/api/limits#update-credit-limits).
:::

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | Yes | Your preferred id: 4–35 characters, lowercase letters, digits and single hyphens, starting with a letter, never containing `-dev-`. It becomes the address `<projectId>.totalum-project.com`. **If it is taken, a short random suffix is added** and `requestedProjectId` is returned |
| `prompt` | string | Yes | What to build (the first prompt, up to 40,000 characters) |
| `tier` | string | No | `"auto"` (default), `"cheap"`, `"normal"` or `"expensive"` — see [agent tiers](https://www.totalum.app/docs/api/agent#agent-tiers). Also accepted as `model` or `modelTier` |
| `engineVersion` | string | No | `"v2"` to require the current engine. A request that cannot get it is refused (`ENGINE_V2_DISABLED`, `ENGINE_REQUEST_NOT_ELIGIBLE`) and nothing is created. New accounts create every project on the current engine, with or without it (`"legacy"` is refused: `ENGINE_LEGACY_UNAVAILABLE`); on an older account a create without it can be a [legacy project](https://www.totalum.app/docs/api/migration-v2) |
| `label` | string | No | Display name, up to 80 characters. Can be changed later; the id cannot |
| `description` | string | No | Up to 500 characters |
| `groupId` | string | No | An existing [project group](https://www.totalum.app/docs/api/project-groups) |
| `dataResidency` | string | No | `"global"` or `"eu"` (data kept in the EU only). Default: your account's setting ([Set Data Residency](https://www.totalum.app/docs/api/account#set-data-residency)), else `"global"`. Fixed for the project's life |
| `audienceCountry` | string | No | Two-letter country code of the app's users (`"US"`); the database is placed nearest them. Default: your account's country. Never makes a project EU-only |
| `files` | object[] | No | Up to 10 attachments for the first prompt. Each has exactly one of `url` or `content` |
| `files[].name` | string | Yes | File name, e.g. `logo.png` |
| `files[].description` | string | No | What the file is for ("use as the logo"). `imageDescription` is accepted too |
| `files[].url` | string | No | Public `http(s)` URL of a hosted file |
| `files[].content` | string | No | The file as base64 (a `data:…;base64,` prefix is accepted). Uploaded for you, free |
| `secrets` | object[] | No | Up to 50 environment variables, stored before the build starts |
| `secrets[].secretName` | string | Yes | Uppercase letters, digits and `_`, starting with a letter (`STRIPE_KEY`) |
| `secrets[].secretValue` | string | Yes | 1–10,000 characters. Never returned again |
| `secrets[].environment` | string | No | `"development"`, `"production"` or `"both"` (default) |
| `creditLimits` | object | No | Monthly caps applied from the first prompt. Set them to control your costs — see [Spending & Rate Limits](https://www.totalum.app/docs/api/limits) |
| `creditLimits.maxDevelopmentCreditsPerMonth` | number \| null | No | Cap on what the AI agent and project actions spend (prompts, builds, publishing). Default `5000` for projects created through the API; `null` = no cap |
| `creditLimits.maxInfrastructureCreditsPerMonth` | number \| null | No | Cap on what the running app spends (hosting, database, AI, e-mail…). Default `300` for projects created through the API; `null` = no cap |
| `figma` | object | No | Connect Figma so the agent can read designs linked in the prompt |
| `figma.token` | string | Yes | A Figma personal access token, checked before anything is created |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | The id that was created. **Use this one from now on** |
| `data.requestedProjectId` | string \| undefined | The id you asked for, only when the created one differs |
| `data.description` | string | Project description |
| `data.plan` | string | Always `"api"` |
| `data.createdAt` | string | ISO 8601 creation time |
| `data.label` | string \| undefined | The label, when one was given |
| `data.groupId` | string \| undefined | The group, when one was given |
| `data.dataResidency` | string | `"global"` or `"eu"` |
| `data.engineVersion` | string | `"v2"` |
| `data.engineState` | string \| undefined | Present only when the project was still being set up when the call answered (`"provisioning"`); the build starts by itself |
| `data.creditLimits` | object | The caps stored on the project, defaults included |
| `data.files` | object[] | The attachments the agent received: `{ name, imageDescription, url }` |
| `data.secrets` | object[] | The secrets stored: `{ _id, secretName, environment }` (never the value) |
| `data.figma` | object \| undefined | `{ connected: true, account }` when Figma was connected |
| `data.agent.started` | boolean | `false` = the project exists but the first prompt did not start (see `warnings`) |
| `data.agent.status` | string \| undefined | `"init"` when the run started |
| `data.agent.runId` | string \| undefined | The run's id |
| `data.agent.message` | string | What to do next |
| `data.agent.expectedMinutes` | number \| undefined | Estimated minutes for the first prompt — an estimate, never a deadline |
| `data.agent.expectedFinishAt` | string \| undefined | Start time + `expectedMinutes` (ISO 8601) |
| `data.agent.warnings` | string[] \| undefined | Run options that were ignored |
| `data.warnings` | object[] | Steps that did not happen after the project was created: `{ step, errorCode, errorMessage }`; `step` is `"secrets"`, `"figma"`, `"files"` or `"agent"`, and the message names the endpoint that retries it |

:::warning A 200 means the project exists — read `agent.started` and `warnings`
Everything checked before creation (body, balance, Figma token, project limit, name) answers `4xx` and creates nothing. After creation a failed step is a warning, never an error: if `agent.started` is `false`, call [Run AI Agent](https://www.totalum.app/docs/api/agent#run-ai-agent) with the same prompt.
:::

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"barber-bookings","prompt":"Build a booking site for a barbershop with online payments","label":"Barber bookings","creditLimits":{"maxDevelopmentCreditsPerMonth":500}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/launch
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "description": "",
    "plan": "api",
    "createdAt": "2026-10-08T10:30:00.000Z",
    "label": "Barber bookings",
    "dataResidency": "global",
    "creditLimits": { "maxDevelopmentCreditsPerMonth": 500, "maxInfrastructureCreditsPerMonth": 300 },
    "files": [],
    "secrets": [],
    "agent": {
      "started": true,
      "status": "init",
      "runId": "run_01k6x8m2c9",
      "message": "Process started. Estimated time: about 9 minutes. This is only an approximation…",
      "expectedMinutes": 9,
      "expectedFinishAt": "2026-10-08T10:39:00.000Z"
    },
    "warnings": [],
    "engineVersion": "v2"
  }
}
```
:::

**Error codes**

Nothing is created or charged for any of these.

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_PROMPT` | 400 | `prompt` is missing or empty |
| `MISSING_PROJECT_ID` | 400 | `projectId` is missing |
| `INVALID_PROJECT_NAME` | 400 | Not lowercase letters, digits and single hyphens starting with a letter |
| `INVALID_PROJECT_NAME_LENGTH` | 400 | Not 4–35 characters |
| `RESERVED_PROJECT_NAME` | 400 | The id contains `-dev-` |
| `INVALID_FILES` / `TOO_MANY_FILES` | 400 | `files` is not an array of objects, or has more than 10 items |
| `INVALID_FILE_NAME` / `INVALID_FILE_SOURCE` / `INVALID_FILE_URL` / `INVALID_FILE_CONTENT` | 400 | An attachment has no name, has both or neither of `url`/`content`, a non-http(s) URL, or invalid base64 |
| `INVALID_SECRETS` / `TOO_MANY_SECRETS` / `MISSING_SECRET_FIELDS` / `DUPLICATE_SECRET_NAME` / `INVALID_SECRET_ENVIRONMENT` | 400 | A malformed `secrets` list |
| `INVALID_CREDIT_LIMITS` / `INVALID_LIMIT` | 400 | `creditLimits` is not an object, or a cap is not a positive number or `null` |
| `INVALID_FIGMA` / `MISSING_FIGMA_TOKEN` / `FIGMA_TOKEN_MALFORMED` / `FIGMA_TOKEN_INVALID` / `FIGMA_TOKEN_FORBIDDEN` | 400 | The Figma token is missing or was rejected |
| `INVALID_PROJECT_GROUP` | 400 | The group does not exist, is not yours, or is full |
| `INVALID_DATA_RESIDENCY` | 400 | `dataResidency` is not `"global"` or `"eu"` |
| `INVALID_AUDIENCE_COUNTRY` | 400 | `audienceCountry` is not a two-letter code |
| `INVALID_ENGINE_VERSION` | 400 | `engineVersion` has an unknown value |
| `INSUFFICIENT_CREDITS` | 402 | Your balance cannot pay the project (or is below 1 credit, needed to start the agent) |
| `MAX_PROJECTS_REACHED` | 403 | Your plan's project limit is in use. `errorDetails`: `maxProjects`, `projectsUsed`, `plan`, `upgradePlan` |
| `ENGINE_REQUEST_NOT_ELIGIBLE` | 403 | `engineVersion: "v2"` was asked but this account cannot create projects on it yet |
| `VCAAS_INSUFFICIENT_CREDITS` | 403 | Your balance is below 1 credit |
| `PROJECT_ALREADY_EXISTS` | 409 | The name and every suffixed candidate were taken |
| `ENGINE_V2_DISABLED` | 409 | New projects cannot be created on the current engine right now |
| `ENGINE_LEGACY_UNAVAILABLE` | 409 | `engineVersion: "legacy"`, or a legacy (`.zip`) export code, on a new account: it creates only on the current engine |
| `RATE_LIMIT_EXCEEDED` | 429 | You are creating projects faster than your plan allows (new projects and prompts share one per-minute allowance, see [Limits](https://www.totalum.app/docs/limits)) — retry after `Retry-After` seconds |
| `RATE_LIMITED` | 429 | Your account has the most prompts running at once its plan allows, or too many Figma token checks (10 a minute) — retry after `Retry-After` seconds |
| `LAUNCH_PROJECT_ERROR` | 503 | A temporary failure; nothing was created. Retry after `Retry-After` seconds |

## Create Project

```endpoint
method: POST
path: /api/v1/vcaas/projects
cost: 2 credits
```

Create an **empty** project: no prompt, nothing built. Use it only when you will [import](https://www.totalum.app/docs/api/transfer#import-project) a project into it, [connect GitHub](https://www.totalum.app/docs/api/github#connect-github-repository) first, or [write the files](https://www.totalum.app/docs/api/project-files#write-file-content) yourself; otherwise use [Launch Project](#launch-project).

The project is set up in the background (about 15 seconds); a write sent before it is ready waits for it.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | Yes | Same rules as [Launch Project](#launch-project); a taken id gets a suffix |
| `label` | string | No | Display name, up to 80 characters |
| `description` | string | No | Up to 500 characters |
| `groupId` | string | No | An existing project group |
| `engineVersion` | string | No | `"v2"` to require the current engine (new accounts always get it), as in Launch Project |
| `dataResidency` | string | No | `"global"` or `"eu"`, as in Launch Project |
| `audienceCountry` | string | No | Two-letter country code of the app's users |
| `sourceImportCode` | string | No | An `importCode` from [Export Project](https://www.totalum.app/docs/api/transfer#export-project): the new project is created to receive it (checked before anything is created) |
| `creditLimits` | object | No | The project's monthly spending caps, as in [Launch Project](#launch-project) |
| `creditLimits.maxDevelopmentCreditsPerMonth` | number \| null | No | Cap on what the AI agent and project actions spend. Default `5000`; `null` = no cap |
| `creditLimits.maxInfrastructureCreditsPerMonth` | number \| null | No | Cap on what the running app spends. Default `300`; `null` = no cap |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | The id that was created. Use this one |
| `data.requestedProjectId` | string \| undefined | The id you asked for, when it differs |
| `data.description` | string | Project description |
| `data.plan` | string | Always `"api"` |
| `data.createdAt` | string | ISO 8601 creation time |
| `data.label` | string \| undefined | The label, when given |
| `data.groupId` | string \| undefined | The group, when given |
| `data.dataResidency` | string | `"global"` or `"eu"` |
| `data.engineVersion` | string | `"v2"` |
| `data.engineState` | string | `"provisioning"` — the project is being set up |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"crm-import","label":"CRM (imported)","creditLimits":{"maxDevelopmentCreditsPerMonth":1000,"maxInfrastructureCreditsPerMonth":500}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "crm-import",
    "description": "",
    "plan": "api",
    "createdAt": "2026-10-08T10:30:00.000Z",
    "label": "CRM (imported)",
    "dataResidency": "global",
    "engineVersion": "v2",
    "engineState": "provisioning"
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_PROJECT_ID` / `INVALID_PROJECT_NAME` / `INVALID_PROJECT_NAME_LENGTH` / `RESERVED_PROJECT_NAME` | 400 | The id is missing or malformed |
| `INVALID_PROJECT_GROUP` | 400 | Unknown or full group |
| `INVALID_DATA_RESIDENCY` / `INVALID_AUDIENCE_COUNTRY` / `INVALID_ENGINE_VERSION` | 400 | An invalid option |
| `INVALID_CREDIT_LIMITS` / `INVALID_LIMIT` | 400 | `creditLimits` is not an object, or a cap is not a positive number or `null` |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits for the project |
| `MAX_PROJECTS_REACHED` | 403 | Your plan's project limit is in use |
| `PROJECT_ALREADY_EXISTS` | 409 | The name and every suffixed candidate were taken |
| `ENGINE_V2_DISABLED` | 409 | New projects cannot be created on the current engine right now |
| `ENGINE_LEGACY_UNAVAILABLE` | 409 | `engineVersion: "legacy"`, or a legacy (`.zip`) export code, on a new account: it creates only on the current engine |
| `RATE_LIMIT_EXCEEDED` | 429 | Too many creates for your plan (new projects and prompts share one per-minute allowance, see [Limits](https://www.totalum.app/docs/limits)); retry after `Retry-After` seconds |
| `CREATE_PROJECT_ERROR` | 503 | A temporary failure; nothing was created. Retry after `Retry-After` seconds |

Project limits per plan:

| Plan | Projects at once | New projects |
|---|---|---|
| Free | 2 | 1 a minute |
| Starter | 10 | 5 a minute |
| Builder | 50 | 10 a minute |
| Professional | 300 | 30 a minute |
| Enterprise | Unlimited | 100 a minute |

## List Projects

```endpoint
method: GET
path: /api/v1/vcaas/projects
cost: Free
```

Your projects, newest first, up to 100 per page. The total is in the `X-Total-Count` header and `X-Has-More` says whether another page exists.

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `limit` | number | No | 1–100 (default 100) |
| `skip` | number | No | Projects to skip, for paging (default 0) |
| `search` | string | No | Case-insensitive match on id, label and description |
| `sortField` | string | No | `date` (default, creation time) or `lastModified` |
| `sortDirection` | string | No | `desc` (default) or `asc` |
| `groupId` | string | No | Only the projects of one group; `none` = only ungrouped projects |
| `createdFrom` | string | No | Created at or after this date (ISO 8601; a bare day is UTC) |
| `createdTo` | string | No | Created at or before this date (a bare day includes the whole day) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data[].projectId` | string | Project id |
| `data[].description` | string | Description |
| `data[].plan` | string | `"api"` |
| `data[].createdAt` | string | ISO 8601 creation time |
| `data[].label` | string \| undefined | Display name, when set |
| `data[].groupId` | string \| undefined | Group, when filed in one |
| `data[].previewImageUrl` | string \| undefined | Screenshot of the home page, once a prompt has finished |
| `data[].lastModifiedAt` | string | Last change (prompt, publish, edit); never null |
| `data[].productionProjectUrl` | string | The published host, as on [Get Project](#get-project): the custom domain once it is active, else `<projectId>.totalum-project.com` (or `<projectId>.webapp-project.com` when chosen) |
| `data[].engineVersion` | string | `"v2"` |
| `data[].liveHostSuffix` | string \| undefined | `"webapp-project.com"` when chosen ([Update Project](#update-project)); absent otherwise |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects?limit=20&sortField=lastModified"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": [
    {
      "projectId": "barber-bookings",
      "description": "",
      "plan": "api",
      "createdAt": "2026-10-08T10:30:00.000Z",
      "label": "Barber bookings",
      "previewImageUrl": "https://files.totalum.app/previews/barber-bookings.png",
      "lastModifiedAt": "2026-10-08T10:41:00.000Z",
      "productionProjectUrl": "barber-bookings.totalum-project.com",
      "engineVersion": "v2"
    }
  ]
}
```
:::

## Get Project

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId
cost: Free
```

Everything about one project: its URLs, the agent's state, the last publish, its domain, secrets and spending. Read it again after every prompt: the preview address can change.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.label` | string \| undefined | Display name, when set |
| `data.groupId` | string \| undefined | Group, when filed in one |
| `data.description` | string | Description |
| `data.plan` | string | `"api"` |
| `data.createdAt` | string | ISO 8601 creation time |
| `data.agentProcessStatus` | string | `"init"` (a prompt is running), `"done"` (the last one finished) or `"idle"` (never prompted) |
| `data.agentServerStatus` | string | `"Creating"` while the project is set up, `"Archived"` while it sleeps after a long inactivity (the next prompt wakes it), else `"Active"` |
| `data.temporalDevelopmentProjectUrl` | string | **The development preview URL** |
| `data.developmentUrlFieldToUse` | string | Always `"temporalDevelopmentProjectUrl"` |
| `data.productionProjectUrl` | string | The published host: the custom domain once it is active, else `<projectId>.totalum-project.com` (it answers only after the first publish) |
| `data.deployment` | object \| null | The last publish: `{ createdAt, status, versionId }`, `status` = `"deploying"`, `"success"` or `"error"`, `versionId` = the version it put online (or tried to); `null` if never published |
| `data.customDomain` | object \| null | `{ _id, projectId, deploymentId, hostname, status, sslStatus, dnsRecordsToAdd, createdAt, updatedAt, source? }` — see [Custom Domains](https://www.totalum.app/docs/api/domains) |
| `data.secrets` | object[] | `{ _id, secretName, environment }` (values are never returned) |
| `data.versionRecovery` | object \| null | While a version restore runs or after it failed: `{ status: "recovering" \| "error", versionId, startedAt, errorMessage? }` |
| `data.importInProgress` | object \| null | `{ startedAt }` while an import runs |
| `data.previewImageUrl` | string \| undefined | Screenshot of the home page |
| `data.totalCreditsSpent` | number | Credits this project has spent in total |
| `data.creditLimits` | object | `{ maxDevelopmentCreditsPerMonth, maxInfrastructureCreditsPerMonth }`, `null` = no cap |
| `data.multiPrompt` | object \| null | The multi-prompt batch, as on [Get Agent Status](https://www.totalum.app/docs/api/agent#get-agent-status) |
| `data.engineVersion` | string | `"v2"` |
| `data.engineState` | string | `"queued"`, `"provisioning"`, `"ready"`, `"error"`, `"suspended"` or `"deleting"` |
| `data.liveHostSuffix` | string | `"totalum-project.com"` or `"webapp-project.com"`: the domain of the main published address and preview ([Update Project](#update-project)) |
| `data.dataResidency` | string | `"global"` or `"eu"` |
| `data.sandbox` | object | The builder: `{ state, lastPromptAt }` |
| `data.lifecycle` | object | `{ state, nextStep, lastActivityAt, pausedAt, resumable }` — see [Pause Project](#pause-project) |
| `data.preview` | object | `{ url, token8, status, deployState }`: the preview address and whether its latest build is live (`status`: `"live"`, `"deploying"`, `"failed"`, `"parked"`, `"none"`) |
| `data.lastDeployError` | object \| null | Why the last preview or publish build failed, when it did |
| `data.currentRun` | object \| null | The running prompt: `{ id, state, startedAt, batchId?, promptPreview }` |
| `data.state` | string | `"active"` or `"paused-by-user"` |
| `data.pausedAt` | string \| null | When it was paused |
| `data.resumable` | boolean | Whether a paused project can be resumed |
| `data.runawayPause` | object \| null | Set while the emergency stop has paused the site: `{ pausedAt, resumesAt, metric, value?, threshold? }` — see [Release Runaway Pause](#release-runaway-pause) |
| `data.contextClearPending` | boolean | The next prompt starts with a fresh context |
| `data.operations` | object[] | Long operations running now (publish, restore, import…): `{ kind, id, status, startedAt, errorMessage?, versionId?, progress? }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "label": "Barber bookings",
    "description": "",
    "plan": "api",
    "agentProcessStatus": "done",
    "createdAt": "2026-10-08T10:30:00.000Z",
    "agentServerStatus": "Active",
    "deployment": { "createdAt": "2026-10-08T11:02:00.000Z", "status": "success", "versionId": "ver_01k6xd2m4n6p8q0r2s4t6v8w0y" },
    "versionRecovery": null,
    "importInProgress": null,
    "secrets": [{ "_id": "sec_01k6x9", "secretName": "STRIPE_KEY", "environment": "production" }],
    "customDomain": null,
    "developmentUrlFieldToUse": "temporalDevelopmentProjectUrl",
    "temporalDevelopmentProjectUrl": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com",
    "productionProjectUrl": "barber-bookings.totalum-project.com",
    "totalCreditsSpent": 24.6,
    "creditLimits": { "maxDevelopmentCreditsPerMonth": 500, "maxInfrastructureCreditsPerMonth": 300 },
    "multiPrompt": null,
    "engineVersion": "v2",
    "engineState": "ready",
    "dataResidency": "global",
    "sandbox": { "state": "active", "lastPromptAt": "2026-10-08T10:30:05.000Z" },
    "preview": { "url": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com", "token8": "k3y9x2ab", "status": "live", "deployState": "idle" },
    "lastDeployError": null,
    "currentRun": null,
    "state": "active",
    "pausedAt": null,
    "resumable": false,
    "contextClearPending": false,
    "operations": []
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_NOT_FOUND` | 404 | No project with this id in your account |
| `API_ACCESS_DISABLED` | 403 | API access is switched off for this project in your account settings |
| `PROJECT_NOT_ALLOWED` | 403 | Your API key is restricted to other projects |

## Get Project State

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/state
cost: Free
```

The project's live state in one read: what is running, the preview, the lifecycle and any long operation with its progress (for example the copy of a database [move to Turso](https://www.totalum.app/docs/api/database-backups#move-to-turso)).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.engineVersion` | string | `"v2"` |
| `data.state` | string | `"queued"`, `"provisioning"`, `"ready"`, `"error"`, `"suspended"` or `"deleting"` |
| `data.lifecycle` | object | `{ state, nextStep: { kind, at } \| null, lastActivityAt, pausedAt, resumable }` |
| `data.sandbox` | object | `{ state, lastPromptAt }` |
| `data.preview` | object | `{ temporalLinkUrl, token8, versionId?, status, deploy: { state, fixAttempt?, error? } }` |
| `data.lastDeployError` | object \| null | The last failed preview or publish build |
| `data.run` | object \| null | The running prompt: `{ id, state, startedAt, batchId?, promptPreview }` |
| `data.batch` | object \| null | The newest multi-prompt batch |
| `data.contextClearPending` | boolean | The next prompt starts fresh |
| `data.operations` | object[] | `{ kind, id, status, startedAt, errorMessage?, versionId?, progress? }` |
| `data.liveHostSuffix` | string | Domain of the published host |
| `data.liveUrl` | string | Published URL |
| `data.dataResidency` | string | `"global"` or `"eu"` |
| `data.updatedAt` | string | When this state last changed |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/state
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "engineVersion": "v2",
    "state": "ready",
    "lifecycle": { "state": "active", "nextStep": null, "lastActivityAt": "2026-10-08T11:02:00.000Z", "pausedAt": null, "resumable": false },
    "sandbox": { "state": "active", "lastPromptAt": "2026-10-08T10:30:05.000Z" },
    "preview": { "temporalLinkUrl": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com", "token8": "k3y9x2ab", "status": "live", "deploy": { "state": "idle" } },
    "lastDeployError": null,
    "run": null,
    "batch": null,
    "contextClearPending": false,
    "operations": [],
    "liveHostSuffix": "totalum-project.com",
    "liveUrl": "https://barber-bookings.totalum-project.com",
    "dataResidency": "global",
    "updatedAt": "2026-10-08T11:02:00.000Z"
  }
}
```
:::

## Update Project

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId
cost: Free
```

Change the label, the description, the group or the live address's domain. The project id never changes. A field you leave out is untouched; `null` clears it.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string \| null | No | Display name, up to 80 characters; `null` clears it |
| `description` | string \| null | No | Up to 500 characters; `null` clears it |
| `groupId` | string \| null | No | An existing group; `null` takes the project out of its group |
| `liveHostSuffix` | string | No | `"totalum-project.com"` (default) or `"webapp-project.com"` — the main domain of the published site and its preview, e.g. `<projectId>.webapp-project.com`. `webapp-project.com` needs the Professional or Enterprise plan. On those plans both domains serve the site and its preview; this setting only picks the main one (`productionProjectUrl`, canonical and sitemap links). Links inside your app follow at your next publish. If the account moves to a lower plan, the project goes back to `totalum-project.com` and its `webapp-project.com` address redirects for 90 days. |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.label` | string \| undefined | The label after the change |
| `data.description` | string | The description after the change |
| `data.groupId` | string \| undefined | The group after the change |
| `data.liveHostSuffix` | string \| undefined | The domain chosen, when `liveHostSuffix` was sent |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"label":"Barber shop","groupId":null}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings", "label": "Barber shop", "description": "" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `NOTHING_TO_UPDATE` | 400 | None of `label`, `description`, `groupId`, `liveHostSuffix` was sent |
| `INVALID_LIVE_HOST_SUFFIX` | 400 | `liveHostSuffix` is not `totalum-project.com` or `webapp-project.com` |
| `PLAN_REQUIRED` | 403 | `webapp-project.com` needs the Professional or Enterprise plan (`errorDetails.upgradePlan`) |
| `AGENT_RUNNING` / `OPERATION_IN_PROGRESS` | 409 | A prompt, an operation or a preview deploy is in progress; try again when it ends |
| `INVALID_PROJECT_GROUP` | 400 | The group does not exist or is not yours |
| `PROJECT_NOT_FOUND` | 404 | No such project in your account |

## Delete Project

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId
cost: Free
```

Permanently delete a project: its published site, preview, custom domain, databases, files and versions. It cannot be undone, and **its id can never be used again**. Deleting is free and always allowed, whatever your balance.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_NOT_FOUND` | 404 | No such project in your account |
| `PLAN_NOT_API` | 400 | The project was not created through the API or the Totalum app's API plan and cannot be deleted here |

## Pause Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/pause
cost: 2 credits
```

Take the whole project offline so it consumes nothing: the published site and the preview show a paused page and the databases are saved first. Answers `202` while the pause runs; follow it on [Get Project State](#get-project-state) (`lifecycle.state` becomes `paused-by-user`).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.accepted` | boolean | `true` |
| `data.state` | string | The lifecycle state now |
| `data.operation` | object \| undefined | The operation started: `{ kind, id, startedAt, estimateSec }`. Absent (and `200`) when the project was already paused |
| `data.creditsCharged` | number | Credits charged (0 when nothing started) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/pause
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "accepted": true, "state": "active", "operation": { "kind": "pause", "id": "op_01k6xa", "startedAt": "2026-10-08T12:00:00.000Z", "estimateSec": 60 }, "creditsCharged": 2 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |
| `AGENT_RUNNING` | 409 | A prompt is running; wait for it |
| `DEPLOYMENT_RUNNING` | 409 | A publish is running |
| `OPERATION_IN_PROGRESS` | 409 | Another long operation is running |
| `LIFECYCLE_NOT_APPLICABLE` | 409 | The project is asleep after a long inactivity: prompt it (or restart its server) to wake it first |

## Resume Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/resume
cost: 2 credits
```

Bring a paused project back: databases restored, preview and published site online again. Answers `202` while it runs.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.accepted` | boolean | `true` |
| `data.state` | string | The lifecycle state now |
| `data.operation` | object | `{ kind, id, startedAt, estimateSec }` |
| `data.creditsCharged` | number | Credits charged |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/resume
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "accepted": true, "state": "paused-by-user", "operation": { "kind": "resume", "id": "op_01k6xb", "startedAt": "2026-10-09T08:00:00.000Z", "estimateSec": 90 }, "creditsCharged": 2 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |
| `PROJECT_NOT_PAUSED` | 409 | The project is not paused |
| `OPERATION_IN_PROGRESS` | 409 | Another long operation is running |
| `PAUSE_DUMP_EXPIRED` | 410 | The saved databases of this pause are gone; the project cannot be resumed |

## Release Runaway Pause

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/runaway/release
cost: Free
```

Bring a site back online after the emergency stop paused it. The emergency stop pauses a project when its app uses far more than normal (for example a loop that sends thousands of requests or spends credits on built-in features every minute): the site shows a paused page, the app's calls to Totalum answer `429 PROJECT_PAUSED_RUNAWAY`, and you get an e-mail and the `project.kill_switch.fired` [webhook](https://www.totalum.app/docs/api/webhooks). The pause lifts by itself after 24 hours (`runawayPause.resumesAt` on [Get Project](#get-project)); this call lifts it now. Fix the cause first, or the same usage pauses it again. Free at any balance; the site answers again within about a minute. A project that is not paused by the emergency stop answers `200` with `released: false` and nothing changes. This is not [Resume Project](#resume-project), which brings back a project you paused yourself.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.released` | boolean | `true` when the pause was lifted, `false` when the project was not paused by the emergency stop |
| `data.message` | string | What happened, in plain words |

**`runawayPause.metric`** (on [Get Project](#get-project)) says which limit was crossed: `credits_hour` (credits spent on built-in features in one hour), `usd_hour` (unusually heavy use of built-in features in one hour; no `value` or `threshold`), `requests_5min`, `rows_written_5min` or `rows_read_5min` (in 5 minutes). `value` is what was measured and `threshold` the limit.

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/runaway/release
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "released": true, "message": "The site is back online; it can take up to a minute to show everywhere." } }
```
:::

## Rotate Preview URL

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/preview/rotate-url
cost: Free
```

Give the development preview a new address; the old one stops working at once. The preview address also changes by itself every month, so never store it: read `temporalDevelopmentProjectUrl` from [Get Project](#get-project). The published site and custom domains never change. Owner (or a member allowed to manage API keys) only; at most once a minute.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.temporalLinkUrl` | string | The new preview address |
| `data.rotatedAt` | string | ISO 8601 time of the change |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/preview/rotate-url
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "temporalLinkUrl": "https://barber-bookings-dev-temporal-link-preview-p7m2q9rt.totalum-project.com", "rotatedAt": "2026-10-08T12:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `OWNER_ONLY` | 403 | Only the owner or a member allowed to manage API keys |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `OPERATION_IN_PROGRESS` | 409 | A publish or other operation is running |
| `RATE_LIMIT_EXCEEDED` | 429 | Rotated less than a minute ago |


---

# AI Agent

> Prompt the agent that builds and changes your app, and follow it live.

The AI agent writes and changes the project's code from natural-language prompts. A run is **asynchronous**: [Run AI Agent](#run-ai-agent) answers at once with `status: "init"`, then you poll [Get Agent Status](#get-agent-status) every 10–15 seconds until `status` is `"done"`. Every finished run that changed files is saved as a new [version](https://www.totalum.app/docs/api/versions) and deployed to the project's **development preview** — never to the published site, which only changes when you [publish](https://www.totalum.app/docs/api/deployments). The agent checks its work in its own browser and reads the app's [logs](https://www.totalum.app/docs/api/server) (development and live); when a user says "it doesn't work", it reads the error logs first.

## Run AI Agent

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/start
cost: Metered, at least 3 credits
```

Send one prompt to the agent (or a multi-prompt batch). One run at a time per project. Once the running prompt's change is in the preview and the run is only finishing, the next prompt is accepted and waits: it starts as soon as that run ends. One prompt can wait; [Stop Agent](#stop-agent) cancels the waiting one first.

A prompt is priced by the work the agent does: at least 3 credits, usually 3 to 50. A question that changes nothing is answered in seconds for 3 credits and creates no version. A run that fails without producing anything is refunded. The agent works up to about 40 minutes on one prompt, then wraps up and saves what it built; most prompts take a few minutes.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `prompt` | string | Yes¹ | The instruction, up to 40,000 characters |
| `tier` | string | No | `"auto"` (default), `"cheap"`, `"normal"` or `"expensive"` — see [Agent tiers](#agent-tiers). `model` and `modelTier` are accepted as aliases |
| `inputFiles` | object[] | No | Up to 10 files for this prompt. Say in the prompt what each one is for ("use this as the logo") |
| `inputFiles[].name` | string | Yes | File name |
| `inputFiles[].imageDescription` | string | No | What the file shows |
| `inputFiles[].url` | string | Yes | Public URL, or the `url` returned by [Upload File](https://www.totalum.app/docs/api/source-files#upload-file) |
| `multiPrompt` | object | No | Run a batch of prompts one after another, unattended. Omit for normal use |
| `multiPrompt.prompts` | string[] | No² | 1–50 prompts, run in order |
| `multiPrompt.letTotalumDecide` | boolean | No² | `true` = Totalum plans the prompts from `prompt` (the goal) |

¹ Not needed when `multiPrompt.prompts` is given.
² Exactly one of `prompts` or `letTotalumDecide: true`.

`fastMode` and `effort` are accepted for compatibility and ignored (an ignored option is listed in `data.warnings`; the run still starts).

### Agent tiers

| Tier | Use it for |
|---|---|
| `"auto"` | **The default.** Totalum picks the tier for each prompt from the prompt, the recent conversation and the project: `cheap` for small edits and questions, `normal` for builds and features, `expensive` only when a fix keeps failing or you ask for the best |
| `"cheap"` | The lowest price for real work: landing pages, redesigns, small apps, edits |
| `"normal"` | Full apps and features, with more thinking |
| `"expensive"` | The most capable agent, for complex apps and hard bugs. Uses the most credits |

With `"auto"`, the run's final message carries `autoMode: true` and `modelUsed`: the tier that ran (`"cheap"`, `"normal"` or `"expensive"`), charged as that tier.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | Always `"init"` |
| `data.message` | string | How to follow the run |
| `data.expectedMinutes` | number \| undefined | Estimated minutes — an estimate, never a deadline |
| `data.expectedFinishAt` | string \| undefined | Start + `expectedMinutes` (ISO 8601) |
| `data.warnings` | string[] \| undefined | Options that were ignored |
| `data.runId` | string \| undefined | The run's id (single prompt) |
| `data.multiPrompt.totalPrompts` | number \| undefined | Batches only: number of prompts (absent while Totalum plans them) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"Add a contact form that emails me every message"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/start
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "status": "init",
    "message": "Process started. Estimated time: about 6 minutes. This is only an approximation… Fetch GET /api/v1/vcaas/projects/barber-bookings/agent/status every 10-15 seconds to track progress; the status there is the only reliable signal that the run has finished.",
    "expectedMinutes": 6,
    "expectedFinishAt": "2026-10-08T11:16:00.000Z",
    "runId": "run_01k6xc4n7d"
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_PROMPT` | 400 | `prompt` is missing |
| `INVALID_MULTI_PROMPT` | 400 | `multiPrompt` needs exactly one of `prompts` or `letTotalumDecide: true` |
| `TOO_MANY_PROMPTS` | 400 | More than 50 prompts |
| `INVALID_PROMPT_ITEM` | 400 | A prompt in `prompts` is empty |
| `VALIDATION_ERROR` | 400 | The prompt is over 40,000 characters, or a field has the wrong type |
| `INSUFFICIENT_CREDITS` | 402 | Your balance is below 1 credit |
| `PROJECT_CREDIT_LIMIT_REACHED` | 403 | The project's monthly development cap is reached — raise it with [Update Credit Limits](https://www.totalum.app/docs/api/limits#update-credit-limits) |
| `AGENT_RUNNING` | 409 | A prompt is already running and its change is not in the preview yet, or a prompt is already waiting (a batch answers `400 AGENT_ALREADY_RUNNING`) |
| `PROJECT_PAUSED_BY_USER` | 409 | The project is paused — [resume](https://www.totalum.app/docs/api/projects#resume-project) it first |
| `OPERATION_IN_PROGRESS` | 409 | A restore, import or other long operation holds the project |
| `PROJECT_NOT_FOUND` | 404 | No such project in your account |
| `RATE_LIMITED` | 429 | Your account sent more prompts this minute than its plan allows, or has the most prompts running at once (all projects together, see [Limits](https://www.totalum.app/docs/limits)) — retry after `Retry-After` seconds |

:::info A run that runs out of credits
If your balance reaches 0 during a run, the run stops: what it built is saved as a version, it ends with `INSUFFICIENT_CREDITS`, and you are charged only what it used. A run that crosses the project's development cap stops the same way with `PROJECT_CREDIT_LIMIT_REACHED`.
:::

## Get Agent Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/agent/status
cost: Free
```

Whether a prompt is running, and the messages of the current (or last) run. Poll every 10–15 seconds. When `status` becomes `"done"`, read the preview URL again from [Get Project](https://www.totalum.app/docs/api/projects#get-project).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"init"` (running), `"done"` (the last run finished) or `"idle"` (never prompted) |
| `data.startedAt` | string \| null | Start of the current or last run |
| `data.runId` | string \| undefined | The current or last run |
| `data.realtimeConversation` | object[] | The run's messages, oldest first (the user's prompt is in [Get Full Conversation](#get-full-conversation)) |
| `data.realtimeConversation[].author` | string | `"user"` or `"agent"` |
| `data.realtimeConversation[].message` | string | Text |
| `data.realtimeConversation[].messageType` | string | `"regular"`, `"starting"`, `"building"` (a status note), `"finished"` (the run's summary), `"error"`, `"limit-reached"` or `"individual"` (a one-off notice) |
| `data.realtimeConversation[].createdAt` | string | ISO 8601 |
| `data.realtimeConversation[].versionId` | string \| undefined | The version this message created |
| `data.realtimeConversation[].secretKeysNeeded` | object \| undefined | Secrets the app needs: `{ NAME: { isProvided, description } }`. Add them with [Create Secret](https://www.totalum.app/docs/api/secrets#create-secret) |
| `data.realtimeConversation[].files` | object[] \| undefined | Files attached to the message |
| `data.realtimeConversation[].autoMode` | boolean \| undefined | `true` on the final message of an `auto` run |
| `data.realtimeConversation[].modelUsed` | string \| undefined | The tier an `auto` run used |
| `data.creditsSpent` | number \| undefined | What the last run cost, once `"done"` |
| `data.expectedMinutes` | number \| null | Estimate of the running prompt |
| `data.expectedFinishAt` | string \| null | Start + estimate |
| `data.multiPrompt` | object \| null | The batch, when one exists: `{ status, totalPrompts, currentPromptIndex, prompts[{ order, prompt, status }], startedAt, updatedAt }`. `status`: `"executing"`, `"paused"`, `"done"`, `"cancelled"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/status
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "status": "done",
    "startedAt": "2026-10-08T11:10:00.000Z",
    "realtimeConversation": [
      {
        "author": "agent",
        "message": "Added a contact form on /contact. Every message is saved and e-mailed to you. I sent a test message: saved, 200.",
        "messageType": "finished",
        "versionId": "ver_01k6xd2m8q",
        "createdAt": "2026-10-08T11:15:40.000Z",
        "autoMode": true,
        "modelUsed": "normal"
      }
    ],
    "creditsSpent": 7.4,
    "expectedMinutes": null,
    "expectedFinishAt": null,
    "multiPrompt": null,
    "runId": "run_01k6xc4n7d"
  }
}
```
:::

:::info Right after "done"
For a few seconds after a run ends, the preview may answer `503` with the header `x-totalum-preview: updating` while the new version goes live. Retry after the `retry-after` header; the preview never serves the previous version in that window.
:::

## Get Agent Progress

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/agent/progress
cost: Free
```

A step-by-step view of the current run (or the last one): its stage, the files it changed, the builds and checks it ran. Use it to show a live "building" timeline. `data` is `null` before the first run.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.runId` | string | The run |
| `data.state` | string | `"queued"`, `"provisioning"`, `"running"`, `"finishing"`, `"done"`, `"failed"`, `"cancelled"` or `"incomplete"` |
| `data.stage` | string | Where the run is: `"queued"`, `"sandbox"`, `"starting"`, `"thinking"`, `"working"`, `"build"`, `"deploy"`, `"fixing"`, `"wrapping-up"`, `"entry-page"`, `"done"`, `"failed"`, `"cancelled"`, `"incomplete"` |
| `data.startedAt` | string | Start time |
| `data.finishedAt` | string \| undefined | End time |
| `data.turns` | number | Agent turns so far |
| `data.steps` | object[] | Up to the 40 latest steps: `{ at, kind, file?, task?, ok?, added?, removed? }`. `kind`: `read`, `search`, `create`, `edit`, `delete`, `command`, `web`, `browser`, `integration`, `helper`, `plan`, `waiting`; `task` (commands): `build`, `test`, `install`, `database`, `check`, `save`, `other` |
| `data.totalSteps` | number | All steps of the run |
| `data.files` | object[] | Files changed: `{ path, change: "create" \| "edit" \| "delete", added, removed }` |
| `data.counts` | object | `{ reads, searches, edits, commands, failedCommands }` |
| `data.build` | object \| undefined | `{ ok }` of the last build |
| `data.tests` | object \| undefined | `{ runs, failed, failedFlows? }`: the checks the agent ran on the pages it changed |
| `data.versionId` | string \| undefined | The version the run saved |
| `data.failure` | string \| undefined | Why it failed: `INSUFFICIENT_CREDITS`, `PROJECT_CREDIT_LIMIT_REACHED`, `DEPLOY_FAILED`, `RUN_TIMED_OUT`, `RUN_INTERRUPTED`, `CONTENT_BLOCKED`, … or `OTHER` |
| `data.creditsSpent` | number \| undefined | What the run cost, once it ended |
| `data.previewAt` | string \| undefined | When the preview last started serving this run's new build (reload your preview frame when it changes) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/progress
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "runId": "run_01k6xc4n7d",
    "state": "running",
    "stage": "working",
    "startedAt": "2026-10-08T11:10:00.000Z",
    "turns": 12,
    "steps": [
      { "at": "2026-10-08T11:12:01.000Z", "kind": "edit", "file": "src/app/contact/page.tsx", "added": 64, "removed": 0 },
      { "at": "2026-10-08T11:12:30.000Z", "kind": "command", "task": "database", "ok": true }
    ],
    "totalSteps": 18,
    "files": [{ "path": "src/app/contact/page.tsx", "change": "create", "added": 64, "removed": 0 }],
    "counts": { "reads": 6, "searches": 2, "edits": 4, "commands": 3, "failedCommands": 0 }
  }
}
```
:::

## Get Full Conversation

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/agent/full-conversation
cost: Free
```

The project's whole chat: every prompt and every agent message, oldest first (the latest 1,000).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.conversation` | object[] | Messages with the fields of `realtimeConversation[]` in [Get Agent Status](#get-agent-status) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/full-conversation
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "conversation": [
      { "author": "user", "message": "Add a contact form that emails me every message", "messageType": "regular", "createdAt": "2026-10-08T11:10:00.000Z" },
      { "author": "agent", "message": "Added a contact form on /contact…", "messageType": "finished", "versionId": "ver_01k6xd2m8q", "createdAt": "2026-10-08T11:15:40.000Z" }
    ]
  }
}
```
:::

## Stop Agent

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/stop
cost: Free
```

Stop the running prompt (and the rest of a multi-prompt batch). What the run built so far is kept; you pay only for what it used.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.message` | string | `"Agent process cancelled successfully"` |
| `data.paused` | boolean | `false` |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/stop
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "message": "Agent process cancelled successfully", "paused": false } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `NO_PROCESS_RUNNING` | 400 | No prompt is running |

## Resume Multi-Prompt Batch

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/multi-prompt/resume
cost: The prompts it runs
```

When a prompt of a batch fails, the batch pauses there (`multiPrompt.status: "paused"`) because the next prompts build on it. This runs the failed prompt again, then the rest. [Stop Agent](#stop-agent) cancels the batch instead.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"init"` |
| `data.message` | string | Which prompt the batch continues from |
| `data.multiPrompt.totalPrompts` | number | Prompts in the batch |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/multi-prompt/resume
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings", "status": "init", "message": "Multi-prompt run continued from prompt 2.", "multiPrompt": { "totalPrompts": 3 } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_AUTO_STATUS` | 400 | No batch is paused |
| `AGENT_RUNNING` | 400 | A prompt is running |

## Clear Agent Context

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/clear-context
cost: Free
```

The next prompt starts with a fresh agent memory: it works from the project's current code and its change history, plus the messages sent after the clear. Restate anything from before that still matters.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.message` | string | `"Agent context cleared"` |
| `data.contextClearPending` | boolean | `true` until the next prompt starts |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/clear-context
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "message": "Agent context cleared", "contextClearPending": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_RUNNING` | 400 | A prompt is running |

## Get System Prompt

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/agent/system-prompt
cost: Free
```

The project's own instructions for the agent, added to every prompt (house style, rules, context).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.systemPrompt` | string \| null | The instructions, or `null` |
| `data.updatedAt` | string \| null | When they were last set |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/system-prompt
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "systemPrompt": "Write every page in Spanish. Use our brand blue #1d4ed8.", "updatedAt": "2026-10-08T09:00:00.000Z" } }
```
:::

## Set System Prompt

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/agent/system-prompt
cost: Free
```

Set or clear the project's instructions for the agent.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `systemPrompt` | string \| null | Yes | Up to 20,000 characters; `null` or `""` clears it |
| `systemPromptBase64` | string | No | The same text as base64 (UTF-8), instead of `systemPrompt` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.systemPrompt` | string \| null | The saved instructions |
| `data.updatedAt` | string | When they were saved |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"systemPrompt":"Write every page in Spanish. Use our brand blue #1d4ed8."}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/system-prompt
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "systemPrompt": "Write every page in Spanish. Use our brand blue #1d4ed8.", "updatedAt": "2026-10-08T09:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not text, or over 20,000 characters |

## List MCP Servers

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/agent/mcp-servers
cost: Free
```

The MCP servers you added for the agent to use while it builds (header values are never returned).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.servers` | object[] | The servers |
| `data.servers[].id` | string | Server id |
| `data.servers[].name` | string | Name |
| `data.servers[].transport` | string | `"http"` or `"sse"` |
| `data.servers[].url` | string | Server URL |
| `data.servers[].headerNames` | string[] | Names of the headers sent (values are write-only) |
| `data.servers[].enabled` | boolean | Whether the agent uses it |
| `data.servers[].verifiedAt` | string | When it last answered the handshake |
| `data.servers[].tools` | string[] | Its tools |
| `data.servers[].createdAt` | string | When it was added |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/mcp-servers
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "servers": [{ "id": "mcp_01k6xe", "name": "crm", "transport": "http", "url": "https://mcp.example.com/mcp", "headerNames": ["Authorization"], "enabled": true, "verifiedAt": "2026-10-08T09:00:00.000Z", "tools": ["find_customer"], "createdAt": "2026-10-08T09:00:00.000Z" }] } }
```
:::

## Verify MCP Server

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/mcp-servers/verify
cost: Free
```

Check that a server answers the MCP handshake and lists at least one tool, without saving it.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `mcp` | object | Yes | The server |
| `mcp.name` | string | Yes | 2–41 characters: lowercase letters, digits, `_`, `-`. Not `totalum`, `playwright`, `figma`, `github` or `context7` |
| `mcp.transport` | string | Yes | `"http"` (streamable HTTP) or `"sse"` |
| `mcp.url` | string | Yes | A public `https` URL |
| `mcp.headers` | object | No | Up to 20 headers, e.g. `{ "Authorization": "Bearer …" }` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.ok` | boolean | `true` |
| `data.serverInfo` | object | `{ name, version }` |
| `data.protocolVersion` | string | MCP protocol version |
| `data.tools` | object[] | `{ name, description? }` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"mcp":{"name":"crm","transport":"http","url":"https://mcp.example.com/mcp","headers":{"Authorization":"Bearer abc"}}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/mcp-servers/verify
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "ok": true, "serverInfo": { "name": "crm", "version": "1.0.0" }, "protocolVersion": "2025-06-18", "tools": [{ "name": "find_customer" }] } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MCP_VERIFY_FAILED` | 400 | The handshake failed. `errorDetails`: `{ stage: "connect" \| "initialize" \| "tools_list" \| "auth", message, authHint? }` |
| `VALIDATION_ERROR` | 400 | A field is invalid or the name is reserved |

## Add MCP Server

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/mcp-servers
cost: Free
```

Verify and add an MCP server for the agent (at most 10 per project). Same body as [Verify MCP Server](#verify-mcp-server).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `mcp` | object | Yes | `{ name, transport, url, headers? }` as in Verify MCP Server |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.server` | object | The stored server, with the fields of `servers[]` in [List MCP Servers](#list-mcp-servers) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"mcp":{"name":"crm","transport":"http","url":"https://mcp.example.com/mcp"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/mcp-servers
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "server": { "id": "mcp_01k6xe", "name": "crm", "transport": "http", "url": "https://mcp.example.com/mcp", "headerNames": [], "enabled": true, "verifiedAt": "2026-10-08T09:00:00.000Z", "tools": ["find_customer"], "createdAt": "2026-10-08T09:00:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MCP_LIMIT_REACHED` | 400 | The project already has 10 servers |
| `MCP_VERIFY_FAILED` | 400 | The server did not pass the handshake |
| `VALIDATION_ERROR` | 400 | A field is invalid or the name is taken or reserved |

## Update MCP Server

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId/agent/mcp-servers/:mcpId
cost: Free
```

Turn a server on or off, or change it (a changed configuration is verified again).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `mcpId` | string | The server id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | No | Use it or not |
| `mcp` | object | No | A new `{ name, transport, url, headers? }` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.server` | object | The server after the change |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"enabled":false}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/mcp-servers/mcp_01k6xe
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "server": { "id": "mcp_01k6xe", "name": "crm", "transport": "http", "url": "https://mcp.example.com/mcp", "headerNames": [], "enabled": false, "verifiedAt": "2026-10-08T09:00:00.000Z", "tools": ["find_customer"], "createdAt": "2026-10-08T09:00:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MCP_SERVER_NOT_FOUND` | 404 | No such server |
| `MCP_VERIFY_FAILED` | 400 | The new configuration did not pass the handshake |

## Delete MCP Server

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/agent/mcp-servers/:mcpId
cost: Free
```

Remove an MCP server.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `mcpId` | string | The server id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/mcp-servers/mcp_01k6xe
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MCP_SERVER_NOT_FOUND` | 404 | No such server |


---

# Publishing

> Move the development version to production.

Every prompt changes the **development preview** only. **Publishing** builds the current version, applies its pending database migrations to the **live database**, and puts it on the live address (`<projectId>.totalum-project.com` and the custom domain). Publishing is asynchronous: start it, then poll [Get Deployment Status](#get-deployment-status) every 10–15 seconds.

## Deploy to Production

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/deployments/deploy
cost: 0.5 credits
```

Publish the project's current version. It takes about 2 to 5 minutes. If the build or a migration fails, Totalum's agent tries to repair it once or twice before the publish ends in `error`; the live site keeps serving the previous version until a publish succeeds.

**Migrations that delete data.** A migration that drops a table or column, or deletes or overwrites existing live rows, runs only when the prompt that made it asked for it, or when you publish with `confirmDestructive: true`. Otherwise the publish ends in `error` with `DESTRUCTIVE_MIGRATION_NEEDS_CONFIRMATION` and nothing changes on live. Rows already in the live database that break a new rule end the publish with the list of those rows; Totalum never changes production data on its own.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `confirmDestructive` | boolean | No | `true` = run the pending migrations that drop columns or tables or delete/overwrite live rows |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"deploying"` |
| `data.deploymentId` | string | This publish |
| `data.message` | string | How to follow it |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/deployments/deploy
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "status": "deploying",
    "message": "Deployment started. It will take from 2 to 5 minutes depending on project size. Fetch GET /api/v1/vcaas/projects/barber-bookings/deployments/status every 10-15 seconds to track progress.",
    "deploymentId": "dep_01k6xf3a9b"
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_RUNNING` | 409 | A prompt is running; publish when it is done |
| `DEPLOYMENT_RUNNING` | 409 | A publish is already running |
| `OPERATION_IN_PROGRESS` | 409 | A restore, import or other long operation holds the project, or it is still being set up |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |
| `PROJECT_CREDIT_LIMIT_REACHED` | 403 | The project's monthly development cap is reached |

## Get Deployment Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/deployments/status
cost: Free
```

The newest publish and where it is. Poll every 10–15 seconds until `status` is `"success"` or `"error"`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string \| null | `"deploying"`, `"success"`, `"error"`, or `null` if never published |
| `data.createdAt` | string \| null | When the publish started |
| `data.deploymentId` | string \| undefined | The publish |
| `data.step` | string \| undefined | `"build"`, `"migration"`, `"upload"`, `"probe"`, `"fixing"` (the agent is repairing a failed step) or `"done"` |
| `data.progress` | object \| undefined | While `step` is `"fixing"`: `{ stage: "fixing", failedStage, fixAttempt, fixAttempts }` |
| `data.errorDetails` | object \| undefined | On `"error"`: `{ stage, title, message, logExcerpt, versionId, deploymentId?, migrationName?, fixSummary?, at }` — `fixSummary` says what the repair tried and what you need to do |
| `data.liveUrl` | string | The live address |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/deployments/status
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "status": "success",
    "createdAt": "2026-10-08T11:20:00.000Z",
    "deploymentId": "dep_01k6xf3a9b",
    "step": "done",
    "liveUrl": "https://barber-bookings.totalum-project.com"
  }
}
```
:::

When it is `"success"`, the address to show is `productionProjectUrl` on [Get Project](https://www.totalum.app/docs/api/projects#get-project) (the custom domain once active).

## Unpublish

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/deployments/unpublish
cost: Free
```

Take the published site offline and remove the project's custom domain. The preview, the code, the databases and the versions stay. Publish again with [Deploy to Production](#deploy-to-production); a custom domain has to be added again. Free, at any balance.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"unpublished"` |
| `data.customDomainsRemoved` | string[] | Domains removed |
| `data.customDomainsFailed` | string[] | Domains that could not be removed (they serve nothing; call again to retry) |
| `data.warnings` | string[] | Non-fatal problems |
| `data.message` | string | What happened |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/deployments/unpublish
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "projectId": "barber-bookings",
    "status": "unpublished",
    "customDomainsRemoved": [],
    "customDomainsFailed": [],
    "warnings": [],
    "message": "The project is offline: its published site, preview snapshot and custom domains were removed. Deploy again to publish it; custom domains have to be added again."
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DEPLOYMENT_RUNNING` | 409 | A publish is running and would put the site back; wait until it ends, then retry |


---

# Versions

> Every change is a version. Browse them, compare them, roll back.

Every change to a project's code is saved as a **version**: each prompt that changed files, each file write through the API, each visual edit, GitHub pull, import and restore. Restoring a version changes the **development preview**; [publish](https://www.totalum.app/docs/api/deployments) to put it live.

## List Versions

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/versions
cost: Free
```

The project's versions, newest first.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `limit` | number | No | 1–100 (default 20) |
| `skip` | number | No | Versions to skip (default 0) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.versions` | object[] | The versions |
| `data.versions[]._id` | string | Version id — for [Recover Version](#recover-version) |
| `data.versions[].name` | string | Display name |
| `data.versions[].commitSha` | string | Commit of the version — for [Get Version Diff](#get-version-diff) |
| `data.versions[].commitMessage` | string \| undefined | Commit message |
| `data.versions[].prompt` | string \| undefined | The prompt that produced it (first 300 characters) |
| `data.versions[].gcsUploaded` | boolean | `true` once the version's archive is stored |
| `data.versions[].recoveredVersionId` | string \| undefined | When this version is a restore: the version it restored |
| `data.versions[].createdAt` | string | ISO 8601 |
| `data.versions[].updatedAt` | string | ISO 8601 (same as `createdAt`) |
| `data.totalCount` | number | Total versions |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/versions?limit=20"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "versions": [
      {
        "_id": "ver_01k6xd2m8q",
        "commitSha": "9f2c1ab4d3e5f60718293a4b5c6d7e8f90a1b2c3",
        "commitMessage": "Add a contact form",
        "prompt": "Add a contact form that emails me every message",
        "gcsUploaded": true,
        "createdAt": "2026-10-08T11:15:40.000Z",
        "updatedAt": "2026-10-08T11:15:40.000Z",
        "name": "Version 4"
      }
    ],
    "totalCount": 4
  }
}
```
:::

## Get Version Diff

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/version-diff
cost: Free
```

The unified diff (`git diff` format) a version introduced.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `commitSha` | string | Yes | The version's `commitSha`, 7–40 hexadecimal characters |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.commitSha` | string | The commit |
| `data.diff` | string | The diff; `""` when nothing tracked changed |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/version-diff?commitSha=9f2c1ab"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "commitSha": "9f2c1ab", "diff": "diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx\nnew file mode 100644\n+export default function Contact() {\n" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_COMMIT_SHA` | 400 | `commitSha` is missing |
| `INVALID_COMMIT_SHA` | 400 | Not 7–40 hexadecimal characters |
| `VERSION_NOT_FOUND` | 404 | No version with this commit |

## Recover Version

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/versions/:id/recover
cost: 2 credits
```

Restore an earlier version's code as a new version, deployed to the preview. It runs in the background (1–4 minutes): poll [Get Project](https://www.totalum.app/docs/api/projects#get-project) until `versionRecovery` is `null` (on failure it shows `status: "error"` with `errorMessage`).

The database is never rolled back by a code restore: the restored code keeps working on the current schema (a column it needs that a later change dropped is added back, empty). Send `restoreDevDatabase: true` to also put the **development** database back to that version's time.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `id` | string | The version's `_id` |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `restoreDevDatabase` | boolean | No | Also restore the development database to the version's time (a restore point is taken first) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.message` | string | `"Version recovered successfully"` — the restore has started |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/versions/ver_01k6xd2m8q/recover
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "message": "Version recovered successfully" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VERSION_NOT_FOUND` | 404 | No such version |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `DEPLOYMENT_RUNNING` | 409 | A publish is running |
| `OPERATION_IN_PROGRESS` | 409 | Another restore or long operation is running |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Clear Recovery Marker

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/versions/recovery
cost: Free
```

Clear `versionRecovery` on the project after a failed restore, so it no longer shows. A restore that is really running ends by itself; never more than 25 minutes.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.cleared` | boolean | `false` when there was nothing to clear |
| `data.wasRunning` | boolean | The cleared restore had started less than 10 minutes ago |
| `data.previousStatus` | string \| undefined | Its status before clearing |
| `data.message` | string | What to do next |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/versions/recovery
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "cleared": true, "wasRunning": false, "previousStatus": "error", "message": "Cleared. Restart, deploy, prompts and a new recovery are allowed again." } }
```
:::


---

# Database

> Read and write your app's data, in development or in production.

Every project has its own SQL database (SQLite, on Cloudflare D1 — or Turso after a [move](https://www.totalum.app/docs/api/database-backups#move-to-turso)) in **two environments**: `dev`, used by the agent and the development preview, and `live`, used by the published site. Every request names the environment in `env`. The app reaches the same database through Drizzle ORM in its own code ([Totalum SDK — database](https://www.totalum.app/docs/sdk/database)).

The schema belongs to the app's code (`src/db/schema.ts`): it changes through **migrations** the agent writes, which apply to `dev` at once and to `live` when you [publish](https://www.totalum.app/docs/api/deployments). These endpoints never change the schema. They are free to call; the rows they read and write count as the project's database usage.

Backups, restore points, point-in-time restore and the move to Turso are on [Database Backups & Restore](https://www.totalum.app/docs/api/database-backups).

## Get Schema

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/database/schema
cost: Free
```

The tables, columns, foreign keys and relations of one environment's database, read from the database itself, plus the display settings shown in the Totalum database explorer.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.env` | string | The environment read |
| `data.tables[].name` | string | Table name |
| `data.tables[].label` | string | Display label |
| `data.tables[].kind` | string | `"table"`, `"junction"` (a many-to-many link table) or `"system"` (auth and migrations: read-only here) |
| `data.tables[].primaryKey` | string[] | The primary key's columns in key order; `[]` when the table has none (its rows are addressed by `rowid`) |
| `data.tables[].columns[].name` | string | Column name |
| `data.tables[].columns[].sqlType` | string | SQLite type, e.g. `TEXT`, `INTEGER` |
| `data.tables[].columns[].kind` | string | Value kind, e.g. `text`, `number`, `datetime`, `options`, `relation` |
| `data.tables[].columns[].label` | string | Display label |
| `data.tables[].columns[].notNull` | boolean | `NOT NULL` |
| `data.tables[].columns[].unique` | boolean | Unique on its own: a one-column primary key or a one-column `UNIQUE` index. A column of a composite primary key is **not** unique on its own |
| `data.tables[].columns[].default` | string \| null | Default value expression |
| `data.tables[].columns[].options` | string[] \| undefined | Allowed values (a `CHECK … IN (…)` list) |
| `data.tables[].columns[].relation` | object \| undefined | `{ table, fkColumn, onDelete }` the column references |
| `data.tables[].indexes[]` | object | `{ name, columns, unique }` |
| `data.tables[].foreignKeys[]` | object | `{ column, table, toColumn, onDelete }` |
| `data.tables[].rowCountEstimate` | number | Rows in the table (`0` unless counted) |
| `data.ledger` | object | `{ count, lastCreatedAt, lastHash }` of the applied migrations |
| `data.ddlHash` | string | Hash of the whole schema: changes whenever a migration changes it |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/schema?env=live"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "env": "live",
    "tables": [
      {
        "name": "bookings",
        "label": "Bookings",
        "kind": "table",
        "primaryKey": ["id"],
        "columns": [
          { "name": "id", "sqlType": "TEXT", "kind": "text", "label": "Id", "notNull": true, "unique": true, "default": null },
          { "name": "status", "sqlType": "TEXT", "kind": "options", "label": "Status", "notNull": true, "unique": false, "default": "'pending'", "options": ["pending", "paid", "cancelled"] },
          { "name": "user_id", "sqlType": "TEXT", "kind": "relation", "label": "User", "notNull": true, "unique": false, "default": null, "relation": { "table": "users", "fkColumn": "id", "onDelete": "cascade" } }
        ],
        "indexes": [{ "name": "bookings_user_id", "columns": ["user_id"], "unique": false }],
        "foreignKeys": [{ "column": "user_id", "table": "users", "toColumn": "id", "onDelete": "cascade" }],
        "rowCountEstimate": 0
      }
    ],
    "ledger": { "count": 4, "lastCreatedAt": "2026-10-08T11:15:00.000Z", "lastHash": "a41f9c" },
    "ddlHash": "9c0e2b"
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |
| `PROJECT_NOT_FOUND` | 404 | Project does not exist or you don't own it |

## Query Rows

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/rows
cost: Free
```

Rows of one table, filtered, searched, sorted and paginated. Filters compile to parameterised SQL on the server; values are never interpolated.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `table` | string | Yes | Table name |
| `filter` | object | No | `{ and: [...] }` or `{ or: [...] }` of conditions `{ column, op, value }` and nested groups. `op`: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `startsWith`, `in`, `isNull`, `notNull`. At most 4 levels, 50 conditions and 1,000 `in` values |
| `search` | string | No | Text search, at most 200 characters |
| `sort` | array | No | Up to 3 `{ column, dir: "asc" \| "desc" }` |
| `page` | object | Yes | `{ limit, after? , offset? }` — `limit` 1–200; `after` is the previous page's `nextAfter` (keyset, the default way to page); `offset` (≤ 10,000) is the alternative. Send `after` or `offset`, not both |
| `expand` | string[] | No | Up to 5 foreign-key columns of `table`; each row gets the referenced row as `<column>__expanded` (`null` when missing) |
| `count` | boolean | No | Also return `total`, the number of matching rows |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.columns` | array | `{ name, sqlType?, kind? }` of each column |
| `data.rows` | object[] | The rows, one object per row |
| `data.nextAfter` | string \| undefined | Cursor of the next page; absent on the last page |
| `data.total` | number \| undefined | Matching rows, when `count` was `true` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","table":"orders","filter":{"and":[{"column":"status","op":"eq","value":"paid"},{"column":"total","op":"gte","value":100}]},"sort":[{"column":"created_at","dir":"desc"}],"page":{"limit":20}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/rows
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "columns": [
      { "name": "id", "sqlType": "TEXT" },
      { "name": "status", "sqlType": "TEXT" },
      { "name": "total", "sqlType": "REAL" },
      { "name": "created_at", "sqlType": "TEXT" }
    ],
    "rows": [
      { "id": "01J8ZK3W6Q2Y4N7B9C1D3F5G7H", "status": "paid", "total": 120, "created_at": "2026-09-20T10:04:11.000Z" }
    ],
    "nextAfter": "WyIyMDI2LTA5LTIwVDEwOjA0OjExLjAwMFoiXQ"
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `TABLE_NOT_FOUND` | 404 | No such table; `errorDetails.suggestions` lists close names |
| `COLUMN_NOT_FOUND` | 400 | A filter, sort or expand column does not exist (or is not a foreign key, for `expand`) |
| `FILTER_TOO_COMPLEX` | 400 | Filter deeper than 4 levels, more than 50 conditions or more than 1,000 `in` values |
| `VALIDATION_ERROR` | 400 | Invalid body, or `page.after` is not a cursor of this table and sort |
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |

## Insert Record

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/records
cost: Free
```

Insert one row and return it as stored.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `table` | string | Yes | Table name |
| `data` | object | Yes | Column → value. Columns with a default may be omitted |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.row` | object | The inserted row, every column |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","table":"bookings","data":{"id":"bk_1001","status":"paid","user_id":"usr_42"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/records
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "row": { "id": "bk_1001", "status": "paid", "user_id": "usr_42", "created_at": "2026-10-08T12:00:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `TABLE_NOT_FOUND` | 404 | No such table |
| `COLUMN_NOT_FOUND` | 400 | `data` names a column the table does not have |
| `COLUMN_REQUIRED` | 400 | A `NOT NULL` column without a default is missing |
| `VALUE_NOT_ALLOWED` | 400 | The value is not one of the column's allowed values |
| `SYSTEM_TABLE_READ_ONLY` | 403 | The table is written by Totalum only (e.g. `_migrations`) |

### Record ids

The `:recordId` of the single-record routes and the `id` of a bulk item address **exactly one row** by the table's key (see `primaryKey` and `unique` in [Get Schema](#get-schema)):

- the value of `id` when that column is unique, else of the table's one unique column — e.g. `42` or `01J8ZK3W6Q2Y4N7B9C1D3F5G7H`;
- for a **composite primary key**, an object naming every key column and nothing else — e.g. `{"post_id":"p1","user_id":"u1"}`. In the URL it is that JSON, URL-encoded: `…/database/records/%7B%22post_id%22%3A%22p1%22%2C%22user_id%22%3A%22u1%22%7D`; in a bulk item it may be the object itself;
- for a table with no key, the row's `rowid` (returned by [Query Rows](#query-rows) as the `rowid` column).

An id that does not name every key column (e.g. `p1` alone on a `(post_id, user_id)` key) is refused with `400 RECORD_KEY_REQUIRED` — it is never matched against part of the key. If an id would match more than one row (a key column whose collation lets two values compare equal), the write is refused with `400 AMBIGUOUS_RECORD_ID` and nothing is changed.

## Update Record

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId/database/records/:recordId
cost: Free
```

Update the given columns of one row, found by its [record id](#record-ids), and return the full row.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |
| `recordId` | string | The row's [record id](#record-ids): its key value, or for a composite primary key the URL-encoded JSON object of every key column |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `table` | string | Yes | Table name |
| `data` | object | Yes | Column → new value; at least one column |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.row` | object | The updated row, every column |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","table":"bookings","data":{"status":"cancelled"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/records/bk_1001
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "row": { "id": "bk_1001", "status": "cancelled", "user_id": "usr_42", "created_at": "2026-10-08T12:00:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No row has that id |
| `RECORD_KEY_REQUIRED` | 400 | The id does not name every column of the table's key; `errorDetails.keyColumns` lists them |
| `AMBIGUOUS_RECORD_ID` | 400 | The id matches more than one row; nothing was changed |
| `TABLE_NOT_FOUND` | 404 | No such table |
| `COLUMN_NOT_FOUND` | 400 | `data` names a column the table does not have |
| `VALUE_NOT_ALLOWED` | 400 | The value is not one of the column's allowed values |
| `SYSTEM_TABLE_READ_ONLY` | 403 | The table is written by Totalum only |

## Delete Record

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/database/records/:recordId
cost: Free
```

Delete one row by its [record id](#record-ids). There is no trash: a deleted row comes back only from a backup.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |
| `recordId` | string | The row's [record id](#record-ids): its key value, or for a composite primary key the URL-encoded JSON object of every key column |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `table` | string | Yes | Table name |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | Always `true` |
| `data.id` | string \| number \| object | The deleted row's id, as sent |

**Example request**

```bash
curl -X DELETE \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","table":"bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/records/bk_1001
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true, "id": "bk_1001" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No row has that id |
| `RECORD_KEY_REQUIRED` | 400 | The id does not name every column of the table's key; `errorDetails.keyColumns` lists them |
| `AMBIGUOUS_RECORD_ID` | 400 | The id matches more than one row; nothing was changed |
| `TABLE_NOT_FOUND` | 404 | No such table |
| `SYSTEM_TABLE_READ_ONLY` | 403 | The table is written by Totalum only |

## Bulk Write

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/records/bulk
cost: Free
```

Insert, update or delete up to 500 rows of one table in one atomic batch: if one item fails, none is applied.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `table` | string | Yes | Table name |
| `op` | string | Yes | `"insert"`, `"update"` or `"delete"` |
| `items` | array | Yes | 1–500 items: row objects for `insert`, `{ id, data }` for `update`, `{ id }` for `delete` — each `id` a [record id](#record-ids) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.op` | string | The operation |
| `data.count` | number | Rows inserted, updated or deleted |
| `data.rows` | object[] \| undefined | The inserted or updated rows (`insert`, `update`) |
| `data.ids` | array \| undefined | The ids actually deleted (`delete`) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"dev","table":"services","op":"insert","items":[{"id":"cut","name":"Haircut","price":20},{"id":"beard","name":"Beard trim","price":12}]}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/records/bulk
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "op": "insert", "count": 2, "rows": [{ "id": "cut", "name": "Haircut", "price": 20 }, { "id": "beard", "name": "Beard trim", "price": 12 }] } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `BULK_TOO_LARGE` | 400 | More than 500 items |
| `RECORD_KEY_REQUIRED` | 400 | An item's id does not name every column of the table's key; `errorDetails.keyColumns` lists them |
| `AMBIGUOUS_RECORD_ID` | 400 | An item's id matches more than one row; nothing was changed |
| `SQL_ERROR` | 400 | An item failed; nothing was applied |
| `TABLE_NOT_FOUND` | 404 | No such table |
| `COLUMN_NOT_FOUND` | 400 | An item names a column the table does not have |
| `SYSTEM_TABLE_READ_ONLY` | 403 | The table is written by Totalum only |

## Link or Unlink Records

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/relations
cost: Free
```

Add or remove the junction-table row that links two rows in a many-to-many relation.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `a` | object | Yes | `{ table, id }` — one side |
| `b` | object | Yes | `{ table, id }` — the other side |
| `op` | string | Yes | `"link"` or `"unlink"` |
| `junctionTable` | string | No | The junction table to use; required when more than one connects the two tables |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.op` | string | The operation |
| `data.junctionTable` | string | The junction table used |
| `data.changed` | number | `1` when a row was added or removed, `0` when it already was (or was not) linked |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","op":"link","a":{"table":"bookings","id":"bk_1001"},"b":{"table":"services","id":"cut"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/relations
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "op": "link", "junctionTable": "booking_services", "changed": 1 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `AMBIGUOUS_RELATION` | 400 | Zero or several junction tables connect the two tables; `errorDetails.candidates` lists them — send one as `junctionTable` |
| `NOT_A_JUNCTION_TABLE` | 400 | `junctionTable` does not reference both tables |
| `RECORD_NOT_FOUND` | 404 | `a` or `b` does not exist |
| `TABLE_NOT_FOUND` | 404 | No such table |

## Run SQL

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/sql
cost: Free
```

Run one SQL statement with bound parameters. Reads return at most 1,000 rows. Writes run only when asked for. Schema changes (DDL) are refused: they are migrations in the project's code.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"live"` or `"dev"` |
| `sql` | string | Yes | One statement, at most 100 KB, with `?` placeholders |
| `params` | array | No | Up to 100 values bound to the placeholders |
| `allowWrites` | boolean | No | Required for `INSERT`, `UPDATE` and `DELETE` |
| `confirmLive` | boolean | No | Required for a write on `live` (the published site's data) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.columns` | array | `{ name }` of each result column |
| `data.rows` | object[] | Result rows (at most 1,000) |
| `data.truncated` | boolean | `true` when the result had more than 1,000 rows |
| `data.meta` | object | The database's statement metadata (`rows_read`, `rows_written`, `changes`, `duration`, …) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","sql":"SELECT status, SUM(total) AS total FROM orders GROUP BY status"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/sql
```


:::success Success · 200 OK
```json
{ "errors": null, "data": { "columns": [{ "name": "status" }, { "name": "total" }], "rows": [{ "status": "paid", "total": 4810 }, { "status": "refunded", "total": 120 }], "truncated": false, "meta": { "rows_read": 312, "rows_written": 0, "changes": 0, "duration": 0.4 } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DDL_NOT_ALLOWED` | 400 | The statement changes the schema — change `src/db/schema.ts`, then `npm run db:generate` and `npm run db:migrate` |
| `SQL_READ_ONLY` | 403 | The statement writes and `allowWrites` is not `true` |
| `LIVE_WRITE_CONFIRMATION_REQUIRED` | 409 | A write on `live` without `confirmLive: true` |
| `SYSTEM_TABLE_READ_ONLY` | 403 | The statement writes `_migrations` |
| `SQL_MULTI_STATEMENT` | 400 | More than one statement |
| `SQL_ERROR` | 400 | The database rejected the statement |

## Ask Database

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/ask
cost: Metered (one AI call)
```

Turn a plain-language question into one SQL statement for this database. The statement is shown, never run: run it with [Run SQL](#run-sql).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `question` | string | Yes | What you want from the data |
| `questionBase64` | string | No | The question as base64 (UTF-8), instead of `question` |
| `env` | string | No | `"live"` or `"dev"`. Default: `live` once the project has been published, else `dev` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.sql` | string | The statement |
| `data.explanation` | string | What it does |
| `data.isReadOnly` | boolean | `true` when the statement only reads |
| `data.creditsCharged` | number | What the question cost |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"question":"How many bookings were paid last week?","env":"live"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/ask
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "sql": "SELECT COUNT(*) AS paid FROM bookings WHERE status = 'paid' AND created_at >= datetime('now', '-7 days')", "explanation": "Counts the bookings paid in the last 7 days.", "isReadOnly": true, "creditsCharged": 0.02 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `NL_QUERY_UNANSWERABLE` | 422 | The database's tables cannot answer the question (`errorMessage` says why) |
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |

## Get Database Usage

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/database/usage
cost: Free
```

The database's usage and health: rows read and written and storage, in credits (today, 7 and 30 days), size, tables and row counts, the slowest queries, tips (missing indexes, large tables, backups) and whether moving to Turso is worth it.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | No | `"all"` (default), `"live"` or `"dev"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.generatedAt` | string | When this was computed |
| `data.envs[].env` | string | `"live"` or `"dev"` |
| `data.envs[].provider` | string | `"d1"` or `"turso"` |
| `data.envs[].region` | string \| null | Where the primary copy runs |
| `data.envs[].readReplicas` | boolean | Whether reads are served from copies near visitors |
| `data.envs[].sizeBytes` | number \| null | Size of the database |
| `data.envs[].lastBackupAt` | string \| null | Last manual backup |
| `data.envs[].usage` | object | `{ today, last7Days, last30Days }`, each `{ rowsRead, rowsWritten, credits: { rowsRead, rowsWritten, storage, total } }` |
| `data.envs[].daily` | object[] | One row per day: `{ date, rowsRead, rowsWritten, requests, credits, sizeBytes }` |
| `data.envs[].tables` | object \| null | `{ count, rows, list: [{ name, rows, estimated? }] }` |
| `data.envs[].queries` | object[] \| null | The heaviest query shapes of the last 7 days: `{ sql, count, totalMs, rowsRead, rowsReturned, rowsWritten }` |
| `data.turso` | object | `{ verdict, reasons[], caveats[], location, envs, estimateSec, databaseBytes, credits, paidPlanRequired }`; `verdict`: `"recommended"`, `"consider"`, `"not_needed"` or `"on_turso"` |
| `data.tips` | object[] | `{ env, severity: "warn" \| "info", code, message, params? }` |
| `data.notes` | object | `{ liveTrafficSettledNightly, queriesSince }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/usage?env=live"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "generatedAt": "2026-10-08T12:00:00.000Z",
    "envs": [
      {
        "env": "live",
        "provider": "d1",
        "region": "WEUR",
        "readReplicas": true,
        "sizeBytes": 2457600,
        "lastBackupAt": null,
        "usage": {
          "today": { "rowsRead": 4120, "rowsWritten": 36, "credits": { "rowsRead": 0.0002, "rowsWritten": 0.0021, "storage": 0.0001, "total": 0.0024 } },
          "last7Days": { "rowsRead": 30211, "rowsWritten": 240, "credits": { "rowsRead": 0.0017, "rowsWritten": 0.0137, "storage": 0.0007, "total": 0.0161 } },
          "last30Days": { "rowsRead": 30211, "rowsWritten": 240, "credits": { "rowsRead": 0.0017, "rowsWritten": 0.0137, "storage": 0.0007, "total": 0.0161 } }
        },
        "tables": { "count": 7, "rows": 412, "list": [{ "name": "bookings", "rows": 240 }] },
        "queries": []
      }
    ],
    "turso": { "verdict": "not_needed", "reasons": [{ "code": "fits_d1", "message": "The database is small (2.5 MB of 10 GB) and its traffic fits D1 well. There is no need to move." }], "caveats": [] },
    "tips": [],
    "notes": { "liveTrafficSettledNightly": true, "queriesSince": "2026-10-01T12:00:00.000Z" }
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `env` is not `all`, `live` or `dev` |

## Undo Agent Run Database Change

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/undo-run
cost: Free
```

Undo the latest agent run's database change: the database goes back to the restore point taken at the start of that run (a backup is taken first), then the code goes back to before the run. Only the latest run can be undone. Follow it on [Get Project State](https://www.totalum.app/docs/api/projects#get-project-state).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `runId` | string | Yes | The run to undo |
| `confirm` | string | No | The project id, typed to confirm |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"restoring"` |
| `data.env` | string | The database restored |
| `data.runId` | string | The run undone |
| `data.restoredTo` | string | The restore point used |
| `data.previousBookmark` | string | The point taken just before (restoring to it undoes the undo) |
| `data.preRestoreBackupId` | string | The backup taken first |
| `data.codeVersionId` | string \| null | The version the code goes back to; `null` when the run changed no code |
| `data.operation` | object | `{ kind, id }` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"runId":"run_01k6xc4n7d","confirm":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/undo-run
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "env": "dev", "status": "restoring", "restoredTo": "00000085-00000001-…", "previousBookmark": "00000091-00000002-…", "preRestoreBackupId": "bkp_01k6xg", "operation": { "kind": "backupRestore", "id": "op_01k6xh" }, "runId": "run_01k6xc4n7d", "codeVersionId": "ver_01k6xh9q" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_RUNNING` | 409 | A prompt is running |
| `OPERATION_IN_PROGRESS` | 409 | Another restore or long operation is running |


---

# Database Backups & Restore

> Nothing in your database is ever lost for good.

**Time Travel** keeps every minute of the last 30 days of each database; **backups** are SQL dumps you can download; a **restore point** is a bookmark Totalum records before a change: at the start of every agent run (on the development database) and before every restore. A restore puts one database back in place — every write made since is lost — so it always takes a backup and a restore point first, never runs during an agent run and is never done automatically. Every long step answers `202` and runs in the background. A restore of the **live** database never goes back past the database changes the published site needs: a point before the publish that last changed the live database's structure answers `409 RESTORE_BEFORE_LIVE_MIGRATION` with `errorDetails.earliestPoint`, the first point allowed. To go back further, unpublish the site first; the next publish applies those changes again.

**Scheduled backups (paid plans).** The **live** database of every project on a paid plan is backed up once a night, in the night of the region where the database lives. Nothing to set up. A night with no write since the last backup makes no new copy: `schedule.lastStatus` is `"skipped_unchanged"` and that backup stays current. Each backup is one gzip SQL dump with its SHA-256, kept by plan:

| Plan | Daily | Weekly | Monthly |
|---|---|---|---|
| Starter | 7 days | 4 weeks | – |
| Builder | 7 days | 4 weeks | 3 months |
| Professional | 7 days | 4 weeks | 6 months |
| Enterprise | 7 days | 4 weeks | 12 months |

The first backup of each week is kept as the weekly one, the first of each month as the monthly one. The backups count as storage of the project, like its files; there is no other fee. After a downgrade the older backups are trimmed to the new plan at the next run; on the free plan no new backup is made and the existing ones stay until they expire. A database of several gigabytes is backed up **weekly** instead of nightly (`schedule.frequency` is `"weekly"`), copied table by table while the site keeps running. The development database is not backed up on a schedule: Time Travel and the restore points cover it. A deleted project's last backup is deleted 30 days later.

## List Backups

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/database/backups
cost: Free
```

The project's backups, newest first, the schedule of the live database's backups and the Time-Travel window.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.backups[].id` | string | Backup ID |
| `data.backups[].env` | string | `"live"` or `"dev"` |
| `data.backups[].kind` | string | `"daily"`, `"weekly"` or `"monthly"` (scheduled), `"manual"`, or `"pre_restore"` (taken before a restore) |
| `data.backups[].status` | string | `"running"`, `"complete"` or `"failed"` (`error` says why) |
| `data.backups[].sizeBytes` | number \| null | Size of the stored file |
| `data.backups[].bookmark` | string \| null | The restore point taken with it |
| `data.backups[].createdAt` | string | ISO time |
| `data.backups[].expiresAt` | string \| null | When a scheduled backup is deleted; `null` for the others |
| `data.schedule.enabled` | boolean | `true` on a paid plan |
| `data.schedule.frequency` | string | `"daily"`, or `"weekly"` for a database of several gigabytes |
| `data.schedule.nextRunAt` | string \| null | The next run |
| `data.schedule.lastRunAt` | string \| null | The last run |
| `data.schedule.lastStatus` | string \| null | `"complete"`, `"skipped_unchanged"` (no write since `lastBackupAt`) or `"failed"` (`lastError` says why; the next night tries again) |
| `data.schedule.lastBackupAt` | string \| null | The newest scheduled backup |
| `data.schedule.retention` | object \| null | `{ daily, weekly, monthly }`: days, weeks and months each kind is kept; `null` on the free plan |
| `data.timeTravel` | object | `{ availableFrom, availableTo }` — the window of [Restore to a Point in Time](#restore-to-a-point-in-time) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/backups
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "backups": [{ "id": "bkp_01k6xg", "env": "live", "kind": "daily", "status": "complete", "sizeBytes": 18233, "bookmark": "00000085-00000001-4f1c", "createdAt": "2026-10-08T02:10:00.000Z", "expiresAt": "2026-10-15T02:10:00.000Z" }], "schedule": { "enabled": true, "frequency": "daily", "nextRunAt": "2026-10-09T02:10:00.000Z", "lastRunAt": "2026-10-08T02:10:00.000Z", "lastStatus": "complete", "lastBackupAt": "2026-10-08T02:10:00.000Z", "retention": { "daily": 7, "weekly": 4, "monthly": 3 } }, "timeTravel": { "availableFrom": "2026-09-08T12:00:00.000Z", "availableTo": "2026-10-08T12:00:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|

## Create Backup

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/backups
cost: 2 credits
```

Dump one database now. One manual backup per 10 minutes per project. Poll [List Backups](#list-backups) until `status` is `"complete"`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | No | `"live"` (default) or `"dev"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.backupId` | string | The backup being written |
| `data.status` | string | `"running"` |
| `data.creditsCharged` | number | 2 (`BACKUP_CREATE_MANUAL` in [Get Credit Costs](https://www.totalum.app/docs/api/account#get-credit-costs)) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/backups
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "backupId": "bkp_01k6xj", "status": "running", "creditsCharged": 2 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `BACKUP_RATE_LIMITED` | 429 | A manual backup was made less than 10 minutes ago |
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |

## Download Backup

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/database/backups/:backupId/download
cost: Free
```

A 15-minute link to a complete backup as standard SQLite SQL (every row, including password hashes and sessions; uploaded files are not included). A scheduled backup is gzip-compressed (`.sql.gz`).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |
| `backupId` | string | From List Backups |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string | Signed download URL (15 minutes) |
| `data.fileName` | string | Suggested file name |
| `data.sizeBytes` | number | Size of the file |
| `data.sha256` | string | SHA-256 of the file |
| `data.importRecipe` | string | `sqlite3 my-project.sqlite < <fileName>`, or `gunzip -c <fileName> \| sqlite3 my-project.sqlite` for a `.sql.gz` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/backups/bkp_01k6xj/download
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://files.totalum.app/backups/…", "fileName": "barber-bookings-live-2026-10-08.sql", "sizeBytes": 18233, "sha256": "3b1f…", "importRecipe": "sqlite3 my-project.sqlite < barber-bookings-live-2026-10-08.sql" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `BACKUP_NOT_FOUND` | 404 | No such backup, or it has no complete dump |

## Restore Backup

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/backups/:backupId/restore
cost: 4 credits
```

Put a database back to one of its backups (scheduled, manual or pre-restore). A backup of the current state is taken first; then the database is emptied, the backup is loaded and its tables, row counts and migration history are checked against the backup. If the load fails, the database goes back to the state before the restore. Every write made since the backup is lost. Poll `GET /projects/:projectId/state` until the operation ends.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |
| `backupId` | string | From List Backups, with `status` `"complete"` |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `confirm` | string | Yes | The project ID, typed to confirm |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"restoring"` |
| `data.env` | string | The database restored (the backup's own) |
| `data.restoredTo` | string | `backup:<backupId>` (on a database moved to Turso: `turso:<ISO time of the backup>`) |
| `data.previousBookmark` | string | The point taken just before — restoring to it undoes this restore |
| `data.preRestoreBackupId` | string | The backup taken first; the restore runs only once it is complete |
| `data.operation` | object | `{ kind: "backupRestore", id }` — progress in `GET /projects/:projectId/state` |
| `data.creditsCharged` | number | 4 (`BACKUP_RESTORE` in [Get Credit Costs](https://www.totalum.app/docs/api/account#get-credit-costs)) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"confirm":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/backups/bkp_01k6xj/restore
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "env": "live", "status": "restoring", "restoredTo": "backup:bkp_01k6xj", "previousBookmark": "00000091-00000002-77aa", "preRestoreBackupId": "bkp_01k6xk", "operation": { "kind": "backupRestore", "id": "op_01k6xm" }, "creditsCharged": 4 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CONFIRMATION_REQUIRED` | 400 | `confirm` is not the project ID |
| `VALIDATION_ERROR` | 400 | On a database moved to Turso, the backup is from before the move (`errorDetails.availableFrom`): download it, or restore a point in time from `availableFrom` on. Nothing is charged |
| `BACKUP_NOT_FOUND` | 404 | No such backup, or it has no complete dump |
| `RESTORE_BEFORE_LIVE_MIGRATION` | 409 | The live backup is older than the database changes the published site needs; unpublish first |
| `BACKUP_TOO_LARGE_FOR_IMPORT` | 409 | The backup is too large to load in place: download it, or use [Restore to a Point in Time](#restore-to-a-point-in-time) |
| `AGENT_RUNNING` | 409 | The agent is running |
| `OPERATION_IN_PROGRESS` | 409 | Another operation is in progress |

## Delete Backup

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/database/backups/:backupId
cost: Free
```

Delete a manual or pre-restore backup. Scheduled backups are deleted by their retention.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |
| `backupId` | string | From List Backups |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/backups/bkp_01k6xj
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `BACKUP_NOT_DELETABLE` | 409 | Only manual and pre-restore backups can be deleted |
| `BACKUP_NOT_FOUND` | 404 | No such backup |

## List Restore Points

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/database/bookmarks
cost: Free
```

Every restore point, newest first. Query `runId` to see one agent run's.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.databaseCase` | string | `"dev+live"`: the agent works on the development database |
| `data.items[].env` | string | The database |
| `data.items[].bookmark` | string | Send it to Restore to a Restore Point |
| `data.items[].at` | string | ISO time it was taken |
| `data.items[].kind` | string | `"run"` or `"pre_restore"` |
| `data.items[].runId` | string \| undefined | The agent run that took it |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/bookmarks
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "databaseCase": "dev+live", "items": [{ "env": "dev", "bookmark": "00000085-00000001-4f1c", "at": "2026-10-08T11:10:02.000Z", "kind": "run", "runId": "run_01k6xc4n7d" }] } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|

## Restore to a Restore Point

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/restore
cost: Free
```

Put one database back to a restore point from List Restore Points — for example the moment before the agent changed it. Free: it undoes an agent step.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `bookmark` | string | Yes | From List Restore Points |
| `env` | string | No | `"live"` (default) or `"dev"` |
| `confirm` | string | Yes | The project ID, typed to confirm |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"restoring"` |
| `data.env` | string | The environment restored |
| `data.restoredTo` | string | The Time-Travel bookmark restored to |
| `data.previousBookmark` | string | The point taken just before — restoring to it undoes this restore |
| `data.preRestoreBackupId` | string | The backup taken first; the restore runs only once it is complete |
| `data.operation` | object | `{ kind: "backupRestore", id }` — progress in `GET /projects/:projectId/state` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"bookmark":"00000085-00000001-4f1c","env":"dev","confirm":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/restore
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "env": "dev", "status": "restoring", "restoredTo": "00000085-00000001-4f1c", "previousBookmark": "00000091-00000002-77aa", "preRestoreBackupId": "bkp_01k6xn", "operation": { "kind": "backupRestore", "id": "op_01k6xp" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `BOOKMARK_NOT_FOUND` | 404 | The bookmark is not a restore point of this database |
| `RESTORE_BEFORE_LIVE_MIGRATION` | 409 | The live point lies before database changes the published site needs (`errorDetails`: `deploymentId`, `migrations`, `earliestPoint`); choose `earliestPoint` or later, or unpublish first |
| `CONFIRMATION_REQUIRED` | 400 | `confirm` is not the project ID |
| `AGENT_RUNNING` | 409 | An agent run is in progress |
| `OPERATION_IN_PROGRESS` | 409 | Another operation (a restore, a publish…) is running |
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |

## Restore to a Point in Time

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/time-travel/restore
cost: 4 credits
```

Put one database back to any minute of the last 30 days (on a database moved to Turso: from the move on, `timeTravel.availableFrom` in [List Backups](#list-backups)).

A refused request costs nothing. A restore that is accepted and then fails gives its 4 credits back; the same holds for [Restore a Backup](#restore-backup).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | No | `"live"` (default) or `"dev"` |
| `timestamp` | string | Yes | ISO time within the last 30 days |
| `confirm` | string | Yes | The project ID, typed to confirm |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"restoring"` |
| `data.env` | string | The environment restored |
| `data.restoredTo` | string | The Time-Travel bookmark restored to |
| `data.previousBookmark` | string | The point taken just before — restoring to it undoes this restore |
| `data.preRestoreBackupId` | string | The backup taken first; the restore runs only once it is complete |
| `data.operation` | object | `{ kind: "backupRestore", id }` — progress in `GET /projects/:projectId/state` |
| `data.creditsCharged` | number | 4 (`BACKUP_RESTORE` in [Get Credit Costs](https://www.totalum.app/docs/api/account#get-credit-costs)) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"env":"live","timestamp":"2026-10-08T09:00:00.000Z","confirm":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/time-travel/restore
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "env": "live", "status": "restoring", "restoredTo": "00000080-00000003-1a2b", "previousBookmark": "00000091-00000002-77aa", "preRestoreBackupId": "bkp_01k6xq", "operation": { "kind": "backupRestore", "id": "op_01k6xr" }, "creditsCharged": 4 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `timestamp` is outside the last 30 days or, on a database moved to Turso, before the move (its point-in-time history starts there; `errorDetails.availableFrom`, also in `GET …/database/backups?env=` as `timeTravel.availableFrom`) |
| `RESTORE_BEFORE_LIVE_MIGRATION` | 409 | The live point lies before database changes the published site needs (`errorDetails`: `deploymentId`, `migrations`, `earliestPoint`); choose `earliestPoint` or later, or unpublish first |
| `RESTORE_VERIFY_FAILED` | 409 | Time Travel resolved `timestamp` to a state older or newer than a restore point recorded around that time; nothing was restored (`errorDetails`: `timestamp`, `resolvedBookmark`, `restorePoint`). Restore to that recorded point instead: its `bookmark` with [Restore to a Restore Point](#restore-to-a-restore-point) |
| `CONFIRMATION_REQUIRED` | 400 | `confirm` is not the project ID |
| `AGENT_RUNNING` | 409 | An agent run is in progress |
| `OPERATION_IN_PROGRESS` | 409 | Another operation (a restore, a publish…) is running |
| `DEV_DATABASE_NOT_FOUND` | 404 | `env` is `"dev"` and the project has no development database |

## Move to Turso

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/database/migrate-provider
cost: 50 credits
```

Move the project's database to Turso, (a managed Turso database, run by Totalum). **One way**: it cannot be moved back. The 50 credits are refunded in full when the move fails before the switch. Both databases, live and dev, move. Available on the Builder plan and above.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `to` | string | Yes | `"turso"` |
| `confirm` | string | Yes | The project ID, typed to confirm |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.operation` | object | `{ kind: "dbProviderMigration", id, status, startedAt, to, envs, estimateSec, databaseBytes }` — follow it in `GET /projects/:projectId/state` (below) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"to":"turso","confirm":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/database/migrate-provider
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "operation": { "kind": "dbProviderMigration", "id": "op_01k6xs", "status": "running", "startedAt": "2026-10-08T12:00:00.000Z", "to": "turso", "envs": ["live", "dev"], "estimateSec": 240, "databaseBytes": 2457600 } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CONFIRMATION_REQUIRED` | 400 | `confirm` is not the project ID |
| `MIGRATION_NOT_REVERSIBLE` | 400 | `to` is not `"turso"` |
| `PLAN_REQUIRED` | 403 | The account's plan is below Builder |
| `ALREADY_ON_PROVIDER` | 409 | The project is already on Turso |
| `INSUFFICIENT_CREDITS` | 402 | Fewer than 50 credits |
| `AGENT_RUNNING` | 409 | An agent run is in progress |

**Following the move**

It takes about 4 minutes per GB. While it runs, `GET /api/v1/vcaas/projects/:projectId/state` lists the running `dbProviderMigration` operation with its `progress` (the environments move one after the other, live first):

| Field | Type | Description |
|---|---|---|
| `operations[].progress.phase` | string | `"copying"`, then `"verifying"` (the copy is checked), then `"switching"` |
| `operations[].progress.env` | string | The database moving now: `"live"` or `"dev"` |
| `operations[].progress.copiedBytes` | number | Bytes copied so far, updated after each copied part (a few hundred MB) |
| `operations[].progress.totalBytes` | number | The database's size |
| `operations[].progress.etaSec` | number | About how many seconds are left for this database, from the speed measured so far |

Meanwhile the agent and code changes are refused with `409 OPERATION_IN_PROGRESS` and the live site is read-only: its pages keep answering and every write gets a short maintenance answer (`503`). When the move ends, the project's chat says so and the owner gets an e-mail: moved, or rolled back with the 50 credits refunded (the operation then stays listed with `status: "error"` and `errorMessage`).


---

# Project Files

> Read and edit your app's code file by file.

Work on the code directly instead of through prompts: list the tree, read a file, write it back. **Every write is saved as a new [version](https://www.totalum.app/docs/api/versions) and deployed to the development preview**; [publish](https://www.totalum.app/docs/api/deployments) to put it live.

## Get Project Tree

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/files/tree
cost: Free
```

The project's files and folders, as a flat, sorted list. `node_modules`, `.next`, `.git`, `dist` and other build output are never included.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | No | Limit the tree to one subfolder, e.g. `src/app`. Default: the whole project |
| `limit` | number | No | Page size. Default `2000`, max `5000` |
| `offset` | number | No | Entries to skip. Default `0` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.entries[].path` | string | Path relative to the project root, e.g. `src/app/page.tsx` |
| `data.entries[].name` | string | File or folder name |
| `data.entries[].type` | string | `"file"` or `"folder"` |
| `data.entries[].size` | number \| undefined | Size in bytes. Files only |
| `data.entries[].depth` | number | `0` at the project root |
| `data.totalEntries` | number | Entries matching the request before paging |
| `data.offset` | number | The offset used |
| `data.limit` | number | The limit used |
| `data.hasMore` | boolean | `true` when more entries follow |
| `data.commitSha` | string \| null | Git commit the tree was read from |
| `data.filesCount` | number | Files tracked in the project, before exclusions |

Entries are sorted the way a file explorer shows them: folders before files, alphabetically, within each parent.

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/files/tree?path=src/app&limit=100"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "entries": [
      { "path": "src/app", "name": "app", "type": "folder", "depth": 1 },
      { "path": "src/app/layout.tsx", "name": "layout.tsx", "type": "file", "size": 1284, "depth": 2 },
      { "path": "src/app/page.tsx", "name": "page.tsx", "type": "file", "size": 3902, "depth": 2 }
    ],
    "totalEntries": 3,
    "offset": 0,
    "limit": 100,
    "hasMore": false,
    "commitSha": "a1b2c3d",
    "filesCount": 47
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_PATH` | 400 | The `path` is absolute, contains `..`, or points inside an excluded folder |
| `PROJECT_NOT_FOUND` | 404 | Project does not exist or you don't own it |
| `PROJECT_TOO_LARGE` | 413 | The project archive is too large to browse through the API — download it instead |

## Get File Content

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/files/content
cost: Free
```

The complete content of one file. Files up to 1 MB.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | Yes | Path relative to the project root, e.g. `src/app/page.tsx` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.path` | string | The normalised path |
| `data.name` | string | File name |
| `data.size` | number | Size of the stored file in bytes |
| `data.encoding` | string | `"utf8"` for text, `"base64"` for binary |
| `data.content` | string | The file content, in `encoding` |
| `data.commitSha` | string \| null | Git commit the file was read from |

Binary files (anything with a NUL byte in the first 8 KB) come back base64-encoded, so they survive a round trip through `PUT /files/content`.

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/files/content?path=src/app/page.tsx"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "path": "src/app/page.tsx",
    "name": "page.tsx",
    "size": 62,
    "encoding": "utf8",
    "content": "export default function Page() {\n  return <h1>Hello</h1>;\n}\n",
    "commitSha": "a1b2c3d"
  }
}
```
:::

:::danger Error · 404
```json
{
  "errors": {
    "errorCode": "FILE_NOT_FOUND",
    "errorMessage": "No file at \"src/app/missing.tsx\" in this project."
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_PATH` | 400 | The path is absolute, contains `..`, or points inside an excluded folder |
| `PROJECT_NOT_FOUND` | 404 | Project does not exist or you don't own it |
| `FILE_NOT_FOUND` | 404 | No file at that path |
| `FILE_TOO_LARGE` | 413 | The file is larger than 1 MB — download the source archive instead |

## Write File Content

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/files/content
cost: Free
```

Create or replace one file (up to 512 KB). Missing folders are created. The write is saved as a version and the preview is rebuilt with it (a new file under `public/` can make the preview answer `503 updating` for a minute or two).

If the save is still in progress after about 45 seconds (the project's environment was asleep), the call answers `202` with `saved: false`: read the file again a minute later before relying on it, and do not send it again.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | Yes | Path from the project root, e.g. `src/app/page.tsx` |
| `content` | string | Yes | The complete new content (`""` empties the file) |
| `encoding` | string | No | `"utf8"` (default) or `"base64"` for binary files |
| `baseSha256` | string | No | SHA-256 (hex) of the file as you read it: if it changed since (the agent, a restore), the write is refused with `409 FILE_CHANGED` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.path` | string | Normalised path |
| `data.created` | boolean | `true` when the file is new |
| `data.bytesWritten` | number \| undefined | Bytes written (saved writes only) |
| `data.commitSha` | string \| undefined | The commit of the new version |
| `data.filesCount` | number \| undefined | Files in the project after the write |
| `data.rebuildRequired` | boolean | Always `false`: the write deploys itself |
| `data.saved` | boolean \| undefined | `false` on a `202` (still saving) |
| `data.versionId` | string \| undefined | On a `202`: the version being saved |
| `data.message` | string \| undefined | On a `202`: what to do |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"path":"src/app/hello/page.tsx","content":"export default function Hello() {\n  return <h1>Hello</h1>;\n}\n"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/files/content
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "path": "src/app/hello/page.tsx", "bytesWritten": 58, "created": true, "commitSha": "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", "filesCount": 97, "rebuildRequired": false } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_PATH` | 400 | Absolute, contains `..`, or inside an excluded folder |
| `MISSING_CONTENT` | 400 | `content` is missing or not a string |
| `INVALID_ENCODING` | 400 | Not `utf8` or `base64` |
| `FILE_TOO_LARGE` | 413 | Over 512 KB |
| `FILE_CHANGED` | 409 | The file changed since your `baseSha256` |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `OPERATION_IN_PROGRESS` | 409 | A publish, restore or import is running |
| `RATE_LIMITED` | 429 | More than 300 writes a minute |

## Rebuild Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/rebuild
cost: 2 credits
```

Build the current version again and redeploy the preview — for example after the preview failed to build. Takes 1–4 minutes; poll [Get Rebuild Status](#get-rebuild-status).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"rebuilding"` |
| `data.startedAt` | string | ISO 8601 |
| `data.message` | string | Next step |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/rebuild
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings", "status": "rebuilding", "startedAt": "2026-10-08T12:20:00.000Z", "message": "Rebuild started. It usually takes 1-4 minutes. Poll GET /projects/barber-bookings/rebuild/status until status is \"success\" or \"error\"." } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `REBUILD_RUNNING` | 409 | A rebuild is already running |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `DEPLOYMENT_RUNNING` | 409 | A publish is running |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Get Rebuild Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/rebuild/status
cost: Free
```

The newest rebuild. A failed rebuild is `status: "error"` in a `200` answer.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | Project id |
| `data.status` | string | `"idle"` (never rebuilt), `"rebuilding"`, `"success"` or `"error"` |
| `data.startedAt` | string \| null | ISO 8601 |
| `data.finishedAt` | string \| null | ISO 8601 |
| `data.elapsedSeconds` | number \| null | Duration so far, or total |
| `data.livePreviewUrl` | string \| undefined | The preview, after a success |
| `data.errorCode` | string \| undefined | On `"error"` |
| `data.errorMessage` | string \| undefined | On `"error"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/rebuild/status
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings", "status": "success", "startedAt": "2026-10-08T12:20:00.000Z", "finishedAt": "2026-10-08T12:22:10.000Z", "elapsedSeconds": 130, "livePreviewUrl": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com" } }
```
:::


---

# Source Code & Uploads

> Take your code anywhere; give the agent your files.

The source code is yours: a standard Next.js project you can run locally or host yourself ([For developers](https://www.totalum.app/docs/developers/self-hosting)). Uploaded files are stored in the project's file storage.

## Download Source Code

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/source-code
cost: 2 credits
```

A short-lived link to the project's current code as an archive. With the project's `.env` ([Get Project Env](https://www.totalum.app/docs/api/secrets#get-project-env)) it runs locally with `npm ci && npm run dev`. Downloading source code is included on paid plans.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `intent` | string | No | `view` when you only show the code (for example in a code viewer), which works on every plan |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.filesCount` | number | Files in the archive |
| `data.lastCommitSha` | string \| undefined | Commit of the current version |
| `data.downloadUrl` | string \| null | Signed download URL (valid a few minutes) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/source-code
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "filesCount": 96, "lastCommitSha": "9f2c1ab4d3e5f60718293a4b5c6d7e8f90a1b2c3", "downloadUrl": "https://files.totalum.app/source/barber-bookings.tar.gz?sig=…" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | Source download is not included on your plan (`upgradePlan` says which is needed) |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Upload File

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/files/upload
cost: Free
```

Upload one file (up to **20 MB**) as `multipart/form-data` with the field `file`. Use the returned `url` in `inputFiles` of [Run AI Agent](https://www.totalum.app/docs/api/agent#run-ai-agent). For a bigger file, pass its own public URL in `inputFiles` instead; no upload is needed.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters** (multipart/form-data)

| Field | Type | Required | Description |
|---|---|---|---|
| `file` | file | Yes | The file, up to 20 MB |
| `private` | string | No | `"true"` = stored privately (served only through signed links) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.fileNameId` | string | The stored file's key |
| `data.url` | string | Its URL (for `inputFiles`) |
| `data.private` | boolean \| undefined | `true` for a private file |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -F "file=@./logo.png" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/files/upload
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "fileNameId": "uploads/01k6y1-logo.png", "url": "https://files.totalum.app/barber-bookings/uploads/01k6y1-logo.png" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_FILE` | 400 | No multipart field `file` |
| `FILE_TOO_LARGE` | 413 | Over 20 MB — pass the file's own URL in `inputFiles` instead |
| `RATE_LIMITED` | 429 | More than 300 uploads a minute |


---

# Secrets, Keys & .env

> Environment variables, project keys and the .env file.

**Secrets** are environment variables the app reads on the server (`process.env.STRIPE_KEY`): values are write-only and never returned. **Project API keys** (`tlm_pk_…`) let your own servers call the project's database, files and built-in features. They are not your account key (`tlm_sk_…`).

## Create Secret

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/secrets
cost: Free
```

Add an environment variable. It reaches the preview and the agent at once and the published site at the next publish. The agent never asks for a key in its reply text: when the app needs one, its message carries `secretKeysNeeded` ([Get Agent Status](https://www.totalum.app/docs/api/agent#get-agent-status)). Let your user choose the environment, add each name here, then prompt again ("the keys are saved, carry on").

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `secretName` | string | Yes | Uppercase letters, digits and `_`, starting with a letter. Names starting with `TOTALUM_` (except `TOTALUM_VCAAS_API_KEY`) and `NODE_ENV`, `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_SECRET`, `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, `TURSO_AUTH_TOKEN`, `TURSO_DATABASE_URL` are reserved |
| `secretValue` | string | Yes | 1–10,000 characters |
| `environment` | string | No | `"development"` (preview), `"production"` (published site) or `"both"` (default) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data._id` | string | Secret id (to delete it) |
| `data.secretName` | string | Name |
| `data.environment` | string | Environment |
| `data.createdAt` | string | ISO 8601 |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"secretName":"OPENWEATHER_KEY","secretValue":"abc123","environment":"both"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/secrets
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "_id": "sec_01k6xy", "secretName": "OPENWEATHER_KEY", "environment": "both", "createdAt": "2026-10-08T12:00:00.000Z" } }
```
:::

:::danger Validation error · 400
```json
{ "errors": { "errorCode": "VALIDATION_ERROR", "errorMessage": "secretName: Secret name must be uppercase, separated by underscores, cannot start with a number, and can only contain letters, numbers, and underscores", "errorDetails": { "errors": [{ "value": "openweather", "msg": "Secret name must be uppercase, separated by underscores, cannot start with a number, and can only contain letters, numbers, and underscores", "param": "secretName", "location": "body" }] } }, "data": null }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_SECRET_FIELDS` | 400 | `secretName` or `secretValue` is missing |
| `VALIDATION_ERROR` | 400 | The name, value or environment is invalid; `errorDetails.errors` lists each field |
| `SECRET_CREATE_FAILED` | 400 | A secret with this name already exists in that environment |
| `RESERVED_SECRET_NAME` | 400 | The name is reserved |
| `SECRET_LIMIT_EXCEEDED` | 400 | The project already has 500 secrets |

## Delete Secret

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/secrets/:secretId
cost: Free
```

Remove an environment variable. The names and ids of a project's secrets are in [Get Project](https://www.totalum.app/docs/api/projects#get-project) (`secrets[]`).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `secretId` | string | The secret's `_id` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/secrets/sec_01k6xy
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true } }
```
:::

## List Project Keys

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/api-keys
cost: Free
```

The project's own API keys, revoked ones included (secrets are never listed). Two are managed by Totalum: `managed: "app"` runs the app itself, `managed: "env"` is the key in the downloaded `.env`; rotate them, never revoke them.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.keys[].id` | string | Key id |
| `data.keys[].name` | string | Name |
| `data.keys[].prefix` | string | The visible start of the key |
| `data.keys[].scopes` | string[] | Scopes |
| `data.keys[].env` | string | `"dev"`, `"live"` or `"both"` |
| `data.keys[].createdAt` | string \| null | ISO 8601 |
| `data.keys[].expiresAt` | string \| null | Expiry |
| `data.keys[].revokedAt` | string \| null | When it was revoked |
| `data.keys[].managed` | string \| undefined | `"app"` or `"env"` for Totalum's own keys |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/api-keys
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "keys": [{ "id": "key_01k6xz", "name": "backend", "prefix": "tlm_pk_a1b2", "scopes": ["db:read", "db:write"], "env": "both", "createdAt": "2026-10-08T12:00:00.000Z", "expiresAt": null, "revokedAt": null }] } }
```
:::

## Create Project API Key

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/api-keys
cost: Free
```

Create a `tlm_pk_` key for calling this project's database, files and built-in features from your servers (with [`totalum-sdk`](https://www.totalum.app/docs/sdk) or HTTP). It works only on this project and only for its scopes. The secret is returned **once**. Owner (or a member allowed to manage API keys) only.

| Scope | Allows |
|---|---|
| `db:read`, `db:write`, `db:schema` | Read rows, write rows, change the schema |
| `files:read`, `files:write` | Read and upload files (`files:write` also makes PDFs) |
| `ai`, `email`, `web`, `scan`, `speech`, `vectors`, `cron`, `seo` | One built-in feature each |
| `integrations:*` | Every built-in feature above |
| `browser`, `payments`, `realtime` | Cloud browser, payments, real-time |
| `logs:read`, `analytics:read`, `meter:write` | Logs, site analytics, usage reporting |
| `*` | Everything |

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | A name |
| `scopes` | string[] | Yes | One or more scopes from the table |
| `env` | string | No | `"dev"`, `"live"` or both (default): the database environment the key may use |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.key` | object | The key, with the fields of [List Project Keys](#list-project-keys) |
| `data.key.plaintext` | string | The secret — shown only now |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"backend","scopes":["db:read","db:write","integrations:*"]}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/api-keys
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "key": { "id": "key_01k6xz", "name": "backend", "prefix": "tlm_pk_a1b2", "scopes": ["db:read", "db:write", "integrations:*"], "env": "both", "createdAt": "2026-10-08T12:00:00.000Z", "expiresAt": null, "revokedAt": null, "plaintext": "tlm_pk_a1b2…" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_SCOPES` | 400 | Empty or unknown scopes; the message lists the valid ones |
| `OWNER_ONLY` | 403 | Only the owner or a member allowed to manage API keys |
| `PROJECT_NOT_READY` | 409 | The project is still being set up; retry after `Retry-After` seconds |

## Rotate Project Key

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/api-keys/:keyId/rotate
cost: Free
```

Replace a key with a new secret (same name, scopes and environment); the old one stops at once. For the app's own key, the new key reaches the app before the old one stops.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `keyId` | string | The key id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.key` | object | The new key, with `plaintext` (shown once) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/api-keys/key_01k6xz/rotate
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "key": { "id": "key_01k6y0", "name": "backend", "prefix": "tlm_pk_c3d4", "scopes": ["db:read", "db:write", "integrations:*"], "env": "both", "createdAt": "2026-10-08T12:10:00.000Z", "expiresAt": null, "revokedAt": null, "plaintext": "tlm_pk_c3d4…" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `KEY_NOT_FOUND` | 404 | Unknown or already revoked |
| `OWNER_ONLY` | 403 | Only the owner or a member allowed to manage API keys |

## Revoke Project Key

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/api-keys/:keyId
cost: Free
```

Revoke a key at once.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `keyId` | string | The key id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.revoked` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/api-keys/key_01k6y0
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "revoked": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `KEY_NOT_FOUND` | 404 | Unknown or already revoked |
| `KEY_MANAGED` | 409 | The app's own key or the `.env` key: rotate it instead |
| `OWNER_ONLY` | 403 | Only the owner or a member allowed to manage API keys |

## Get Project Env

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/env
cost: Free
```

The project's `.env` file for one environment, as **plain text** (not JSON): the project key, the auth secret, the URLs and that environment's secrets. Save it as `.env` (dev) or `.env.prod` (live) next to the [downloaded source](https://www.totalum.app/docs/api/source-files#download-source-code) to run the app locally; never commit it. Owner (or a member allowed to manage API keys) only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | Yes | `"dev"` or `"live"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| (body) | text | The `.env` file (`Content-Disposition: attachment`) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" -o .env \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/env?env=dev"
```

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_ENV` | 400 | `env` is not `dev` or `live` |
| `OWNER_ONLY` | 403 | Only the owner or a member allowed to manage API keys |


---

# Logs & Preview Server

> See what your app prints, in development and in production.

A project writes logs in two places: the **development preview** (`dev`) and the **published site** (`live`). Each log line belongs to one request, so you can follow a failing request from its status to the error it threw. [Get Project Logs](#get-project-logs) reads both with filters; the other two endpoints keep simpler shapes.

## Get Project Logs

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/logs
cost: Free
```

Log lines of the preview or the published site, newest first, with filters.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `env` | string | No | `"dev"` (preview) or `"live"` (published site) |
| `from` | string | No | Start, ISO 8601 |
| `to` | string | No | End, ISO 8601 |
| `level` | string | No | `"error"`, `"warn"`, `"info"`, `"log"` or `"debug"` |
| `q` | string | No | Text to find in the message (up to 200 characters) |
| `status` | string | No | HTTP status of the request: `500`, or a class like `5xx` |
| `path` | string | No | Request path |
| `requestId` | string | No | One request's lines |
| `limit` | number | No | 1–500 |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.items` | object[] | Log lines |
| `data.items[].ts` | string | ISO 8601 time |
| `data.items[].level` | string | `error`, `warn`, `info`, `log` or `debug` |
| `data.items[].message` | string | The text (secrets redacted) |
| `data.items[].requestId` | string \| null | The request it belongs to |
| `data.items[].method` | string \| null | HTTP method |
| `data.items[].path` | string \| null | Request path |
| `data.items[].status` | number \| null | Response status |
| `data.items[].cpuMs` | number \| null | CPU time of the request |
| `data.items[].wallMs` | number \| null | Duration of the request |
| `data.items[].outcome` | string | How the request ended (`ok`, `exception`, `canceled`, …) |
| `data.items[].exception` | object \| undefined | `{ name, message, stack? }` of an uncaught error |
| `data.window` | object | `{ from, to, source }`: the range searched |
| `data.nextCursor` | string \| null | Cursor for older lines |
| `data.sampled` | boolean | `true` when a very busy window was sampled |
| `data.hint` | string \| undefined | A suggestion when nothing matched |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/logs?env=live&level=error&limit=50"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "items": [
      {
        "ts": "2026-10-08T12:03:11.000Z",
        "level": "error",
        "message": "TotalumError [INSUFFICIENT_CREDITS]: The account has no credits",
        "requestId": "9a1f2c",
        "method": "POST",
        "path": "/api/summary",
        "status": 502,
        "cpuMs": 4,
        "wallMs": 210,
        "outcome": "ok"
      }
    ],
    "window": { "from": "2026-10-08T06:03:00.000Z", "to": "2026-10-08T12:03:30.000Z", "source": "buffer" },
    "nextCursor": null,
    "sampled": false
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A filter has an invalid value |

## Get Production Logs

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/backend/prod/logs
cost: Free
```

The published site's requests, one record per request with the console output and exceptions of that request nested in it. Without `from`/`to` it covers the last 6 hours (the last 3 days when `to` or `regexSearch` is given). At most 500 log lines are read.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | No | Start, ISO 8601 |
| `to` | string | No | End, ISO 8601 |
| `regexSearch` | string | No | Text to find — the fastest way to a specific request |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.ok` | boolean | `true` |
| `data.organizationId` | string | The project id |
| `data.fromMs` | number | Start of the range (epoch ms) |
| `data.toMs` | number | End of the range (epoch ms) |
| `data.regexSearch` | string \| null | The search |
| `data.count` | number | Records returned |
| `data.records` | object[] | One per request |
| `data.records[].EventTimestampMs` | number | When it was served |
| `data.records[].Outcome` | string | How it ended |
| `data.records[].WallTimeMs` | number \| undefined | Duration |
| `data.records[].CPUTimeMs` | number \| undefined | CPU time |
| `data.records[].Event.Request` | object | `{ URL, Method }` |
| `data.records[].Event.Response` | object \| undefined | `{ Status }` |
| `data.records[].Logs` | object[] | `{ Level, Message: [text], TimestampMs }` |
| `data.records[].Exceptions` | object[] | `{ Name, Message, TimestampMs }` |
| `data.totalChars` | number | Size of the records |
| `data.truncated` | boolean | `true` when the 500-line read limit was hit — narrow the range |
| `data.window` | object | `{ from, to, source }` actually searched |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/backend/prod/logs?regexSearch=checkout"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "ok": true,
    "organizationId": "barber-bookings",
    "fromMs": 1791547200000,
    "toMs": 1791806400000,
    "regexSearch": "checkout",
    "count": 1,
    "records": [
      {
        "EventTimestampMs": 1791806380000,
        "Outcome": "ok",
        "WallTimeMs": 128,
        "CPUTimeMs": 14,
        "Event": { "Request": { "URL": "/api/checkout", "Method": "POST" }, "Response": { "Status": 500 } },
        "Logs": [{ "Level": "error", "Message": ["checkout failed: missing price"], "TimestampMs": 1791806380000 }],
        "Exceptions": []
      }
    ],
    "totalChars": 312,
    "truncated": false
  }
}
```
:::

## Get Dev Server Logs

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/backend/dev/logs
cost: Free
```

The development preview's log of the last hour, as plain text (`[time] message` per line, oldest first).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.logs` | string | The lines, or a note when there are none yet |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/backend/dev/logs
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "logs": "[2026-10-08T11:58:02.000Z] booking saved bk_1001\n[2026-10-08T11:58:03.000Z] e-mail sent to owner" } }
```
:::

## Start or Restart Server

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/agent/server/start-or-restart
cost: 2 credits (free to wake a sleeping project)
```

Restart the project's development environment from its saved code, for example when the preview is stuck. On a project asleep after a long inactivity it wakes it instead (free): the preview answers again within seconds. You rarely need this — a prompt wakes a project by itself.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.message` | string | `"Server is restarting"` or `"Project is reactivating"` |
| `data.status` | string | `"restarting"` or `"reactivating"` |
| `data.operationId` | string | The operation, visible in [Get Project State](https://www.totalum.app/docs/api/projects#get-project-state) |
| `data.creditsCharged` | number | 2 for a restart, 0 to wake |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/agent/server/start-or-restart
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "message": "Server is restarting", "status": "restarting", "operationId": "op_01k6xt", "creditsCharged": 2 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `AGENT_RUNNING` | 409 | A prompt is running |
| `OPERATION_IN_PROGRESS` | 409 | Another operation (publish, restore…) is running |


---

# Usage & Analytics

> What your apps use, what they cost, and who visits them.

Everything is reported in **credits**. A project spends credits in two categories: **development** (prompts, publishes and other actions in the builder) and **infrastructure** (what the running app uses: hosting, database, files, AI, e-mail and the other built-in features).

## Get Project Usage

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/usage
cost: Free
```

What one project's app used, per feature, in a date range.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | Yes | ISO 8601 start (`2026-10-01T00:00:00Z`) |
| `to` | string | Yes | ISO 8601 end |
| `groupBy` | string | No | `"day"` (default) or `"month"` |
| `env` | string | No | `"dev"`, `"live"` or `"all"` (default) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.range` | object | `{ from, to }` as asked |
| `data.groupBy` | string | The bucket used |
| `data.env` | string | `dev`, `live` or `all` |
| `data.dimensions` | object[] | One row per feature: `{ dimension, units, unit, credits, period }` (a feature with no use has `units: 0`) |
| `data.series` | object[] | `{ ts, integration, units, unit, credits }` per bucket and feature |
| `data.totals` | object | `{ credits, byDimension }` for the range |
| `data.period` | object | `{ from, to }` the figures cover |
| `data.refusals` | object[] | Calls a feature limit refused: `{ feature, kind: "limit" \| "disabled", cap, period, consumed, at, count }` |
| `data.infrastructure` | object | `{ d1: { rowsRead, rowsWritten, storageBytes }, worker: { requests, cpuMs }, storage: { bytes } }` |
| `data.accrualPending` | number \| undefined | Credits used but not settled yet |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/usage?from=2026-10-01T00:00:00Z&to=2026-10-08T00:00:00Z"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "range": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" },
    "groupBy": "day",
    "env": "all",
    "dimensions": [{ "dimension": "email", "units": 42, "unit": "recipients", "credits": 0.42, "period": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" } }],
    "series": [{ "ts": "2026-10-07T00:00:00Z", "integration": "email", "units": 12, "unit": "recipients", "credits": 0.12 }],
    "totals": { "credits": 0.61, "byDimension": { "email": { "units": 42, "unit": "recipients", "credits": 0.42 } } },
    "period": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" },
    "refusals": [],
    "infrastructure": { "d1": { "rowsRead": 30211, "rowsWritten": 240, "storageBytes": 2457600 }, "worker": { "requests": 8120, "cpuMs": 40210 }, "storage": { "bytes": 1048576 } }
  }
}
```
:::

## Get Account Usage

```endpoint
method: GET
path: /api/v1/vcaas/usage
cost: Free
```

The same report for the whole account, with a per-project breakdown (100 projects a page).

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | Yes | ISO 8601 start |
| `to` | string | Yes | ISO 8601 end |
| `groupBy` | string | No | `"day"` (default) or `"month"` |
| `env` | string | No | `"dev"`, `"live"` or `"all"` (default) |
| `cursor` | string | No | `nextCursor` of the previous page |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.byProject` | object[] | `{ projectId, label, engineVersion, totals, period }` for the projects of this page |
| `data.account` | object | `{ plan, balance, accountCaps: { hour, day }, consumedThisPeriod }`: `accountCaps` are the account's [spending caps](https://www.totalum.app/docs/limits#account-spending-caps) in credits per hour and per 24 hours (`0` when they are agreed per account) |
| `data.nextCursor` | string \| null | Next page of projects; `null` on the last |
| `data.dimensions`, `data.series`, `data.totals`, `data.period` | | As in [Get Project Usage](#get-project-usage), for this page's projects |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/usage?from=2026-10-01T00:00:00Z&to=2026-10-08T00:00:00Z"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "byProject": [{ "projectId": "barber-bookings", "label": "Barber bookings", "engineVersion": "v2", "totals": { "credits": 0.61 }, "period": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" } }],
    "account": { "plan": "starter", "balance": 412.5, "accountCaps": { "hour": 360, "day": 900 }, "consumedThisPeriod": 31.2 },
    "nextCursor": null,
    "dimensions": [],
    "series": [],
    "totals": { "credits": 0.61, "byDimension": {} },
    "period": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" }
  }
}
```
:::

## Get Site Analytics

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/analytics/site
cost: Free
```

Visits of the published site (or the preview): a time series, or the top values of one dimension. Counts are sampled at high traffic; unique visitors are approximate.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | No | ISO 8601 start |
| `to` | string | No | ISO 8601 end |
| `granularity` | string | No | `"minute"`, `"hour"` or `"day"` |
| `metric` | string | No | Up to 4, comma-separated: `page_views`, `hits`, `uniques`, `errors`, `bytes`, `ttfb_p50`, `ttfb_p95` |
| `dimension` | string | No | Top values of: `path`, `referrer`, `country`, `device`, `browser`, `os`, `status`, `hostname` |
| `limit` | number | No | 1–100 top values |
| `bots` | string | No | `"exclude"` (default), `"include"` or `"only"` |
| `env` | string | No | `"live"` (published and custom hosts) or `"dev"` (preview); both when omitted |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.series` | object[] \| undefined | Time series: `{ t, <metric>: number, … }` |
| `data.top` | object[] \| undefined | With `dimension`: `{ key, share, <metric>: number, … }` |
| `data.window` | object | `{ from, to, granularity }` |
| `data.source` | string | `"wae"` |
| `data.approximate` | object | `{ uniques: boolean }` |
| `data.cachedAt` | string | When the figures were computed |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/analytics/site?dimension=path&metric=page_views&env=live"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "top": [{ "key": "/", "share": 0.62, "page_views": 310 }, { "key": "/book", "share": 0.21, "page_views": 105 }],
    "window": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-08T00:00:00.000Z", "granularity": "day" },
    "source": "wae",
    "approximate": { "uniques": true },
    "cachedAt": "2026-10-08T12:00:00.000Z"
  }
}
```
:::

## Spending Analytics

```endpoint
method: GET
path: /api/v1/credits/spending-analytics
cost: Free
```

Credits spent per day, by category and usage type, for charts and reports (90 days at most). Note the path: it is not under `/vcaas`.

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | Yes | Start date `YYYY-MM-DD` |
| `to` | string | Yes | End date `YYYY-MM-DD`, at most 90 days after `from` |
| `projectId` | string | No | Only one project |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.daily` | object[] | `{ date, development, infrastructure, byType, recurrent, oneTime }` per day |
| `data.totals` | object | `{ development, infrastructure, total, byType, recurrent, oneTime, unsplit, uncategorized }` |
| `data.projects` | string[] | Projects with spending in the range |
| `data.byProject` | object[] | `{ projectId, total, development, infrastructure, recurrent, oneTime }` |

`byType` keys are the usage types of the charges, for example `prompt`, `deploy`, `create_project`, `start_server`, `project_monthly_fee` and the built-in features the app used.

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/credits/spending-analytics?from=2026-10-01&to=2026-10-07"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "daily": [{ "date": "2026-10-07", "development": 7.9, "infrastructure": 0.3, "byType": { "prompt": 7.4, "deploy": 0.5, "email": 0.1 }, "recurrent": 8.2, "oneTime": 0 }],
    "totals": { "development": 7.9, "infrastructure": 0.3, "total": 8.2, "byType": { "prompt": 7.4, "deploy": 0.5, "email": 0.1 }, "recurrent": 8.2, "oneTime": 0, "unsplit": 0, "uncategorized": 0 },
    "projects": ["barber-bookings"],
    "byProject": [{ "projectId": "barber-bookings", "total": 8.2, "development": 7.9, "infrastructure": 0.3, "recurrent": 8.2, "oneTime": 0 }]
  }
}
```
:::

A missing or invalid range answers `400 VALIDATION_ERROR` in the usual envelope (an account with legacy projects, and the older host `api-accounts.totalum.app`: `{ "success": false, "error": "…" }`).


---

# Spending & Rate Limits

> Keep every project's spending and traffic under control.

Three independent controls, all per project. Every limit of an account and a project, and which ones Totalum can raise, is on [Limits](https://www.totalum.app/docs/limits).

- **Credit limits** — a monthly cap on **development** credits (prompts, publishes, builder actions) and on **infrastructure** credits (what the running app uses).
- **Feature limits** — switch each built-in feature of the app (AI, e-mail, web scraping…) on or off and give it its own monthly budget.
- **Rate limit** — requests per minute each visitor (IP) may send to the published site.

A refused call never reaches a provider and costs nothing. Budgets reset on the 1st of each month (UTC).

## Get Credit Limits

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/credit-limits
cost: Free
```

The project's monthly caps and what it spent this month (UTC), per category.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.creditLimits.maxDevelopmentCreditsPerMonth` | number \| null | The development cap; `null` = no cap |
| `data.creditLimits.maxInfrastructureCreditsPerMonth` | number \| null | The infrastructure cap; `null` = no cap |
| `data.usedThisMonth` | object \| null | `{ development, infrastructure }` credits spent this month; `null` when it cannot be read right now |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/credit-limits
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "creditLimits": { "maxDevelopmentCreditsPerMonth": 5000, "maxInfrastructureCreditsPerMonth": 300 }, "usedThisMonth": { "development": 42.5, "infrastructure": 3.1 } } }
```
:::

## Update Credit Limits

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId/credit-limits
cost: Free
```

Set the project's monthly caps. `null` removes a cap. Defaults for a project created through the API: a development cap of 5,000 credits a month and an infrastructure cap of 300 credits a month. Both can also be set when the project is created ([Launch Project](https://www.totalum.app/docs/api/projects#launch-project), [Create Project](https://www.totalum.app/docs/api/projects#create-project)).

At the development cap a prompt is refused with `403 PROJECT_CREDIT_LIMIT_REACHED`, and a running prompt that crosses it stops (keeping what it built). At the infrastructure cap the app's paid built-in calls are refused until the next month or until you raise it. You get the `project.credit_limit.reached` [webhook](https://www.totalum.app/docs/api/webhooks) and an e-mail.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `maxDevelopmentCreditsPerMonth` | number \| null | No¹ | Positive number, or `null` for no cap |
| `maxInfrastructureCreditsPerMonth` | number \| null | No¹ | Positive number, or `null` for no cap |

¹ At least one of the two.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.creditLimits.maxDevelopmentCreditsPerMonth` | number \| null | When sent: the new development cap |
| `data.creditLimits.maxInfrastructureCreditsPerMonth` | number \| null | When sent: the new infrastructure cap |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"maxDevelopmentCreditsPerMonth":500,"maxInfrastructureCreditsPerMonth":null}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/credit-limits
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "creditLimits": { "maxDevelopmentCreditsPerMonth": 500, "maxInfrastructureCreditsPerMonth": null } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_LIMIT_FIELDS` | 400 | Neither cap was sent |
| `INVALID_CREDIT_LIMITS` | 400 | The body is not an object |
| `INVALID_LIMIT` | 400 | A cap is not a positive number or `null` |
| `OWNER_ONLY` | 403 | Only the account owner can change the caps |

## List Projects at Their Limit

```endpoint
method: GET
path: /api/v1/vcaas/projects/credit-limits/at-limit
cost: Free
```

The account's projects that reached a monthly credit limit this month (UTC), with their caps and spend — the list behind the platform's "projects at their monthly limit" notice. Raise one with [Update Credit Limits](#update-credit-limits).

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projects` | object[] | The projects at a limit (empty when none) |
| `data.projects[].projectId` | string | The project id |
| `data.projects[].label` | string \| null | Its display name |
| `data.projects[].createdVia` | string \| null | `"ui"` (the platform) or `"api"` |
| `data.projects[].creditLimits` | object | `{ maxDevelopmentCreditsPerMonth, maxInfrastructureCreditsPerMonth }` |
| `data.projects[].usedThisMonth` | object | `{ development, infrastructure }` credits spent this month |
| `data.projects[].atLimit` | object | `{ development, infrastructure }`: `true` for each limit reached |
| `data.resetsAt` | string | When the limits reset (the start of next month, UTC) |
| `data.canEdit` | boolean | Whether this caller may raise them (the account owner) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/credit-limits/at-limit
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projects": [{ "projectId": "barber-bookings", "label": "Barber bookings", "createdVia": "api", "creditLimits": { "maxDevelopmentCreditsPerMonth": 5000, "maxInfrastructureCreditsPerMonth": 300 }, "usedThisMonth": { "development": 812.4, "infrastructure": 300.2 }, "atLimit": { "development": false, "infrastructure": true } }], "resetsAt": "2026-11-01T00:00:00.000Z", "canEdit": true } }
```
:::

## Get Feature Limits

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/limits
cost: Free
```

Every built-in feature of the app: whether it is on, its monthly budgets and what it used this month. Every feature is on by default, with no budget of its own.

| Feature | Covers |
|---|---|
| `ai` | Every AI call below |
| `ai_text` | Chat, structured output, vision, embeddings, reranking |
| `ai_image` | Image generation and editing |
| `ai_video` | Video generation |
| `speech` | Speech-to-text and text-to-speech |
| `web` | Web scraping, crawling, search, extraction |
| `browser` | Cloud browser sessions |
| `scan` | Document scanning (OCR) |
| `storage` | File uploads and stored files |
| `email` | Sending e-mail |
| `pdf` | PDFs, screenshots, SEO pings |
| `vectors` | Vector search |
| `realtime` | Real-time rooms |
| `cron` | Scheduled job runs |
| `mobile` | Mobile app builds |
| `database` | The database (cannot be switched off) |
| `worker` | The site itself (cannot be switched off) |
| `all` | Everything: one switch and one total monthly budget, in credits |

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.plan` | string | The account's plan |
| `data.features` | object | Feature → its entry |
| `data.features.<feature>.enabled` | boolean | `false` = switched off |
| `data.features.<feature>.parent` | string \| undefined | `"ai"` for `ai_text`, `ai_image`, `ai_video` (the parent's switch and budget also apply) |
| `data.features.<feature>.monthlyCreditCap` | number \| undefined | Budget in credits, when set |
| `data.features.<feature>.monthlyUnitCap` | number \| undefined | Budget in the feature's unit, when set |
| `data.features.<feature>.unit` | string | `calls`, `recipients`, `pages`, `bytes`, `session_minutes`, `room_minutes`, `executions`, `builds`, `rows`, `requests` or `credits` |
| `data.features.<feature>.consumed` | object | `{ credits, units }` this month |
| `data.features.<feature>.period` | object | `{ from, to }`: the month |
| `data.features.<feature>.planCeiling` | object | Highest budget your plan allows: `{ monthlyCreditCap, monthlyUnitCap }` (`null` = none) |
| `data.features.<feature>.source` | string | `plan` (default), `user` (you set it) or `ceo` (set by Totalum support) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/limits
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "plan": "starter",
    "features": {
      "ai_video": { "enabled": false, "parent": "ai", "unit": "calls", "consumed": { "credits": 0, "units": 0 }, "period": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-11-01T00:00:00.000Z" }, "planCeiling": { "monthlyCreditCap": null, "monthlyUnitCap": 20 }, "source": "user" },
      "all": { "enabled": true, "monthlyCreditCap": 300, "unit": "credits", "consumed": { "credits": 12.4, "units": 0 }, "period": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-11-01T00:00:00.000Z" }, "planCeiling": { "monthlyCreditCap": null, "monthlyUnitCap": null }, "source": "user" }
    }
  }
}
```
:::

The answer lists every feature; the example shows two.

**Concurrency.** Besides budgets, two features have per-project concurrency limits: `browser` (3 open sessions, 1 on the free plan; a call up to 60 s) and `web` (1 call at a time, up to 10 s). A call over the limit waits in a fair queue, then answers `429 CONCURRENCY_QUEUE_TIMEOUT` (or `CONCURRENCY_QUEUE_FULL`) with `errorDetails.retryAfter`. A project makes at most **100 built-in calls per second** in total; above it a call answers `429 RATE_LIMITED` with `Retry-After`.

**Busy hours.** When an app spends half of what would trigger the emergency stop within one hour, its image, video and speech AI calls answer `429 AI_MEDIA_PAUSED_THIS_HOUR` until that hour ends (UTC), with `Retry-After`; text AI and everything else keep working. They come back by themselves at the next hour; [releasing an emergency-stop pause](https://www.totalum.app/docs/api/projects#release-runaway-pause) also brings them back.

## Set Feature Limits

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/limits
cost: Free
```

Switch features on or off and set monthly budgets. Send only the features you change; `null` removes a budget. Account owner only. Setting the budget of `all` also sets the project's infrastructure credit limit.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `features` | object | Yes | Feature → `{ enabled?, monthlyCreditCap?, monthlyUnitCap? }` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.plan` | string | The account's plan |
| `data.features` | object | Every feature after the change, as in [Get Feature Limits](#get-feature-limits) |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"features":{"ai_video":{"enabled":false},"ai":{"monthlyCreditCap":200},"all":{"monthlyCreditCap":500}}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/limits
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "plan": "starter", "features": { "ai_video": { "enabled": false, "parent": "ai", "unit": "calls", "consumed": { "credits": 0, "units": 0 }, "period": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-11-01T00:00:00.000Z" }, "planCeiling": { "monthlyCreditCap": null, "monthlyUnitCap": 20 }, "source": "user" } } } }
```
:::

What the app's code gets when a feature is refused (show a message, do not retry):

| App error | HTTP | Meaning |
|---|---|---|
| `FEATURE_DISABLED` | 403 | The feature (or `ai`, or `all`) is switched off; `errorDetails.feature` names it |
| `FEATURE_LIMIT_REACHED` | 429 | Its budget is spent; `errorDetails`: `feature`, `limit`, `used`, `unit`, `resetsAt` |
| `AI_MEDIA_PAUSED_THIS_HOUR` | 429 | The app used a lot of credits this hour, so image, video and speech AI wait until the hour ends (UTC); `Retry-After` and `errorDetails.resetsAt` say when. Text AI and everything else keep working |

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `UNKNOWN_FEATURE` | 400 | Not a feature of the table above |
| `INVALID_LIMIT` | 400 | A negative or non-integer budget, or a `monthlyUnitCap` on `all` |
| `FEATURE_NOT_DISABLABLE` | 400 | `database` and `worker` cannot be switched off |
| `LIMIT_ABOVE_PLAN_CEILING` | 400 | A budget above your plan's ceiling |
| `OWNER_ONLY` | 403 | Only the account owner can change budgets |

## Get Rate Limit

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/rate-limit
cost: Free
```

How many requests per minute each visitor (IP) may send to the published site.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.perIpPerMinute` | number \| null | Requests per minute per IP; `null` = off |
| `data.allowlistCidrs` | string[] | IP ranges that are never limited (up to 50) |
| `data.verifiedBotsExempt` | boolean | Search engines and other verified bots are not limited |
| `data.planDefault` | number | Your plan's default |
| `data.planMax` | number \| null | The highest value your plan allows |
| `data.canDisable` | boolean | Whether your plan may switch it off |
| `data.source` | string | `"plan"` or `"user"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/rate-limit
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "perIpPerMinute": 5000, "allowlistCidrs": [], "verifiedBotsExempt": true, "planDefault": 5000, "planMax": 60000, "canDisable": true, "source": "plan" } }
```
:::

## Update Rate Limit

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId/rate-limit
cost: Free
```

Change the per-visitor limit, the allowlist or the bot exemption. Send only what changes.

Separately, each visitor may cause at most **20 paid built-in calls a minute** (AI, web, browser, screenshots), so nobody can spend the project's credits by calling a public AI page in a loop; over it the app's call answers `429 RATE_LIMIT_EXCEEDED` with `Retry-After`. Ask the agent to change it ("let each visitor send 60 AI messages a minute").

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `perIpPerMinute` | number \| null | No | From 10 to 60,000 on paid plans (5,000 on the free plan, the default everywhere); `null` = off (paid plans only) |
| `allowlistCidrs` | string[] | No | Up to 50 IP ranges (`203.0.113.0/24`) |
| `verifiedBotsExempt` | boolean | No | Exempt verified bots |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The setting after the change, as in [Get Rate Limit](#get-rate-limit) |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"perIpPerMinute":300}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/rate-limit
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "perIpPerMinute": 300, "allowlistCidrs": [], "verifiedBotsExempt": true, "planDefault": 5000, "planMax": 60000, "canDisable": true, "source": "user" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A value is out of range |
| `RATE_LIMIT_ABOVE_PLAN` | 400 | Above your plan's maximum (`errorDetails.planMax`) |
| `INVALID_CIDR` | 400 | An allowlist entry is not an IPv4 or IPv6 range |
| `RATE_LIMIT_DISABLE_NOT_ALLOWED` | 403 | The free plan cannot switch the limit off |


---

# Scheduled Jobs

> Run your app's work on a schedule.

A scheduled job calls one of the app's own routes (for example `POST /api/cron/daily-report`) on a schedule. A job belongs to one environment: a **`dev`** job calls the development preview, a **`live`** job calls the published site. Create `dev` jobs while building: **publishing creates their `live` twins** and every later publish carries the dev job's changes (schedule, address, time zone, on/off) to its twin and deletes the twin of a deleted dev job. `live` jobs you create yourself are never touched by a publish. The app can also create jobs itself with `totalum.cron` ([SDK](https://www.totalum.app/docs/sdk/cron)).

| Plan | Jobs per environment | Shortest interval |
|---|---|---|
| Free | 2 | 1 hour |
| Starter | 10 | 1 minute |
| Builder, Professional, Enterprise | 20 | 1 minute |

**Cost.** Creating and keeping jobs is free. Each run costs about **0.0004 credits** for the scheduling itself, plus what the request costs like any visit (its compute time, its database rows, any built-in feature it calls). A light job every hour costs about 0.36 credits a month; every minute, about 18.6. A failed run (an answer that is not 2xx, or no answer in time) counts toward `pauseAfterFailures`; the owner is told by e-mail and the `cron.job.paused` / `cron.job.failing` [webhooks](https://www.totalum.app/docs/api/webhooks).

## List Cron Jobs

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/cron-jobs
cost: Free
```

The project's jobs, both environments.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.items` | object[] | The jobs |
| `data.items[].id` | string | Job id |
| `data.items[].name` | string | Name |
| `data.items[].schedule` | string \| object | A 5-field cron expression, or `{ everyMinutes }` |
| `data.items[].timezone` | string \| undefined | IANA time zone of the schedule (absent = UTC) |
| `data.items[].request` | object | `{ method, url, headers?, body?, timeoutMs? }` |
| `data.items[].env` | string | `"dev"` or `"live"` |
| `data.items[].enabled` | boolean | Switched on |
| `data.items[].status` | string | `"active"`, `"paused"` or `"paused_failures"` (paused after too many failures) |
| `data.items[].nextRunAt` | string \| null | Next run |
| `data.items[].lastRun` | object \| undefined | `{ at, status, latencyMs }`; `status` is the HTTP status, or `"timeout"`, `"error"`, `"skipped"` |
| `data.items[].pauseAfterFailures` | number \| null | Failures in a row that pause it; `null` = never |
| `data.items[].description` | string \| undefined | Description |
| `data.items[].createdAt` | string | ISO 8601 |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "items": [{ "id": "cj_01k6xv", "name": "daily-report", "schedule": "0 9 * * *", "timezone": "Europe/Madrid", "request": { "method": "POST", "url": "https://barber-bookings.totalum-project.com/api/cron/daily-report" }, "env": "live", "enabled": true, "status": "active", "nextRunAt": "2026-10-09T07:00:00.000Z", "pauseAfterFailures": 3, "createdAt": "2026-10-08T12:00:00.000Z" }] } }
```
:::

## Create Cron Job

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/cron-jobs
cost: Free
```

Create a job. The URL must be on the environment's own host: the preview address for `dev`, the published `<projectId>.totalum-project.com` for `live` (which needs a published project). Without `env` the job belongs to the environment of its URL: the preview address makes a `dev` job (so an unpublished project needs no `env`), any other address a `live` one.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | 1–64 characters |
| `schedule` | string \| object | Yes | A 5-field cron expression (`"*/15 * * * *"`), or `{ "everyMinutes": 30 }` |
| `request` | object | Yes | What to call |
| `request.method` | string | Yes | `"GET"` or `"POST"` |
| `request.url` | string | Yes | `https://` URL on the environment's host |
| `request.headers` | object | No | Up to 10 headers; a value may be `{{secret:NAME}}` to send a project secret |
| `request.body` | string | No | Up to 64 KB |
| `request.timeoutMs` | number | No | Up to 30,000 |
| `env` | string | No | `"dev"` or `"live"` (default: the environment of `request.url` — `"dev"` for the preview address, else `"live"`) |
| `timezone` | string | No | IANA zone, e.g. `"Europe/Madrid"`: `"0 9 * * *"` runs at 09:00 there all year. Default UTC |
| `pauseAfterFailures` | number \| null | No | 1–100 failures in a row pause the job (default 3); `null` or `0` = never pause |
| `enabled` | boolean | No | Default `true` |
| `description` | string | No | Free text |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The job, with the fields of `items[]` in [List Cron Jobs](#list-cron-jobs) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"daily-report","env":"dev","schedule":"0 9 * * *","timezone":"Europe/Madrid","request":{"method":"POST","url":"https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com/api/cron/daily-report"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "id": "cj_01k6xw", "name": "daily-report", "schedule": "0 9 * * *", "timezone": "Europe/Madrid", "request": { "method": "POST", "url": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com/api/cron/daily-report" }, "env": "dev", "enabled": true, "status": "active", "nextRunAt": "2026-10-09T07:00:00.000Z", "pauseAfterFailures": 3, "createdAt": "2026-10-08T12:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_SCHEDULE` | 400 | Invalid, or more frequent than your plan allows (`errorDetails.minIntervalSeconds`) |
| `VALIDATION_ERROR` | 400 | A field is invalid (an unknown `timezone`, `pauseAfterFailures` out of range…) |
| `CRON_URL_NOT_ALLOWED` | 403 | The URL is not on the environment's host (`errorDetails.allowedHost`) |
| `CRON_REQUIRES_PUBLISHED_PROJECT` | 403 | A `live` job on a project that is not published |
| `CRON_LIMIT_REACHED` | 429 | The environment already has your plan's number of jobs (`errorDetails.limit`) |

## Get Cron Job

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/cron-jobs/:jobId
cost: Free
```

One job.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `jobId` | string | The job id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The job, as in [List Cron Jobs](#list-cron-jobs) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs/cj_01k6xw
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "cj_01k6xw", "name": "daily-report", "schedule": "0 9 * * *", "request": { "method": "POST", "url": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com/api/cron/daily-report" }, "env": "dev", "enabled": true, "status": "active", "nextRunAt": "2026-10-09T09:00:00.000Z", "pauseAfterFailures": 3, "createdAt": "2026-10-08T12:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No such job |

## Update Cron Job

```endpoint
method: PATCH
path: /api/v1/vcaas/projects/:projectId/cron-jobs/:jobId
cost: Free
```

Change any field of a job, or switch it on or off (`enabled`). A paused job runs again once it is enabled.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `jobId` | string | The job id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `name`, `schedule`, `request`, `env`, `timezone`, `pauseAfterFailures`, `enabled`, `description` | | No | Any field of [Create Cron Job](#create-cron-job) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The job after the change |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"enabled":false}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs/cj_01k6xw
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "cj_01k6xw", "name": "daily-report", "schedule": "0 9 * * *", "request": { "method": "POST", "url": "https://barber-bookings-dev-temporal-link-preview-k3y9x2ab.totalum-project.com/api/cron/daily-report" }, "env": "dev", "enabled": false, "status": "paused", "nextRunAt": null, "pauseAfterFailures": 3, "createdAt": "2026-10-08T12:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No such job |
| `INVALID_SCHEDULE` | 400 | Invalid or too frequent schedule |
| `CRON_URL_NOT_ALLOWED` | 403 | The URL is not on the environment's host |

## Delete Cron Job

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/cron-jobs/:jobId
cost: Free
```

Delete a job.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `jobId` | string | The job id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs/cj_01k6xw
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No such job |

## Run Cron Job

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/cron-jobs/:jobId/run
cost: Free
```

Run a job now, once. The run appears in its executions. At most 10 manual runs a minute per project.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `jobId` | string | The job id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.runId` | string | The execution |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs/cj_01k6xw/run
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "runId": "cr_01k6xx" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No such job |
| `RATE_LIMITED` | 429 | More than 10 manual runs in a minute; retry after `errorDetails.retryAfterSeconds` |

## List Cron Job Executions

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/cron-jobs/:jobId/executions
cost: Free
```

A job's executions of the last 30 days, newest first.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `jobId` | string | The job id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `limit` | number | No | Page size |
| `cursor` | string | No | `nextCursor` of the previous page |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.items[].id` | string | Execution id |
| `data.items[].scheduledFor` | string | When it was due |
| `data.items[].startedAt` | string \| null | When it ran |
| `data.items[].status` | number \| string | HTTP status, or `"timeout"`, `"error"`, `"skipped"` (not run: no credits, or the `cron` feature is off or over budget) |
| `data.items[].latencyMs` | number \| null | Response time |
| `data.items[].error` | string \| undefined | Why it failed or was skipped |
| `data.items[].responseSnippet` | string \| undefined | The first 1 KB of the answer |
| `data.nextCursor` | string \| null | Next page |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/cron-jobs/cj_01k6xw/executions?limit=20"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "items": [{ "id": "cr_01k6xx", "scheduledFor": "2026-10-08T12:05:00.000Z", "startedAt": "2026-10-08T12:05:00.000Z", "status": 200, "latencyMs": 142, "responseSnippet": "{\"ok\":true}" }], "nextCursor": null } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No such job |


---

# Custom Domains

> Put your app on your own domain.

A published project answers at `<projectId>.totalum-project.com`. On the Professional and Enterprise plans it also answers at `<projectId>.webapp-project.com` — both addresses serve the site — and [Update Project](https://www.totalum.app/docs/api/projects#update-project) `{ "liveHostSuffix": "webapp-project.com" }` makes that one the main address (the one shown as `productionProjectUrl` and used for links). Add **one** custom domain per project: a subdomain (`app.example.com`) or a whole domain (`example.com` or `www.example.com`, served as `www.example.com`; the bare domain redirects to it once it points here, which needs an `ALIAS`/`ANAME` record or CNAME flattening at your DNS provider — otherwise add a URL redirect from `example.com` to `https://www.example.com` there). You create the DNS records at your DNS provider; SSL is issued automatically. A domain [bought on Totalum](https://www.totalum.app/docs/api/domain-registration) is attached with its own endpoint and needs no DNS work.

> **An address without "totalum" in it.** For a white-label app, use either option:
> - **`webapp-project.com`** (Professional and Enterprise): set `liveHostSuffix` to `"webapp-project.com"`, and the main address, links, sitemap and preview use `<projectId>.webapp-project.com`. The `totalum-project.com` address keeps working too, so old links never break; it just isn't shown anywhere.
> - **Your own domain** (any paid plan): [add a custom domain](#add-custom-domain) or [buy one on Totalum](https://www.totalum.app/docs/api/domain-registration). It becomes the published address.
>
> Domains are set **per project**: each project can have its own custom domain and/or use `webapp-project.com`. One domain that replaces the address of every project of an account at once is not supported yet.

A custom domain costs 4 credits when added and 3 credits a month while active. Custom domains are included on paid plans.

## Add Custom Domain

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/domain
cost: 4 credits (first domain)
```

Connect a domain you own. Publish the project first. Then create the records in `dnsRecordsToAdd` at your DNS provider and poll [Get Project](https://www.totalum.app/docs/api/projects#get-project) until `customDomain.status` and `customDomain.sslStatus` are `"active"` (usually minutes, up to a few hours for DNS). Calling it again with another hostname replaces the domain.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `hostname` | string | Yes | `app.example.com` or `example.com` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |
| `data.hostname` | string | The hostname that will serve the site |
| `data.status` | string | `"pending"` until the DNS records are found, then `"active"` |
| `data.dnsRecordsToAdd` | object[] | Records to create: `{ type: "CNAME" \| "TXT" \| "ALIAS", name, value }`; `name` is the full host name. For a whole domain the bare domain's `ALIAS` and `TXT` come last: optional, they make it redirect to `www.` |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"book.barbershop.com"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "success": true,
    "hostname": "book.barbershop.com",
    "status": "pending",
    "dnsRecordsToAdd": [
      { "type": "CNAME", "name": "book.barbershop.com", "value": "barber-bookings.totalum-project.com" },
      { "type": "TXT", "name": "_cf-custom-hostname.book.barbershop.com", "value": "4f6a2c9e-…" }
    ]
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_HOSTNAME` | 400 | `hostname` is missing |
| `VALIDATION_ERROR` | 400 | Not a valid domain name, or a Totalum host |
| `NO_DEPLOYMENT` | 404 | Publish the project first |
| `DOMAIN_ALREADY_ADDED` | 409 | This domain is already on the project |
| `DOMAIN_IN_USE` | 409 | The domain is connected to another project |
| `PROJECT_HAS_CUSTOM_DOMAIN` | 409 | The project uses a domain bought on Totalum; detach it first |
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | Custom domains are not included on your plan |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Get Custom Domain

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/domain
cost: Free
```

The project's custom domain and the state of each of its addresses — the site and, for a whole domain, the bare domain that redirects to it. Statuses are checked again with Cloudflare while an address is still pending (at most once a minute), so poll this (or [Get Project](https://www.totalum.app/docs/api/projects#get-project), whose `customDomain` carries the same fields) until every `status` and `sslStatus` is `"active"`. New engine projects only. `data` is `null` when the project has no custom domain.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.hostname` | string | Where the site answers |
| `data.status` | string | That address: `"pending"` until its DNS records are found, then `"active"` |
| `data.sslStatus` | string | Its HTTPS certificate: `"active"` once issued |
| `data.domain` | string | The domain (`barbershop.io`) |
| `data.kind` | string | `"domain"` or `"subdomain"` |
| `data.canonicalHost` | string \| null | `"www"`, `"app"`, any label, or `"apex"`; `null` for a subdomain |
| `data.source` | string | `"purchase"` (bought on Totalum: no DNS work) or `"manual"` |
| `data.accountDomainId` | string \| undefined | The [bought domain](https://www.totalum.app/docs/api/domain-registration#get-domain), when `source` is `"purchase"` |
| `data.hostnames[]` | object[] | Each address: `{ hostname, role, status, sslStatus, dnsRecordsToAdd, verificationErrors }`; `role` is `"canonical"` (the site), `"alias"` (redirects to it with a 301, path and query kept) or `"sub"` |
| `data.dnsRecordsToAdd` | object[] | Every record still to create, as in [Add Custom Domain](#add-custom-domain) (empty for a bought domain: Totalum writes them) |
| `data.redirects` | object | `{ canonical, applyToDefaultHost, rules }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "hostname": "app.barbershop.io", "status": "active", "sslStatus": "active", "domain": "barbershop.io", "kind": "domain", "canonicalHost": "app", "source": "purchase", "accountDomainId": "dom_01k6y3", "hostnames": [{ "hostname": "app.barbershop.io", "role": "canonical", "status": "active", "sslStatus": "active", "dnsRecordsToAdd": [], "verificationErrors": [] }, { "hostname": "barbershop.io", "role": "alias", "status": "active", "sslStatus": "active", "dnsRecordsToAdd": [], "verificationErrors": [] }], "dnsRecordsToAdd": [], "redirects": { "canonical": "app.barbershop.io", "applyToDefaultHost": false, "rules": [] } } }
```
:::

## Remove Custom Domain

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/domain
cost: Free
```

Disconnect the custom domain. The site keeps answering at `<projectId>.totalum-project.com`. Your DNS records are not touched. A domain [bought on Totalum](https://www.totalum.app/docs/api/domain-registration) is detached the same way: it stays yours and is not refunded, and it keeps renewing until you switch `autoRenew` off ([Detach Domain](https://www.totalum.app/docs/api/domain-registration#detach-domain)).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |
| `data.hostname` | string \| null | The domain removed |
| `data.message` | string | `"Custom domain removed"` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true, "hostname": "book.barbershop.com", "message": "Custom domain removed" } }
```
:::


---

# Buy a Domain

> Register a domain on Totalum and connect it in one step.

A domain bought on Totalum is registered in your name, paid with the **card saved on your Totalum account** (not with credits), renewed every year automatically (the renewal is charged 30 days before the expiry and retried 21 and 15 days before; if it is still unpaid 14 days before the expiry, automatic renewal is switched off and you are told by e-mail — renew by hand until the expiry), and connected to a project without any DNS work. Buying domains needs the Starter plan or above; a plan holds up to 3 (Starter), 10 (Builder), 50 (Professional) or unlimited (Enterprise) domains, and at most 5 can be bought a day. Prices are in your billing currency.

These endpoints are account-level (no project in the path). Purchases, renewals, contacts and transfers are the account owner's.

**Before buying**, create the registrant contact (the domain's legal owner, published in WHOIS with privacy on): [Create Registrant Contact](#create-registrant-contact). The registrant must verify their e-mail address by the deadline shown in `verification.verifyBy`, or the domain is suspended until they do.

## Search Domains

```endpoint
method: GET
path: /api/v1/vcaas/domains/search
cost: Free
```

Which names are available, and their price.

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `q` | string | Yes | A name (`barbershop`) or a full domain (`barbershop.com`) |
| `tlds` | string | No | Comma-separated extensions to check (`com,io`); default: every extension sold |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.results[].domain` | string | The domain |
| `data.results[].available` | boolean | Can be bought now |
| `data.results[].premium` | boolean | Always `false` (premium names are not offered) |
| `data.results[].price` | number | First-year price |
| `data.results[].renewalPrice` | number | Yearly renewal price. It can be much higher than `price` (a registry's first-year promotion), so show both: "€3.59 first year, then €91.19/year" |
| `data.results[].currency` | string | `eur` or `usd` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/domains/search?q=barbershop&tlds=com,io"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "results": [{ "domain": "barbershop.io", "available": true, "premium": false, "price": 52.8, "renewalPrice": 52.8, "currency": "eur" }, { "domain": "barbershop.com", "available": false, "premium": false, "price": 0, "renewalPrice": 0, "currency": "eur" }] } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `q` is missing |

## List TLDs

```endpoint
method: GET
path: /api/v1/vcaas/domains/tlds
cost: Free
```

The extensions sold and their prices.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.tlds[].tld` | string | Extension (`com`) |
| `data.tlds[].price` | number | First-year price |
| `data.tlds[].renewalPrice` | number | Yearly renewal |
| `data.tlds[].currency` | string | `eur` or `usd` |
| `data.tlds[].requirements` | boolean | The registry asks for extra registrant data |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/tlds
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "tlds": [{ "tld": "com", "price": 14.4, "renewalPrice": 14.4, "currency": "usd", "requirements": false }] } }
```
:::

## Buy Domain

```endpoint
method: POST
path: /api/v1/vcaas/domains/purchase
cost: Card payment (not credits)
```

Charge the saved card and register the domain. If the registration fails, the payment is refunded at once. Send an `Idempotency-Key` header so a retried request never buys twice (a retry of a domain you already hold returns it without a new charge). With `projectId`, the domain is attached to that project in the same call.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | Yes | The domain to buy |
| `contactId` | string | Yes | The registrant contact |
| `years` | number | No | Always `1`: a domain is bought for one year, then renews every year. Any other value is refused (`400 VALIDATION_ERROR`) |
| `projectId` | string | No | Attach to this project after the purchase |
| `canonicalHost` | string | No | Where the site answers: `"www"` (default), `"app"` or any DNS label (letters, digits, inner hyphens) — the site is served at `<label>.<domain>` and the bare domain redirects there (301, path and query kept) — or `"apex"` (the bare domain itself). Refused with `400 VALIDATION_ERROR` before any charge when it is not a valid label |
| `expectedPrice` | number | No | The price you showed: if the live price differs, nothing is bought (`409 PRICE_CHANGED`) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.domain.id` | string | Domain id |
| `data.domain.domain` | string | The domain |
| `data.domain.status` | string | `"active"` |
| `data.domain.registeredAt` | string | ISO 8601 |
| `data.domain.expiresAt` | string | ISO 8601 |
| `data.domain.autoRenew` | boolean | `true` |
| `data.domain.pricePaid` | number | What was charged |
| `data.domain.renewalPriceEstimate` | number | The expected yearly renewal |
| `data.domain.attachedProjectId` | string \| undefined | The project it serves |
| `data.domain.contactVerificationPending` | boolean | The registrant still has to verify their e-mail |
| `data.warnings` | object[] \| undefined | `{ code }` — e.g. the attach did not happen (attach it with [Attach Domain](#attach-domain)) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d1e4c2a-buy-barbershop-io" \
  -d '{"domain":"barbershop.io","contactId":"ct_01k6y2","projectId":"barber-bookings","expectedPrice":52.8}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/purchase
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "domain": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "registeredAt": "2026-10-08T12:30:00.000Z", "expiresAt": "2027-10-08T12:30:00.000Z", "autoRenew": true, "pricePaid": 52.8, "renewalPriceEstimate": 52.8, "attachedProjectId": "barber-bookings", "contactVerificationPending": true } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `domain` or `contactId` is missing |
| `TLD_NOT_ALLOWED` | 400 | The extension is not sold |
| `DOMAIN_PREMIUM_NOT_ALLOWED` | 400 | Premium names are not sold |
| `NO_PAYMENT_METHOD` | 402 | Add a card to your Totalum account first |
| `PAYMENT_FAILED` | 402 | The card was declined (`errorDetails.declineCode`) |
| `FEATURE_DISABLED` | 403 | Buying domains is not available yet on your account |
| `PLAN_REQUIRED` | 403 | Needs the Starter plan or above |
| `DOMAIN_LIMIT_REACHED` | 403 | Your plan's number of domains is reached |
| `OWNER_ONLY` | 403 | Only the account owner can buy |
| `DOMAIN_UNAVAILABLE` | 409 | The domain is taken |
| `PRICE_CHANGED` | 409 | The price differs from `expectedPrice` (`errorDetails.newPrice`) |
| `DOMAIN_CONTACT_REQUIRED` | 422 | Create the registrant contact first |
| `REGISTRATION_FAILED` | 422 | The registration was refused; the payment was refunded |
| `RATE_LIMIT_EXCEEDED` | 429 | More than 5 purchases today |

## List Domains

```endpoint
method: GET
path: /api/v1/vcaas/domains
cost: Free
```

The account's domains.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.domains` | object[] | Domains, with the fields of [Get Domain](#get-domain) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "domains": [{ "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "registeredAt": "2026-10-08T12:30:00.000Z", "expiresAt": "2027-10-08T12:30:00.000Z", "autoRenew": true, "renewal": { "status": "scheduled", "nextChargeAt": "2027-09-08T12:30:00.000Z", "attempts": [] }, "pricing": { "pricePaid": 52.8, "currency": "eur", "renewalPriceEstimate": 52.8 }, "privacy": true, "locked": true, "contactId": "ct_01k6y2", "contactVerified": false, "nameservers": ["ns1.totalum-dns.com"], "dnsManaged": true, "attachedProjectId": "barber-bookings", "canonicalHost": "www", "exportAvailableAt": "2026-12-07T12:30:00.000Z", "verification": { "contactVerificationPending": true, "verifyBy": "2026-10-23T12:30:00.000Z" } }] } }
```
:::

## Get Domain

```endpoint
method: GET
path: /api/v1/vcaas/domains/:domainId
cost: Free
```

One domain.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.id` | string | Domain id |
| `data.domain` | string | The domain |
| `data.status` | string | `"active"`, `"expired_grace"`, `"expired"`, `"export_pending"`, `"transferred_out"`, `"external_dns"` or `"suspended_verification"` |
| `data.registeredAt` | string | ISO 8601 |
| `data.expiresAt` | string | ISO 8601 |
| `data.autoRenew` | boolean | Renews automatically |
| `data.renewal` | object | `{ status: "scheduled" \| "charging" \| "renewed" \| "cancelled", nextChargeAt, attempts[], lastRenewedAt?, cancelledAt? }` |
| `data.pricing` | object | `{ pricePaid, currency, renewalPriceEstimate }` |
| `data.privacy` | boolean | WHOIS privacy |
| `data.locked` | boolean | Transfer lock |
| `data.contactId` | string | Registrant contact |
| `data.contactVerified` | boolean | Registrant e-mail verified |
| `data.nameservers` | string[] | Nameservers |
| `data.dnsManaged` | boolean | DNS is managed on Totalum |
| `data.attachedProjectId` | string \| undefined | The project it serves |
| `data.canonicalHost` | string \| undefined | `"www"` or `"apex"` |
| `data.exportAvailableAt` | string | From when it can be transferred away (60 days after registration) |
| `data.verification` | object | `{ contactVerificationPending, verifyBy }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "registeredAt": "2026-10-08T12:30:00.000Z", "expiresAt": "2027-10-08T12:30:00.000Z", "autoRenew": true, "renewal": { "status": "scheduled", "nextChargeAt": "2027-09-08T12:30:00.000Z", "attempts": [] }, "pricing": { "pricePaid": 52.8, "currency": "eur", "renewalPriceEstimate": 52.8 }, "privacy": true, "locked": true, "contactId": "ct_01k6y2", "contactVerified": true, "nameservers": ["ns1.totalum-dns.com"], "dnsManaged": true, "attachedProjectId": "barber-bookings", "canonicalHost": "www", "exportAvailableAt": "2026-12-07T12:30:00.000Z", "verification": { "contactVerificationPending": false, "verifyBy": null } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DOMAIN_NOT_FOUND` | 404 | No such domain in your account |

## Update Domain

```endpoint
method: PATCH
path: /api/v1/vcaas/domains/:domainId
cost: Free
```

Turn WHOIS privacy or automatic renewal on or off. Owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `privacy` | boolean | No | WHOIS privacy |
| `autoRenew` | boolean | No | Automatic yearly renewal |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The domain after the change, as in [Get Domain](#get-domain) |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"autoRenew":false}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "autoRenew": false, "renewal": { "status": "cancelled", "nextChargeAt": null, "attempts": [] } } }
```
:::

## Renew Domain

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/renew
cost: Card payment
```

Renew now for one year (within 90 days of expiry, or during the grace period after it). Owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.domain` | object | The domain after the renewal |
| `data.order` | object \| null | The renewal order |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/renew
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "domain": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "expiresAt": "2028-10-08T12:30:00.000Z" }, "order": { "kind": "renewal", "status": "completed", "customerPrice": 52.8, "currency": "eur" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DOMAIN_NOT_RENEWABLE` | 409 | Not within 90 days of expiry (`errorDetails.expiresAt`) |
| `PAYMENT_FAILED` | 402 | The card was declined |
| `REGISTRATION_FAILED` | 422 | The renewal was refused; the payment was refunded |

## Attach Domain

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/attach
cost: 4 credits (first domain of the project)
```

Serve a project from this domain; Totalum writes the DNS records itself (and the ones that prove ownership of each address). The project must be published. The site answers at `<canonicalHost>.<domain>` and the bare domain redirects there (301, path and query kept); follow both addresses with [Get Custom Domain](https://www.totalum.app/docs/api/domains#get-custom-domain).

**Change the prefix:** call it again with the same `projectId` and another `canonicalHost` (`{"projectId":"barber-bookings","canonicalHost":"app"}`): the site moves to the new address in a few minutes and the old one stops answering. No second fee; the same prefix again changes nothing.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | Yes | The project |
| `canonicalHost` | string | No | `"www"` (default), `"app"` or any DNS label, or `"apex"` — as in [Buy Domain](#buy-domain) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.domain` | object | The domain, as in [Get Domain](#get-domain) |
| `data.customDomain` | object | The project's custom domain: `{ domain, hostnames, status, … }` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"barber-bookings"}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/attach
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "domain": { "id": "dom_01k6y3", "domain": "barbershop.io", "attachedProjectId": "barber-bookings", "canonicalHost": "www" }, "customDomain": { "domain": "barbershop.io", "status": "pending" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `projectId` is missing, or `canonicalHost` is not `"apex"` or one DNS label |
| `PROJECT_NOT_FOUND` | 404 | No such project in your account |
| `NO_DEPLOYMENT` | 404 | Publish the project first |
| `DOMAIN_ALREADY_ATTACHED` | 409 | Attached to another project; detach it first |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits for the domain fee |
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | Custom domains are not included on your plan |

## Detach Domain

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/detach
cost: Free
```

Stop serving the project from this domain. The domain stays yours and is **not refunded**. Its automatic renewal keeps running unless you stop it: to remove the domain for good, detach it and then [Update Domain](#update-domain) with `{"autoRenew":false}` — it stays registered to you until `expiresAt`, then expires. Afterwards the project can [attach](#attach-domain) another domain you bought, [buy one](#buy-domain) with `projectId`, or [connect one you own](https://www.totalum.app/docs/api/domains#add-custom-domain).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The domain, as in [Get Domain](#get-domain) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/detach
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active" } }
```
:::

## List DNS Records

```endpoint
method: GET
path: /api/v1/vcaas/domains/:domainId/dns/records
cost: Free
```

The domain's DNS records. `managed: true` records serve the attached project and cannot be changed.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.records[].id` | string | Record id |
| `data.records[].type` | string | `A`, `AAAA`, `CNAME`, `ALIAS`, `MX`, `TXT`, `SRV` or `NS` |
| `data.records[].host` | string | Host (`""` = the domain itself) |
| `data.records[].answer` | string | Value |
| `data.records[].ttl` | number | Seconds (≥ 300) |
| `data.records[].priority` | number \| undefined | MX/SRV priority |
| `data.records[].managed` | boolean | Written by Totalum for the attached project |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/dns/records
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "records": [{ "id": "rec_1", "type": "MX", "host": "", "answer": "mx.mailprovider.com", "ttl": 300, "priority": 10, "managed": false }] } }
```
:::

## Create DNS Record

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/dns/records
cost: Free
```

Add a record (for example your e-mail provider's MX and TXT records).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `record` | object | Yes | `{ type, host, answer, ttl, priority? }` as in [List DNS Records](#list-dns-records) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The record, with `id` and `managed: false` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"record":{"type":"TXT","host":"","answer":"v=spf1 include:_spf.mailprovider.com ~all","ttl":300}}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/dns/records
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "id": "rec_2", "type": "TXT", "host": "", "answer": "v=spf1 include:_spf.mailprovider.com ~all", "ttl": 300, "managed": false } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DNS_RECORD_CONFLICT` | 400 | Conflicts with an existing record |
| `DNS_RECORD_LIMIT` | 400 | The domain already has 200 records |
| `VALIDATION_ERROR` | 400 | Invalid record |

## Update DNS Record

```endpoint
method: PATCH
path: /api/v1/vcaas/domains/:domainId/dns/records/:recordId
cost: Free
```

Change a record you created.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |
| `recordId` | string | The record id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `record` | object | Yes | `{ type, host, answer, ttl, priority? }` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The record after the change |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"record":{"type":"MX","host":"","answer":"mx2.mailprovider.com","ttl":300,"priority":20}}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/dns/records/rec_1
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "rec_1", "type": "MX", "host": "", "answer": "mx2.mailprovider.com", "ttl": 300, "priority": 20, "managed": false } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DOMAIN_NOT_FOUND` | 404 | No such record |
| `DNS_RECORD_MANAGED` | 409 | The record serves the attached project; detach the domain first |

## Delete DNS Record

```endpoint
method: DELETE
path: /api/v1/vcaas/domains/:domainId/dns/records/:recordId
cost: Free
```

Delete a record you created.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |
| `recordId` | string | The record id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/dns/records/rec_2
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DOMAIN_NOT_FOUND` | 404 | No such record |
| `DNS_RECORD_MANAGED` | 409 | The record serves the attached project |

## Get Forwarding

```endpoint
method: GET
path: /api/v1/vcaas/domains/:domainId/forwarding
cost: Free
```

The domain's URL forwarding rules (a host of the domain redirecting to another URL).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.forwarding[].host` | string | Host of the domain (`shop.barbershop.io`) |
| `data.forwarding[].forwardsTo` | string | Target URL |
| `data.forwarding[].type` | string | `"redirect"` (301), `"302"` or `"masked"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/forwarding
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "forwarding": [{ "host": "shop.barbershop.io", "forwardsTo": "https://store.example.com", "type": "redirect" }] } }
```
:::

## Set Forwarding

```endpoint
method: PUT
path: /api/v1/vcaas/domains/:domainId/forwarding
cost: Free
```

Replace the whole set of forwarding rules (up to 50). A host the attached project serves is refused.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `forwarding` | object[] | Yes | `{ host, forwardsTo, type }`; `[]` removes every rule |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.forwarding` | object[] | The rules now in place |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"forwarding":[{"host":"shop.barbershop.io","forwardsTo":"https://store.example.com","type":"redirect"}]}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/forwarding
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "forwarding": [{ "host": "shop.barbershop.io", "forwardsTo": "https://store.example.com", "type": "redirect" }] } }
```
:::

## Get Transfer Code

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/export
cost: Free
```

Unlock the domain and get its transfer (auth) code, to move it to another registrar. Also e-mailed to the owner. Possible 60 days after registration. Owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.authCode` | string | The transfer code |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/export
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "authCode": "Xk7#pQ2!mZ9w" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `DOMAIN_TRANSFER_LOCKED` | 423 | Still inside the 60-day lock (`errorDetails.availableAt`) |
| `DOMAIN_NOT_RENEWABLE` | 409 | A renewal charge is in progress; retry shortly |

## Cancel Transfer

```endpoint
method: POST
path: /api/v1/vcaas/domains/:domainId/export/cancel
cost: Free
```

Lock the domain again and cancel a pending transfer. Owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The domain, as in [Get Domain](#get-domain) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/export/cancel
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "dom_01k6y3", "domain": "barbershop.io", "status": "active", "locked": true } }
```
:::

## List Domain Orders

```endpoint
method: GET
path: /api/v1/vcaas/domains/:domainId/orders
cost: Free
```

The domain's payments: registration, renewals and refunds.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `domainId` | string | The domain id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data[].kind` | string | `"registration"`, `"renewal"` or `"refund"` |
| `data[].status` | string | Order status |
| `data[].customerPrice` | number | Amount |
| `data[].currency` | string | `eur` or `usd` |
| `data[].at` | string | ISO 8601 |
| `data[].stripePaymentIntentId` | string \| undefined | The card payment |
| `data[].refunded` | boolean | The payment was refunded |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/dom_01k6y3/orders
```

:::success Success · 200 OK
```json
{ "errors": null, "data": [{ "kind": "registration", "status": "completed", "customerPrice": 52.8, "currency": "eur", "at": "2026-10-08T12:30:00.000Z", "stripePaymentIntentId": "pi_3Q…", "refunded": false }] }
```
:::

## List Registrant Contacts

```endpoint
method: GET
path: /api/v1/vcaas/domains/contacts
cost: Free
```

The account's registrant contacts.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.contacts[].id` | string | Contact id (for `contactId`) |
| `data.contacts[].label` | string \| undefined | Your label |
| `data.contacts[].contact` | object | The contact data (see [Create Registrant Contact](#create-registrant-contact)) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/contacts
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "contacts": [{ "id": "ct_01k6y2", "label": "Main", "contact": { "firstName": "Ana", "lastName": "García", "email": "ana@barbershop.io", "phone": "+34600111222", "address1": "Calle Mayor 1", "city": "Madrid", "zip": "28013", "country": "ES" } }] } }
```
:::

## Create Registrant Contact

```endpoint
method: POST
path: /api/v1/vcaas/domains/contacts
cost: Free
```

Create the legal owner data used to register domains. Owner only.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | No | Up to 100 characters |
| `contact` | object | Yes | The registrant |
| `contact.firstName` | string | Yes | First name |
| `contact.lastName` | string | Yes | Last name |
| `contact.organization` | string | No | Company |
| `contact.email` | string | Yes | E-mail (must be verified after a purchase, see `verification.verifyBy`) |
| `contact.phone` | string | Yes | International format: `+`, the country calling code and the number, `+34644479777`. Spaces, dashes, dots and parentheses are accepted and removed, and a leading `00` is read as `+` (`0034 644 47 97 77` is saved as `+34644479777`) |
| `contact.address1` | string | Yes | Address |
| `contact.address2` | string | No | Address, second line |
| `contact.city` | string | Yes | City |
| `contact.state` | string | No | State or province |
| `contact.zip` | string | Yes | Postal code |
| `contact.country` | string | Yes | Two-letter ISO 3166-1 alpha-2 code (`ES`, `US`), any case, saved uppercase. A country name (`United States`) is refused |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.id` | string | Contact id |
| `data.label` | string \| undefined | Label |
| `data.contact` | object | The contact |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"label":"Main","contact":{"firstName":"Ana","lastName":"García","email":"ana@barbershop.io","phone":"+34600111222","address1":"Calle Mayor 1","city":"Madrid","zip":"28013","country":"ES"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/contacts
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "id": "ct_01k6y2", "label": "Main", "contact": { "firstName": "Ana", "lastName": "García", "email": "ana@barbershop.io", "phone": "+34600111222", "address1": "Calle Mayor 1", "city": "Madrid", "zip": "28013", "country": "ES" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A field is missing or invalid; the message names it, e.g. `country must be a 2-letter ISO code like ES, US` or `phone must be in international format like +34644479777 (spaces are removed)` |
| `OWNER_ONLY` | 403 | Only the account owner |

## Update Registrant Contact

```endpoint
method: PATCH
path: /api/v1/vcaas/domains/contacts/:contactId
cost: Free
```

Change a contact. Every active domain registered with it is updated; a change of registrant restarts the 60-day transfer lock of those domains.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `contactId` | string | The contact id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | No | Label |
| `contact` | object | Yes | The full contact, as in Create Registrant Contact |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.contact` | object | The saved contact |
| `data.warnings` | object[] | `{ code: "DOMAIN_TRANSFER_LOCKED", domains }` when domains were re-locked |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"contact":{"firstName":"Ana","lastName":"García","email":"ana@barbershop.io","phone":"+34600111222","address1":"Calle Mayor 2","city":"Madrid","zip":"28013","country":"ES"}}' \
  https://builder-api.totalum.app/api/v1/vcaas/domains/contacts/ct_01k6y2
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "contact": { "id": "ct_01k6y2", "label": "Main" }, "warnings": [] } }
```
:::

## Resend Registrant Verification

```endpoint
method: POST
path: /api/v1/vcaas/domains/contacts/:contactId/resend-verification
cost: Free
```

Send the registrant's verification e-mail again.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `contactId` | string | The contact id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.sent` | number | Verification e-mails sent (0 when nothing is pending) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/domains/contacts/ct_01k6y2/resend-verification
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "sent": 1 } }
```
:::


---

# Mobile Apps

> Turn your web app into store-ready Android and iOS apps.

A project's mobile app is a native shell that opens the **published site**, so publish first. Ask the agent for a mobile app ("make it work as a mobile app") and it prepares the project; then build here. Mobile apps need a paid plan.

- Builds run on Totalum's build servers **one at a time for everyone**: a build may wait in a queue (`queuePosition`) and can be cancelled while it waits. Each project builds **at most once per hour**.
- A build is metered by its build time, in credits; a reservation of 13.3 credits is held when it starts (`creditsCharged`) and settled on the real build time when it ends (given back when it fails). Queue time is never charged.
- **Faster builds on your own Codemagic account:** set your Codemagic API token ([Set Signing Key](#set-signing-key) with `slot = codemagic`; in Codemagic: Account settings → API token). Builds then run on your Codemagic account: they start at once (no queue, no hourly limit), Totalum charges no build credits (`creditsCharged: 0`) and Codemagic bills its build minutes to you. Store uploads to TestFlight still run on Totalum's build servers.
- **Signed** builds (`aab`, `apk` with a key, `ipa`) need the signing key ([Set Signing Key](#set-signing-key)); unsigned debug builds do not.
- With "send to stores on publish" ([Set Mobile Settings](#set-mobile-settings)) every successful publish builds and uploads the signed apps to the Google Play internal track and TestFlight.

Signing, stores, settings and store uploads are the account owner's.

## Create Mobile Build

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/mobile/builds
cost: Metered (build time)
```

Start a build. Answers `202` with the build queued.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `platform` | string | Yes | `"android"` or `"ios"` |
| `artifact` | string | No | Android: `"debug-apk"`, `"apk"` (default) or `"aab"` (for Google Play). iOS: `"simulator-app"` (default, no Apple account needed) or `"ipa"` (signed, for TestFlight) |
| `mode` | string | No | `"remote_shell"` (Android default: the app loads the site) or `"hybrid"` (iOS default: a bundled shell that loads the site) |
| `signed` | boolean | No | Android: sign the `apk` (default: signed when a key is set) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.buildId` | string | The build |
| `data.status` | string | `"queued"` |
| `data.creditsReserved` | number | Credits held for the build |
| `data.creditsCharged` | number | Credits taken now (the reservation) |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"platform":"android","artifact":"aab"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "buildId": "mb_01k6y4", "status": "queued", "creditsReserved": 13.3, "creditsCharged": 13.3 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | The artifact does not match the platform |
| `PLAN_REQUIRED` | 403 | Mobile builds need a paid plan |
| `OWNER_ONLY` | 403 | A signed build is the owner's |
| `FEATURE_DISABLED` | 403 | That build type is not available yet |
| `MOBILE_REQUIRES_PUBLISHED_PROJECT` | 409 | Publish first: the app opens the published site |
| `SIGNING_NOT_CONFIGURED` | 409 | A signed build without a signing key |
| `MOBILE_BUILD_RUNNING` | 409 | A build is already running |
| `PROCESS_IN_PROGRESS` | 409 | A signed Android build while a prompt runs |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |
| `MOBILE_BUILD_RATE_LIMITED` | 429 | One build per hour; `errorDetails.retryAt` says when |

## List Mobile Builds

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/builds
cost: Free
```

The project's builds, newest first.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.builds` | object[] | Builds, with the fields of [Get Mobile Build](#get-mobile-build) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "builds": [{ "id": "mb_01k6y4", "platform": "android", "mode": "remote_shell", "status": "success", "startedAt": "2026-10-08T13:00:10.000Z", "finishedAt": "2026-10-08T13:06:40.000Z", "artifact": { "type": "aab", "sizeBytes": 4194304, "expiresAt": "2026-11-07T13:06:40.000Z" }, "signed": true, "versionName": "1.0.3", "versionCode": 3, "trigger": "user", "hasLog": true }] } }
```
:::

## Get Mobile Build

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/builds/:buildId
cost: Free
```

One build. Poll it until `status` is `"success"`, `"failed"` or `"cancelled"`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `buildId` | string | The build id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.id` | string | Build id |
| `data.platform` | string | `"android"` or `"ios"` |
| `data.mode` | string | `"remote_shell"` or `"hybrid"` |
| `data.status` | string | `"queued"`, `"running"`, `"success"`, `"failed"` or `"cancelled"` |
| `data.queuePosition` | number \| undefined | Place in the queue while `queued` |
| `data.startedAt` | string \| null | Start |
| `data.finishedAt` | string \| null | End |
| `data.artifact` | object \| null | `{ type, sizeBytes, expiresAt }` once built |
| `data.signed` | boolean | Signed build |
| `data.signingFingerprintSha256` | string \| undefined | Certificate fingerprint |
| `data.versionName` | string \| undefined | Version name |
| `data.versionCode` | number \| undefined | Version code |
| `data.trigger` | string \| undefined | `"user"` or `"publish"` |
| `data.error` | object \| undefined | `{ code, message, logExcerpt? }` of a failed build |
| `data.hasLog` | boolean \| undefined | A full log is available |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds/mb_01k6y4
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "mb_01k6y4", "platform": "android", "mode": "remote_shell", "status": "queued", "queuePosition": 2, "startedAt": null, "finishedAt": null, "artifact": null, "signed": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_NOT_FOUND` | 404 | No such build |

## Get Mobile Build Download

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/builds/:buildId/download
cost: Free
```

A 15-minute link to the built file.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `buildId` | string | The build id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string | Download URL |
| `data.fileName` | string | File name |
| `data.type` | string | The artifact type |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds/mb_01k6y4/download
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://files.totalum.app/mobile/mb_01k6y4/app-release.aab?sig=…", "fileName": "barber-bookings-1.0.3.aab", "type": "aab" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_NOT_READY` | 409 | The build has no file (yet) |
| `DOWNLOAD_EXPIRED` | 410 | The file has expired |

## Get Mobile Build Log

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/builds/:buildId/log
cost: Free
```

A 15-minute link to the build's full log. Ask the agent to fix a failed build ("the Android build fails"): it reads the log itself.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `buildId` | string | The build id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string | Log URL |
| `data.fileName` | string | `"build.log"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds/mb_01k6y4/log
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://files.totalum.app/mobile/mb_01k6y4/build.log?sig=…", "fileName": "build.log" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_NOT_READY` | 409 | The build has no log |

## Cancel Mobile Build

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/mobile/builds/:buildId/cancel
cost: Free
```

Cancel a build that is still waiting in the queue (its reservation is given back). A build that has started runs to its end.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `buildId` | string | The build id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The build after cancelling, as in [Get Mobile Build](#get-mobile-build) |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds/mb_01k6y4/cancel
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "mb_01k6y4", "platform": "android", "mode": "remote_shell", "status": "cancelled", "startedAt": null, "finishedAt": "2026-10-08T13:01:00.000Z", "artifact": null, "signed": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_NOT_CANCELLABLE` | 409 | The build is no longer waiting in the queue |

## Upload Build To Store

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/mobile/builds/:buildId/store-upload
cost: Free (Android) · metered (iOS)
```

Send a signed build to its store: an `aab` to the Google Play **internal** track, an `ipa` to **TestFlight**. Promotion to production stays your step in each store's console. At most 10 store uploads a day.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `buildId` | string | A signed `aab` or `ipa` build |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.uploadId` | string | The upload |
| `data.store` | string | `"google-play"` or `"app-store"` |
| `data.track` | string | `"internal"` or `"testflight"` |
| `data.status` | string | `"queued"` |
| `data.creditsReserved` | number | Credits held |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/builds/mb_01k6y4/store-upload
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "uploadId": "su_01k6y5abcdefghjkmnpqrstvwx", "store": "google-play", "track": "internal", "status": "queued", "creditsReserved": 0 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_NOT_SIGNED` | 409 | Only a signed `aab` or `ipa` |
| `STORE_NOT_CONFIGURED` | 409 | Connect the store first |
| `STORE_UPLOAD_RUNNING` | 409 | An upload is already running |
| `STORE_UPLOAD_LIMIT_REACHED` | 429 | 10 uploads today |

## List Store Uploads

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/store-uploads
cost: Free
```

The project's store uploads.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.uploads[].id` | string | Upload id |
| `data.uploads[].buildId` | string | The build |
| `data.uploads[].store` | string | `"google-play"` or `"app-store"` |
| `data.uploads[].track` | string | `"internal"` or `"testflight"` |
| `data.uploads[].status` | string | `"queued"`, `"uploading"`, `"processing"`, `"success"`, `"failed"` or `"manual_required"` (Google Play's first upload of an app must be made by hand) |
| `data.uploads[].versionName` | string | Version name |
| `data.uploads[].versionCode` | number | Version code |
| `data.uploads[].consoleUrl` | string \| null | Link to the store console |
| `data.uploads[].error` | object \| undefined | `{ code, message }` |
| `data.uploads[].createdAt` | string | ISO 8601 |
| `data.uploads[].finishedAt` | string \| null | ISO 8601 |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/store-uploads
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "uploads": [{ "id": "su_01k6y5abcdefghjkmnpqrstvwx", "buildId": "mb_01k6y4", "store": "google-play", "track": "internal", "status": "success", "versionName": "1.0.3", "versionCode": 3, "consoleUrl": "https://play.google.com/console/…", "createdAt": "2026-10-08T13:07:00.000Z", "finishedAt": "2026-10-08T13:09:00.000Z" }] } }
```
:::

## Get Store Upload

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/store-uploads/:uploadId
cost: Free
```

One store upload, with the fields of [List Store Uploads](#list-store-uploads).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `uploadId` | string | The upload id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The upload |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/store-uploads/su_01k6y5abcdefghjkmnpqrstvwx
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "id": "su_01k6y5abcdefghjkmnpqrstvwx", "buildId": "mb_01k6y4", "store": "google-play", "track": "internal", "status": "processing", "versionName": "1.0.3", "versionCode": 3, "consoleUrl": null, "createdAt": "2026-10-08T13:07:00.000Z", "finishedAt": null } }
```
:::

## Get Mobile Settings

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/settings
cost: Free
```

The app name and whether publishing also sends the apps to the stores.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.sendToStoresOnPublish` | boolean | Every successful publish builds and uploads the signed apps |
| `data.appName` | string \| null | The name under the icon (≤ 30 characters); `null` = the store listing title, else the site's name |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/settings
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "sendToStoresOnPublish": false, "appName": "Barber Book" } }
```
:::

## Set Mobile Settings

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/mobile/settings
cost: Free
```

Change either setting. Turning on `sendToStoresOnPublish` needs a connected store.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `sendToStoresOnPublish` | boolean | No¹ | Build and upload on every publish |
| `appName` | string \| null | No¹ | 1–30 characters; `null` = default |

¹ At least one.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.sendToStoresOnPublish` | boolean | After the change |
| `data.appName` | string \| null | After the change |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"appName":"Barber Book"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/settings
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "sendToStoresOnPublish": false, "appName": "Barber Book" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `STORE_NOT_CONFIGURED` | 409 | Connect Google Play or the App Store first |

## Get Signing Key

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/signing/:slot
cost: Free
```

The signing key of one platform (metadata only, never a secret). `slot` is `android`, `ios`, or `codemagic` (your own Codemagic API token: `configured`, `tokenHint` = its last 4 characters, `addedAt`).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `slot` | string | `"android"`, `"ios"` or `"codemagic"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.configured` | boolean | A key is set |
| `data.source` | string \| null | Android: `"uploaded"` or `"generated"` |
| `data.keyAlias` | string \| null | Android key alias |
| `data.fingerprintSha256` | string \| null | Android certificate fingerprint |
| `data.validUntil` | string \| null | Android certificate expiry |
| `data.keystoreDownloadAvailable` | boolean | Android: a generated keystore can still be downloaded |
| `data.keyId`, `data.issuerId`, `data.teamId`, `data.bundleId`, `data.validatedAt` | string \| null | iOS: the App Store Connect key |
| `data.createdAt`, `data.lastUsedAt` | string \| null | Dates |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/signing/android
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "configured": true, "source": "generated", "keyAlias": "upload", "fingerprintSha256": "AB:CD:…", "validUntil": "2051-10-08T00:00:00.000Z", "keystoreDownloadAvailable": false, "createdAt": "2026-10-08T12:50:00.000Z", "lastUsedAt": "2026-10-08T13:00:10.000Z" } }
```
:::

## Set Signing Key

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/mobile/signing/:slot
cost: Free
```

Set the signing key. **Android** (`slot = android`): `multipart/form-data` with the keystore file (`keystore`, a `.jks`/`.keystore` up to 64 KB) and its passwords — or generate one with [Android Signing Action](#android-signing-action). **iOS** (`slot = ios`): JSON with the App Store Connect API key, checked against Apple. **Codemagic** (`slot = codemagic`): JSON `{ "apiToken": "…" }`, checked against Codemagic; Totalum adds a `totalum-mobile-builds` app to that Codemagic account and runs the project's next builds there (refused `409 MOBILE_BUILD_RUNNING` while a build runs).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `slot` | string | `"android"`, `"ios"` or `"codemagic"` |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `keystore` | file | Android | The keystore file |
| `keyAlias` | string | Android | Key alias |
| `storePassword` | string | Android | 6–200 characters |
| `keyPassword` | string | Android | 6–200 characters |
| `apiKeyP8` | string | iOS | The `.p8` key, PEM text |
| `keyId` | string | iOS | 10-character key id |
| `issuerId` | string | iOS | Issuer UUID |
| `bundleId` | string | iOS | The app's bundle id (`com.example.app`) |
| `apiToken` | string | Codemagic | Your Codemagic API token (Account settings → API token) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | object | The key, as in [Get Signing Key](#get-signing-key) |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -F "keystore=@./upload-keystore.jks" -F "keyAlias=upload" \
  -F "storePassword=your-store-password" -F "keyPassword=your-key-password" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/signing/android
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "configured": true, "source": "uploaded", "keyAlias": "upload", "fingerprintSha256": "AB:CD:…", "validUntil": "2051-10-08T00:00:00.000Z", "keystoreDownloadAvailable": false, "createdAt": "2026-10-08T12:50:00.000Z", "lastUsedAt": null } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `SIGNING_INVALID_KEYSTORE` | 400 | The keystore was refused (`errorDetails.reason`: `parse`, `password`, `alias`, `expired`, `weak_key`) |
| `SIGNING_ALREADY_CONFIGURED` | 409 | Delete the current keystore first |
| `VALIDATION_ERROR` | 400 | Codemagic refused the API token |
| `PLAN_REQUIRED` | 403 | Needs a paid plan |
| `OWNER_ONLY` | 403 | Owner only |

## Android Signing Action

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/mobile/signing/android/:action
cost: Free
```

`action = generate`: create an Android upload key (RSA 4096, 25 years, alias `upload`) and get a one-time, 15-minute token to download it. `action = keystore-download`: exchange that token for the keystore file and its passwords — **keep them safe**; Google Play needs the same key for every update.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `action` | string | `"generate"` or `"keystore-download"` |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `commonName` | string | No | `generate`: certificate name |
| `organization` | string | No | `generate`: organisation |
| `countryCode` | string | No | `generate`: two-letter country |
| `token` | string | No | `keystore-download`: the token from `generate` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.keystoreDownload` | object | `generate`: `{ token, expiresAt }` plus the key's fields |
| `data.url` | string | `keystore-download`: a single-use URL (5 minutes) |
| `data.fileName` | string | `"upload-keystore.jks"` |
| `data.keyAlias`, `data.storePassword`, `data.keyPassword` | string | `keystore-download`: shown once |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"organization":"Barber Shop SL","countryCode":"ES"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/signing/android/generate
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "configured": true, "source": "generated", "keyAlias": "upload", "fingerprintSha256": "AB:CD:…", "validUntil": "2051-10-08T00:00:00.000Z", "keystoreDownloadAvailable": true, "createdAt": "2026-10-08T12:50:00.000Z", "lastUsedAt": null, "keystoreDownload": { "token": "kd_9f2c…", "expiresAt": "2026-10-08T13:05:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `SIGNING_ALREADY_CONFIGURED` | 409 | A key exists; delete it first |
| `KEYSTORE_NOT_DOWNLOADABLE` | 409 | An uploaded keystore is never downloadable |
| `KEYSTORE_DOWNLOAD_EXPIRED` | 410 | The token was used or expired |

## Delete Signing Key

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/mobile/signing/:slot
cost: Free
```

Remove a platform's signing key (or, with `slot = codemagic`, your Codemagic API token: builds go back to Totalum's build servers). Answers `204` with no body.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `slot` | string | `"android"`, `"ios"` or `"codemagic"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| (none) | | `204 No Content` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/signing/android
```

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MOBILE_BUILD_RUNNING` | 409 | A signed build is using the key |

## List Store Connections

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/stores
cost: Free
```

The connected store accounts (metadata only).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.googlePlay` | object \| null | `{ clientEmail, packageName, packageFound, validatedAt }` |
| `data.appStore` | object \| null | `{ appAppleId, bundleId, validatedAt }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/stores
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "googlePlay": { "clientEmail": "uploader@my-play.iam.gserviceaccount.com", "packageName": "com.barbershop.book", "packageFound": true, "validatedAt": "2026-10-08T12:55:00.000Z" }, "appStore": null } }
```
:::

## Get Store Connection

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/mobile/stores/:store
cost: Free
```

One store's connection (metadata only). The answer has only that store's field: `googlePlay` for `google-play`, `appStore` for `app-store`; `null` when it is not connected.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `store` | string | `"google-play"` or `"app-store"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.googlePlay` | object \| null | `google-play` only: `{ clientEmail, packageName, packageFound, validatedAt }` |
| `data.appStore` | object \| null | `app-store` only: `{ appAppleId, bundleId, validatedAt }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/stores/app-store
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "appStore": null } }
```
:::

## Connect Store

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/mobile/stores/:store
cost: Free
```

Connect a store account. **Google Play**: a Play Console service-account key (the JSON Google downloads) with release rights on the app, and the package name; a package Google does not know yet is stored with `packageFound: false` and its first bundle must be uploaded by hand. **App Store**: uses the iOS signing key; the app is found by its bundle id (or give `appAppleId`). The answer has only the store you connected.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `store` | string | `"google-play"` or `"app-store"` |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `serviceAccountJson` | string | Google Play | The service-account JSON, as text |
| `packageName` | string | Google Play | `com.example.app` |
| `appAppleId` | string | No | App Store: the app's Apple id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.googlePlay` | object | `google-play` only: the connection, as in [List Store Connections](#list-store-connections) |
| `data.appStore` | object | `app-store` only: the connection, as in List Store Connections |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"packageName":"com.barbershop.book","serviceAccountJson":"{\"type\":\"service_account\",…}"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/stores/google-play
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "googlePlay": { "clientEmail": "uploader@my-play.iam.gserviceaccount.com", "packageName": "com.barbershop.book", "packageFound": true, "validatedAt": "2026-10-08T12:55:00.000Z" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Invalid key or package name |
| `SIGNING_NOT_CONFIGURED` | 409 | App Store: add the iOS signing key first |

## Disconnect Store

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/mobile/stores/:store
cost: Free
```

Remove a store connection. Answers `204` with no body.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `store` | string | `"google-play"` or `"app-store"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| (none) | | `204 No Content` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/mobile/stores/google-play
```


---

# Payments

> Let your app take real payments.

An app takes payments into **its owner's Stripe account** — no Stripe key in the code. Ask the agent ("add a checkout for the courses") and it builds the payment flow with `totalum.payments` ([SDK](https://www.totalum.app/docs/sdk/payments)). These calls are the account owner's.

1. **Try it in the preview right away.** The preview always takes Stripe **test** cards, with no setup: pay with `4242 4242 4242 4242`, any future date and any CVC. No real money moves.
2. **Publish, then connect your Stripe to receive real payments** — one click in the project's **Payments** window, or [Create Payments Onboarding Link](#create-payments-onboarding-link): Stripe asks for your business and bank details on its own page and the money goes to your Stripe account.
3. **Switch the published site to real payments** when you are ready ([Set Payments Mode](#set-payments-mode)). The preview stays in test mode.

**Payment events arrive by themselves.** Renewals, cancellations, failed payments and refunds reach the app's own `/api/webhooks/stripe` route with nothing to set up, on the site where the checkout was made: test payments made in the preview go to the preview, test payments made on the published site (before it takes real payments) and real payments go to the published site (on its custom domain if it has one). They are signed by Totalum, and the app checks them with `totalum.webhooks.verify` ([SDK](https://www.totalum.app/docs/sdk/webhooks)). To also send them to another address (your own server, Zapier…), set the optional [project webhook](https://www.totalum.app/docs/api/webhooks#set-project-webhook) (in the platform: **Payments** → **Advanced**).

**Checkout opens in a new tab.** Stripe's payment page cannot run inside the platform's preview frame, so the app opens Checkout and the customer portal in a new tab unless you ask otherwise.

**Already have a Stripe account and prefer your own keys?** Add your secret key as a [project secret](https://www.totalum.app/docs/api/secrets) named `STRIPE_SECRET_KEY` and ask the agent to use it ("take payments with my own Stripe key"). The agent then calls Stripe from the app's server only, never from the browser. To receive Stripe's events, also add your endpoint's signing secret as `STRIPE_WEBHOOK_SECRET` and add `https://<your site>/api/webhooks/stripe` as an endpoint in your Stripe Dashboard: with your own keys that route checks Stripe's signature instead of Totalum's. Whether it charges test or real cards is up to the key (`sk_test_…` or `sk_live_…`).

## Get Payments Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/payments/status
cost: Free
```

Your connected Stripe account and whether it can take payments.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `mode` | string | No | `"live"` (default: your Stripe account) or `"test"` (the preview's test account) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.enabled` | boolean | A Stripe account is connected |
| `data.mode` | string | `"connect"` |
| `data.accountId` | string \| null | The Stripe account id |
| `data.livemode` | boolean | `true` for the live account |
| `data.chargesEnabled` | boolean | It can take payments |
| `data.payoutsEnabled` | boolean | It can receive payouts |
| `data.detailsSubmitted` | boolean | Onboarding is complete |
| `data.requirements` | object | `{ currentlyDue, pastDue, eventuallyDue }`: what Stripe still asks for |
| `data.liveEnabled` | boolean | The published site charges in live mode |
| `data.disconnectedAt` | string \| null \| undefined | When the owner disconnected the account from Stripe |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/payments/status?mode=live"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "enabled": true, "mode": "connect", "accountId": "acct_1Q2w3E4r", "livemode": true, "chargesEnabled": true, "payoutsEnabled": true, "detailsSubmitted": true, "requirements": { "currentlyDue": [], "pastDue": [], "eventuallyDue": [] }, "liveEnabled": false, "disconnectedAt": null } }
```
:::

## Create Payments Onboarding Link

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/payments/onboarding-link
cost: Free
```

A single-use link to Stripe's onboarding, where the owner connects (or creates) their Stripe account to receive real payments. The first link creates the account and needs its `country`. **Publish the project first**: real payments go to the published site, so a project that was never published is refused with `PAYMENTS_PUBLISH_FIRST`. The preview needs no onboarding: it takes test cards with no setup.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `mode` | string | No | `"live"` (default and the only one: `"test"` is refused, the preview's test account needs no onboarding) |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `returnUrl` | string | Yes | `https://` page to come back to when done |
| `refreshUrl` | string | Yes | `https://` page that asks for a new link if this one expired |
| `type` | string | No | `"account_onboarding"` (default) or `"account_update"` |
| `country` | string | No | Two-letter country of the business: required for the first link (and after a disconnection), cannot change later |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string | The onboarding link |
| `data.expiresAt` | string | When it expires |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"returnUrl":"https://yourapp.com/payments/done","refreshUrl":"https://yourapp.com/payments/retry","country":"ES"}' \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/payments/onboarding-link"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://connect.stripe.com/setup/e/acct_1Q2w3E4r/…", "expiresAt": "2026-10-08T13:10:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `OWNER_ONLY` | 403 | Account owner only |
| `VALIDATION_ERROR` | 400 | Missing or non-https URLs, or `mode=test` |
| `MISSING_INPUT` | 400 | No `country` for a new account |
| `PAYMENTS_PUBLISH_FIRST` | 409 | The project was never published: [publish it](https://www.totalum.app/docs/api/deployments#deploy-to-production), then connect Stripe |

## Set Payments Mode

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/payments/mode
cost: Free
```

Switch the **published site** between test and real (`live`) payments (10 switches an hour). Live needs your connected Stripe account to have completed onboarding. The preview always stays in test mode.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `mode` | string | Yes | `"test"` or `"live"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.mode` | string | The mode now |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"mode":"live"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/payments/mode
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "mode": "live" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PAYMENTS_LIVE_ONBOARDING_REQUIRED` | 403 | Finish connecting your Stripe account first |
| `OWNER_ONLY` | 403 | Account owner only |
| `RATE_LIMITED` | 429 | More than 10 switches in an hour |


---

# Webhooks

> Get told when things happen.

Two kinds of webhooks:

- **Account webhooks** — Totalum calls your server when something happens to your projects (a prompt finished, a publish ended, a limit was reached…). One subscription per event, optionally filtered to some projects.
- **The project webhook** — an optional address where the app's own integration events (for example payments) are also sent, signed with the project's webhook secret, verified with `totalum.webhooks.verify` ([SDK](https://www.totalum.app/docs/sdk/webhooks)). Payment events need no address: they reach the app's own `/api/webhooks/stripe` by themselves (the preview for test payments, the published site for real ones).

**Events**

| Event | Sent when | `data` |
|---|---|---|
| `agent.prompt.finished` | A prompt finished | `{ projectId, status, prompt, … }` |
| `deployment.finished` | A publish ended | `{ projectId, deploymentId, status: "success" \| "error", fixAttempts, creditRefunded?, errorDetails?, at }` |
| `project.credit_limit.reached` | A project reached a monthly credit limit | `{ projectId, category, spent, limit }` |
| `project.feature_limit.warning` | A feature's monthly budget reached 80 % (once per feature and month) | `{ projectId, feature, scope, refusing, cap, capKind, used, period, at }` |
| `project.feature_limit.reached` | A feature's monthly budget is spent | same as above |
| `project.feature.disabled` / `project.feature.enabled` | A feature was switched off / on | `{ projectId, feature, scope, refusing, at }` |
| `project.paused` / `project.resumed` | The project was paused / resumed | `{ projectId, state, pausedAt, resumable, at }` |
| `project.lifecycle.state_changed` | The project went to sleep after inactivity, or woke up | `{ projectId, to, at, reason, nextStep }` |
| `project.kill_switch.fired` | The app's usage spiked far above normal and it was paused to protect your credits | `{ projectId, … }` |
| `project.credits.exhausted` | The account ran out of credits; the app's paid features stop | `{ projectId, … }` |
| `project.mobile_build.finished` | A mobile build ended | `{ projectId, buildId, status, … }` |
| `project.preview.url_rotated` | The preview address changed | `{ projectId, … }` |
| `project.payments.disconnected` | The app's Stripe account was disconnected | `{ projectId, … }` |
| `cron.job.paused` | A scheduled job failed too many runs in a row and was paused | `{ projectId, jobId, name, env, failures, lastStatus, at }` |
| `cron.job.failing` | A job set never to pause failed 10 runs in a row (once per streak) | same as above |

**Payload** — every delivery is a `POST` with this JSON body:

```json
{ "event": "deployment.finished", "timestamp": "2026-10-08T11:23:10.000Z", "data": { "projectId": "barber-bookings", "deploymentId": "dep_01k6xf3a9b", "status": "success", "fixAttempts": 0, "at": "2026-10-08T11:23:10.000Z" } }
```

**Verify a delivery.** Each delivery carries `X-Totalum-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` keyed with the subscription's secret ([Get Webhook Secret](#get-webhook-secret)): drop the `whsec_` prefix and hex-decode the rest. `X-Totalum-Event-Id` identifies the event; a retry carries the same id. A delivery that does not answer `2xx` within 10 s is retried after 30 s and after 2 minutes, then marked `failed` (the owner gets at most one e-mail a day about it).

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyTotalumWebhook(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'hex');
  const expected = createHmac('sha256', key).update(`${t}.${rawBody}`).digest();
  return Math.abs(Date.now() / 1000 - Number(t)) < 300 && timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
```

## Register Webhook

```endpoint
method: PUT
path: /api/v1/vcaas/webhooks
cost: Free
```

Subscribe an HTTPS URL to one event. One unfiltered subscription per event; filtered ones (by project or feature) can sit beside it.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | Yes | `https://` URL that receives the POST |
| `event` | string | Yes | One event from the table above |
| `headers` | object | No | Extra headers sent with every delivery (stored encrypted) |
| `projectIds` | string[] | No | Only these projects (up to 50) |
| `features` | string[] | No | Only these features (`ai`, `email`…) — for the `project.feature*` events |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.id` | string | Subscription id |
| `data.url` | string | URL |
| `data.headers` | object \| undefined | Extra headers |
| `data.event` | string | Event |
| `data.createdAt` | string | ISO 8601 |
| `data.projectIds` | string[] \| undefined | Project filter |
| `data.features` | string[] \| undefined | Feature filter |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourserver.com/totalum-webhook","event":"deployment.finished"}' \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks
```

:::success Success · 201 Created
```json
{ "errors": null, "data": { "id": "6701f2a3c4d5e6f7a8b9c0d1", "url": "https://yourserver.com/totalum-webhook", "event": "deployment.finished", "createdAt": "2026-10-08T10:00:00.000Z" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_WEBHOOK_FIELDS` | 400 | `url` and `event` are required |
| `INVALID_WEBHOOK_URL` | 400 | The URL is not HTTPS |
| `INVALID_WEBHOOK_EVENT` | 400 | Unknown event; the message lists every event you can register |
| `VALIDATION_ERROR` | 400 | `projectIds`/`features` is not a list of strings, or more than 50 projects |
| `WEBHOOK_FILTER_NOT_APPLICABLE` | 400 | `features` on an event that is not a feature event |
| `WEBHOOK_PROJECT_NOT_OWNED` | 403 | A `projectIds` entry is not your project |
| `WEBHOOK_EVENT_ALREADY_EXISTS` | 409 | A subscription for this event (and filter) already exists — delete it first |

## List Webhooks

```endpoint
method: GET
path: /api/v1/vcaas/webhooks
cost: Free
```

Your subscriptions, newest first.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.webhooks[].id` | string | Subscription id |
| `data.webhooks[].url` | string | URL |
| `data.webhooks[].headers` | object \| undefined | Extra headers |
| `data.webhooks[].event` | string | Event |
| `data.webhooks[].createdAt` | string | ISO 8601 |
| `data.webhooks[].updatedAt` | string | ISO 8601 |
| `data.webhooks[].projectIds` | string[] \| undefined | Project filter |
| `data.webhooks[].features` | string[] \| undefined | Feature filter |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "webhooks": [{ "id": "6701f2a3c4d5e6f7a8b9c0d1", "url": "https://yourserver.com/totalum-webhook", "event": "deployment.finished", "createdAt": "2026-10-08T10:00:00.000Z", "updatedAt": "2026-10-08T10:00:00.000Z" }] } }
```
:::

## Delete Webhook

```endpoint
method: DELETE
path: /api/v1/vcaas/webhooks/:webhookId
cost: Free
```

Remove a subscription.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `webhookId` | string | Subscription id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `WEBHOOK_NOT_FOUND` | 404 | No such subscription |

## Get Webhook Secret

```endpoint
method: GET
path: /api/v1/vcaas/webhooks/:webhookId/secret
cost: Free
```

The subscription's signing secret, to verify `X-Totalum-Signature`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `webhookId` | string | Subscription id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.webhookId` | string | Subscription id |
| `data.secret` | string | `whsec_…` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/secret
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "webhookId": "6701f2a3c4d5e6f7a8b9c0d1", "secret": "whsec_3f9a…" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `WEBHOOK_NOT_FOUND` | 404 | No such subscription |

## List Webhook Deliveries

```endpoint
method: GET
path: /api/v1/vcaas/webhooks/:webhookId/deliveries
cost: Free
```

The subscription's deliveries of the last 30 days, newest first.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `webhookId` | string | Subscription id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `limit` | number | No | 1–200 (default 50) |
| `cursor` | number | No | `nextCursor` of the previous page |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deliveries[].id` | number | Delivery id |
| `data.deliveries[].webhookId` | string | Subscription |
| `data.deliveries[].event` | string | Event |
| `data.deliveries[].eventId` | string | The event's id (`X-Totalum-Event-Id`) |
| `data.deliveries[].projectId` | string \| null | Project |
| `data.deliveries[].feature` | string \| null | Feature, for feature events |
| `data.deliveries[].status` | string | `pending`, `delivered` or `failed` |
| `data.deliveries[].attempts` | number | Attempts made |
| `data.deliveries[].httpStatus` | number \| null | Your server's last answer |
| `data.deliveries[].durationMs` | number \| null | Its duration |
| `data.deliveries[].error` | string \| null | Why it failed |
| `data.deliveries[].at` | string | When the event happened |
| `data.deliveries[].updatedAt` | string | Last attempt |
| `data.nextCursor` | number \| null | Next page |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/deliveries?limit=20"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deliveries": [{ "id": 1042, "webhookId": "6701f2a3c4d5e6f7a8b9c0d1", "event": "deployment.finished", "eventId": "deploy:dep_01k6xf3a9b", "projectId": "barber-bookings", "feature": null, "status": "delivered", "attempts": 1, "httpStatus": 200, "durationMs": 182, "error": null, "at": "2026-10-08T11:23:10.000Z", "updatedAt": "2026-10-08T11:23:10.000Z" }], "nextCursor": null } }
```
:::

## Get Project Webhook

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/webhook
cost: Free
```

The project's optional extra webhook address (for the app's integration events; payment events reach the app's `/api/webhooks/stripe` without it).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string \| null | The address |
| `data.enabled` | boolean | A URL and a secret are set |
| `data.secretPrefix` | string \| null | The start of the current secret |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "enabled": true, "secretPrefix": "whsec_3f9a" } }
```
:::

## Set Project Webhook

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/webhook
cost: Free
```

Set the project's optional extra webhook address (your own server, Zapier…). Account owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | Yes | `https://` address |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.url` | string | The address |
| `data.enabled` | boolean | A URL and a secret are set |
| `data.secretPrefix` | string \| null | The start of the secret |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.zapier.com/hooks/catch/123456/abcdef/"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "enabled": true, "secretPrefix": "whsec_3f9a" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not an `https://` URL |
| `OWNER_ONLY` | 403 | Account owner only |

## Rotate Project Webhook Secret

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/webhook/rotate-secret
cost: Free
```

Create a new signing secret (shown once). The app receives it at once; the old secret keeps signing for 24 hours. Account owner only.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.secret` | string | The new `whsec_…` secret |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook/rotate-secret
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "secret": "whsec_0b1c…" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `OWNER_ONLY` | 403 | Account owner only |


---

# Export & Import

> Clone a project, or hand it to another account.

Export returns a secret `importCode`; whoever holds it can import the project into a new one (on any account). Use it to duplicate a project, to start projects from a template, or to give a project to a client.

**What travels:** the current code, the database schema and migrations, the live data with `includeRecords`, the uploaded files with `includeFiles`, and the scheduled jobs. **What never travels:** secrets, and the app's users and sign-in data (users, passwords, sessions) — their tables arrive empty, so everyone signs up again on the copy; with `includeRecords`, rows owned by a user follow that relation's delete rule.

**Clone:** export the source → [Create Project](https://www.totalum.app/docs/api/projects#create-project) with the new project's `projectId` and `"sourceImportCode": "<code>"` → [Import Project](#import-project) into it → poll [Get Project](https://www.totalum.app/docs/api/projects#get-project) until `importInProgress` is `null`.

**Choosing the new project's id:** an import keeps the id of the project it goes into, so the `projectId` you create it with is the imported project's id (legacy or new-engine code alike). A [migration](#migrate-to-the-new-engine) takes it as `projectId` in its body.

## Export Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/export
cost: 4 credits
```

Create an export and get its code. At most 5 exports an hour per project. The records exported are the **live** database's (a project never published exports none).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project to export |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `includeRecords` | boolean | No | Also export the live data (default `false`: schema only) |
| `includeFiles` | boolean | No | Also export the uploaded files (default `true`) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.importCode` | string | Secret: anyone with it can import the project |
| `data.includeRecords` | boolean | Data was included |
| `data.includeFiles` | boolean | Files were included |
| `data.files` | object \| null | `{ count, bytes }` of the files exported |
| `data.message` | string | Confirmation |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"includeRecords":true}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/export
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "importCode": "barber-bookings-export-project-8f3b1c9a47e2d650b9114af0c7e3a2d1", "includeRecords": true, "message": "Export ready. Save the importCode (keep it secret) and pass it to import-project as importCode.", "includeFiles": true, "files": { "count": 12, "bytes": 3145728 } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_EXPORT_LIMIT_REACHED` | 400 | 5 exports in the last hour |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Import Project

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/import
cost: 12 credits
```

Import an export into this project, which must be new and empty. The imported project keeps this project's id: to give it an id of your choice, create the project with that id first (with `sourceImportCode`, so it is made on the code's engine). The data fills both the development and the live database; then the code is built. Runs in the background (a few minutes): poll [Get Project](https://www.totalum.app/docs/api/projects#get-project) until `importInProgress` is `null`. Prompts are refused (`IMPORT_IN_PROGRESS`) meanwhile.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The target project; the imported project keeps this id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `importCode` | string | Yes | The code from [Export Project](#export-project) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | The target project |
| `data.status` | string | `"importing"` |
| `data.message` | string | How to follow it |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"importCode":"barber-bookings-export-project-8f3b1c9a47e2d650b9114af0c7e3a2d1"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings-copy/import
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings-copy", "status": "importing", "message": "Import started. Poll GET /api/v1/vcaas/projects/barber-bookings-copy every 10-15s until importInProgress is null, then until agentServerStatus='Active'. Prompting is blocked until the import finishes." } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_IMPORT_CODE` | 400 | `importCode` is missing |
| `INVALID_IMPORT_CODE` | 400 | Not an import code, or the export no longer exists |
| `PROJECT_NOT_IMPORTABLE` | 400 | The target already has content; use a new project |
| `ENGINE_MISMATCH` | 409 | The code comes from a project on the [legacy engine](https://www.totalum.app/docs/api/migration-v2) and can only be imported into a legacy project |
| `IMPORT_IN_PROGRESS` | 409 | An import is already running |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Migrate to the New Engine

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/migrate-to-v2
cost: 50 credits
```

Move a project built on the legacy engine to the new engine. It makes a **new** project, `<projectId>v2` (with a few random letters added when that name is taken) or the id you pass as `projectId` in the body, and leaves the legacy project exactly as it is: it keeps working and serving its users until you switch. Needs the **Starter** plan or above.

What the new project gets:

- **The data**: the legacy database (its tables, fields and every record) is kept as a private file of the new project, `legacy-database.json`, and every uploaded file the records point at (a file field, or a storage link kept as text) is copied into the new project's storage (the records point at the copies).
- **The secrets**: the project's own secrets are copied with the same development and live values, stored encrypted on the new project before its first prompt, so the app reads them as before (`process.env.NAME` on the server). Their values never appear in the response or the agent's prompt; the response lists their names. The legacy Totalum API key and `BETTER_AUTH_SECRET` are not copied: the new project has its own.
- **The code**: the AI agent receives the legacy code, the legacy database structure and the legacy SDK reference, and rebuilds the same app on the new engine (pages, flows, design, sign-in; its users keep their accounts). It designs the SQL tables and moves every record into them with a data migration generated by code from `legacy-database.json`: on the development database during the run, on the live database when you first [publish](https://www.totalum.app/docs/api/deployments#deploy-to-production) the new project. The databases hold only the app's own tables; `legacy-database.json` stays in the project's files.

It answers once the new project exists and its first prompt has started (this call can take a minute). Follow the agent with [Get Agent Status](https://www.totalum.app/docs/api/agent#get-agent-status) on the **new** `projectId`. The 50 credits are charged once; the agent's work is then billed like any prompt. If the migration cannot start, nothing is charged and no project is kept. Custom domains, GitHub and Figma connections do not move: set them again on the new project. A secret the new engine cannot take (a name it reserves, such as one starting with `TOTALUM_`, or an empty value) is listed in `secretsNotCopied`: set it again under another name.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The legacy project to migrate |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `projectId` | string | No | The new project's id, with the rules of [Create Project](https://www.totalum.app/docs/api/projects#create-project). A taken id is refused (`PROJECT_ALREADY_EXISTS`), never changed. Default `<projectId>v2` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.projectId` | string | The new project |
| `data.sourceProjectId` | string | The legacy project (unchanged) |
| `data.engineVersion` | string | `"v2"` |
| `data.creditsCharged` | number | Credits charged for the migration |
| `data.migrated` | object | `{ tables, records, files, filesNotCopied, codeFiles, codeFilesSkipped, secrets, secretsNotCopied }`; `secrets` and `secretsNotCopied` are secret names |
| `data.agent` | object | The first prompt: `{ started, status, runId, expectedMinutes, message }` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"barber-bookings-new"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/migrate-to-v2
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "projectId": "barber-bookings-new", "sourceProjectId": "barber-bookings", "engineVersion": "v2", "creditsCharged": 50, "migrated": { "tables": 6, "records": 412, "files": 38, "filesNotCopied": 0, "codeFiles": 126, "codeFilesSkipped": 0, "secrets": ["STRIPE_SECRET_KEY", "RESEND_API_KEY"], "secretsNotCopied": [] }, "agent": { "started": true, "status": "init", "runId": "run_01m4b8w0d9", "expectedMinutes": 18, "message": "Process started. …" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PLAN_REQUIRED` | 403 | The account is below the Starter plan (`errorDetails.upgradePlan`) |
| `INVALID_PROJECT_NAME` | 400 | The body's `projectId` breaks the id rules (also `INVALID_PROJECT_NAME_LENGTH`, `RESERVED_PROJECT_NAME`) |
| `PROJECT_ALREADY_EXISTS` | 409 | The body's `projectId` is taken |
| `INSUFFICIENT_CREDITS` | 402 | Fewer than 50 credits |
| `ENGINE_MISMATCH` | 409 | The project is already on the new engine |
| `PROJECT_NOT_FOUND` | 404 | No legacy project with this id on your account |
| `MAX_PROJECTS_REACHED` | 403 | Your plan's project limit is reached (the migration makes a new project) |
| `MIGRATION_TOO_LARGE` | 413 | Over 20,000 records, 20 MB of data, 20 MB of code or 500 files: contact support |
| `RATE_LIMITED` | 429 | This project is already being migrated |
| `MIGRATION_START_FAILED` | 502 | The migration could not start; nothing was charged, try again |


---

# GitHub

> Two-way GitHub sync for your project via a fine-grained personal access token.

:::note
For a step-by-step walkthrough, see the [GitHub guide](https://www.totalum.app/docs/developers/github). GitHub sync is included on paid plans.
:::

Connect a GitHub repository to your project for two-way sync. After connecting, every new version (a prompt, a file save, a restore, an import) is pushed to `develop`, the published code is pushed to `main` on every publish, and commits you push to `develop` are pulled in before the next prompt. All endpoints require the `api-key` header and are **free**.

## Connect GitHub Repository

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/github/connect
cost: Free
```

Connect a GitHub repository to your project using a Fine-grained Personal Access Token (PAT). This validates permissions, stores credentials, sets up branches (`develop` and `main`), and performs the initial sync. After connecting, every new version is pushed to `develop`, and the published code to `main` on every publish.

:::tip Create a GitHub token
1. Go to `https://github.com/settings/personal-access-tokens`.
2. Click "Generate new token".
3. Select "Only select repositories" and choose your repo.
4. Under "Repository permissions", set **Contents: Read and write** (Metadata: Read is added automatically).
5. Click "Generate token" and copy it (it starts with `github_pat_`).
:::

:::note Branch setup
For empty repositories, Totalum creates the `develop` and `main` branches automatically. For non-empty repositories, both branches must already exist.
:::

**`syncDirection`** (controls behavior when the repo has existing content with no common git history):

- `"totalum_to_github"` (default) — pushes the Totalum project code to GitHub, replacing the repository content on both `develop` and `main` branches.
- `"github_to_totalum"` — pulls the GitHub repository code into Totalum, replacing the current project code. If the `develop` branch doesn't exist on GitHub, it is created from `main`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | Yes | GitHub Fine-grained Personal Access Token |
| `repositoryFullName` | string | Yes | Full repository name (e.g. `"owner/repo"`) |
| `syncDirection` | string | No | `"totalum_to_github"` (default) or `"github_to_totalum"`. Controls behavior when repo has existing content with no common history |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.connected` | boolean | true if connection was successful |
| `data.repositoryFullName` | string | The connected repository |
| `data.syncAction` | string | `"push_new"`, `"push"`, `"pull"`, `"merge_and_push"`, or `"already_synced"` |
| `data.repoHasContent` | boolean | Whether the repo had existing content |
| `data.requiresRebuild` | boolean | Whether a rebuild was triggered. If true, poll `GET /github/pull-status` until status is `"success"` or `"error"` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"token":"github_pat_xxx","repositoryFullName":"myuser/my-repo","syncDirection":"totalum_to_github"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/connect
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "connected": true,
    "repositoryFullName": "myuser/my-repo",
    "syncAction": "push_new",
    "repoHasContent": false,
    "requiresRebuild": false
  }
}
```
:::

:::danger Error · 400
```json
{
  "errors": {
    "errorCode": "MISSING_GITHUB_FIELDS",
    "errorMessage": "token and repositoryFullName are required"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_GITHUB_FIELDS` | 400 | token and repositoryFullName are required |
| `INVALID_SYNC_DIRECTION` | 400 | syncDirection must be "totalum_to_github" or "github_to_totalum" |
| `GITHUB_VALIDATION_FAILED` | 400 | Token invalid, missing permissions, or repo not found |
| `GITHUB_SYNC_FAILED` | 400 | Initial sync with GitHub failed |
| `AGENT_RUNNING` | 409 | Cannot connect while agent is running |
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | GitHub sync is not included on your plan |

## Disconnect GitHub

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/github/connect
cost: Free
```

Remove the GitHub integration from your project. Deletes the stored token and repository credentials. Code already in your project is not affected.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | true on success |
| `data.message` | string | Confirmation message |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/connect
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true, "message": "GitHub disconnected successfully" } }
```
:::

:::danger Error · 400
```json
{
  "errors": {
    "errorCode": "GITHUB_NOT_CONNECTED",
    "errorMessage": "GitHub is not connected to this project"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `GITHUB_NOT_CONNECTED` | 400 | GitHub is not connected to this project |

## Get GitHub Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/github/status
cost: Free
```

Check whether GitHub is connected and the token is still valid.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.connected` | boolean | Whether GitHub is connected |
| `data.tokenValid` | boolean | Whether the PAT token is still valid |
| `data.tokenExpired` | boolean | True when the last linked PAT was detected as expired/revoked. Sticky until the user reconnects. Use this to prompt the user to reconnect |
| `data.repositoryFullName` | string | Connected repository, or last-known repo when tokenExpired is true |
| `data.developBranch` | string | `"develop"` (only if connected) |
| `data.productionBranch` | string | `"main"` (only if connected) |
| `data.lastPushAt` | string \| undefined | When Totalum last pushed to GitHub |
| `data.lastPushSha` | string \| null \| undefined | The commit it pushed |
| `data.needsReconnect` | boolean \| undefined | `true` when the token must be replaced (connect again) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/status
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "connected": true,
    "tokenValid": true,
    "tokenExpired": false,
    "repositoryFullName": "myuser/my-repo",
    "developBranch": "develop",
    "productionBranch": "main"
  }
}
```
:::

## Pull from GitHub

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/github/pull
cost: Free
```

Pull the latest changes from the GitHub `develop` branch into your project. If files changed, the server rebuilds automatically in the background. Poll the pull status endpoint until complete. This is an async operation.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"pulling"` (async rebuild started) or `"no_changes"` |
| `data.message` | string | Status message |
| `data.filesUpdated` | number | Number of files updated |

**Example request**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/pull
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "status": "pulling",
    "message": "Pull started successfully",
    "filesUpdated": 5
  }
}
```
:::

:::danger Error · 400
```json
{
  "errors": {
    "errorCode": "GITHUB_NOT_CONNECTED",
    "errorMessage": "GitHub is not connected (connect first)"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `GITHUB_NOT_CONNECTED` | 400 | GitHub is not connected (connect first) |
| `GITHUB_PULL_ERROR` | 400 | Failed to pull changes |
| `AGENT_RUNNING` | 409 | Cannot pull while agent is running |
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | GitHub sync is not included on your plan |

## Get Pull Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/github/pull-status
cost: Free
```

Poll this endpoint after calling Pull from GitHub, OR after a Connect GitHub call that returned `requiresRebuild: true` (e.g. `github_to_totalum` direction). Returns the current rebuild status. Auto-completes after 10 minutes.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.status` | string | `"pulling"`, `"success"`, `"error"`, or null (no pull) |
| `data.createdAt` | string | ISO 8601 date when the pull started |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/pull-status
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "status": "success", "createdAt": "2026-03-17T10:00:00.000Z" } }
```
:::

## Download Environment Variables

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/github/env
cost: Free
```

Get the project's environment variables as `.env` file content for both development and production environments. Use this to set up a local development environment or configure production deployments.

Same content as [Get Project Env](https://www.totalum.app/docs/api/secrets#get-project-env), both environments at once, as JSON. Included on paid plans (source download).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.envDev` | string | .env file content for development (KEY=VALUE format, one per line) |
| `data.envProd` | string | .env file content for production (KEY=VALUE format, one per line) |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/github/env
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "envDev": "TOTALUM_ENV=dev\nTOTALUM_PROJECT_KEY=tlm_pk_xxx\nBETTER_AUTH_SECRET=xxxx\nNEXT_PUBLIC_APP_URL=http://localhost:3100\n...",
    "envProd": "TOTALUM_ENV=live\nTOTALUM_PROJECT_KEY=tlm_pk_xxx\nBETTER_AUTH_SECRET=xxxx\nNEXT_PUBLIC_APP_URL=https://my-app.totalum-project.com\n..."
  }
}
```
:::


**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | Source download is not included on your plan |
| `PROJECT_NOT_READY` | 409 | The project is still being set up; retry after `Retry-After` seconds |


---

# Figma

> Build from your designs.

Connect Figma with a token, then paste a Figma link in a prompt ("build the pricing section from this design: https://www.figma.com/design/…?node-id=42-1337"); the agent reads that frame. In Figma, right-click a frame → **Copy link to selection** for a link to exactly that frame. Tokens are stored encrypted and never returned.

**Create a token:** Figma → **Settings** → **Security** → **Personal access tokens** → *Generate new token*, with the scopes `current_user:read` and `file_content:read` (it starts with `figd_`). An Organization or Enterprise **plan access token** works the same way and survives people leaving the team.

## Connect Figma

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/figma/connect
cost: Free
```

Link a Figma account to the project. The token is checked with Figma before anything is stored. [Launch Project](https://www.totalum.app/docs/api/projects#launch-project) also takes `figma.token`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | Yes | Figma personal or plan access token |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.connected` | boolean | `true` |
| `data.account` | object | `{ id, handle, email?, imgUrl? }` of the Figma account |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"token":"figd_xxx"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/figma/connect
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "connected": true, "account": { "id": "123456", "handle": "Ada Lovelace", "email": "ada@example.com", "imgUrl": "https://s3-alpha.figma.com/profile/…" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_FIGMA_TOKEN` | 400 | `token` is missing |
| `FIGMA_TOKEN_MALFORMED` | 400 | Not a Figma token (whitespace, a URL, too short) |
| `FIGMA_TOKEN_INVALID` | 400 | Figma rejected it: wrong, expired or revoked |
| `FIGMA_TOKEN_FORBIDDEN` | 400 | Missing the `current_user:read` / `file_content:read` scopes |
| `FIGMA_RATE_LIMITED` | 400 | Figma is rate-limiting; retry shortly |
| `FIGMA_UNREACHABLE` | 400 | Figma could not be reached |
| `AGENT_RUNNING` | 409 | A prompt is running |

## Check a Figma Token

```endpoint
method: POST
path: /api/v1/vcaas/figma/validate
cost: Free
```

Check a token without connecting it — for a connect form. 10 checks a minute per account.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | Yes | The token |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.valid` | boolean | `true` |
| `data.account` | object | `{ id, handle, email?, imgUrl? }` |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"token":"figd_xxx"}' \
  https://builder-api.totalum.app/api/v1/vcaas/figma/validate
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "valid": true, "account": { "id": "123456", "handle": "Ada Lovelace", "email": "ada@example.com" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `FIGMA_TOKEN_MISSING` | 400 | No token |
| `FIGMA_TOKEN_MALFORMED` / `FIGMA_TOKEN_INVALID` / `FIGMA_TOKEN_FORBIDDEN` / `FIGMA_RATE_LIMITED` / `FIGMA_UNREACHABLE` | 400 | As in [Connect Figma](#connect-figma) |
| `RATE_LIMITED` | 429 | More than 10 checks a minute |

## Get Figma Status

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/figma/status
cost: Free
```

Whether Figma is connected, and to which account. A failed re-check never disconnects it.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `verify` | boolean | No | `true` re-checks the token with Figma (a live call: on screen open, not in a poll) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.connected` | boolean | Connected |
| `data.account` | object \| undefined | `{ id, handle, email?, imgUrl? }` |
| `data.connectedAt` | string \| undefined | When it was connected |
| `data.tokenValid` | boolean \| undefined | With `verify=true`: Figma still accepts the token |
| `data.tokenError` | string \| undefined | With `verify=true`: why it failed |
| `data.expiresAt` | string \| undefined | Token expiry, when known |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/figma/status?verify=true"
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "connected": true, "account": { "id": "123456", "handle": "Ada Lovelace" }, "connectedAt": "2026-10-08T10:24:00.000Z", "tokenValid": true } }
```
:::

## Disconnect Figma

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/figma/connect
cost: Free
```

Remove the Figma connection (nothing changes in your Figma account). Calling it on a project that is not connected also answers `200`.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.disconnected` | boolean | `true` |
| `data.message` | string | Confirmation |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/figma/connect
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "disconnected": true, "message": "Figma disconnected successfully" } }
```
:::


---

# Visual Edits

> Edit the preview directly, no prompt needed.

The preview marks every element with its source location (`data-tlm-loc="src/app/page.tsx:12:5"`). A visual editor sends a **batch** of changes to those elements; Totalum patches the code, checks that it still builds, and saves the batch as **one version** deployed to the preview. All or nothing: a change that cannot be applied leaves the code untouched. Visual edits need a paid plan.

## Apply Visual Edit

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/visual-edit/apply
cost: 0.6 credits
```

Apply a batch. Answers `202`; follow it with [Get Visual Edit](#get-visual-edit). Sending the same `batchId` again returns the same result without a second charge.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `batchId` | string | Yes | A UUID you generate (the idempotency key) |
| `baseVersionId` | string | Yes | The version the preview showed |
| `changes` | object[] | Yes | 1–200 changes |
| `changes[].loc` | string | Yes | The element's `data-tlm-loc` (`path:line:column`) |
| `changes[].kind` | string | Yes | `"text"`, `"style"`, `"image"`, `"link"`, `"visibility"` or `"delete"` |
| `changes[].before` | string \| object | No | The current value (text, classes, src or href) |
| `changes[].after` | string \| object \| null | Yes | `text`: the new text · `style`: `{ add?, remove?, replace? }` of Tailwind classes · `image`: `{ src, alt? }` · `link`: `{ href, target? }` · `visibility`: `{ hidden }` · `delete`: `null` |
| `changes[].id` | string | No | Your id for the change |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.batchId` | string | The batch |
| `data.versionId` | string | The version being saved |
| `data.operation` | object | `{ kind: "visualEdit", id, status: "building" }` |
| `data.creditsCharged` | number | 0.6 the first time, 0 on a repeat |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"batchId":"5f0c2e1a-8d3b-4c6e-9a7f-1b2c3d4e5f60","baseVersionId":"ver_01k6xd2m8q","changes":[{"loc":"src/app/page.tsx:14:7","kind":"text","before":"Book now","after":"Book your cut"}]}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/visual-edit/apply
```

:::success Success · 202 Accepted
```json
{ "errors": null, "data": { "batchId": "5f0c2e1a-8d3b-4c6e-9a7f-1b2c3d4e5f60", "versionId": "ver_01k6y6", "operation": { "kind": "visualEdit", "id": "op_01k6y7", "status": "building" }, "creditsCharged": 0.6 } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `batchId` is not a UUID, or a change is invalid |
| `FREE_PLAN_NO_SOURCE_EDITING` | 403 | Editing code needs a paid plan |
| `BATCH_ID_IN_USE` | 409 | The `batchId` belongs to another project |
| `AGENT_RUNNING` | 409 | A prompt is running |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Get Visual Edit

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/visual-edit/:batchId
cost: Free
```

Whether a batch was saved. A batch that failed before it was saved is refunded.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |
| `batchId` | string | The batch |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.batchId` | string | The batch |
| `data.versionId` | string \| null | Its version |
| `data.state` | string | `"saving"`, `"saved"` or `"failed"` |
| `data.errorCode` | string \| undefined | On failure, e.g. `VISUAL_EDIT_NOT_PATCHABLE`, `VISUAL_EDIT_TYPECHECK_FAILED`, `VISUAL_EDIT_BASE_MISMATCH` (the file changed since `baseVersionId`) |
| `data.errorMessage` | string \| undefined | On failure |
| `data.refunded` | boolean | The credits were given back |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/visual-edit/5f0c2e1a-8d3b-4c6e-9a7f-1b2c3d4e5f60
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "batchId": "5f0c2e1a-8d3b-4c6e-9a7f-1b2c3d4e5f60", "versionId": "ver_01k6y6", "state": "saved", "refunded": false } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `VISUAL_EDIT_NOT_FOUND` | 404 | No such batch on this project |


---

# Project Groups

> Optional folders for organising projects — additive, and never required.

Project groups are **optional folders** for projects. Nothing in the API requires one: a project with no group behaves exactly as every project did before groups existed, and filing one away never hides it from `GET /projects`. All endpoints require the `api-key` header and are **free**.

A project joins a group through `groupId` on [Launch Project](https://www.totalum.app/docs/api/projects#launch-project) or [Update Project](https://www.totalum.app/docs/api/projects#update-project), and you filter by group with `GET /projects?groupId=<id>` (or `?groupId=none` for the ungrouped ones).

:::info Membership lives on the project
A group is a label, not a container: the link is stored on the project, not as a list of project ids on the group. That is why deleting a group is a cheap operation that releases its members, and why a group can hold 100,000 projects without any document growing.
:::

:::warning A group belongs to one account
Every lookup is scoped to the calling account, and a group owned by somebody else is indistinguishable from one that does not exist — you get `404 PROJECT_GROUP_NOT_FOUND` either way.
:::

**Limits:** 3,000 groups per account · 100,000 projects per group · name up to 80 characters · description up to 500 characters.

## Create Project Group

```endpoint
method: POST
path: /api/v1/vcaas/project-groups
cost: Free
```

Create a folder for projects.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Trimmed, up to 80 characters. Required — an empty name is rejected |
| `description` | string | No | Trimmed and **truncated** to 500 characters rather than rejected |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.groupId` | string | The new group's ID — use this as `groupId` elsewhere |
| `data.name` | string | The group name |
| `data.description` | string | The description, or an empty string when none was given |
| `data.createdAt` | string | ISO 8601 creation date |
| `data.updatedAt` | string | ISO 8601 last-modified date |

**Example request**

```bash
curl -X POST \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"Client work","description":"Everything billed to clients"}' \
  https://builder-api.totalum.app/api/v1/vcaas/project-groups
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
    "name": "Client work",
    "description": "Everything billed to clients",
    "createdAt": "2026-08-04T10:00:00.000Z",
    "updatedAt": "2026-08-04T10:00:00.000Z"
  }
}
```
:::

:::danger Error · 400
```json
{
  "errors": {
    "errorCode": "CREATE_PROJECT_GROUP_ERROR",
    "errorMessage": "Group name is required"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `CREATE_PROJECT_GROUP_ERROR` | 400 | Name missing or longer than 80 characters, or the account already has 3,000 groups |

## List Project Groups

```endpoint
method: GET
path: /api/v1/vcaas/project-groups
cost: Free
```

Your groups, **newest first**, each with the number of projects currently filed under it.

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `search` | string | No | Case-insensitive match on the group **name** only |
| `limit` | number | No | Page size, default 100, maximum 200 |
| `skip` | number | No | Groups to skip, for paging (default: 0) |

**Response headers**

| Header | Type | Description |
|---|---|---|
| `X-Total-Count` | number | Total groups matching the filters, across all pages |
| `X-Limit` | number | Page size actually applied |
| `X-Skip` | number | Offset actually applied |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data` | array | Array of group objects, newest first |
| `data[].groupId` | string | The group ID |
| `data[].name` | string | The group name |
| `data[].description` | string | The description, or an empty string |
| `data[].createdAt` | string | ISO 8601 creation date |
| `data[].updatedAt` | string | ISO 8601 last-modified date |
| `data[].projectCount` | number | How many of your projects are filed under this group |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/project-groups?limit=100"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": [
    {
      "groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
      "name": "Client work",
      "description": "Everything billed to clients",
      "createdAt": "2026-08-04T10:00:00.000Z",
      "updatedAt": "2026-08-04T10:00:00.000Z",
      "projectCount": 12
    }
  ]
}
```
:::

:::danger Error · 400
```json
{
  "errors": {
    "errorCode": "LIST_PROJECT_GROUPS_ERROR",
    "errorMessage": "Error listing project groups"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `LIST_PROJECT_GROUPS_ERROR` | 400 | Internal error listing project groups |

## Get Project Group

```endpoint
method: GET
path: /api/v1/vcaas/project-groups/:groupId
cost: Free
```

One group, with its current project count.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `groupId` | string | The group ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.groupId` | string | The group ID |
| `data.name` | string | The group name |
| `data.description` | string | The description, or an empty string |
| `data.createdAt` | string | ISO 8601 creation date |
| `data.updatedAt` | string | ISO 8601 last-modified date |
| `data.projectCount` | number | How many of your projects are filed under this group |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/project-groups/65f1a2b3c4d5e6f7a8b9c0d1
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
    "name": "Client work",
    "description": "",
    "createdAt": "2026-08-04T10:00:00.000Z",
    "updatedAt": "2026-08-04T10:00:00.000Z",
    "projectCount": 12
  }
}
```
:::

:::danger Error · 404
```json
{
  "errors": {
    "errorCode": "PROJECT_GROUP_NOT_FOUND",
    "errorMessage": "Project group not found"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_GROUP_NOT_FOUND` | 404 | No such group, the id is malformed, or it is not yours |
| `GET_PROJECT_GROUP_ERROR` | 400 | Internal error getting the project group |

## Update Project Group

```endpoint
method: PATCH
path: /api/v1/vcaas/project-groups/:groupId
cost: Free
```

Rename a group or change its description. Keys you do not send are left alone, so renaming a group cannot blank its description.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `groupId` | string | The group ID |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | No | Trimmed, up to 80 characters. Sending an empty name is an error — omit the key instead |
| `description` | string | No | Trimmed and truncated to 500 characters. Send an empty string to clear it |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.groupId` | string | The group ID |
| `data.name` | string | The name after the update |
| `data.description` | string | The description after the update |
| `data.createdAt` | string | ISO 8601 creation date |
| `data.updatedAt` | string | ISO 8601 last-modified date |
| `data.projectCount` | number | How many of your projects are filed under this group |

**Example request**

```bash
curl -X PATCH \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"Client work 2026"}' \
  https://builder-api.totalum.app/api/v1/vcaas/project-groups/65f1a2b3c4d5e6f7a8b9c0d1
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "groupId": "65f1a2b3c4d5e6f7a8b9c0d1",
    "name": "Client work 2026",
    "description": "Everything billed to clients",
    "createdAt": "2026-08-04T10:00:00.000Z",
    "updatedAt": "2026-08-04T12:30:00.000Z",
    "projectCount": 12
  }
}
```
:::

:::danger Error · 404
```json
{
  "errors": {
    "errorCode": "PROJECT_GROUP_NOT_FOUND",
    "errorMessage": "Project group not found"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_GROUP_NOT_FOUND` | 404 | No such group, the id is malformed, or it is not yours |
| `UPDATE_PROJECT_GROUP_ERROR` | 400 | Empty name, name longer than 80 characters, or an internal error |

## Delete Project Group

```endpoint
method: DELETE
path: /api/v1/vcaas/project-groups/:groupId
cost: Free
```

Delete the **folder only**.

:::warning Deleting a group never deletes a project
Its members simply become ungrouped — the same state every project is in by default — and `releasedProjects` tells you how many were released. There is deliberately no cascade: a folder is not an owner. To delete the projects themselves, call [Delete Project](https://www.totalum.app/docs/api/projects#delete-project) for each one.
:::

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `groupId` | string | The group ID |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.deleted` | boolean | `true` on successful deletion |
| `data.releasedProjects` | number | How many projects became ungrouped. None were deleted |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/project-groups/65f1a2b3c4d5e6f7a8b9c0d1
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "deleted": true, "releasedProjects": 12 } }
```
:::

:::danger Error · 404
```json
{
  "errors": {
    "errorCode": "PROJECT_GROUP_NOT_FOUND",
    "errorMessage": "Project group not found"
  },
  "data": null
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `PROJECT_GROUP_NOT_FOUND` | 404 | No such group, the id is malformed, or it is not yours |
| `DELETE_PROJECT_GROUP_ERROR` | 400 | Internal error deleting the project group |


---

# Account

> Balance, prices and account-wide settings.

Account-level endpoints: no project in the path.

## Get Account Info

```endpoint
method: GET
path: /api/v1/vcaas/account
cost: Free
```

Your spendable credit balance. Check it before a large batch of prompts.

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.credits` | number | Total spendable balance (`recurrentCredits + oneTimeCredits`) |
| `data.recurrentCredits` | number | Plan credits left this billing period (used first) |
| `data.oneTimeCredits` | number | Purchased credits (never expire) |
| `data.engineOptIn` | object \| undefined | `{ eligible, mode }`: whether new projects can be created with `engineVersion: "v2"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/account
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "credits": 425.5, "recurrentCredits": 100, "oneTimeCredits": 325.5, "engineOptIn": { "eligible": true, "mode": "default" } } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `ACCOUNT_SERVICE_UNAVAILABLE` | 503 | The balance cannot be read right now; retry in a few minutes |

## Get Credit Costs

```endpoint
method: GET
path: /api/v1/vcaas/credit-costs
cost: Free
```

The price in credits of every fixed-price action, and the monthly fees. Read it at runtime instead of hard-coding prices. Prompts are not listed: they are metered (at least 3 credits each). The app's own usage (hosting, database, AI, e-mail…) is metered too: see [Usage & Analytics](https://www.totalum.app/docs/api/analytics).

**Query parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `engine` | string | No | `v2` (default for new accounts) |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.creditCosts.CREATE_PROJECT` | number | Creating a project |
| `data.creditCosts.CREATE_DEPLOYMENT` | number | Publishing |
| `data.creditCosts.START_SERVER` | number | Restarting the development environment |
| `data.creditCosts.GET_SOURCE_CODE` | number | Downloading the source code |
| `data.creditCosts.RECOVER_VERSION` | number | Restoring a version |
| `data.creditCosts.UPLOAD_FILE` | number | Uploading a file (free) |
| `data.creditCosts.ADD_CUSTOM_DOMAIN` | number | Adding a custom domain |
| `data.creditCosts.EXPORT_PROJECT` | number | Exporting a project |
| `data.creditCosts.IMPORT_PROJECT` | number | Importing a project |
| `data.creditCosts.UPDATE_FILE` | number | Writing a file (free) |
| `data.creditCosts.REBUILD_PROJECT` | number | Rebuilding the preview |
| `data.creditCosts.BACKUP_CREATE_MANUAL` | number | A manual database backup |
| `data.creditCosts.BACKUP_RESTORE` | number | Restoring a backup or a point in time |
| `data.creditCosts.VISUAL_EDIT_APPLY` | number | Applying a visual-edit batch |
| `data.creditCosts.PAUSE_RESUME` | number | Pausing, or resuming, a project |
| `data.creditCosts.MONTHLY_PROJECT_FEE` | number | A month a project is published on a paid plan |
| `data.creditCosts.MONTHLY_HOSTING_FEE` | number | That project's hosting, the same month |
| `data.creditCosts.MONTHLY_CUSTOM_DOMAIN_FEE` | number | A month a custom domain is active |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/credit-costs?engine=v2"
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "creditCosts": {
      "CREATE_PROJECT": 2,
      "CREATE_DEPLOYMENT": 0.5,
      "START_SERVER": 2,
      "GET_SOURCE_CODE": 2,
      "RECOVER_VERSION": 2,
      "UPLOAD_FILE": 0,
      "ADD_CUSTOM_DOMAIN": 4,
      "EXPORT_PROJECT": 4,
      "IMPORT_PROJECT": 12,
      "UPDATE_FILE": 0,
      "REBUILD_PROJECT": 2,
      "BACKUP_CREATE_MANUAL": 2,
      "BACKUP_RESTORE": 4,
      "VISUAL_EDIT_APPLY": 0.6,
      "PAUSE_RESUME": 2,
      "MONTHLY_PROJECT_FEE": 1,
      "MONTHLY_HOSTING_FEE": 2,
      "MONTHLY_CUSTOM_DOMAIN_FEE": 3
    }
  }
}
```
:::

## Get Data Residency

```endpoint
method: GET
path: /api/v1/vcaas/account/data-residency
cost: Free
```

The account's default for new projects: `global` (each database placed nearest its users, with read copies elsewhere) or `eu` (databases created and kept inside the European Union only).

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.dataResidency` | string | `"global"` or `"eu"` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/account/data-residency
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "dataResidency": "global" } }
```
:::

## Set Data Residency

```endpoint
method: PUT
path: /api/v1/vcaas/account/data-residency
cost: Free
```

Set the default for projects created from now on. Existing projects never move; a project's own `dataResidency` on create or launch wins. A key restricted to some projects cannot change it.

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `dataResidency` | string | Yes | `"global"` or `"eu"` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.dataResidency` | string | The new default |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"dataResidency":"eu"}' \
  https://builder-api.totalum.app/api/v1/vcaas/account/data-residency
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "dataResidency": "eu" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_DATA_RESIDENCY` | 400 | Not `"global"` or `"eu"` |
| `API_KEY_SCOPE_FORBIDDEN` | 403 | This key cannot change account settings |


---

# Building on Totalum's API from your app

> For apps whose own users create apps — the key, where it lives, and who pays.

Some apps are themselves app builders: their users describe an app and the app creates it. Such an app calls this API from its own server, with a Totalum API key of its own.

## Where the key comes from

- **In the Totalum app** — on the **first prompt of a new project**, when that prompt asks for an app that creates or hosts apps for its own users, Totalum adds the key for you, but only if you may manage the account's API keys (owner, admin, or a member allowed to manage API keys). It creates an account API key named `App builder — <project name>` and stores it in the project as the secret `TOTALUM_VCAAS_API_KEY`. A card in the chat tells you it was added. Later prompts are never checked, and nothing is added for anyone who may not manage API keys.
- **Through this API or the MCP** — nothing is ever added automatically, whatever the prompt says. Create a key on your account and pass it yourself as the project secret `TOTALUM_VCAAS_API_KEY` (environment `both`): in the `secrets` of [Launch Project](https://www.totalum.app/docs/api/projects#launch-project), or with [Create Secret](https://www.totalum.app/docs/api/secrets#create-secret) before the prompt that needs it.

## Rules for the key

- It is an **account** key (`tlm_sk_…`): everything the app does with it — every project it creates and every prompt it sends — is billed to your account and counts against your plan's limits.
- It lives only in the project secret `TOTALUM_VCAAS_API_KEY`, read on the server. Never put it in source code, a committed `.env`, or a `NEXT_PUBLIC_*` variable, and never call this API from browser code.
- You can revoke it at any time from your API keys, like any other key.

## What the app calls

The same endpoints as any other client: [Launch Project](https://www.totalum.app/docs/api/projects#launch-project), [Get Agent Status](https://www.totalum.app/docs/api/agent#get-agent-status), [Run AI Agent](https://www.totalum.app/docs/api/agent#run-ai-agent), [Get Project Details](https://www.totalum.app/docs/api/projects#get-project), [Deploy to Production](https://www.totalum.app/docs/api/deployments#deploy-to-production), [List Versions](https://www.totalum.app/docs/api/versions#list-versions) and [Delete Project](https://www.totalum.app/docs/api/projects#delete-project). The whole reference is at `https://www.totalum.app/totalum-api.md`.
