API development
API development
Section titled “API development”Sencai backend is built on Strapi v5. This guide teaches how to add new endpoints and content-types.
Project structure
Section titled “Project structure”sencai.space/├── src/│ ├── api/ # Content-types│ │ ├── [content-type]/│ │ │ ├── controllers/ # Request handlers│ │ │ ├── services/ # Business logic│ │ │ └── routes/ # Route definitions│ │ └── middleware/ # Global middleware│ ├── policies/ # Global RBAC policies (global:: prefix)│ ├── lifecycles/ # Lifecycle hooks│ └── config/ # Configuration├── database/│ └── migrations/ # Database migrations└── types/ # TypeScript typesAdding a new content-type
Section titled “Adding a new content-type”Via Admin UI (easiest)
Section titled “Via Admin UI (easiest)”-
Log in to Strapi Admin at
http://localhost:1337/admin -
Click Content-Type Builder in left menu
-
Click Create new collection type
-
Fill in:
- Display name — e.g., “Organisation”
- API ID — e.g., “organisation”
- Visible in API — enabled
-
Add fields by clicking Add another field
-
Click Save
-
Restart Strapi
Changes are saved to database/migrations/.
Adding a custom controller
Section titled “Adding a custom controller”Controllers handle API requests.
import type { Core } from '@strapi/strapi';
const controller = ({ strapi }: { strapi: Core.Strapi }) => ({ async getMembers(ctx) { const { id } = ctx.params;
if (!id) { return ctx.badRequest('ID required'); }
try { const org = await strapi.entityService.findOne( 'api::organisation.organisation', id, { populate: ['members'] } );
ctx.send({ members: org.members || [] }); } catch (error) { ctx.internalServerError('Error fetching members'); } },});
export default controller;entityService vs. Document Service API
Section titled “entityService vs. Document Service API”The example above uses the legacy strapi.entityService API, which still works in Strapi v5.
Newer code should prefer the Document Service API (strapi.documents(uid)), which is the
Strapi v5-native way to read/write content:
const org = await strapi.documents('api::organisation.organisation').findOne({ documentId, populate: ['members'],});Note the identifier difference: entityService takes the numeric id, while the Document
Service API takes the string documentId (Strapi v5’s stable, non-incrementing identifier).
Route params in this codebase are generally the documentId string — see the RBAC policies
below, which resolve params.id || params.documentId for exactly this reason.
Defining routes
Section titled “Defining routes”export default { routes: [ { method: 'GET', path: '/organisations/:id/members', handler: 'organisations.getMembers', config: { policies: ['global::require-auth', 'global::is-organisation-member'], auth: false, }, }, ],};Policies (RBAC)
Section titled “Policies (RBAC)”Real RBAC policies live as global policies in src/policies/ (not per-content-type) and are
referenced with the global:: prefix. The main ones:
| File | Purpose | Config |
|---|---|---|
is-organisation-member.ts | User has any membership (any role) in the organisation | none |
is-organisation-role.ts | User’s role meets a minimum threshold | { minRole: 'owner' | 'admin' | 'member' | 'viewer' } |
is-organisation-scoped.ts | Resolves and enforces organisation scope on a request | — |
is-organisation-scoped-or-cross-tenant.ts | Same, but also allows relationship-derived cross-tenant access (MSP/agency) | — |
Role hierarchy (highest to lowest): owner (4) > admin (3) > member (2) > viewer (1).
Apply policies in routes:
{ config: { policies: [ 'global::require-auth', { name: 'global::is-organisation-role', config: { minRole: 'admin' } }, ], },}Or for a plain membership check:
policies: ['global::require-auth', 'global::is-organisation-member'],Lifecycle hooks
Section titled “Lifecycle hooks”Hooks run on CRUD events.
// Automatically runs on User updatesexport default { async afterCreate(event) { const { result } = event; await syncUserToKeycloak(result); },};Input validation
Section titled “Input validation”async createOrganisation(ctx) { const { name, description } = ctx.request.body;
if (!name || name.trim().length === 0) { return ctx.badRequest('Name required'); }
const org = await strapi.entityService.create( 'api::organisation.organisation', { data: { name, description } } );
ctx.created(org);}Authentication
Section titled “Authentication”All authenticated requests have a JWT from Keycloak:
async getOrganisations(ctx) { const user = ctx.state.user;
if (!user) { return ctx.unauthorized('Authentication required'); }
// user contains KC subject, email, etc.}Audit logging
Section titled “Audit logging”Every mutating endpoint on the platform is expected to produce an audit trail. Reference
.claude/context/audit-patterns.md for the full pattern reference before writing a mutation.
HTTP mutations are captured automatically. The global global::audit-logger middleware
(src/middlewares/audit-logger.ts) intercepts every POST/PUT/PATCH/DELETE response with
status < 400 and publishes an audit event asynchronously — you do not need to call anything
explicitly from a standard Strapi controller.
Explicit calls are required in non-HTTP code paths — cron jobs, background workers, and
lifecycle hooks that mutate data outside of an HTTP request. In those cases, call
publishAuditEvent() (or the equivalent audit.log() helper in non-Strapi services) directly:
import { publishAuditEvent, AuditAction } from '@sencai/audit';
await publishAuditEvent({ actor_user_id: `system:${serviceName}`, organisation_id: orgId, action: AuditAction.CLOUD_INSTANCE_PROVISIONED, resource_type: 'cloud-instance', resource_id: String(entity.id), changes: { before: null, after: sanitizeForAudit(entity) }, risk_level: 'high',});Lint enforcement. The sencai/require-audit-log ESLint rule (in eslint-plugin-sencai)
hard-fails npm run lint on any mutating function (matched by name: create, update,
delete, revoke, …) that has no audit call. If a function is a thin wrapper over an
already-audited call one level down (domain boundary), annotate it instead of adding a
redundant audit call:
/** @no-audit { reason: "low-level HTTP wrapper; audit emitted at domain boundary" } */Testing
Section titled “Testing”sencai.space already ships a full test setup — Jest v29 + ts-jest, with 89 existing test
files. There is no need to install Jest from scratch.
cd sencai.spacenpm test # run all tests oncenpm run test:watch # watch modenpm run test:coverage # generate coverage reportReuse the existing helpers rather than writing mocks from scratch:
| Helper | Purpose |
|---|---|
tests/helpers/auth.helper.ts | JWT creation, KC mock responses |
tests/helpers/strapi.helper.ts | Mock Strapi context, db.query, lifecycle mocks |
tests/helpers/fixtures.helper.ts | User, organisation, member fixture factories |
Configuration lives in jest.config.ts (ts-jest preset, SQLite-style in-memory setup via
tests/setup.ts, sequential execution with maxWorkers: 1 for DB stability).
Best practices
Section titled “Best practices”- Validate input — always
- Log important actions — via the audit pattern above
- Write tests — at least CRUD, using the existing helpers
- Avoid N+1 queries — use
.populate() - Sync with KC — via lifecycle hooks
- Document — with JSDoc
Endpoint reference
Section titled “Endpoint reference”This page is not an exhaustive index — the examples below cover the most commonly
integrated-against endpoints. For a complete, generated reference of every /api/v1/*
operation (all ~213 Strapi content-types plus hand-documented custom routes, request/response
schemas included), see the full API Reference, generated from
sencai.space/openapi/sencai-platform.public.v1.yaml (F4.DEVPORTAL.01/.03). Read
API versioning first — it covers the /api/v1/ namespace and
deprecation policy that the reference assumes.
Building an external integration in TypeScript, Python, or Go instead of calling the REST API directly? See the generated SDK Getting Started guides (F4.DEVPORTAL.02/.04) — installation, client setup, a complete list + create example, and error-handling conventions per language.
Auth header
Section titled “Auth header”All API requests require a JWT token in the Authorization header:
Authorization: Bearer YOUR_JWT_TOKENGET /api/users— List all usersGET /api/users/{id}— Get user detailsPUT /api/users/{id}— Update user (standard users-permissions update)PUT /api/users/me/profile— Update the authenticated user’s own profile (whitelisted fields:name,surname,jobTitle,street,city,zip_code,country) — the preferred endpoint for self-service profile edits, backed byapi::auth.auth.updateMyProfile
Organizations
Section titled “Organizations”GET /api/organisations— List organizationsPOST /api/organisations— Create organizationPUT /api/organisations/{id}— Update organization (requiresadmin+ role)POST /api/organisations/:id/invite— Invite a user by email (owner/admin only)
All endpoints above are also reachable under the versioned /api/v1/ namespace
(e.g. /api/v1/organisations) — see API versioning for the
deprecation policy and migration guide.
Other platform APIs
Section titled “Other platform APIs”The Strapi backend is not the only API surface on the platform — and the generated API Reference above covers Strapi only. Other services expose their own REST APIs, none of which are indexed elsewhere in this documentation:
- API Manager (port 3200) — issues and rotates Strapi API tokens for other microservices;
GET /api/token/:service(see API versioning for its versioned/v1/token/:servicealias). - GraphQL Gateway (port 4000) — unified GraphQL schema over core Strapi entities, Apollo
Server 5 on Fastify; resolvers delegate to the underlying Strapi REST endpoints. Requires
GRAPHQL_ENABLED=trueand a Keycloak Bearer JWT. See the dedicated GraphQL Gateway page for the schema, an example query, auth/org-scoping, and the depth/complexity/alias limits. - LLM Service (port 4430) — provider-agnostic LLM facade, exposes
/chatand/completewith Keycloak JWT auth and per-org budget enforcement. - Billing Adapter (port 3600) — Lago/Stripe billing integration; also runs sync/webhook jobs rather than being purely request-driven.
- Agent Gateway (port 4400) — HTTPS/WebSocket fleet C2 gateway for on-host Sencai agents (enrollment, heartbeat, runbook dispatch).
See sencai.space/README.md and Root CLAUDE.md for more details.