API Design Guidelines
This document defines the design rules for the API. All new endpoints and DTOs must follow these guidelines. When in doubt, consistency with existing endpoints takes priority over personal preference.
Embedding Rule
When designing a DTO, use this rule to decide whether to embed a related domain or expose it as a separate endpoint:
Embed when ALL three conditions are true
- Bounded -- the relationship has a small, predictable maximum (typically ≤5 items per parent)
- Always needed for display -- every consumer of that endpoint needs this data to render meaningfully without a follow-up request
- Static reference data -- it is identity or metadata, not metrics or computed data that varies by context or time period
If any one of these is false, the relationship belongs on a separate endpoint.
The one-sentence rule
Embed small, stable, always-needed reference objects. Everything else gets its own endpoint.
Applied to the current domains
| Relationship | Bounded? | Always needed? | Static? | Decision |
|---|---|---|---|---|
| User → Departments | Yes (1–3) | Yes | Yes | Embed in User |
| User → Devices | No (1–10+) | No | No | GET /users/{id}/devices |
| User → Apps | No (10–100+) | No | No | GET /users/{id}/apps |
| App → Categories | Yes (1–5) | Yes | Yes | Embed in App |
| App → Vendor | Yes (always 1) | Yes | Yes | Embed in App |
| App → URL Patterns | No (10–50+) | No | Yes | GET /catalog-apps/{id}/url-patterns |
| App → Desktop Aliases | Borderline (1–5) | No | Yes | GET /catalog-apps/{id}/desktop-aliases |
| Department → Users | No (100s–1000s) | No | No | GET /users?department_id= |
| Client → Partner | Yes (always 1) | Yes | Yes | Embed in Client |
| Client → Enrollment Token | Yes (0–1 active) | Yes | Yes | Embed in Client |
DTO Naming: When to use a Ref
After deciding to embed a related object, you need to decide whether the embedded shape deserves its own Ref type or should just reuse the full entity name.
Use a Ref suffix when the embedded shape is a genuine subset
If the entity's own endpoints return additional fields beyond what is embedded (e.g. computed aggregates, audit timestamps, joined relationships), define a Ref type for the smaller embedded shape.
DepartmentRef (id, name, description) ← embedded in User.departments
Department (id, name, description, ← returned by GET /departments
member_count, created_at, updated_at)
The Ref signals to consumers: "this is a lightweight projection -- fetch the entity's own endpoint if you need the full object."
Use the full entity name when the shapes are identical
If the embedded shape contains every field the entity's own endpoints would return, there is no subset relationship. Just call it by the entity name -- adding Ref implies a fuller version exists when it doesn't.
CatalogVendor (id, name, url) ← embedded in CatalogApp.vendor
CatalogVendor (id, name, url) ← returned by GET /catalog-vendors
Decision table
| Embedded shape vs. full entity | Naming | Example |
|---|---|---|
| Strict subset (fewer fields) | EntityRef | UserRef, DeviceRef, DepartmentRef |
| Identical (same fields) | Entity | CatalogVendor, CatalogCategory, Partner |
One-sentence rule
Name it
Refonly if a consumer would need to call the entity's own endpoint to get more data. If the embedded shape is the full shape, drop the suffix.
Response Shape Rules
Collection endpoints
GET /resources returns the full resource object including all bounded, always-needed embedded relationships. The response is always paginated.
{
"data": [ /* Resource objects */ ],
"total_count": 24,
"next_cursor": "WyJ0aWNrZXQiXQ"
}
Detail endpoints
GET /resources/{id} returns the same full resource object as the collection endpoint. Use it when you need to fetch a single known resource directly by ID -- for example, loading a profile page, refreshing one record after an update, or resolving a link from another domain.
Pagination is always cursor-based
All collection endpoints use cursor / next_cursor. Offset pagination is not used.
| Parameter | Type | Description |
|---|---|---|
cursor | string | Opaque cursor from a previous response. Omit on first request. |
page_size | integer | Items per page. Each endpoint defines its own default and maximum. |
next_cursor | string | null | Returned in response. null means no more pages. |
Naming Conventions
Resource IDs
All resource IDs are exposed as id in DTOs (not user_id, department_id, etc.). The resource type is clear from context.
{ "id": "a1d97031-..." } // correct
{ "user_id": "a1d97031-..." } // incorrect
Audit timestamps
Audit timestamps use created_at and updated_at. These refer strictly to when the database record was created or last modified -- not to domain-specific events (e.g. last_activity.at on a user, or enrolled_at on a device). When both exist on the same resource, the field name makes the distinction clear.
| Field | Maps to | Description |
|---|---|---|
created_at | created_at | When the record was first created |
updated_at | updated_at | When the record was last modified |
Omitted internal fields
The following fields are present in the DB but never exposed in v2 DTOs:
| DB Column | Reason omitted |
|---|---|
client_id | Present in the URL path |
partner_id | Internal tenant field |
created_by | Internal audit field |
updated_by | Internal audit field |
Timestamps
All timestamps are ISO 8601 with UTC timezone: 2026-02-24T21:23:53.082Z
HTTP Methods
| Method | Usage |
|---|---|
GET | Read resources (safe, idempotent) |
POST | Create resources or bulk relationship operations (both add and remove) |
PATCH | Partial update -- only send fields you want to change |
DELETE | Remove a single resource identified by its URL path |
PUT is not used. All updates are PATCH.
Why bulk removal uses POST, not DELETE
DELETE with a request body is unreliable -- many proxies, CDNs, and HTTP client libraries strip or reject bodies on DELETE requests. Major APIs (GitHub, Stripe, Google) avoid this pattern.
For bulk removal, use POST to a /remove sub-path:
POST /departments/{id}/members ← bulk add
POST /departments/{id}/members/remove ← bulk remove
DELETE is reserved for removing a single resource whose identity is entirely in the URL path (e.g. DELETE /departments/{id}).
Summary Endpoints
The /summary sub-path appears in two distinct contexts with different semantics. Both share the same URL shape (/{resource}/summary) but serve different purposes and follow different rules.
CRUD summary
A CRUD summary lives under a resource domain (/users, /devices, /catalog-apps) and returns a point-in-time snapshot of the resource collection — counts and metadata computed from the current state of the DB, with no concept of a date range.
GET /clients/{client_id}/users/summary
GET /clients/{client_id}/devices/summary
GET /catalog-apps/summary
Characteristics:
- No date parameters — reflects the current state
- No
periodobject in the response - Typically powers a static health/overview card that doesn't change with time filter selections
- Response is a flat object, not a paginated collection
Example response (/users/summary):
{
"total": 312,
"active": 289,
"stale": 23
}
Analytics summary
An analytics summary lives under the /analytics namespace and returns aggregate metrics computed over an explicit date range. It is the card-count companion to a paginated analytics list endpoint.
GET /clients/{client_id}/analytics/apps/utilization/summary
GET /clients/{client_id}/analytics/apps/ai/users/summary
GET /clients/{client_id}/analytics/apps/ai/recent/summary
Characteristics:
- Requires
start_date,end_date, andgranularityparameters - Always includes a
periodobject in the response (see Response Period Object) - Supports optional comparison period (
compare_start_date,compare_end_date); change percentages arenullwhen omitted - Is always a sibling of a paginated list endpoint under the same
/{kind}path - Response is a flat object with named metric fields, no pagination
Example response (/analytics/apps/utilization/summary):
{
"period": {
"start_date": "2026-02-01",
"end_date": "2026-02-28",
"compare_start_date": "2026-01-01",
"compare_end_date": "2026-01-31"
},
"apps_used": { "value": 125, "change_pct": 4.2, "compare_value": 120 },
"apps_discovered": { "value": 12, "change_pct": 50.0, "compare_value": 8 },
"apps_dropped_off": { "value": 4, "change_pct": 33.3, "compare_value": 3 }
}
At a glance
| CRUD summary | Analytics summary | |
|---|---|---|
| Namespace | Resource domain (/users, /devices) | Analytics domain (/analytics/...) |
| Date params | None | start_date, end_date, granularity required |
period in response | No | Yes |
| Comparison period | No | Optional |
| Sibling list endpoint | No | Yes (always) |
| Reflects | Current state | Computed period window |
Analytics Pattern: Time-Series
The /analytics namespace is a dedicated domain for all read-only, time-series data. It is completely separate from the identity/CRUD resource domains (/users, /devices, /apps).
URL convention
/clients/{client_id}/analytics/{resource}/{id}/{analytics-kind}
The {analytics-kind} sub-path (e.g. /activity) prevents collisions when new analytics types are added later (e.g. /utilization, /performance).
Standard query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
start_date | string | Yes | Start of date range (YYYY-MM-DD) |
end_date | string | Yes | End of date range, inclusive (YYYY-MM-DD) |
metric | string | Yes | Which metric to return. Valid values are endpoint-specific. |
Standard response shape (ActivityTimeSeries)
All /activity endpoints return the same shape:
{
"metric": "activity-count",
"label": "Total Activity",
"period": {
"start": "2024-01-01",
"end": "2024-01-15"
},
"data": [
{ "date": "2024-01-01", "value": 42 },
{ "date": "2024-01-02", "value": 38 },
{ "date": "2024-01-03", "value": 0 }
]
}
Constraints
- Max date range: 90 days
- Zero-fill: Days with no activity return
value: 0. The response always includes every date in the range. - Timezone: All dates are in the client's configured timezone
- One metric per request: Each request returns a single metric. To display multiple metrics, make parallel requests.
Analytics Pattern: Aggregate Endpoint Structure
Aggregate analytics endpoints (those that return period-level metrics rather than daily time-series) follow a consistent three-role structure under a single resource path.
The three roles
| Sub-path | Role | Returns |
|---|---|---|
/{kind} | List | Paginated table of items with per-row metrics. Standard { period, data, total_count, next_cursor } envelope. |
/{kind}/summary | Summary | Flat aggregate counts for dashboard cards. No pagination. |
/{kind}/rankings | Rankings | Named top-performer slots (leader, biggest increase, biggest decrease) for one or more metrics. No pagination. |
Rules
- The parent route is always the list. It is never a container or index for its sub-paths — it returns data directly.
/summaryand/rankingsare siblings to the parent list, not nested children of each other.- Not every resource needs all three. Add only the roles that the product surface requires.
- Summary and rankings endpoints always include the
periodobject. See Response Period Object.
Example — App Utilization
GET /analytics/apps/utilization ← list: paginated per-app metrics table
GET /analytics/apps/utilization/summary ← summary: apps used / discovered / dropped off counts
GET /analytics/apps/utilization/rankings ← rankings: top app by interactions, time, active users
Example — AI Apps
GET /analytics/apps/ai/users ← list: paginated AI apps by user count
GET /analytics/apps/ai/users/summary ← summary: aggregate AI adoption card metrics
GET /analytics/apps/ai/recent ← list: apps first seen in the period
GET /analytics/apps/ai/recent/summary ← summary: count of newly discovered AI tools
GET /analytics/apps/ai/departments ← list: departments with AI app usage
GET /analytics/apps/ai/departments/summary ← summary: total AI users across all departments
Analytics Pattern: Granularity
All analytics endpoints accept a required granularity parameter alongside the explicit date range.
When granularity matters
granularity affects how metrics are calculated — for example, whether "average daily interactions" is computed over days, weeks, or months. For simple COUNT-style summaries where the window doesn't change the formula, the server may ignore the value, but the parameter is still required for consistency across all analytics endpoints.
granularity values
| Value | Description |
|---|---|
daily | Metrics computed per day |
weekly | Metrics computed per ISO week |
monthly | Metrics computed per calendar month |
quarterly | Metrics computed per calendar quarter |
Usage with date range
granularity is always used alongside start_date and end_date. The caller provides the exact period boundaries; granularity tells the server how to aggregate within that window.
GET /analytics/apps/utilization/rankings?granularity=monthly&start_date=2026-02-01&end_date=2026-02-28
granularity is required on all endpoints that accept it — there is no default, because different aggregation windows produce meaningfully different results.
MetricWithDeltaDto
All analytics endpoints that pair a numeric metric with a comparison-period change percentage use the MetricWithDeltaDto wrapper instead of a flat field pair.
Shape
{
"value": 125,
"change_pct": 4.2,
"compare_value": 120
}
| Field | Type | Nullable | Description |
|---|---|---|---|
value | number | No | The metric value for the primary period |
change_pct | number | Yes | Percentage change vs comparison period. null if no comparison was requested. |
compare_value | number | Yes | The raw metric value for the comparison period. null if no comparison was requested. Useful when change_pct is -100% (dropped-off items) or when absolute change is needed without rounding loss. |
Rules
- Use
MetricWithDeltaDtowhenever a metric field is accompanied by a_change_pctsibling. - The object is never
nullitself — onlychange_pctandcompare_valueinside it are nullable. - The field name is the metric name only (e.g.
apps_used,unique_users), not a flat pair likeapps_used+apps_used_change_pct.
Example — before vs. after
Before (flat):
{
"apps_used": 125,
"apps_used_change_pct": 4.2
}
After (MetricWithDeltaDto):
{
"apps_used": { "value": 125, "change_pct": 4.2, "compare_value": 120 }
}
Response Period Object
Aggregate analytics endpoints that accept date range + comparison parameters echo the resolved dates back in a period object in the response. This makes responses self-describing and is useful when server-side validation adjusts dates (e.g. rounding to period boundaries).
Shape
{
"period": {
"start_date": "2026-02-01",
"end_date": "2026-02-28",
"compare_start_date": "2026-01-01",
"compare_end_date": "2026-01-31"
}
}
| Field | Type | Nullable | Description |
|---|---|---|---|
start_date | string | No | Start of the primary period (YYYY-MM-DD) |
end_date | string | No | End of the primary period (YYYY-MM-DD) |
compare_start_date | string | Yes | Start of comparison period. null if no comparison requested. |
compare_end_date | string | Yes | End of comparison period. null if no comparison requested. |
Rules
- Field names mirror the query parameters exactly (
start_date, notperiod_start). - When no comparison dates were provided,
compare_start_dateandcompare_end_datearenull(not omitted). - The
periodobject is included on both summary and list variants of an analytics endpoint. CRUD summaries do not includeperiod— see Summary Endpoints.
Filter Parameters: Plural IDs
All filter parameters that accept a resource UUID use the plural form and accept a comma-separated list of UUIDs. This allows callers to filter by multiple values in a single request.
?category_ids=uuid1,uuid2,uuid3
?department_ids=uuid1,uuid2
?vendor_ids=uuid1
| Parameter | Type | Description |
|---|---|---|
category_ids | string | Comma-separated catalog category UUIDs |
department_ids | string | Comma-separated department UUIDs |
vendor_ids | string | Comma-separated catalog vendor UUIDs |
Single-value use is valid — pass one UUID with no comma. The plural naming is consistent regardless of how many values are passed.
The previous singular forms (category_id, department_id, vendor_id) are supported as deprecated aliases on existing endpoints and will be removed in a future version.
Error Response Shape
All errors follow a consistent envelope:
{
"error": {
"code": "not_found",
"message": "User not found",
"details": {
"user_id": "ffffffff-ffff-ffff-ffff-ffffffffffff"
}
}
}
| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable error code (snake_case) |
error.message | string | Human-readable description |
error.details | object | null | Additional context about the specific failure |