Legacy Database API
Read the schema, query with rich filters, and mutate records and relations in your project database.
These endpoints serve legacy projects only (no engineVersion; see Legacy Totalum SDK). Current projects use the Database endpoints — the map between the two is in Legacy projects: what differs.
The database API exposes your project's tables and records. You can read the table structure, query with filtering/sorting/pagination/aggregation and nested relations up to 6 levels deep, create, edit and delete records, and link or unlink records across a many-to-many relation. All endpoints require the api-key header and are free — reading and writing a project's own data is not metered; only agent work and infrastructure are.
#Get Database Tables Structure
/api/v1/vcaas/projects/:projectId/database/tables-structureapi-keyFreeRetrieve all database table definitions including field names, types, and configuration.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
Response fields
| Field | Type | Description |
|---|---|---|
data.tables | array | Array of table definitions |
data.tables[]._id | string | Table ID |
data.tables[].type | string | Table name (snake_case) |
data.tables[].label | string | Human-friendly display name |
data.tables[].description | string | Table description |
data.tables[].icon | string | FontAwesome icon class |
data.tables[].properties | object | Map of property name to property definition |
data.tables[].properties.{name}.id | string | Property ID |
data.tables[].properties.{name}.name | string | Property field name |
data.tables[].properties.{name}.propertyType | string | "string" | "number" | "date" | "options" | "file" | "long-string" | "objectReference" |
data.tables[].properties.{name}.label | string | Human-friendly field label |
data.tables[].properties.{name}.description | string | Field description |
data.tables[].properties.{name}.objectReference | object | Relationship config (if objectReference type) |
data.tables[].properties.{name}.typeExtras | object | Type-specific config (options values, date settings, etc.) |
Example request
curl -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/tables-structureconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/tables-structure", {
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{
"errors": null,
"data": {
"tables": [
{
"_id": "65f1a2b3c4d5e6f7",
"type": "customers",
"label": "Customers",
"description": "Customer records",
"icon": "fa-solid fa-users",
"properties": {
"name": {
"id": "prop_abc123",
"name": "name",
"propertyType": "string",
"label": "Full Name"
},
"email": {
"id": "prop_def456",
"name": "email",
"propertyType": "string",
"label": "Email",
"typeExtras": { "string": { "type": "link" } }
}
}
}
]
}
}{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project does not exist or you don't own it"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
#Query Database
/api/v1/vcaas/projects/:projectId/database/queryapi-keyFreeQuery records from any table with advanced filtering, sorting, pagination, aggregations, and nested related data up to 6 levels deep.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table name to query (use type from tables-structure response) |
queryOptions | object | No | Advanced query options |
queryOptions._filter | object | No | Field filters (see filter operators below) |
queryOptions._sort | object | No | Sort by fields: { fieldName: "asc" | "desc" } |
queryOptions._limit | number | No | Max records to return (default 50, max 1000) |
queryOptions._offset | number | No | Records to skip (for pagination) |
queryOptions._select | object | No | Include only specified fields: { fieldName: true }. Cannot use with _omit |
queryOptions._omit | object | No | Exclude specified fields: { fieldName: true }. Cannot use with _select |
queryOptions._count | boolean | No | Adds _count._total with total matching records (before pagination) |
queryOptions._aggregate | object | No | Aggregations: { _sum: { field: true }, _avg, _min, _max, _count } |
queryOptions._groupBy | string | string[] | No | Group results by field(s). Requires _aggregate |
queryOptions.[property] | true | object | No | Expand related data. true = all fields, object = nested queryOptions |
Filter operators (use inside _filter)
- Exact match:
{ "status": "active" } - With operator:
{ "fieldName": { "operator": value } }
| Operator | Description | Example |
|---|---|---|
gte | Greater than or equal | { "age": { "gte": 18 } } |
lte | Less than or equal | { "age": { "lte": 65 } } |
gt | Greater than | { "price": { "gt": 0 } } |
lt | Less than | { "price": { "lt": 100 } } |
ne | Not equal | { "status": { "ne": "deleted" } } |
in | Matches any value in array | { "status": { "in": ["active", "pending"] } } |
nin | Matches none in array | { "role": { "nin": ["admin", "super"] } } |
regex | Regex pattern (+options for flags) | { "email": { "regex": "@gmail", "options": "i" } } |
contains | Case-insensitive contains | { "name": { "contains": "john" } } |
startsWith | Case-insensitive starts with | { "name": { "startsWith": "J" } } |
endsWith | Case-insensitive ends with | { "email": { "endsWith": ".com" } } |
_or | OR logic (array of conditions) | { "_or": [{ "status": "active" }, { "role": "admin" }] } |
Nested queries(related data)
Expand related tables by using the property name as a key in queryOptions.
- Shorthand:
{ "orders": true }— expands all related orders - Full:
{ "orders": { "_filter": {...}, "_sort": {...}, "_limit": 5 } }— with options (max 300 children) { "orders": { "_has": true } }— only parents with at least one matching child{ "orders": { "_has": "none" } }— only parents with zero matching children{ "orders": { "_count": true } }— include child count per parent- Nesting up to 6 levels deep supported
Response fields
| Field | Type | Description |
|---|---|---|
data.results | array | Array of matching records |
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"tableName":"customers","queryOptions":{"_filter":{"status":"active","age":{"gte":18}},"_sort":{"createdAt":"desc"},"_limit":10,"orders":{"_filter":{"total":{"gt":50}},"_limit":5}}}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/queryconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/query", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"tableName": "customers",
"queryOptions": {
"_filter": {
"status": "active",
"age": {
"gte": 18
}
},
"_sort": {
"createdAt": "desc"
},
"_limit": 10,
"orders": {
"_filter": {
"total": {
"gt": 50
}
},
"_limit": 5
}
}
}),
});
const { errors, data } = await res.json();{
"errors": null,
"data": {
"results": [
{
"_id": "65f1a2b3c4d5e6f7a8b9c0d1",
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"age": 30,
"orders": [
{ "_id": "...", "total": 120.50, "createdAt": "2026-03-09T..." }
],
"createdAt": "2026-03-10T08:00:00.000Z"
}
]
}
}{
"errors": {
"errorCode": "MISSING_TABLE_NAME",
"errorMessage": "tableName is required"
},
"data": null
}More filter examples
// OR logic
"_filter": { "_or": [{ "status": "active" }, { "role": "admin" }] }
// IN operator
"_filter": { "status": { "in": ["active", "pending"] } }
// Regex case-insensitive
"_filter": { "email": { "regex": "@gmail", "options": "i" } }
// Aggregation with groupBy
"_aggregate": { "_sum": { "total": true }, "_count": true }, "_groupBy": "status"
// Nested: only parents with children matching filter
"orders": { "_has": true, "_filter": { "total": { "gt": 100 } } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_TABLE_NAME | 400 | tableName is required |
#Create Database Record
/api/v1/vcaas/projects/:projectId/database/recordsapi-keyFreeCreate a new record in any table. The _id field is auto-generated by the database — do not include it in the request body. The response returns the full created record including the generated _id and any default fields.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table name to insert into (use type from tables-structure response) |
data | object | Yes | The record properties as key-value pairs. Do NOT include _id (auto-generated) |
Response fields
| Field | Type | Description |
|---|---|---|
data | object | The full created record including the auto-generated _id and all fields |
data._id | string | The auto-generated unique identifier for the new record |
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"tableName":"customers","data":{"name":"John Doe","email":"john@example.com","status":"active","age":30}}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/recordsconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"tableName": "customers",
"data": {
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"age": 30
}
}),
});
const { errors, data } = await res.json();{
"errors": null,
"data": {
"_id": "65f1a2b3c4d5e6f7a8b9c0d1",
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"age": 30,
"createdAt": "2026-03-25T10:00:00.000Z",
"updatedAt": "2026-03-25T10:00:00.000Z"
}
}{
"errors": {
"errorCode": "MISSING_DATA",
"errorMessage": "data is required and must be an object"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_TABLE_NAME | 400 | tableName is required |
MISSING_DATA | 400 | data is required and must be an object |
TABLE_NOT_FOUND | 400 | Table doesn't exist in the project |
#Edit Database Record
/api/v1/vcaas/projects/:projectId/database/records/:recordIdapi-keyFreeUpdate specific fields of an existing record by its ID. Only the fields included in data will be modified — all other fields remain unchanged. The response returns the full updated record.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
recordId | string | The _id of the record to update |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table name (use type from tables-structure response) |
data | object | Yes | The properties to update as key-value pairs. Only included fields are modified |
Response fields
| Field | Type | Description |
|---|---|---|
data | object | The full updated record with all fields (including unchanged ones) |
data._id | string | The record ID |
Example request
curl -X PATCH \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"tableName":"customers","data":{"email":"newemail@example.com","age":31}}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1const res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1", {
method: "PATCH",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"tableName": "customers",
"data": {
"email": "newemail@example.com",
"age": 31
}
}),
});
const { errors, data } = await res.json();{
"errors": null,
"data": {
"_id": "65f1a2b3c4d5e6f7a8b9c0d1",
"name": "John Doe",
"email": "newemail@example.com",
"status": "active",
"age": 31,
"createdAt": "2026-03-25T10:00:00.000Z",
"updatedAt": "2026-03-25T10:05:00.000Z"
}
}{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project does not exist or you don't own it"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_RECORD_ID | 400 | recordId is required |
MISSING_TABLE_NAME | 400 | tableName is required |
MISSING_DATA | 400 | data is required and must be an object |
TABLE_NOT_FOUND | 400 | Table doesn't exist in the project |
#Delete Database Record
/api/v1/vcaas/projects/:projectId/database/records/:recordIdapi-keyFreePermanently delete one record by its ID.
A DELETE with a request body is awkward for several HTTP clients, so this endpoint takes the table as a query parameter: ?tableName=customers.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
recordId | string | The _id of the record to delete |
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table name (use type from the tables-structure response) |
Response fields
| Field | Type | Description |
|---|---|---|
data.deleted | boolean | true on successful deletion |
data.recordId | string | The ID of the record that was deleted |
Example request
curl -X DELETE -H "api-key: tlm_sk_your_key" \
"https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1?tableName=customers"const res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1?tableName=customers", {
method: "DELETE",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{
"errors": null,
"data": {
"deleted": true,
"recordId": "65f1a2b3c4d5e6f7a8b9c0d1"
}
}{
"errors": {
"errorCode": "MISSING_TABLE_NAME",
"errorMessage": "tableName is required (query parameter)"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_RECORD_ID | 400 | recordId is required |
MISSING_TABLE_NAME | 400 | tableName is required as a query parameter |
TABLE_NOT_FOUND | 400 | Table doesn't exist in the project |
#Link Records
/api/v1/vcaas/projects/:projectId/database/records/:recordId/linkapi-keyFreeLink two records across a many-to-many relation.
Totalum owns the junction table for a manyToMany relation, so links are made by asking it to add a reference — never by creating a junction row yourself.
A manyToOne or oneToMany link is an ordinary field on one of the two records: set it with Edit Database Record instead. Routing one of those through this endpoint would silently do nothing.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
recordId | string | The _id of the record on this side of the relation |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table recordId belongs to (use type from the tables-structure response) |
propertyId | string | Yes | The objectReference property on tableName that holds the relation |
referenceId | string | Yes | The _id of the record on the other side of the relation |
Response fields
| Field | Type | Description |
|---|---|---|
data.linked | boolean | true when the link exists after the call |
Example request
curl -X POST \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"tableName":"customers","propertyId":"tags","referenceId":"65f1a2b3c4d5e6f7a8b9c0d2"}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1/linkconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1/link", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"tableName": "customers",
"propertyId": "tags",
"referenceId": "65f1a2b3c4d5e6f7a8b9c0d2"
}),
});
const { errors, data } = await res.json();{ "errors": null, "data": { "linked": true } }{
"errors": {
"errorCode": "MISSING_PROPERTY_ID",
"errorMessage": "propertyId is required (the tableLink field holding the relation)"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_RECORD_ID | 400 | recordId is required |
MISSING_TABLE_NAME | 400 | tableName is required |
MISSING_PROPERTY_ID | 400 | propertyId is required — the relation field on the table |
MISSING_REFERENCE_ID | 400 | referenceId is required — the _id of the record to link |
TABLE_NOT_FOUND | 400 | Table doesn't exist in the project |
#Unlink Records
/api/v1/vcaas/projects/:projectId/database/records/:recordId/linkapi-keyFreeRemove a many-to-many link. The two records are untouched — only the link between them goes.
Removing a link that is not there succeeds. You do not need to check first.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project ID |
recordId | string | The _id of the record on this side of the relation |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
tableName | string | Yes | The table recordId belongs to (use type from the tables-structure response) |
propertyId | string | Yes | The objectReference property on tableName that holds the relation |
referenceId | string | Yes | The _id of the record on the other side of the relation |
Response fields
| Field | Type | Description |
|---|---|---|
data.unlinked | boolean | true when the link is gone after the call |
Example request
curl -X DELETE \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"tableName":"customers","propertyId":"tags","referenceId":"65f1a2b3c4d5e6f7a8b9c0d2"}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1/linkconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/my-app/database/records/65f1a2b3c4d5e6f7a8b9c0d1/link", {
method: "DELETE",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"tableName": "customers",
"propertyId": "tags",
"referenceId": "65f1a2b3c4d5e6f7a8b9c0d2"
}),
});
const { errors, data } = await res.json();{ "errors": null, "data": { "unlinked": true } }{
"errors": {
"errorCode": "PROJECT_NOT_FOUND",
"errorMessage": "Project does not exist or you don't own it"
},
"data": null
}Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_PROJECT_ID | 400 | projectId is required |
PROJECT_NOT_FOUND | 404 | Project does not exist or you don't own it |
MISSING_RECORD_ID | 400 | recordId is required |
MISSING_TABLE_NAME | 400 | tableName is required |
MISSING_PROPERTY_ID | 400 | propertyId is required — the relation field on the table |
MISSING_REFERENCE_ID | 400 | referenceId is required — the _id of the record to unlink |
TABLE_NOT_FOUND | 400 | Table doesn't exist in the project |
