Skip to main content

Usage Profiles

A usage profile bundles an AI credit budget with a set of allowed model tiers, then is assigned to teams (or agents) to govern what their members can do. See Usage & Credits for how profiles fit into enforcement.

These routes do not require AI to be enabled on the tenant. They gate purely on permission. Reading profiles (GET /api/usage-profiles and GET /api/usage-profiles/{id}) requires permission.tenant.superuser. Creating, updating, and deleting profiles require permission.usage.write (which delegates to the billingManage global permission; superusers pass via the global-permission fallback). The per-team assignment routes require permission.billing.manage (also billingManage).

Profile fields

FieldTypeDescription
idstring (UUID)Profile identifier
namestringHuman-readable name
slugstringMachine identifier, format ^[a-z][a-z0-9-]*$, immutable after creation
descriptionstringDescription of the profile's intent (NOT NULL)
creditCapPerMonthinteger | nullPer-actor monthly credit ceiling. null = unlimited, 0 = hard stop, positive = capped
allowedModelTiersstring[]Subset of everyday, advanced, strategic
isUnlimitedbooleanDerived getter: true when creditCapPerMonth is null
tenantstringOwning tenant slug
createdAt / updatedAtdateTimestamps
Effective profile resolution

A user's effective profile is resolved from their team memberships. When a user belongs to multiple teams with different profiles, the result is merged to the most permissive: the union of allowedModelTiers and the highest creditCapPerMonth (null/unlimited always wins). If no team assignment applies, the tenant's default profile is used.


GET /api/usage-profiles

List all usage profiles for the tenant.

Authentication: Required (session)

Pre-blocks: permission.tenant.superuser

Response:

A paged envelope { _links, items, start, count, total } (with a HAL _links.self), not a bare array and not a HAL _embedded collection. Each row carries the isUnlimited getter (true when creditCapPerMonth is null).

Row order is not guaranteed

The route uses the model default scope (row-level security only, no explicit ORDER BY), so callers should not rely on row order. System-seeded Standard and Premium profiles are inserted before any tenant-created profile, but applying a stable sort is the caller's job.

List usage profilesGET /api/usage-profiles
GET /api/usage-profiles is a HAL collection embedding `inf:usage-profile`. The route uses the model defaultScope (RLS only, no explicit ORDER BY), so callers should not rely on row order; system-seeded Standard/Premium profiles are inserted before any tenant-created profile but a stable sort is the caller’s job. Requires permission.tenant.superuser. Each row includes the `isUnlimited` getter (true when creditCapPerMonth is null).
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage-profiles"
}
},
"items": [
{
"isUnlimited": false,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Scratch Profile",
"slug": "scratch-profile",
"description": "Disposable profile used only for the delete example",
"creditCapPerMonth": 1000,
"allowedModelTiers": [
"everyday"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
},
{
"isUnlimited": false,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000002",
"name": "Standard",
"slug": "standard",
"description": "Everyday + advanced AI models for routine and harder tasks",
"creditCapPerMonth": 5000,
"allowedModelTiers": [
"everyday",
"advanced"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
},
{
"isUnlimited": false,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000003",
"name": "Premium",
"slug": "premium",
"description": "Adds strategic-tier models for complex analysis",
"creditCapPerMonth": 15000,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
},
"… 2 more items (5 total) — omitted from docs"
],
"start": 0,
"count": 5,
"total": 5
}
Captured from the API examples test suite; ids and timestamps are normalized.

POST /api/usage-profiles

Create a new usage profile.

Authentication: Required (session)

Pre-blocks: permission.usage.write

Request Body:

The payload is stripUnknown, so unrecognized fields are dropped rather than rejected.

FieldTypeRequiredDescription
namestringYesProfile name
slugstringYesMachine identifier, ^[a-z][a-z0-9-]*$
descriptionstringYesProfile description
creditCapPerMonthinteger | nullNoMonthly credit cap (default null = unlimited)
allowedModelTiersstring[]NoAllowed tiers (default []; valid tiers: everyday, advanced, strategic)

Example Request:

{
"name": "Analysts",
"slug": "analysts",
"description": "Strategic models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": ["everyday", "advanced", "strategic"]
}

Response:

Responds 200 with the created profile (the handler returns the row directly; it does not use .created(), so the status is 200, not 201).

Create a usage profilePOST /api/usage-profiles
Requires permission.usage.write (billingManage; superusers pass). Payload { name, slug, description } are required; slug matches ^[a-z][a-z0-9-]*$; creditCapPerMonth defaults null (unlimited), allowedModelTiers defaults [] (valid tiers: everyday, advanced, strategic). Payload is stripUnknown. The handler returns the created profile with status 200 (it does NOT use .created()/201). A duplicate slug 409s.
Request body
{
"name": "Analysts",
"slug": "analysts",
"description": "Strategic models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
]
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage-profiles"
}
},
"isUnlimited": true,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Analysts",
"slug": "analysts",
"description": "Strategic models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"updatedAt": "2026-01-15T16:20:00.000Z",
"createdAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.
Duplicate slug

