Skip to content

API development

Sencai backend is built on Strapi v5. This guide teaches how to add new endpoints and content-types.

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 types
  1. Log in to Strapi Admin at http://localhost:1337/admin

  2. Click Content-Type Builder in left menu

  3. Click Create new collection type

  4. Fill in:

    • Display name — e.g., “Organisation”
    • API ID — e.g., “organisation”
    • Visible in API — enabled
  5. Add fields by clicking Add another field

  6. Click Save

  7. Restart Strapi

Changes are saved to database/migrations/.

Controllers handle API requests.

src/api/organisations/controllers/organisations.ts
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;

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.

src/api/organisations/routes/organisations.ts
export default {
routes: [
{
method: 'GET',
path: '/organisations/:id/members',
handler: 'organisations.getMembers',
config: {
policies: ['global::require-auth', 'global::is-organisation-member'],
auth: false,
},
},
],
};

Real RBAC policies live as global policies in src/policies/ (not per-content-type) and are referenced with the global:: prefix. The main ones:

FilePurposeConfig
is-organisation-member.tsUser has any membership (any role) in the organisationnone
is-organisation-role.tsUser’s role meets a minimum threshold{ minRole: 'owner' | 'admin' | 'member' | 'viewer' }
is-organisation-scoped.tsResolves and enforces organisation scope on a request
is-organisation-scoped-or-cross-tenant.tsSame, 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'],

Hooks run on CRUD events.

// Automatically runs on User updates
export default {
async afterCreate(event) {
const { result } = event;
await syncUserToKeycloak(result);
},
};
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);
}

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.
}

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" } */

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.

Terminal window
cd sencai.space
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # generate coverage report

Reuse the existing helpers rather than writing mocks from scratch:

HelperPurpose
tests/helpers/auth.helper.tsJWT creation, KC mock responses
tests/helpers/strapi.helper.tsMock Strapi context, db.query, lifecycle mocks
tests/helpers/fixtures.helper.tsUser, 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).

  • 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

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.

All API requests require a JWT token in the Authorization header:

Authorization: Bearer YOUR_JWT_TOKEN
  • GET /api/users — List all users
  • GET /api/users/{id} — Get user details
  • PUT /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 by api::auth.auth.updateMyProfile
  • GET /api/organisations — List organizations
  • POST /api/organisations — Create organization
  • PUT /api/organisations/{id} — Update organization (requires admin+ 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.

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/:service alias).
  • 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=true and 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 /chat and /complete with 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.