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 list-appropriate row shape for that resource. The response is always paginated.
The list shape should include the fields needed to render, sort, and filter the collection view efficiently. It does not need to include every field exposed by the detail endpoint.
{
"data": [ /* Resource objects */ ],
"total_count": 24,
"next_cursor": "WyJ0aWNrZXQiXQ"
}
Filter row membership
Filters always constrain which rows appear in data. total_count reflects the number of visible rows after filtering, not the unfiltered total. Export endpoints must use the exact same filtered row set as the JSON list.
Detail endpoints
GET /resources/{id} returns the full resource object for a single known resource. Use it when you need the complete record directly by ID -- for example, loading a profile page, refreshing one record after an update, or resolving a link from another domain.
The detail shape may extend the list shape with additional fields such as audit timestamps, long-form text, joined relationships, or debugging data that are unnecessary in the collection view.
One-sentence rule
List endpoints return the list shape; detail endpoints return the full shape.
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. Default: 50. Max: 200. |
next_cursor | string | null | Returned in response. null means no more pages. |
Export endpoints
Exports are a table projection of a collection endpoint, not a separate reporting domain. Add an export only when the list endpoint backs a real table/list workflow that users need to download.
Path rule:
GET /resources/export
GET /parents/{parent_id}/resources/export
GET /clients/{client_id}/analytics/apps/utilization/export
Use /export as a sibling of the list endpoint. Do not overload the paginated JSON list with format=csv, and do not add exports to detail or summary endpoints.
Contract rules:
- The export reuses the same filter, search, date-range, and sort parameters as the list endpoint.
- The export returns the full filtered result set, so
cursor,page_size, and other pagination-only parameters do not apply. - Row order must follow the same
sort_by/sort_ordersemantics as the list endpoint. - The exported shape is a flattened, file-friendly projection of the list rows. Nested objects should be flattened into stable columns (
vendor_name,category_names,unique_users, etc.), not emitted as raw JSON blobs. - If the list includes comparison metrics, export them as separate columns (for example
*_value,*_change_pct,*_compare_value). - If the endpoint supports only one export format, make that format implicit. For example, a CSV export endpoint should simply return
text/csv. - Return a downloadable response with
Content-Disposition: attachment. The exact filename pattern is endpoint-specific, but it should be stable and derived from the exported resource and period.
One-sentence rule
A list export is the same filtered/sorted table as the JSON collection endpoint, returned as a downloadable file from a sibling
/exportroute.
Query Cost Awareness
Every embedded relationship or computed field in a DTO typically maps to a JOIN or subquery in the backing SQL. The Embedding Rule governs what to embed; this section governs when to pause and evaluate the cost of doing so.
List endpoints are the hot path
List endpoints execute on every page load, run against potentially large result sets, and are the most sensitive to join count. Detail endpoints serve a single row by primary key and tolerate more joins.
Default stance: fields that require an additional join should live on the detail shape only unless there is a clear product need for them in the list (sorting, filtering, or essential display).
Join budget
Use the following thresholds as review triggers, not hard limits:
| Shape | Comfortable | Review required |
|---|---|---|
| List endpoint | ≤ 3 joins | 4+ joins |
| Detail endpoint | ≤ 6 joins | 7+ joins |
When an endpoint exceeds the review threshold, the design must be explicitly discussed before implementation. Options include:
- Moving the field to the detail shape only
- Exposing the data as a separate sub-resource endpoint
- Denormalizing the value into the source table (if it is stable and read-heavy)
When adding a field to an existing endpoint
Before adding a field to a DTO that is already implemented:
- Check the current join count of the backing query.
- If the new field adds a join, default to adding it to the detail shape only.
- If it must be on the list shape, justify why (sorting, filtering, or always-needed display) and confirm the total join count stays within budget.
- If the total exceeds the review threshold, flag it for discussion before proceeding.
What this section does NOT cover
This section is about join count as a design-time heuristic. It does not replace database-level query analysis (execution plans, index coverage, row estimates). When in doubt, profile the query.
One-sentence rule
Treat every join as a cost. List shapes pay the highest price -- default new joins to the detail shape unless the product need justifies them on the list.
Naming Conventions
Path parameters
Path parameters must use resource-specific names, not a generic id.
/clients/{client_id}/users/{user_id} // correct
/clients/{client_id}/devices/{device_id} // correct
/catalog-apps/{catalog_app_id}/url-patterns // correct
/clients/{client_id}/users/{id} // incorrect
/catalog-apps/{id}/url-patterns // incorrect
This is the standard across the API because path parameters are part of the contract surface and must remain unambiguous in generated clients, copied examples, logs, and error payloads.
When this document uses shorthand examples like /resources/{id} or /users/{id}, treat them as illustrative only, not as the naming standard for real endpoint definitions.
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}).
Bulk state transitions
The relationship-based bulk pattern above (/members, /favorites) is for adding and removing associations. A second bulk pattern exists for state transitions -- changing an entity's status or mode.
For state transitions, use POST to a verb sub-path that names the target state:
POST /clients/{client_id}/devices/enable ← set state to enabled
POST /clients/{client_id}/devices/disable ← set state to disabled
POST /clients/{client_id}/devices/delete ← set state to deleted
POST /clients/{client_id}/users/archive ← set status to archived
POST /clients/{client_id}/users/activate ← set status to active
POST /clients/{client_id}/users/delete ← set status to deleted
The request body contains entity IDs (and optionally related settings). The response uses the standard bulk-result envelope with succeeded / failed arrays. Domain docs may give this envelope a resource-specific name (for example BulkUserControlResult) as long as the wire shape stays the same.
When to use which pattern:
| Scenario | Pattern | Example |
|---|---|---|
| Add/remove a relationship between entities | Noun sub-path | POST /departments/{id}/members |
| Transition an entity to a new state | Verb sub-path | POST /devices/enable |
Both patterns share the same traits: POST method, IDs in the body, idempotent behavior, per-entity failure reporting.
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_dateandend_dateparameters - Include
granularityonly when it materially changes the response - 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, "change_value": 5 },
"apps_discovered": { "value": 12, "change_pct": 50.0, "compare_value": 8, "change_value": 4 },
"apps_dropped_off": { "value": 4, "change_pct": 33.3, "compare_value": 3, "change_value": 1 }
}
At a glance
| CRUD summary | Analytics summary | |
|---|---|---|
| Namespace | Resource domain (/users, /devices) | Analytics domain (/analytics/...) |
| Date params | None | start_date, end_date (granularity only when it changes response) |
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_date": "2024-01-01",
"end_date": "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 session count, 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
granularity is required only when changing it changes the value or structure of the response.
When granularity matters
granularity affects how metrics are calculated — for example, whether "average daily session count" is computed over days, weeks, or months. When a response is strictly whole-period counts (or otherwise does not change with bucket size), omit the parameter instead of accepting it as a no-op.
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 endpoints where 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,
"change_value": 5
}
| 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. |
change_value | number | Yes | Absolute metric change vs comparison period (abs(value - compare_value)). null if no comparison was requested. Always included in the response; only the value is nullable. |
Rules
- Use
MetricWithDeltaDtowhenever a metric field is accompanied by a_change_pctsibling. - The object is never
nullitself — onlychange_pct,compare_value, andchange_valueinside it are nullable. For the rare case wherevalueitself can benull, use NullableMetricWithDeltaDto. - 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, "change_value": 5 }
}
NullableMetricWithDeltaDto
A variant where value itself is nullable. Used when the metric may have no data for the period (e.g. avg_time_ms_per_day when no active days exist).
{
"value": null,
"change_pct": null,
"compare_value": null,
"change_value": null
}
| Field | Type | Nullable | Description |
|---|---|---|---|
value | number | Yes | The metric value for the primary period. null when there is no data to aggregate (e.g. zero active days). |
change_pct | number | Yes | Percentage change vs comparison period. null if no comparison was requested or if either period has no data. |
compare_value | number | Yes | The raw metric value for the comparison period. null if no comparison was requested or if the comparison period has no data. |
change_value | number | Yes | Absolute metric change vs comparison period (abs(value - compare_value)). null if no comparison was requested or if either period has no data. |
Use NullableMetricWithDeltaDto only when the metric itself can be undefined — i.e. when a zero and an absence of data are semantically different. For most metrics (counts, totals), zero is a valid value and MetricWithDeltaDto is correct.
Response Period Object
All analytics responses use the same period shape with start_date / end_date field names. This applies to both time-series and aggregate endpoints and avoids mixing start/end aliases across the domain.
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.
Analytics Vocabulary
Use the following canonical terms across all analytics endpoints, DTOs, and docs.
session_count
The contract term for session-based interaction metrics is session_count (not interactions).
Period field names
Use start_date and end_date for period boundaries everywhere, including time-series responses.
usage_level
usage_level is derived from a shared Usage Score and thresholds:
Usage Score = (0.35 × A) + (0.65 × R)
Where:
| Symbol | Name | Formula |
|---|---|---|
| U | Distinct users | Distinct users of the app in the period |
| N | Total org users | All users for the client |
| A | User Adoption Rate | U / N |
| D | Active days | Days the app was used in the period |
| T | Working days | Distinct days where any app usage was recorded for the client in the period |
| R | Activity Rate | D / T |
Thresholds:
| Usage Level | Score Range |
|---|---|
heavy | >= 0.5 |
medium | 0.2 -- 0.49 |
light | < 0.2 |
If the app has no activity in the period (or the denominators are zero), return usage_level: null.
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 |