Skip to main content

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

MethodPathOperation
POST/api/v1/data/{module}/{model}Create
GET/api/v1/data/{module}/{model}/:idRetrieve
GET/api/v1/data/{module}/{model}List
PATCH/api/v1/data/{module}/{model}/:idUpdate (partial)
DELETE/api/v1/data/{module}/{model}/:idSoft 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",
"email": "[email protected]",
"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:

FieldValue
idUUID v7 (time-sortable, auto-generated)
tenant_idExtracted from JWT — cannot be spoofed
created_atServer UTC timestamp
updated_atServer UTC timestamp
deleted_atnull 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",
"email": "[email protected]",
"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",
"email": "[email protected]",
"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.

ParameterDefaultMaxDescription
limit20100Records per page
cursorFetch 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",
"email": "[email protected]",
"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

OperationRequired 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

ScenarioHTTPtype URI suffix
Missing required field400validation-error
Record size exceeds 1 MB400record-size-exceeded
JSONB field exceeds 256 KB400record-size-exceeded
Record not found404not-found
Duplicate idempotency key (returns cached result)200
before hook rejection422hook-rejected
Insufficient permission403forbidden
limit exceeds 100400validation-error