API Contract
The cPod backend exposes a REST API documented as an OpenAPI 3.1 specification. The spec is the authoritative source of truth for every endpoint, request/response shape, and error code in the platform.
SDK vs direct API. Use the typed SDKs (@zysec-ai/cpod-sdk, cpod-sdk, etc.) rather than
calling the REST API directly. The spec exists to define the contract between SDK and
backend — not as a primary integration point for application developers.
Emulator vs Production Backend#
The cPod emulator is a fully-functional stub backend built to serve the same REST contract
as the production cpod-backend. During development you point the SDK at the emulator;
in production you point it at https://api.cyberpod.app. The contract is identical — the
only difference is where the data lives and how tokens are issued.
| Emulator | Production | |
|---|---|---|
| Base URL | http://localhost:4000 | https://api.cyberpod.app |
| Auth token endpoint | http://localhost:4000/api/v1/auth/token | https://api.cyberpod.app/api/v1/auth/token |
| Token issuance | Any client_id / client_secret accepted | Real OAuth 2.0 credentials required |
| Data persistence | In-memory (reset on restart) | Durable PostgreSQL + S3 |
| Interactive docs | http://localhost:4000/docs | — |
Switching base URL in the SDK:
import { CpodClient } from '@zysec-ai/cpod-sdk'
// Development — emulator
const dev = new CpodClient({
apiKey: 'any-key',
baseUrl: 'http://localhost:4000',
})
// Production
const prod = new CpodClient({
apiKey: process.env.CPOD_API_KEY!,
// baseUrl defaults to https://api.cyberpod.app
})from cpod import CpodClient
# Development — emulator
dev = CpodClient(api_key="any-key", base_url="http://localhost:4000")
# Production
prod = CpodClient.from_env() # reads CPOD_BASE_URL and CPOD_API_KEYimport cpod "github.com/ZySec-AI/cpod-sdk/sdks/go"
// Development — emulator
dev := cpod.New("any-key", cpod.WithBaseURL("http://localhost:4000"))
// Production
prod := cpod.New(os.Getenv("CPOD_API_KEY"))using Cpod.SDK;
// Development — emulator
var dev = new CpodClient("any-key", baseUrl: "http://localhost:4000");
// Production
var prod = new CpodClient(Environment.GetEnvironmentVariable("CPOD_API_KEY")!);Set CPOD_BASE_URL=http://localhost:4000 in your .env file. All four SDKs pick this up automatically via from_env() / CpodClient.from_env() / fromEnv().
Authentication#
All endpoints except /api/v1/auth/token and /health require a Bearer token.
Obtaining a token#
curl -s http://localhost:4000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"client_id":"dev","client_secret":"dev","grant_type":"client_credentials"}'
# → {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}The emulator accepts any client_id and client_secret values.
curl -s https://api.cyberpod.app/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "<your-client-id>",
"client_secret": "<your-client-secret>",
"grant_type": "client_credentials"
}'
# → {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}Obtain credentials by registering an app via /api/v1/apps/register or from the cPod console.
Using the token#
GET /api/v1/people/persons HTTP/1.1
Host: api.cyberpod.app
Authorization: Bearer eyJ...
Accept: application/jsonThe SDKs handle token acquisition and refresh automatically when you provide apiKey (SDK shorthand for the OAuth client secret).
Domain → API Prefix Mapping#
Every EDM domain has a dedicated base path. All entity CRUD operations follow the pattern
GET|POST /v1/{prefix}, GET|PATCH|DELETE /v1/{prefix}/{id}.
| Domain | API Prefix | Entities |
|---|---|---|
| People | /api/v1/people/persons | Person |
| People (self) | GET /api/v1/people/persons/me | Person linked to JWT sub — no userId parameter |
| People (admin sync) | POST /api/v1/people/persons/sync-user-links | Idempotent User ↔ Person link backfill (tenant_admin, supports dry_run) |
| Groups | /v1/groups | Group |
| Technology | /v1/technology | TechnologyAsset, AccessEntitlement |
| Licenses | /v1/licenses | SoftwareLicense, LicenseAssignment |
| Assets | /v1/assets | PhysicalAsset, CloudResource |
| Risk | /v1/risk | Vulnerability, ComplianceControl, RiskItem |
| Relationships | /v1/relationships | Relationship |
| DataSources | /v1/datasources | DataSource |
| Contracts | /v1/contracts | Vendor, Contract, ContractObligation |
| Employee | /v1/employee | PersonSkill, CalendarEvent, LeaveRequest, MeetingNote |
| Work | /v1/work | TimeEntry, Comment, Capacity |
| Performance | /v1/performance | PerformanceReview, PersonGoal, LearningRecord |
| Investments | /v1/investments | TechPortfolioItem, CostCenter |
| Customer | /v1/customer | Account, Contact, Deal, Activity |
| Projects | /v1/projects | Project, Task, Sprint, Feature |
| OKR | /v1/okr | Objective, KeyResult |
| GRC | /v1/grc | GrcFramework, GrcControl, GrcEvidence, GrcIncident, GrcRisk |
| SOC | /v1/soc | SocAlert, SocInvestigation, SocPlaybook |
| Telemetry | /v1/telemetry | AuditEvent, AppEvent, Trace |
| Finance | /v1/finance | Invoice, PurchaseOrder, Expense, Budget, BudgetLine |
| HR | /v1/hr | JobPosting, Applicant, OnboardingTask, OffboardingTask |
| Helpdesk | /v1/helpdesk | Ticket, SlaPolicy |
| Notifications | /v1/notifications | Notification, Announcement |
| Approvals | /v1/approvals | ApprovalRequest, ApprovalStep |
| Org | /v1/org | Location, Department |
| Catalog | /v1/catalog | Product, ProductCategory |
| Procurement | /v1/procurement | Supplier |
| Knowledge | /v1/knowledge | Document, Chunk, KnowledgeEntity |
| Learning | /v1/learning | Cohort, Assessment |
| RFP | /v1/rfp | RfpRecord, RfpQuestion, RfpResponse |
| Integration | /v1/integration | Application, Connector, ApiKey, Webhook |
| Storage | /v1/storage | StorageObject, StorageRecord |
Standard Request/Response Envelope#
Single-entity response (ApiResponse<T>)#
Every endpoint that returns a single entity wraps it in this shape:
{
"data": { /* entity object */ },
"meta": {
"requestId": "req-01J...",
"timestamp": "2026-05-22T10:00:00.000Z"
}
}List response (PaginatedResponse<T>)#
List endpoints (GET /v1/{prefix}) return:
{
"data": [ /* entity objects */ ],
"total": 1024,
"page": 1,
"pageSize": 50,
"hasMore": true,
"meta": {
"requestId": "req-01J...",
"timestamp": "2026-05-22T10:00:00.000Z"
}
}| Field | Type | Description |
|---|---|---|
data | T[] | Array of entities for this page |
total | integer | Total matching record count across all pages |
page | integer | Current page number (1-based) |
pageSize | integer | Number of items per page |
hasMore | boolean | true when page * pageSize < total |
meta.requestId | string | Traceable request ID for support |
meta.timestamp | string | UTC ISO 8601 timestamp of the response |
Mutation request body#
Create and update requests send a JSON body with Content-Type: application/json.
Only the fields you include are applied (partial update / PATCH semantics for updates).
{
"firstName": "Alice",
"lastName": "Smith",
"email": "alice@acme.com",
"type": "employee"
}Error Codes#
All errors follow RFC 9457 Problem Details format:
{
"title": "Validation Error",
"status": 422,
"detail": "email must be a valid email address",
"instance": "/api/v1/people/persons",
"requestId": "req-01J..."
}| Status | Title | Typical Cause |
|---|---|---|
400 | Bad Request | Malformed JSON, missing required fields |
401 | Unauthorized | Missing, expired, or invalid Bearer token |
403 | Forbidden | Valid token but insufficient scope/permissions |
404 | Not Found | Entity ID does not exist in this tenant |
422 | Unprocessable Entity | Payload passes schema validation but fails business rules (e.g. duplicate email) |
429 | Too Many Requests | Rate limit exceeded — back off and retry after Retry-After header |
500 | Internal Server Error | Unexpected backend failure — contact support with requestId |
Specification File#
The OpenAPI 3.1 spec lives at
docs/api-contract/openapi.yaml.
All schemas use JSON Schema Draft 2020-12 with additionalProperties: false.
View interactively#
npx @redocly/cli preview-docs docs/api-contract/openapi.yaml
# Opens http://localhost:8080npx @cpod/emulator --ui
# Opens http://localhost:4000/docsnpx @stoplight/elements-dev-portal
# Point it at docs/api-contract/openapi.yamlContract Validation (CI)#
Every PR touching docs/api-contract/, emulator/src/routes/, or sdks/ triggers the
API Contract Validation workflow (.github/workflows/api-contract.yml), which runs:
1. Spectral lint#
| Rule | Severity | Description |
|---|---|---|
operation-operationId | error | Every operation must have a unique operationId |
operation-description | warn | Every operation should include a description |
operation-tags | warn | Every operation should belong to a tag |
operation-success-response | error | Every operation must define at least one 2xx response |
oas3-schema | error | Schema objects must be valid OAS 3.1 |
cpod-id-pattern | warn | Entity id properties must declare a regex pattern |
cpod-no-inline-schemas | warn | Inline schemas with >3 properties should be extracted to components/schemas |
cpod-pagination-shape | warn | List-endpoint responses should include items and total |
2. Path completeness check#
scripts/check-api-contract.mjs asserts ≥ 40 paths, ≥ 10 component schemas, OpenAPI 3.1.x,
info.contact present, and every path starting with /v1/.
3. SDK alignment check#
scripts/validate-api-contract.ts compares SDK service names against OpenAPI tags and fails
if any service has no corresponding tag (or vice-versa).
Keeping the Contract in Sync#
When you add a new endpoint, follow this order:
- Emulator route — add the handler in
emulator/src/routes/ - OpenAPI spec — add the path + schema to
docs/api-contract/openapi.yaml - SDK methods — implement the client method in all four SDKs
- Tests — add at minimum a smoke test per SDK
- Docs — update the relevant domain page under
docs/pages/docs/domains/
Never hand-edit the spec for an endpoint that does not yet exist in the emulator. The spec and the emulator must stay in lock-step — they are tested together in CI.
Entity ID Prefixes#
All cPod entity IDs use a short kebab prefix so they are self-describing in logs:
| Entity | Prefix | Example |
|---|---|---|
| Person | per- | per-01J... |
| Group | grp- | grp-01J... |
| TechnologyAsset | tast- | tast-01J... |
| AccessEntitlement | ent- | ent-01J... |
| SoftwareLicense | lic- | lic-01J... |
| LicenseAssignment | lasgn- | lasgn-01J... |
| PhysicalAsset | past- | past-01J... |
| CloudResource | cres- | cres-01J... |
| Vulnerability | vuln- | vuln-01J... |
| ComplianceControl | ctrl- | ctrl-01J... |
| RiskItem | risk- | risk-01J... |
| Relationship | rel- | rel-01J... |
| DataSource | ds- | ds-01J... |
| Vendor | vnd- | vnd-01J... |
| Contract | ctr- | ctr-01J... |
| ContractObligation | obl- | obl-01J... |
The cpod-id-pattern Spectral rule warns when an id property is missing a pattern
constraint, keeping the spec honest about ID shapes.
Groups & Permissions API#
All endpoints require Authorization: Bearer <token>. Access rules:
| Role | Groups visible | Can modify |
|---|---|---|
global_admin | All tenants | Any group |
tenant_admin | Own tenant | Any group in own tenant |
user | Only groups they're a member of | Only groups they're a member of |
Create group#
POST /api/v1/groups
Authorization: Bearer <token>
Content-Type: application/json
{
"display_name": "CRM Writers",
"app_id": "app-crm-sync-58d93289", // optional: scope to an app
"description": "Can write people records" // optional
}Response (201):
{
"id": "grp_019f4824-...",
"display_name": "CRM Writers",
"app_id": "app-crm-sync-58d93289",
"tenant_id": "tnt_global",
"members": [],
"member_details": []
}List groups#
GET /api/v1/groups
Authorization: Bearer <token>Returns groups based on access rules above. Each group includes members and app_id.
Get group#
GET /api/v1/groups/{group_id}
Authorization: Bearer <token>Update group#
PATCH /api/v1/groups/{group_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"display_name": "Updated Name",
"description": "Updated description"
}Delete group#
DELETE /api/v1/groups/{group_id}
Authorization: Bearer <token>Add member#
POST /api/v1/groups/{group_id}/members
Authorization: Bearer <token>
Content-Type: application/json
{
"user_id": "usr_711484e0-..."
}Remove member#
DELETE /api/v1/groups/{group_id}/members/{user_id}
Authorization: Bearer <token>Set group permissions#
Grants the group { domain: [operations] } permissions. Operations: read, write
(where write = create + update + delete).
PUT /api/v1/groups/{group_id}/permissions
Authorization: Bearer <token>
Content-Type: application/json
{
"permissions": {
"people": ["read", "write"],
"contracts": ["read"],
"helpdesk": ["read", "write"]
}
}Response:
{
"group_id": "grp_019f4824-...",
"permissions": {
"people": ["read", "write"],
"contracts": ["read"],
"helpdesk": ["read", "write"]
}
}Get group permissions#
GET /api/v1/groups/{group_id}/permissions
Authorization: Bearer <token>Delete all group permissions#
DELETE /api/v1/groups/{group_id}/permissions
Authorization: Bearer <token>Get user effective permissions#
Returns the merged permissions from all groups the user belongs to, optionally
filtered by app_id. This is what the middleware calls internally when X-App-Id
is present.
GET /api/v1/users/{user_id}/permissions
Authorization: Bearer <token>With app_id filter (same endpoint, control-plane internal):
GET /api/v1/users/{user_id}/effective-permissions?app_id=app-crm-sync
Response:
{
"permissions": {
"people": ["read", "write"],
"contracts": ["read"]
}
}When app_id is provided, only groups where app_id IS NULL OR app_id = provided
contribute permissions. Plain groups (no app_id) apply everywhere; app-scoped
groups only contribute when their app_id matches.
Permission enforcement at runtime#
When an app sends a request with X-App-Id:
| Scenario | Result |
|---|---|
User has domain:write for the app | ✅ Granted |
| User lacks the required permission | ❌ 403 EdmForbidden |
No X-App-Id header (normal user call) | ⏭️ Bypassed (owner scope applies) |
| App not registered | ⏭️ Bypassed (X-App-Id stripped) |