The route checks the slug before creating. A slug already used within the tenant answers 409 Conflict.

Create a profile with a duplicate slugPOST /api/usage-profiles
The route checks findBySlug before create; a slug already used in the tenant answers 409 Conflict. Slug `analysts` was created in the previous example.
Request body
{
"name": "Analysts (dupe)",
"slug": "analysts",
"description": "Second profile reusing an existing slug"
}
Response · 409
{
"statusCode": 409,
"error": "Conflict",
"message": "Profile with slug 'analysts' already exists"
}
Captured from the API examples test suite; ids and timestamps are normalized.

GET /api/usage-profiles/{id}

Retrieve a single usage profile.

Authentication: Required (session)

Pre-blocks: permission.tenant.superuser, usageProfile.lookup (a missing id answers 404)

Path Parameters:

ParameterTypeDescription
idstring (UUID)Profile id

Response:

Returns the profile row (including the isUnlimited getter) with its HAL inf:usage-profile link.

Get a usage profileGET /api/usage-profiles/00000000-0000-4000-8000-000000000001
GET /api/usage-profiles/{id} requires permission.tenant.superuser and resolves the profile via usageProfile.lookup (a missing id 404s). Returns the profile row (including the isUnlimited getter) with its HAL `inf:usage-profile` link.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage-profiles/00000000-0000-4000-8000-000000000001"
}
},
"isUnlimited": true,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Analysts",
"slug": "analysts",
"description": "Strategic models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.

PUT /api/usage-profiles/{id}

Update a usage profile. All fields are optional; only the supplied fields are changed.

Authentication: Required (session)

Pre-blocks: permission.usage.write, usageProfile.lookup

Request Body:

The body is stripUnknown. slug is immutable and silently dropped.

FieldTypeDescription
namestringProfile name
descriptionstringProfile description (must be a real value if included; the column is NOT NULL)
creditCapPerMonthinteger | nullMonthly credit cap
allowedModelTiersstring[]Allowed tiers

Example Request:

{
"name": "Senior Analysts",
"creditCapPerMonth": 20000,
"allowedModelTiers": ["everyday", "advanced", "strategic"]
}

Response:

Responds 200 with the reloaded profile. Runs in a database transaction.

Update a usage profilePUT /api/usage-profiles/00000000-0000-4000-8000-000000000001
PUT /api/usage-profiles/{id} requires permission.usage.write. All fields are optional (partial update) and the body is stripUnknown — `slug` is immutable and silently dropped. `description`, if included, must be a real value (NOT NULL column). Runs in a transaction and returns the reloaded profile (200).
Request body
{
"name": "Senior Analysts",
"creditCapPerMonth": 20000,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
]
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage-profiles/00000000-0000-4000-8000-000000000001"
}
},
"isUnlimited": false,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Senior Analysts",
"slug": "analysts",
"description": "Strategic models, no monthly cap",
"creditCapPerMonth": 20000,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
Captured from the API examples test suite; ids and timestamps are normalized.

DELETE /api/usage-profiles/{id}

Delete a usage profile.

Authentication: Required (session)

Pre-blocks: permission.usage.write, usageProfile.lookup

Response:

Responds 200 with the body { success: true }, not 204.

