Data Layer REST API Reference
The Data Layer auto-generates REST endpoints for every module model
that is marked autoApi: true in its SQL migration. Modules do not
write controllers for standard CRUD operations — the platform generates
them from the schema.
See the REST API Overview for authentication, error format, and pagination.
For layout brevity, the /api/v1 base path prefix is omitted from the
endpoint tables below.
Auto-Generated Endpoints
For every autoApi: true model, the following five endpoints are
automatically available:
| Endpoint | Operation |
|---|---|
GET /data/{module}/{model} | List records (filters, sort, paginated) |
GET /data/{module}/{model}/:id | Single record (optional relations via ?select) |
POST /data/{module}/{model} | Create record |
PATCH /data/{module}/{model}/:id | Partial update |
DELETE /data/{module}/{model}/:id | Soft delete (sets deleted_at, retains data) |
Example — CRM module, contacts model:
GET /api/v1/data/crm/contacts
GET /api/v1/data/crm/contacts/01j9pcont000000000000001
POST /api/v1/data/crm/contacts
PATCH /api/v1/data/crm/contacts/01j9pcont000000000000001
DELETE /api/v1/data/crm/contacts/01j9pcont000000000000001
Query Language
Filter, sort, and select fields using URL query parameters.
Syntax: ?{field}={operator}.{value} (PostgREST-style).
Filter Operators
| Operator | Syntax | SQL equivalent | Example |
|---|---|---|---|
eq | ?status=eq.active | WHERE status = 'active' | Exact match |
neq | ?status=neq.deleted | WHERE status != 'deleted' | Not equal |
gt | ?price=gt.100 | WHERE price > 100 | Greater than |
lt | ?price=lt.500 | WHERE price < 500 | Less than |
gte | ?score=gte.80 | WHERE score >= 80 | Greater than or equal |
lte | ?score=lte.100 | WHERE score <= 100 | Less than or equal |
like | ?name=like.alice* | WHERE name LIKE 'alice%' | Pattern, case-sensitive |
ilike | ?name=ilike.alice* | WHERE name ILIKE 'alice%' | Pattern, case-insensitive |
in | ?status=in.(active,pending) | WHERE status IN ('active','pending') | One of values |
is | ?deleted_at=is.null | WHERE deleted_at IS NULL | IS NULL / IS NOT NULL |
SQL injection is impossible. All filter values are parameterized via
sqlc. An unknown operator or unknown field name →400 Bad Request(RFC 9457:validation-error).
OR Conditions
GET /api/v1/data/crm/contacts?or=(status.eq.vip,tier.eq.premium)
Translates to WHERE (status = 'vip' OR tier = 'premium').
Sorting
GET /api/v1/data/crm/contacts?order=createdAt.desc,name.asc
Multiple fields separated by comma. Each field: {field}.{asc|desc}.
Field Selection
GET /api/v1/data/crm/contacts?select=id,name,email,status
Returns only the specified columns. Useful to reduce response size for list views.
Eager Relations (Joins)
GET /api/v1/data/crm/contacts?select=name,company(name,industry)
Loads the company FK relation in the same request. Max depth: 2 levels:
GET /api/v1/data/crm/contacts?select=name,company(name,owner(email))
- Level 1:
company(FK oncontacts) - Level 2:
owner(FK oncompany) - Level 3: ❌ not supported →
400 Bad Request
Pagination
All list endpoints are cursor-paginated:
GET https://api.septemcore.com/v1/data/crm/contacts?limit=20&cursor=eyJpZCI6IjAx...
Response:
{
"data": [
{
"id": "01j9pcont000000000000001",
"name": "Alice Johnson",
"status": "active"
}
],
"meta": {
"cursor": "eyJpZCI6IjAx...",
"hasMore": true,
"total": 1247
}
}
| Parameter | Default | Max |
|---|---|---|
limit | 20 | 100 |
cursor | — | opaque base64 |
Create Record
POST https://api.septemcore.com/v1/data/crm/contacts
Authorization: Bearer <access_token>
Content-Type: application/json
{
"name": "Alice Johnson",
"email": "[email protected]",
"status": "active",
"tags": ["vip", "enterprise"]
}
Response 201 Created:
{
"id": "01j9pcont000000000000001",
"name": "Alice Johnson",
"status": "active",
"tags": ["vip", "enterprise"],
"tenantId": "01j9pten0000000000000001",
"createdAt": "2026-04-22T03:45:00Z",
"updatedAt": "2026-04-22T03:45:00Z",
"deletedAt": null
}
tenantId is always injected by the Gateway from the JWT — module
code cannot override it.
Partial Update
PATCH https://api.septemcore.com/v1/data/crm/contacts/01j9pcont000000000000001
Authorization: Bearer <access_token>
Content-Type: application/json
{
"email": "[email protected]",
"status": "inactive"
}
Only provided fields are updated. All other fields remain unchanged.
Response: updated record 200 OK.
Soft Delete
DELETE https://api.septemcore.com/v1/data/crm/contacts/01j9pcont000000000000001
Authorization: Bearer <access_token>
Response 204 No Content. The record is not physically removed —
deleted_at is set to the current timestamp. Soft-deleted records are
excluded from GET /data/{module}/{model} by default.
To query soft-deleted records:
GET /api/v1/data/crm/contacts?deleted_at=is.notnull
Combining Filters
GET https://api.septemcore.com/v1/data/crm/contacts
?status=eq.active
&company=eq.01j9pcomp000000000000001
&createdAt=gte.2026-01-01T00:00:00Z
&order=name.asc
&select=id,name,email,company(name)
&limit=50
Max 10 filters per request (AND conditions). Exceeded → 400 Bad Request.
Security
Access control is enforced at three layers:
| Layer | Enforcement |
|---|---|
| API Gateway | JWT validation, rate limiting, OpenAPI schema validation |
| Data Layer | PostgreSQL Row-Level Security (RLS) — WHERE tenant_id = $tenantId injected on every query |
| RBAC | Auto-generated permissions per model: {module}.{model}.read, {module}.{model}.write, {module}.{model}.delete |
RBAC permissions are automatically registered when a model with
autoApi: true is created. They appear in the Role Builder UI without
any module configuration.
Modules can add custom business-rule validation via lifecycle hooks
registered in module.manifest.json:
| Hook | When | Type |
|---|---|---|
{model}.create.before | Before create — can reject request or modify data | Synchronous |
{model}.create.after | After create — side effects (notifications, events) | Async (Event Bus) |
{model}.update.before | Before update — can reject | Synchronous |
{model}.update.after | After update | Async (Event Bus) |
{model}.delete.before | Before soft delete — can reject | Synchronous |
{model}.delete.after | After soft delete | Async (Event Bus) |
Limits
| Parameter | Limit | Notes |
|---|---|---|
Max records per page (limit) | 100 | Default: 20 |
| Max filters per request | 10 | AND conditions only |
Max select depth (relations) | 2 levels | Deeper → 400 Bad Request |
Models without autoApi | Not exposed | Opt-in per model |
| Response body | 1 MB | Exceeded → 413 Payload Too Large |
Schema Introspection
Discover available models and their schemas:
GET https://api.septemcore.com/v1/data/crm/models
Authorization: Bearer <access_token>
Response:
{
"data": [
{
"name": "contacts",
"fields": [
{ "name": "id", "type": "uuid", "nullable": false, "primaryKey": true },
{ "name": "name", "type": "text", "nullable": false },
{ "name": "email", "type": "text", "nullable": true },
{ "name": "company_id", "type": "uuid", "nullable": true, "foreignKey": "companies.id" },
{ "name": "created_at", "type": "timestamp", "nullable": false }
]
}
]
}
Data Layer reads information_schema from PostgreSQL. Schema cache is
refreshed on module install, update, or restart.
Error Reference
| Error type | Status | Trigger |
|---|---|---|
problems/validation-error | 400 | Unknown field, unknown operator, missing required field |
problems/forbidden | 403 | Missing {module}.{model}.read or write or delete permission |
problems/not-found | 404 | Record not found in tenant, or model not found, or module not installed |
problems/relations-depth-exceeded | 400 | select nesting > 2 levels |
problems/filter-limit-exceeded | 400 | More than 10 filter conditions in one request |
problems/payload-too-large | 413 | Response body would exceed 1 MB |