CRUD Operations
Every model marked autoApi: true in a module manifest receives five
REST endpoints and the corresponding SDK methods automatically. Modules
never write controller code for standard CRUD — the Data Layer generates
it from the schema.
All responses follow the platform conventions defined in
CONVENTIONS §2–4: single resource objects for retrieve/create/update,
{ data[], meta } envelope for list, and RFC 9457 for errors.
Endpoint Pattern
| Method | Path | Operation |
|---|---|---|
POST | /api/v1/data/{module}/{model} | Create |
GET | /api/v1/data/{module}/{model}/:id | Retrieve |
GET | /api/v1/data/{module}/{model} | List |
PATCH | /api/v1/data/{module}/{model}/:id | Update (partial) |
DELETE | /api/v1/data/{module}/{model}/:id | Soft delete |
All examples below use module crm, model contacts.
1. Create
POST https://api.septemcore.com/v1/data/crm/contacts
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: 01j9pa3kx200000000000000
{
"name": "Alice Chen",
"email": "[email protected]",
"status": "active",
"metadata": {
"source": "inbound",
"campaign_id": "camp_2026_q2"
}
}
Response 201 Created:
{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"status": "active",
"metadata": {
"source": "inbound",
"campaign_id": "camp_2026_q2"
},
"tenant_id": "01j9p3kz5f00000000000000",
"created_at": "2026-04-15T10:30:00Z",
"updated_at": "2026-04-15T10:30:00Z",
"deleted_at": null
}
Idempotency
The Idempotency-Key header is a UUID v7 (time-sortable). The SDK
generates it automatically on every create call. If an identical key
is received within 24 hours, the Data Layer returns the original response
without inserting a new row:
INSERT INTO contacts (id, name, email, ..., idempotency_key)
VALUES ($1, $2, $3, ..., $key)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;
This makes create operations safe to retry after a network failure.
Automatic fields
The Data Layer populates these fields automatically; they cannot be set or overridden by the client:
| Field | Value |
|---|---|
id | UUID v7 (time-sortable, auto-generated) |
tenant_id | Extracted from JWT — cannot be spoofed |
created_at | Server UTC timestamp |
updated_at | Server UTC timestamp |
deleted_at | null on creation |
Lifecycle hooks
Before the row is inserted, any registered contacts.create.before
hook runs synchronously. It can modify the payload or reject the
request (returns 422 Unprocessable Entity on rejection). After
successful insertion, contacts.create.after fires asynchronously
via the Event Bus.
2. Retrieve
GET https://api.septemcore.com/v1/data/crm/contacts/01j9pa5mz700000000000000
Authorization: Bearer <access_token>
Response 200 OK:
{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"status": "active",
"tenant_id": "01j9p3kz5f00000000000000",
"created_at": "2026-04-15T10:30:00Z",
"updated_at": "2026-04-15T10:30:00Z",
"deleted_at": null
}
Soft-deleted records (deleted_at IS NOT NULL) return 404 Not Found
by default. To include them, use ?include_deleted=true (requires
crm.contacts.delete permission).
Retrieve supports field selection and eager loading of FK relations
via ?select= — see Query Language.
3. List
GET https://api.septemcore.com/v1/data/crm/contacts?status=eq.active&order=created_at.desc&limit=20
Authorization: Bearer <access_token>
Response 200 OK:
{
"data": [
{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"status": "active",
"created_at": "2026-04-15T10:30:00Z"
}
],
"meta": {
"cursor": "01j9pa5mz700000000000000",
"hasMore": true,
"limit": 20
}
}
Cursor pagination
List uses cursor-based pagination (not offset). The cursor is the
id of the last record in the current page.
| Parameter | Default | Max | Description |
|---|---|---|---|
limit | 20 | 100 | Records per page |
cursor | — | — | Fetch records after this cursor value |
GET https://api.septemcore.com/v1/data/crm/contacts?limit=20&cursor=01j9pa5mz700000000000000
When meta.hasMore is false, there are no more pages. Cursor
pagination has O(log n) complexity regardless of page number —
offset pagination degrades to O(n) at large offsets.
4. Update (Partial)
PATCH replaces only the specified fields. Fields not included in the
request body are left unchanged.
PATCH https://api.septemcore.com/v1/data/crm/contacts/01j9pa5mz700000000000000
Authorization: Bearer <access_token>
Content-Type: application/json
{
"status": "inactive",
"metadata": {
"source": "outbound"
}
}
Response 200 OK:
{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"status": "inactive",
"metadata": {
"source": "outbound",
"campaign_id": "camp_2026_q2"
},
"updated_at": "2026-04-15T11:00:00Z"
}
updated_at is always set to the server UTC time at the moment of
the write. created_at and tenant_id are immutable.
JSONB fields (like metadata) are merged at the top level, not
replaced wholesale. To remove a key, set it to null explicitly.
5. Delete (Soft)
DELETE https://api.septemcore.com/v1/data/crm/contacts/01j9pa5mz700000000000000
Authorization: Bearer <access_token>
Response 200 OK:
{
"id": "01j9pa5mz700000000000000",
"deleted_at": "2026-04-15T12:00:00Z"
}
Soft delete sets deleted_at = NOW() and does not physically
remove the row. The record is excluded from all list results and
returns 404 on retrieve by default.
Hard delete is not supported at the Data Layer level. For GDPR-compliant data erasure, use the platform's Data Erasure API (Platform Admin only).
Required Permissions
| Operation | Required permission |
|---|---|
| Create | {module}.{model}.write |
| Retrieve | {module}.{model}.read |
| List | {module}.{model}.read |
| Update | {module}.{model}.write |
| Delete | {module}.{model}.delete |
Permissions are auto-registered by the Data Layer when autoApi: true.
The API Gateway checks them on every request before forwarding.
SDK
import { kernel } from '@platform/sdk-core';
const data = kernel.data();
// Create
const contact = await data.create('contacts', {
name: 'Alice Chen',
status: 'active',
});
// SDK auto-generates and attaches Idempotency-Key header
// Retrieve
const contact = await data.retrieve('contacts', '01j9pa5mz700000000000000');
// List with filters
const { data: contacts, meta } = await data.list('contacts', {
filters: { status: 'eq.active' },
order: 'created_at.desc',
limit: 20,
});
// Paginate to next page
const next = await data.list('contacts', {
filters: { status: 'eq.active' },
order: 'created_at.desc',
limit: 20,
cursor: meta.cursor,
});
// Update (partial)
const updated = await data.update('contacts', '01j9pa5mz700000000000000', {
status: 'inactive',
});
// Soft delete
await data.delete('contacts', '01j9pa5mz700000000000000');
Error Reference
| Scenario | HTTP | type URI suffix |
|---|---|---|
| Missing required field | 400 | validation-error |
| Record size exceeds 1 MB | 400 | record-size-exceeded |
| JSONB field exceeds 256 KB | 400 | record-size-exceeded |
| Record not found | 404 | not-found |
| Duplicate idempotency key (returns cached result) | 200 | — |
before hook rejection | 422 | hook-rejected |
| Insufficient permission | 403 | forbidden |
limit exceeds 100 | 400 | validation-error |