Delete a usage profileDELETE /api/usage-profiles/00000000-0000-4000-8000-000000000001
DELETE /api/usage-profiles/{id} requires permission.usage.write. Returns 200 with body { success: true }. It 409s if the profile is assigned to any team/agent, or if it is the tenant default (Tenant.defaultAiProfileId).
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage-profiles/00000000-0000-4000-8000-000000000001"
}
},
"success": true
}
Captured from the API examples test suite; ids and timestamps are normalized.
Deletion guards

A profile cannot be deleted while it is in use. The route answers 409 Conflict if the profile is assigned to any team or agent, or if it is the tenant default (Tenant.defaultAiProfileId).


GET /api/teams/{id}/ai/profile

Get the usage profile currently assigned to a team.

Authentication: Required (session)

Pre-blocks: team.lookup, permission.billing.manage

Path Parameters:

ParameterTypeDescription
idstringTeam id (teams use string ids, not UUIDs)

Response:

Returns { profileId, profile } (with a HAL _links.self). When no profile is assigned, both fields are null. When a profile is assigned, profile is the full Usage Profile row.

Get a team’s usage profile (none assigned)GET /api/teams/order-desk/ai/profile
GET /api/teams/{id}/ai/profile resolves the team via team.lookup and requires permission.billing.manage. When no profile is assigned, both fields are null: { profileId: null, profile: null }. Status 200.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/teams/order-desk/ai/profile"
}
},
"profileId": null,
"profile": null
}
Captured from the API examples test suite; ids and timestamps are normalized.

After a profile is assigned:

Get a team’s usage profile (assigned)GET /api/teams/order-desk/ai/profile
After assignment, GET /api/teams/{id}/ai/profile returns { profileId, profile } where `profile` is the full UsageProfile row. Status 200.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/teams/order-desk/ai/profile"
}
},
"profileId": "00000000-0000-4000-8000-000000000001",
"profile": {
"isUnlimited": true,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Premium",
"slug": "premium-team",
"description": "Adds strategic-tier models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

PUT /api/teams/{id}/ai/profile

Assign a usage profile to a team, or unassign it.

Authentication: Required (session)

Pre-blocks: team.lookup, permission.billing.manage

Request Body:

FieldTypeRequiredDescription
profileIdstring (UUID) | nullYesProfile to assign, or null to unassign

Response:

Responds 200 with the assigned { profileId, profile }. A non-null profileId is validated.

Assign a usage profile to a teamPUT /api/teams/order-desk/ai/profile
PUT /api/teams/{id}/ai/profile requires permission.billing.manage. Body { profileId } is required and may be null. A non-null profileId is validated (unknown -> 404). Returns the assigned { profileId, profile } with status 200.
Request body
{
"profileId": "00000000-0000-4000-8000-000000000001"
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/teams/order-desk/ai/profile"
}
},
"profileId": "00000000-0000-4000-8000-000000000001",
"profile": {
"isUnlimited": true,
"tenant": "acme",
"id": "00000000-0000-4000-8000-000000000001",
"name": "Premium",
"slug": "premium-team",
"description": "Adds strategic-tier models, no monthly cap",
"creditCapPerMonth": null,
"allowedModelTiers": [
"everyday",
"advanced",
"strategic"
],
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z"
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

Passing { profileId: null } deletes the assignment and returns { profileId: null, profile: null }:

Unassign a team’s usage profilePUT /api/teams/order-desk/ai/profile
PUT /api/teams/{id}/ai/profile with { profileId: null } deletes the assignment and returns { profileId: null, profile: null }. Status 200.
Request body
{
"profileId": null
}
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/teams/order-desk/ai/profile"
}
},
"profileId": null,
"profile": null
}
Captured from the API examples test suite; ids and timestamps are normalized.
Unknown profile

A non-null profileId that matches no profile answers 404 Not Found with the message Usage profile not found.

Assign a non-existent profile to a teamPUT /api/teams/order-desk/ai/profile
A non-null profileId that matches no profile answers 404 Not Found (boom.notFound). The id below is a valid uuid that does not exist.
Request body
{
"profileId": "00000000-0000-4000-8000-000000000001"
}
Response · 404
{
"statusCode": 404,
"error": "Not Found",
"message": "Usage profile not found"
}
Captured from the API examples test suite; ids and timestamps are normalized.