Skip to main content

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

  1. Bounded -- the relationship has a small, predictable maximum (typically ≤5 items per parent)
  2. Always needed for display -- every consumer of that endpoint needs this data to render meaningfully without a follow-up request
  3. 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

RelationshipBounded?Always needed?Static?Decision
User → DepartmentsYes (1–3)YesYesEmbed in User
User → DevicesNo (1–10+)NoNoGET /users/{id}/devices
User → AppsNo (10–100+)NoNoGET /users/{id}/apps
App → CategoriesYes (1–5)YesYesEmbed in App
App → VendorYes (always 1)YesYesEmbed in App
App → URL PatternsNo (10–50+)NoYesGET /catalog-apps/{id}/url-patterns
App → Desktop AliasesBorderline (1–5)NoYesGET /catalog-apps/{id}/desktop-aliases
Department → UsersNo (100s–1000s)NoNoGET /users?department_id=
Client → PartnerYes (always 1)YesYesEmbed in Client
Client → Enrollment TokenYes (0–1 active)YesYesEmbed 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 entityNamingExample
Strict subset (fewer fields)EntityRefUserRef, DeviceRef, DepartmentRef
Identical (same fields)EntityCatalogVendor, CatalogCategory, Partner

One-sentence rule

Name it Ref only 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.

ParameterTypeDescription
cursorstringOpaque cursor from a previous response. Omit on first request.
page_sizeintegerItems per page. Each endpoint defines its own default and maximum.
next_cursorstring | nullReturned 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.

FieldMaps toDescription
created_atcreated_atWhen the record was first created
updated_atupdated_atWhen the record was last modified

Omitted internal fields

The following fields are present in the DB but never exposed in v2 DTOs:

DB ColumnReason omitted
client_idPresent in the URL path
partner_idInternal tenant field
created_byInternal audit field
updated_byInternal audit field

Timestamps

All timestamps are ISO 8601 with UTC timezone: 2026-02-24T21:23:53.082Z


HTTP Methods

MethodUsage
GETRead resources (safe, idempotent)
POSTCreate resources or bulk relationship operations (both add and remove)
PATCHPartial update -- only send fields you want to change
DELETERemove 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 period object 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, and granularity parameters
  • Always includes a period object in the response (see Response Period Object)
  • Supports optional comparison period (compare_start_date, compare_end_date); change percentages are null when 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 summaryAnalytics summary
NamespaceResource domain (/users, /devices)Analytics domain (/analytics/...)
Date paramsNonestart_date, end_date, granularity required
period in responseNoYes
Comparison periodNoOptional
Sibling list endpointNoYes (always)
ReflectsCurrent stateComputed 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

ParameterTypeRequiredDescription
start_datestringYesStart of date range (YYYY-MM-DD)
end_datestringYesEnd of date range, inclusive (YYYY-MM-DD)
metricstringYesWhich 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-pathRoleReturns
/{kind}ListPaginated table of items with per-row metrics. Standard { period, data, total_count, next_cursor } envelope.
/{kind}/summarySummaryFlat aggregate counts for dashboard cards. No pagination.
/{kind}/rankingsRankingsNamed 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.
  • /summary and /rankings are 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 period object. 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

ValueDescription
dailyMetrics computed per day
weeklyMetrics computed per ISO week
monthlyMetrics computed per calendar month
quarterlyMetrics 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
}
FieldTypeNullableDescription
valuenumberNoThe metric value for the primary period
change_pctnumberYesPercentage change vs comparison period. null if no comparison was requested.
compare_valuenumberYesThe 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 MetricWithDeltaDto whenever a metric field is accompanied by a _change_pct sibling.
  • The object is never null itself — only change_pct and compare_value inside it are nullable.
  • The field name is the metric name only (e.g. apps_used, unique_users), not a flat pair like apps_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"
}
}
FieldTypeNullableDescription
start_datestringNoStart of the primary period (YYYY-MM-DD)
end_datestringNoEnd of the primary period (YYYY-MM-DD)
compare_start_datestringYesStart of comparison period. null if no comparison requested.
compare_end_datestringYesEnd of comparison period. null if no comparison requested.

Rules

  • Field names mirror the query parameters exactly (start_date, not period_start).
  • When no comparison dates were provided, compare_start_date and compare_end_date are null (not omitted).
  • The period object is included on both summary and list variants of an analytics endpoint. CRUD summaries do not include period — 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
ParameterTypeDescription
category_idsstringComma-separated catalog category UUIDs
department_idsstringComma-separated department UUIDs
vendor_idsstringComma-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"
}
}
}
FieldTypeDescription
error.codestringMachine-readable error code (snake_case)
error.messagestringHuman-readable description
error.detailsobject | nullAdditional context about the specific failure