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 with the new project's projectId and "sourceImportCode": "<code>" → Import Project into it → poll 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 takes it as projectId in its body.
#Export Project
/api/v1/vcaas/projects/:projectId/exportapi-key4 creditsCreate 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
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/exportconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/export", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"includeRecords": true
}),
});
const { errors, data } = await res.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
/api/v1/vcaas/projects/:projectId/importapi-key12 creditsImport 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 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 |
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | The target project |
data.status | string | "importing" |
data.message | string | How to follow it |
Example request
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/importconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings-copy/import", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"importCode": "barber-bookings-export-project-8f3b1c9a47e2d650b9114af0c7e3a2d1"
}),
});
const { errors, data } = await res.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 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
/api/v1/vcaas/projects/:projectId/migrate-to-v2api-key15 creditsMove 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). There is no limit on the number of uploaded files: the first 2,000 are copied during this call, and the rest in the background shortly after (filesPendingcounts them); until each one is copied, its record keeps pointing at the old file, which keeps working. - Many-to-many relations: the legacy engine kept these links apart from the records; every link is copied too (
manyToManyLinkscounts them), and the agent turns each many-to-many relation into a join table of the new database. - Private files stay private: the legacy engine served uploaded files only through signed, expiring links, so each one is copied as a private file of the new project (an ID card scan never gets a public URL). Its field holds
{ name, key, url: null, private: true }; the rebuilt app shows or downloads it through one of its own routes that checks the signed-in user and redirects to a short-lived signed URL,await totalum.files.getSignedUrl(key)(ortotalum.files.presign(keys)for several; see Files). A file that was publicly reachable without a signature stays public. - 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.NAMEon the server). Their values never appear in the response or the agent's prompt; the response lists their names. The legacy Totalum API key andBETTER_AUTH_SECRETare 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 the new project. The databases hold only the app's own tables;legacy-database.jsonstays in the project's files.
What does not carry over, and the new engine's own rules:
- One field value can be at most 2 MB in the new database. Images or documents stored inline as base64 text in a field are larger than that: turn them into uploaded files in the legacy app before migrating.
BETTER_AUTH_SECRETis not reused (the new project has its own), so anything encrypted with the old one does not move: users who had two-factor authentication must set it up again. Passwords keep working.- Sessions do not carry over: every user signs in again once, on the new app.
- The new app's sign-in rules: a session lasts 7 days (renewed once a day while it is used), and on the published site 10 wrong passwords for one e-mail, or 30 from one IP address, lock that e-mail or address out for 15 minutes.
The legacy app keeps running, so its data keeps changing: until the new project's first publish, Refresh Migrated Data reads it again into legacy-database.json, for free.
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 on the new projectId. The migration fee is 15 credits, charged once. The AI rebuild that follows always runs on the top tier and is billed like a prompt: typically 60–80 credits more, depending on the app's size. If the migration cannot start, nothing is charged and no project is kept. Limits: up to 20,000 records and 20 MB of data, and up to 20 MB of code in total (a code file over 1 MB is skipped and counted in codeFilesSkipped). 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. A taken id is refused (PROJECT_ALREADY_EXISTS), never changed. Default <projectId>v2 |
ownerEmail | string | No | The new app's owner e-mail (its admin once confirmed in the app), as in Launch Project. Default: none — then the first person who signs up in the app becomes its admin after entering the 8-digit code e-mailed to them (the preview and the published app each have their own first sign-up); set one later with Set App Owner. An invalid address is refused (INVALID_OWNER_EMAIL) before anything is read |
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, filesPending, manyToManyLinks, codeFiles, codeFilesSkipped, secrets, secretsNotCopied }; files = uploaded files copied during the call, filesPending = the ones past the first 2,000, copied in the background shortly after; manyToManyLinks = many-to-many links copied; secrets and secretsNotCopied are secret names |
data.agent | object | The first prompt: { started, status, runId, expectedMinutes, message } |
Example request
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-v2const res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/migrate-to-v2", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"projectId": "barber-bookings-new"
}),
});
const { errors, data } = await res.json();{ "errors": null, "data": { "projectId": "barber-bookings-new", "sourceProjectId": "barber-bookings", "engineVersion": "v2", "creditsCharged": 15, "migrated": { "tables": 6, "records": 412, "files": 38, "filesNotCopied": 0, "filesPending": 0, "manyToManyLinks": 54, "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 |
INVALID_OWNER_EMAIL | 400 | The body's ownerEmail is not a valid e-mail address |
INSUFFICIENT_CREDITS | 402 | Fewer than 15 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 or 20 MB of code: 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 |
#Refresh Migrated Data
/api/v1/vcaas/projects/:projectId/migrate-to-v2/refresh-dataapi-keyFreeThe legacy app keeps working while its new version is being built, so its data keeps changing. Call this on the new project (the one Migrate to the New Engine made) to read the legacy project again, records, many-to-many links and files, into a fresh legacy-database.json (same file, same key). Files already copied are not copied again; new ones are, private as above (past 2,000 new files, in the background). The legacy project is only read. It costs nothing beyond the storage of the new files.
It works only before the new project's first publish: the live database is loaded from legacy-database.json after that publish, and from then on the live database is the app's data, so the call is refused (MIGRATION_ALREADY_PUBLISHED). To bring the development database up to date after a refresh, ask the agent: "Run scripts/legacy-data.ts again on dev with the refreshed legacy-database.json". The usual order: refresh, publish, then load the live data (the agent's final message tells you the exact prompt).
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The new project made by the migration |
Response fields
| Field | Type | Description |
|---|---|---|
data.projectId | string | The new project |
data.sourceProjectId | string | The legacy project that was read again |
data.snapshotKey | string | The key of legacy-database.json (unchanged) |
data.refreshed | object | { tables, records, manyToManyLinks, files, filesAlreadyCopied, filesNotCopied, filesPending }; files = new files copied now |
data.message | string | What to send the agent next |
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings-new/migrate-to-v2/refresh-dataconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings-new/migrate-to-v2/refresh-data", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "projectId": "barber-bookings-new", "sourceProjectId": "barber-bookings", "snapshotKey": "pv/barber-bookings-new/…/legacy-database.json", "refreshed": { "tables": 6, "records": 431, "manyToManyLinks": 57, "files": 3, "filesAlreadyCopied": 38, "filesNotCopied": 0, "filesPending": 0 }, "message": "legacy-database.json now holds the legacy project as it is now. …" } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
MIGRATION_ALREADY_PUBLISHED | 409 | The new project has been published: its live database holds the app's data now |
OPERATION_IN_PROGRESS | 409 | Files of the migration (or of the last refresh) are still being copied: try again in a few minutes |
PROJECT_NOT_FOUND | 404 | Not a project made by Migrate to the New Engine, or its legacy project is no longer on your account |
MIGRATION_TOO_LARGE | 413 | The legacy project has grown past what one migration copies |
RATE_LIMITED | 429 | A refresh of this legacy project is already running (one every 2 minutes) |
