Every schema and data endpoint applies to one project. Say which:
X-Project-Id: <project id>A request that omits it is answered with 400 and the code MISSING_PROJECT.
A project is a schema namespace, not a trust boundary, and the header is not a credential. The project exists so MorphDB can operate physical schemas on its own judgement — it is an internal operating unit, not a multi-tenancy feature.
Whether a request needs a credential is a separate question, and the answer depends on one thing:
- No master secret injected — the default. No endpoint requires authentication; the service has
none. A request carrying only
X-Project-Idis served. - A master secret injected. Every endpoint except the health and metrics probes requires
Authorization: Bearer <secret>. See Connection secrets.
Either way, never forward a project id supplied by a browser or an end user — whoever picks that value picks which schemas they read. And in the default shape, run MorphDB where only your application can reach it and decide there who may see what.
Creating a project answers with the id it generated, and a caller that can read that answer needs nothing else. A deployment often cannot: the manifest that creates the project and the manifest that scopes requests to it are written before either runs, so an id that only exists after startup has nowhere to be written down.
Say which id instead:
POST /api/projects
{ "projectId": "0197c0de-0000-4000-8000-000000000001", "name": "orders" }The id is then a constant of the deployment rather than something discovered at runtime, and
re-running the same request answers 409 DUPLICATE_PROJECT_ID — enough for a start-up step to
treat "already created" as success. Two start-up steps racing for the same id get the same answer:
one is created and every other is a conflict, so the reply does not depend on which of them arrived
first. Omit the field and MorphDB generates one, which is what an application creating projects on
the fly should keep doing.
# Tables
POST /api/schema/tables # Create table
GET /api/schema/tables # List tables
GET /api/schema/tables/{name} # Get table details
PATCH /api/schema/tables/{name} # Update table
DELETE /api/schema/tables/{name} # Delete table
# Columns — a column is addressed by its id once created, not by table and name
POST /api/schema/tables/{name}/columns # Add column
PATCH /api/schema/columns/{columnId} # Update column
DELETE /api/schema/columns/{columnId} # Delete column
# Relations & Indexes
POST /api/schema/relations # Create relation
DELETE /api/schema/relations/{id} # Delete relation
POST /api/schema/tables/{name}/indexes # Create index
POST /api/schema/batch # Batch DDL operationsA relation is a virtual foreign key: it is metadata this layer checks, not (by default) a
constraint the database holds. Two flags decide how far that goes, and both default to true:
{
"name": "fk_orders_customer",
"sourceTable": "orders", "sourceColumn": "customer_id",
"targetTable": "customers", "targetColumn": "_id",
"type": "one-to-many",
"enforceOnWrite": false
}enforceOnWrite— whether writes are checked against the relation. Setfalseto declare the link without gating writes on it: joins and navigation still see it, but a row referencing a missing parent is accepted. This is what a caller that rebuilds tables wholesale needs — when tables are dropped and reloaded independently, a child can be written before its parent has been reloaded, and enforcing would reject data that is consistent at its source. A non-enforcing relation also gets no physical constraint, so nothing rejects the write underneath either.virtualCascade— whether cascade behaviour is handled at the application layer.
Both are echoed back on the response, so you can see what you got rather than what you asked for.
Omit enforceOnWrite and the project answers. A deployment that rebuilds its tables wholesale
would otherwise have to repeat "enforceOnWrite": false on every relation, and the first one that
forgets enforces:
PATCH /api/projects/{id}
{ "settings": { "defaultEnforceOnWrite": false } }The default is true, so a project that says nothing enforces. A relation that states its own value
overrides it in either direction. The answer is resolved when the relation is created and stored
on it, so changing the project default later leaves existing relations as they are — the physical
constraint was decided at the same moment, and a relation cannot start claiming enforcement that
nothing behind it holds.
settingsreplaces the whole object rather than merging, so send every setting the project should end up with — see Audit retention.
# Schema Changelog
GET /api/schema/tables/{name}/history # Table change history
GET /api/schema/changelog # Global schema changelog# CRUD - Auto-generated per table
GET /api/data/{table} # List records
GET /api/data/{table}/{id} # Get single record
POST /api/data/{table} # Create record
PATCH /api/data/{table}/{id} # Update record
DELETE /api/data/{table}/{id} # Delete record
# Advanced
POST /api/data/{table}/query # Complex queryBatch writes live under
/api/batch, not under/api/data— see Batch Operations.
GET /api/data/customers?filter=grade:eq:VIP&orderBy=_created_at:desc&page=1&pageSize=20| Parameter | Description | Example |
|---|---|---|
filter |
Field filtering (column:operator:value) |
grade:eq:VIP, status:neq:inactive |
orderBy |
Sort order (column:asc or column:desc) |
name:asc, _created_at:desc |
search |
Full-text search across text columns | john |
select |
Comma-separated column names | name,email,grade |
state |
Row state filter (if enabled) | valid, draft, error, all |
page |
Page number | 1 |
pageSize |
Records per page (max 1000) | 20 |
| Operator | Description | Example |
|---|---|---|
eq |
Equals | status:eq:active |
neq |
Not equals | status:neq:deleted |
gt |
Greater than | price:gt:100 |
gte |
Greater than or equal | age:gte:18 |
lt |
Less than | stock:lt:10 |
lte |
Less than or equal | score:lte:50 |
like |
Pattern match (case-sensitive, % wildcards) |
name:like:Jo% |
ilike |
Pattern match (case-insensitive) | name:ilike:jo% |
contains |
String contains | name:contains:john |
startswith |
String starts with | email:startswith:admin |
endswith |
String ends with | file:endswith:.pdf |
An operator outside this list is answered with 400 listing the supported set — it is never
silently coerced. (in/isnull were documented here once but no server ever accepted them — on
this parameter or anywhere else; the operator vocabulary above is the whole set, on every surface.)
For predicates the flat filter parameter cannot express — AND/OR trees — post a filter tree.
A node is either a condition or a group (discriminated by $type); conditions use the same
operator vocabulary as the filter parameter above:
POST /api/data/customers/query
Content-Type: application/json
X-Project-Id: <project id>
{
"filter": {
"$type": "group",
"logic": "and",
"filters": [
{ "$type": "condition", "column": "grade", "operator": "eq", "value": "vip" },
{ "$type": "condition", "column": "amount", "operator": "gte", "value": 50 }
]
},
"select": ["name", "amount"],
"orderBy": ["amount:desc"],
"page": 1,
"pageSize": 10
}filter— optional; acondition(column,operator,value) or agroup(logic:"and"|"or",filters: child nodes).select— optional column list; omitted selects all.orderBy— optionalcolumnorcolumn:descentries.page/pageSize— 1-based;pageSizeis clamped to the server maximum.
The response is the same paged envelope as GET /api/data/{table}:
{ "data": [...], "pagination": { "page", "pageSize", "totalCount", "totalPages", "hasNext", "hasPrevious" } }.
These examples run verbatim in the contract suite (ComplexQueryApiTests) — if the wire shape
drifts, the suite fails before the docs lie.
POST /api/batch/data # Mixed operations, in order, across tables
POST /api/batch/data/{table}/insert # Insert many into one table
PATCH /api/batch/data/{table} # Update many, selected by filter
DELETE /api/batch/data/{table}?filter=... # Delete many, selected by filter
PUT /api/batch/data/{table} # Upsert many, matched on key columns
POST /api/batch/data/{table}/seed # Seed rows (upsert, ignoring conflicts)
POST /api/batch/transaction # Atomic cross-entity operationsAn operation names a table and a data method — it is not an embedded HTTP request:
POST /api/batch/data
Content-Type: application/json
X-Project-Id: <project id>
{
"operations": [
{ "method": "INSERT", "table": "customers", "data": { "name": "Acme" } },
{ "method": "UPDATE", "table": "customers", "id": "…", "data": { "grade": "VIP" } },
{ "method": "DELETE", "table": "orders", "id": "…" },
{ "method": "UPSERT", "table": "customers", "data": {...}, "keyColumns": ["email"] }
]
}Operations run in order and are reported individually. A batch containing failed operations still
returns 200 — read results rather than the status code:
{
"results": [
{ "index": 0, "success": true, "data": { "_id": "…" }, "affectedRows": 1 },
{ "index": 1, "success": false, "error": "null value in column 'name'" }
],
"successCount": 1,
"failureCount": 1
}Inserting many rows into one table has a shorter form that takes a bare array and returns the same response shape:
POST /api/batch/data/customers/insert
[ { "name": "Acme" }, { "name": "Globex" } ]Endpoint: /graphql
Tables automatically generate GraphQL types:
# Auto-generated from table definition
type Customer {
id: ID!
name: String!
email: String
createdAt: DateTime!
orders: [Order!]! # Relations auto-resolved
}
# Queries
query {
customer(id: "123") { name, email }
customers(filter: { grade: "VIP" }, first: 10) {
nodes { id, name }
pageInfo { hasNextPage }
}
}
# Mutations
mutation {
createCustomer(input: { name: "John", email: "john@example.com" }) {
id
}
}
# Subscriptions
subscription {
customerChanged { operation, data { id, name } }
}Endpoint: /odata
Standard OData v4 protocol for enterprise tool integration (Excel, Power BI).
# Metadata
GET /odata/$metadata
# Queries
GET /odata/Customers?$filter=grade eq 'VIP'&$orderby=createdAt desc&$top=10
GET /odata/Customers?$expand=orders&$select=name,email
GET /odata/Customers/$count
# CRUD
POST /odata/Customers
PATCH /odata/Customers('id')
DELETE /odata/Customers('id')Supported operators: $filter, $orderby, $top, $skip, $select, $expand, $count
Connecting from a BI tool. These requests are scoped like every other one — by the
X-Project-Id header, or by the secret the caller authenticates with. Both travel as headers, and
a tool that offers only a URL and a choice of built-in credential has nowhere to put either. In
Power Query that means the point-and-click OData dialog cannot reach this surface; its query
language can, because OData.Feed takes a record of headers as its second argument:
OData.Feed("https://<host>/odata", [#"X-Project-Id" = "<project id>"])
The entity set names in the metadata document are the PascalCase forms of the table names —
order_items is served as OrderItems — while the columns keep the names they were declared
with. Rows carry _id, _created_at and _updated_at alongside them.
Endpoint: /hubs/morph (SignalR)
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/morph")
.build();
// Subscribe to table changes
await connection.invoke("Subscribe", "customers", { grade: "VIP" });
// Receive events
connection.on("DataChanged", (event) => {
// event: { table, operation: "insert"|"update"|"delete", data, previous }
});Register webhooks for external system integration:
POST /api/webhooks
Content-Type: application/json
{
"name": "Order notification",
"table": "orders",
"events": ["insert", "update"],
"url": "https://external.system/callback",
"headers": { "Authorization": "Bearer xxx" },
"filter": { "status": "completed" },
"secret": "webhook-signing-secret"
}Webhook payload:
{
"event": "insert",
"table": "orders",
"data": { "id": "123", "status": "completed" },
"timestamp": "2025-01-01T00:00:00Z"
}Every write door — data CRUD, batch, seed, upsert, bulk import rows, GraphQL mutations — goes
through the same write pipeline: constraint validation (required / unique / FK / CHECK) is
validated and system columns (_id, timestamps, _version, audit fields) are applied uniformly.
The request body of a data write is the record itself — there is no { "data": ..., "options": ... }
envelope:
POST /api/data/{table}
Content-Type: application/json
{ "name": "John", "email": "john@example.com" }Behaviour is selected per request with query parameters:
| Parameter | Effect |
|---|---|
?mode=draft |
Skips validation and stores the row with _row_state = 'draft' (requires row state enabled on the table) |
?ignoreUnknown=true |
Fields naming no declared column are dropped instead of failing the write. Without it, an unknown field is a 400 UNKNOWN_COLUMN naming the field — a typo must not become silent data loss |
The validation and auto-apply behaviours below are pipeline policy (what the server enforces), not request-body switches.
| Option | Default | Description |
|---|---|---|
validateRequired |
true |
Validate required fields (NOT NULL) |
validateForeignKeys |
true |
Validate foreign key references exist |
validateUnique |
true |
Validate unique constraints |
validateCheck |
true |
Validate CHECK constraints (supports AND/OR expressions) |
| Option | Default | Description |
|---|---|---|
applyDefaults |
true |
Apply default values for missing fields |
applyTimestamps |
true |
Auto-manage _created_at and _updated_at |
applyVersion |
true |
Auto-manage _version for optimistic locking |
applyAuditFields |
true |
Auto-manage _created_by and _updated_by |
applyOwnership |
true |
Auto-manage _owner_id for ownership tables |
applySortOrder |
true |
Auto-manage _sort_order for hierarchy tables |
| Option | Default | Description |
|---|---|---|
deferValidation |
false |
Defer validation until after bulk insert |
expectedVersion |
null |
Expected version for optimistic locking |
Default (all enabled):
{ "validateRequired": true, "validateForeignKeys": true, "validateUnique": true, "validateCheck": true, "applyDefaults": true, "applyTimestamps": true, "applyVersion": true }Bulk Import (deferred validation):
{ "validateRequired": true, "validateForeignKeys": false, "validateUnique": false, "validateCheck": false, "applyDefaults": true, "applyTimestamps": true, "applyVersion": false, "deferValidation": true }No Validation (use with caution):
{ "validateRequired": false, "validateForeignKeys": false, "validateUnique": false, "validateCheck": false, "applyDefaults": false, "applyTimestamps": false, "applyVersion": false }Bulk import and export are asynchronous jobs. The request returns 202 Accepted with a job
id; progress and results are read from the job endpoints. The format is part of the path, not a
query parameter, because each format takes its own options.
# Import — one endpoint per format
POST /api/bulk/{table}/import/csv
POST /api/bulk/{table}/import/json
POST /api/bulk/{table}/import/ndjson
Content-Type: text/csv
name,email,grade
John Doe,john@example.com,VIP
# Export — options travel in the body
POST /api/bulk/{table}/export/csv
POST /api/bulk/{table}/export/json
POST /api/bulk/{table}/export/xlsx
# Following a job
GET /api/bulk/jobs/{jobId}/progress # Progress while it runs
POST /api/bulk/jobs/{jobId}/cancel # Stop it
GET /api/bulk/import # List import jobs
GET /api/bulk/export # List export jobs
GET /api/bulk/export/{jobId}/download # Fetch a finished exportUpdate a column's metadata and/or physical constraints:
PATCH /api/schema/columns/{columnId}
Content-Type: application/json
{
"name": "new_column_name",
"type": "biginteger",
"nullable": true,
"unique": false,
"check": "value > 0",
"default": "0",
"version": 3
}All fields except version are optional. Only provided fields are changed.
| Field | Description |
|---|---|
name |
New logical column name |
type |
New data type (safe type widening only: integer→biginteger→decimal, *→text) |
nullable |
Whether the column allows null |
unique |
Whether the column has a unique constraint (physical DDL) |
check |
Check expression (virtual constraint) — see Expression fields |
default |
Default value — see Expression fields |
version |
Expected schema version for optimistic concurrency |
default and an index where are written into DDL, so what they may contain is bounded. check
never reaches DDL at all — it is a virtual constraint, enforced by the app-layer evaluator, and
a declaration is accepted only when that evaluator can enforce it (a stored-but-unenforceable CHECK
would constrain nothing, silently). A value outside these bounds is answered with 400 and an
error code, not applied.
| Field | Accepted | Rejected (400) |
|---|---|---|
default |
A literal (0, pending, O'Brien — quoted for you), or one of gen_random_uuid(), now(), transaction_timestamp(), statement_timestamp(), clock_timestamp() |
Any other value containing parentheses → INVALID_DEFAULT. Notably uuid_generate_v4(): it needs the uuid-ossp extension, which managed PostgreSQL does not grant. Use gen_random_uuid(). |
check |
The CHECK grammar: <field> <op> <value> or <field> <op> <field> (op: > >= < <= = == != <>; value: a 'quoted string', number, true/false/null), <field> MATCHES '<regex>', combined with AND/OR and parentheses — age >= 0 AND age <= 150, status = 'a)b', email MATCHES '^[^@]+@[^@]+$' |
Anything else — SQL functions, IN, BETWEEN, the ~ operator (use MATCHES) → INVALID_ARGUMENT listing the supported forms |
index where |
Any predicate that stays within itself — age >= 0, status = 'a)b' |
Unbalanced parentheses or quotes, a statement separator, or a comment → INVALID_EXPRESSION |
MorphDB requires no PostgreSQL extension. This is what lets it run on Azure Database for PostgreSQL,
Cloud SQL and RDS, where CREATE EXTENSION is gated behind a server-parameter allow-list.
The attachment data type stores file metadata as JSONB. MorphDB does not manage file storage directly — files should be stored in external services (S3, Azure Blob, etc.) and referenced by URL.
{
"url": "https://s3.example.com/bucket/file.pdf",
"filename": "report.pdf",
"size": 1048576,
"mimeType": "application/pdf",
"uploadedAt": "2026-01-01T00:00:00Z"
}POST /api/schema/tables/{name}/columns
{
"name": "document",
"type": "attachment",
"nullable": true
}POST /api/data/{table}
{
"document": {
"url": "https://storage.example.com/file.pdf",
"filename": "file.pdf",
"size": 2048,
"mimeType": "application/pdf"
}
}| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | URL to the file in external storage |
filename |
string | Yes | Original file name |
size |
number | No | File size in bytes |
mimeType |
string | No | MIME type |
uploadedAt |
string | No | ISO 8601 timestamp |
A relational database identifies whoever opens a connection with a user and a password. There is no connection to open here — every access is an API call — so the same position is held by a secret. A secret is not a person: it has no email, no invitation and no organization. It is issued, it carries a role, and it is revoked.
Authentication is off unless you inject a master secret, and this is stated rather than implied: an installation that advertises a boundary it does not enforce is worse than one that says it enforces none.
environment:
Security__MasterSecret: <a long random string>The master secret arrives the way PostgreSQL is given POSTGRES_PASSWORD — from the deployment,
before anything can ask for it. No API issues it, and it is never written to the database. That
is what keeps the bootstrap acyclic: the authority to issue credentials never originates inside the
API.
With it injected, every endpoint requires a secret except /health, /health/live, /health/ready
and /metrics — machine surfaces that must answer before any credential is distributed.
Authorization: Bearer mdb_<secret>A request with no secret or an unrecognized one is answered 401 UNAUTHENTICATED. A recognized
secret that may not do what was asked is answered 403 FORBIDDEN.
This includes /graphql and /hubs/morph — a boundary that held on REST and not on the other
two would not be a boundary. It has a consequence worth knowing before you turn enforcement on:
⚠️ Browser WebSocket clients cannot authenticate. The browser WebSocket API cannot set anAuthorizationheader, so a SignalR or GraphQL-subscription client running in a page is refused at/hubs/morph/negotiateonce a master secret is injected. Server-side and desktop clients set the header normally. If you need browser real-time today, terminate authentication in a proxy in front and leave MorphDB's own enforcement off.
Once a caller is identified, the audit trail records which secret acted — its id and role, never the secret itself.
These routes require the master secret; an issued secret cannot reach them. Without a master
secret injected they answer 503 SECRETS_NOT_CONFIGURED.
POST /api/security/secrets # Issue a secret — the plaintext is returned once
GET /api/security/secrets # List issued secrets (never hashes or plaintexts)
DELETE /api/security/secrets/{secretId} # Revoke{
"name": "reporting-service",
"role": "analyst",
"projectId": null
}role— free-form. MorphDB does not enumerate the roles a database may have; meaning comes from the policies that reference it through{{role}}(see below). The namesmasterandserviceare reserved and are refused with400 VALIDATION_ERROR.projectId—nullfor every project, or a project id to confine the secret to it. A confined secret addressing another project is answered403 FORBIDDEN.
The response carries the plaintext once. It is stored only as a hash; if it is lost, issue another and revoke the old one. Revocation keeps the row so audit records retain a name for it.
Only the master secret bypasses row-level security. An issued secret is subject to the same policies
an anonymous caller is — with {{role}} now resolving to something.
A policy narrows what a table's rows answer, per operation. Applicable policies are combined with
AND, so adding one can only ever restrict a read further.
GET /api/security/policies/{tableName} # Policies applying to a table
POST /api/security/policies # Create a policy
PATCH /api/security/policies/{policyId} # Update name, expression, description or is_active
DELETE /api/security/policies/{policyId} # Delete a policy{
"name": "owner_reads_only",
"tableName": "orders",
"policyType": "Select",
"expression": "owner_id = {{user_id}}"
}policyType is Select, Insert, Update, Delete or All. The expression is a SQL predicate
over the table's own columns, with {{user_id}}, {{email}}, {{role}}, {{project_id}},
{{is_authenticated}} and {{claims.<name>}} substituted from the request's security context
before the query runs. Substituted values are emitted as quoted literals — a caller's identity
cannot become part of the predicate.
Because the service is unauthenticated, an HTTP request's context is the project's anonymous one:
{{project_id}} carries the header's value, {{is_authenticated}} is false, and the
user-bearing placeholders substitute NULL — a policy written against {{user_id}} therefore
matches no rows over HTTP. That is fail-closed on purpose; there is currently no way for a caller
to assert an end-user identity to this service.
The expression is a predicate, not a statement. It is checked before it is stored and again
before it is used: a statement separator, a comment opener, an unbalanced parenthesis or an
unterminated quote is refused with INVALID_EXPRESSION. A stored policy that fails that check
fails the read rather than being quietly dropped from it — a security rule that silently stops
applying is worse than an error.
A project decides how much of its own audit history it keeps, through its settings:
POST /api/projects
{ "name": "orders", "settings": { "auditLogRetentionDays": 30 } }Entries older than the window are removed by the server on a recurring sweep — the audit table is
not reachable from the API, so keeping it within its declared size is the server's obligation
rather than something to ask for. The sweep interval is a deployment setting
(AuditRetention:SweepInterval, one hour by default); the window is per project.
Omit the field, or set it to null, and nothing is removed. A project that has not asked for a
window keeps everything, so introducing the setting does not start deleting history anywhere.
Zero or a negative number is refused with INVALID_ARGUMENT rather than stored — a window that
cannot be applied would read back as configured while governing nothing.
The window can be turned on after the fact and governs history already written:
PATCH /api/projects/{id}
{ "settings": { "auditLogRetentionDays": 30 } }
settingsreplaces the whole object, it does not merge. Fields you omit go back to their defaults rather than keeping their stored values, so send the settings you want the project to end up with — read the project first if you are changing one field of several.
GET /health # Overall health
GET /health/live # Liveness probe
GET /health/ready # Readiness probeEvery error is a JSON envelope — no path answers a 5xx with an empty body:
{ "error": "ValidationError", "message": "what went wrong, and what is possible", "code": "VALIDATION_ERROR" }The envelope belongs to routes this API serves. A URL that matches no route is answered by the
framework, with 404 and no body at all — there is no code to branch on because nothing here
handled it. A client that parses every non-2xx as an envelope should treat an empty body as "this
address is not part of the API", which is a different mistake from any error listed below: the
others say a request was understood and refused.
code is the machine-readable contract; branch on it, not on message text. Request envelopes are
strict: a JSON member a request body does not declare (a typo'd filters for filter, a colums
for columns) answers 400 INVALID_ARGUMENT naming the member and listing the supported ones —
never a silent drop. (Row-data bodies are dictionaries — arbitrary members are the point; their
unknown-field policy is the write pipeline's UNKNOWN_COLUMN.) A 4xx means the
request must change before retrying; a 500 INTERNAL_ERROR is a service defect (its message is a
fixed string — internal exception text never reaches the wire) and retrying may succeed.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
A value failed validation — a required / unique / FK / CHECK constraint, a type mismatch, or any mix of write-validation causes; physical NOT NULL / UNIQUE / foreign-key violations translate to the same code |
| 400 | UNKNOWN_COLUMN |
A write named a column the table does not declare (see ?ignoreUnknown=true) — answered whenever undeclared fields are the only thing wrong with the write |
| 400 | COLUMN_NOT_FOUND |
A query referenced a column the table does not have |
| 400 | INVALID_FILTER |
A malformed filter expression, or an unknown filter operator |
| 400 | INVALID_ARGUMENT |
A malformed value elsewhere in the request (e.g. an unknown column type — the message lists the supported set) |
| 400 | MISSING_PROJECT |
The request did not say which project it applies to — send X-Project-Id |
| 400 | INVALID_EXPRESSION |
A CHECK predicate, index predicate or policy expression that could escape the clause it is written into |
| 400 | TABLE_HAS_DEPENDENTS |
Deleting a table another table still references — delete those relations first |
| 400 | EMPTY_BATCH |
A batch request with no operations |
| 400 | EMPTY_DATA |
A batch write with no rows |
| 400 | EMPTY_TRANSACTION |
A transaction with no operations |
| 400 | EMPTY_RECORD_IDS |
A bulk-delete with no record ids |
| 400 | MISSING_KEY_COLUMNS |
A batch upsert without the key columns to match on |
| 400 | FILTER_REQUIRED |
A batch update-by-filter without a filter (a full-table write must be said out loud) |
| 400 | AGGREGATION_REQUIRED |
An aggregate query with no aggregation |
| 400 | ROW_STATE_NOT_ENABLED |
A row-state operation on a table whose systemColumns.rowState is off |
| 400 | JOB_NOT_COMPLETED |
Reading the result of a bulk job that has not finished |
| 400 | NOT_MATERIALIZED |
Refreshing or reading a view that is not materialized |
| 400 | INVALID_NAME |
A table or column name that is empty or over the length limit |
| 400 | RESERVED_NAME |
A name in the space the system reserves for itself (a leading underscore on a column, the system prefix on a table) |
| 400 | SYSTEM_COLUMN |
An attempt to alter or drop a column the table owns rather than the caller |
| 400 | UNSAFE_TYPE_CAST |
A column type change whose existing values cannot be converted — export, reload, and change the declaration instead |
| 400 | INVALID_OPERATION |
A schema operation that is well-formed but cannot apply to this target |
| 400 | DDL_EXECUTION_FAILED |
The database refused the schema statement — the message carries what it said |
| 400 | BATCH_DDL_FAILED |
One operation in a batch DDL request failed; the batch is not applied |
| 404 | TABLE_NOT_FOUND |
The table (or the project the request scoped it to) does not exist |
| 404 | INDEX_NOT_FOUND |
The index id does not exist |
| 404 | RELATION_NOT_FOUND |
The relation id does not exist |
| 404 | WEBHOOK_NOT_FOUND |
The webhook id does not exist |
| 404 | RECORD_NOT_FOUND |
The record id does not exist in the table |
| 404 | PROJECT_NOT_FOUND |
The project id does not exist |
| 404 | VIEW_NOT_FOUND |
The view does not exist |
| 404 | JOB_NOT_FOUND |
The bulk job does not exist |
| 404 | AUDIT_LOG_NOT_FOUND |
The audit log entry does not exist |
| 401 | UNAUTHENTICATED |
Authentication is enforced and the request presented no valid secret — send Authorization: Bearer <secret>. Only answered when a master secret is injected; see Connection secrets |
| 403 | FORBIDDEN |
The secret is recognized but may not do this — it is confined to another project, or the route requires the master secret |
| 404 | NOT_FOUND |
Another addressable resource (entity set, policy…) does not exist |
| 409 | DUPLICATE_NAME |
Creating a table/column under a name that is taken |
| 409 | DUPLICATE_SLUG |
Creating a project under a slug that is taken |
| 409 | DUPLICATE_PROJECT_ID |
Creating a project under an id that is taken — only reachable when the request chooses the id. A deleted project still holds its id |
| 409 | SCHEMA_VERSION_CONFLICT |
An optimistic schema update lost the race |
| 409 | LOCK_ACQUISITION_FAILED |
A concurrent schema operation holds the lock — retry |
| 500 | INTERNAL_ERROR |
Our defect, logged on the server — never your request's fault |
| 503 | SECRETS_NOT_CONFIGURED |
A secret-management route was called on a service that has no master secret injected — there is no caller it could tell apart, so it declines rather than issuing credentials to anyone who reaches the port |