Skip to main content
Version: next

Management API

The management API is used by the dashboard and CLI. All endpoints require a JWT session token.

Base URL: http://localhost:3000/api

Authentication: Authorization: Bearer <jwt>

Obtain a JWT via POST /api/auth/login.


Authentication​

Login​

POST /api/auth/login
{ "email": "[email protected]", "password": "your-password" }

Response:

{
"token": "eyJ...",
"refreshToken": "a3f8c2...",
"user": { "id": "uuid", "email": "[email protected]", "role": "admin", "permissions": [] }
}
  • token — short-lived JWT (1 hour). Use as Authorization: Bearer <token> on all other endpoints.
  • refreshToken — opaque token used to obtain new access tokens without re-entering credentials. Store securely; see POST /api/auth/refresh. Rotates on every use.

Refresh​

POST /api/auth/refresh

This endpoint is public (no Authorization header required).

{ "refreshToken": "a3f8c2..." }

Response:

{
"token": "eyJ...",
"refreshToken": "b9d4e1...",
"user": { "id": "uuid", "email": "[email protected]", "role": "admin", "permissions": [] }
}

Issues a new 1-hour access token and a new refresh token (rotation). The previous refresh token is immediately invalidated — replace it with the value returned in the response. Returns 401 if the token is invalid or has already been used/revoked.

note

The CLI and dashboard perform this refresh automatically — the CLI tries silently when the token expires or is within 5 minutes of expiry; the dashboard retries on any 401 response. Both clients persist the new refresh token automatically.


Setup​

Check Setup Status​

GET /api/setup/status

Returns { "configured": false } if no admin account exists yet; { "configured": true } otherwise.

Create First Admin​

POST /api/setup/first-admin

Only available when configured: false.

{ "email": "[email protected]", "password": "secure-password" }

Me (Current User)​

Get Profile​

GET /api/me

Update Profile​

PUT /api/me
{ "email": "[email protected]", "currentPassword": "old", "newPassword": "new" }

Models​

List Models​

GET /api/models

Create Model​

POST /api/models
{
"id": "gpt-5-mini",
"provider": "openai",
"apiKey": "sk-...",
"inputPrice": 0.25,
"outputPrice": 2.0,
"contextWindow": 128000,
"capabilities": ["functionCalling", "json"],
"fieldOverrides": { "inputPrice": true }
}

Request fields:

  • fieldOverrides (optional) — object mapping field names to true to lock them against catalog sync. Supported fields: inputPrice, outputPrice, cachePrice, cacheWritePrice, pricingTiers, contextWindow, capabilities.

Response includes:

  • catalogDefaults (if model is in catalog) — last known catalog values for each tracked field
  • fieldOverrides (if any) — which fields are locked against auto-sync

Get Model​

GET /api/models/:id

Response includes:

  • catalogDefaults (if model is in catalog) — object with keys: inputPrice, outputPrice, cachePrice, cacheWritePrice, pricingTiers, contextWindow, capabilities (whichever were synced from the catalog)
  • fieldOverrides (if any) — object with field names as keys, all values set to true

Update Model​

PUT /api/models/:id

Request body (all fields optional):

{
"inputPrice": 0.5,
"fieldOverrides": { "inputPrice": true, "contextWindow": false }
}

Changing a field value automatically sets fieldOverrides[fieldName] = true. To unlock a field for auto-sync, send "fieldOverrides[fieldName] = false". When all overrides are cleared, the fieldOverrides object is removed from the model config.

Delete Model​

DELETE /api/models/:id

Rotate Model API Key​

POST /api/models/:id/apikey
{ "apiKey": "sk-NEW_KEY" }

Projects​

List Projects​

GET /api/projects

Create Project​

POST /api/projects
{
"name": "My App",
"slug": "my-app",
"defaultTimeoutMs": 30000,
"models": ["gpt-5-mini"]
}

Get Project​

GET /api/projects/:slug

Update Project​

PUT /api/projects/:slug

On PUT, the guardrails and pii fields are optional: omit a field to leave it unchanged, send null to clear it, or send an object to replace it.

Content Guardrails and PII (project fields)​

A project may carry two optional security blocks, accepted by both POST /api/projects and PUT /api/projects/:slug and validated server-side. Use PATCH /api/projects/:id/guardrails for partial updates (guardrails or PII only).

Guardrails​

Guardrails evaluate each request and/or response against an ordered list of independent rules; each enabled rule is evaluated in sequence and triggers its configured actions independently.

{
"guardrails": {
"detectInjection": true,
"rules": [
{
"type": "regex",
"enabled": true,
"target": "request",
"block": true,
"log": false,
"config": { "patterns": ["competitor", "rival\\s+product"] }
},
{
"type": "semantic",
"enabled": true,
"target": "both",
"block": true,
"log": true,
"config": {
"embeddingModelId": "text-embedding-3-small",
"fallbackModelIds": ["text-embedding-3-large"],
"examples": ["example of blocked content"],
"threshold": 0.82
}
},
{
"type": "topic",
"enabled": true,
"target": "request",
"inject": true,
"config": {
"modelId": "claude-haiku-4-5",
"fallbackModelIds": ["gpt-4-mini"],
"allowedTopics": "Customer support questions about our product only",
"threshold": 0.5
}
},
{
"type": "topic",
"enabled": true,
"target": "response",
"config": {
"modelId": "claude-haiku-4-5",
"fallbackModelIds": ["gpt-4-mini"],
"allowedTopics": "Customer support questions about our product only",
"threshold": 0.5
}
},
{
"type": "moderation",
"enabled": true,
"target": "request",
"inject": false,
"config": {
"modelId": "claude-haiku-4-5",
"fallbackModelIds": ["gpt-4-mini"],
"threshold": 0.5,
"systemPrompt": "You are a content safety classifier for customer support conversations."
}
}
]
}
}

Rule types:

TypeTargetConfig fieldsDescription
regexrequest / response / bothpatterns: string[]Block text matching any regex pattern (case-insensitive)
semanticrequest / response / bothembeddingModelId, fallbackModelIds?: string[], examples: string[], threshold?: number (default 0.82)Block semantically similar content using embedding cosine similarity; fallback models tried in order if primary fails
topicrequest / response / both (or omitted for inject-only)modelId (optional for inject-only), fallbackModelIds?: string[], allowedTopics: string, threshold?: number (default 0.5)LLM judge: block content not matching the allowed topics description; fallback judges tried in order if primary fails. When target is omitted and inject: true, injects only (no judge)
moderationrequest / response / both (or omitted for inject-only)modelId (optional for inject-only), fallbackModelIds?: string[], threshold?: number (default 0.5), systemPrompt?: stringLLM judge: block harmful content (hate, violence, sexual, self-harm); fallback judges tried in order if primary fails. When target is omitted and inject: true, injects only (no judge)

Rule fields:

  • enabled?: boolean: default true when absent; when false, the rule is skipped
  • target?: 'request' | 'response' | 'both': which side(s) the judge evaluates. Required for regex/semantic. Optional for topic/moderation (omit to enable inject-only). When set, a judge model is required (config.modelId).
  • inject?: boolean: (topic/moderation only) append this rule's instruction to the request system prompt. Independent of target. When true on a topic/moderation rule, the rule steers the model without calling the judge (soft enforcement).
  • block?: boolean: auto-managed when target is set (always true for judged rules); accepted on input for backward compatibility but ignored.
  • log?: boolean: auto-managed when target is set (always true for judged rules); accepted on input for backward compatibility but ignored.
  • blockMessage?: string: the block message now comes from the judge's reason field. This field is still accepted on input for backward compatibility but is no longer surfaced by the dashboard or CLI.
  • useJudgeResponse?: boolean: auto-managed (always true for judged rules); accepted on input for backward compatibility but ignored.

Fallback models (topic/moderation/semantic only):

  • fallbackModelIds?: string[]: ordered list of model IDs to try if the primary model is unavailable or returns an error (other than budget/usage exceeded). Fallbacks are tried in order. If a model returns a usage or budget-exceeded error, the rule fails immediately without trying further fallbacks (fail-closed on budget).

Judge scoring (topic/moderation with judge):

  • The judge responds with {"reason": "<explanation>", "score": <0.00-10.00>}. The reason is in the user's language and becomes the block message. The score (0.00-10.00) is normalized to 0-1 before threshold comparison. Topic rules trigger when normalized score < threshold (off-topic); moderation rules trigger when normalized score > threshold (harmful).

detectInjection flag:

  • When true, run a built-in prompt-injection detector on every request before rule evaluation. A hit blocks and is logged. Injection detection does not support custom messages.

Target values: request evaluates the user messages; response evaluates the model output; both evaluates both sides; omitted (for topic/moderation) skips the judge entirely when inject: true.

Streaming interaction: When any enabled rule has target as response or both (judge on response), the entire response must be buffered before the block decision is made. In this case, streaming is disabled for the request, and the client receives the full response as a single chunk.

PII​

{
"pii": {
"policies": [
{
"enabled": true,
"target": "both",
"entities": ["EMAIL", "PHONE", "CREDIT_CARD", "SSN", "IBAN"],
"outputBufferSize": 30
},
{
"enabled": true,
"target": "request",
"entities": ["EMAIL", "PHONE"],
"customPatterns": ["\\b[A-Z]{2}[0-9]{6,8}\\b"]
}
]
}
}

PII configuration contains a list of policies. All enabled policies are merged per-direction at scrub time (request scrubbing merges policies with target: request or target: both; response scrubbing merges policies with target: response or target: both).

Policy fields:

  • enabled?: boolean: default true when absent; when false, this policy is skipped
  • target: 'request' | 'response' | 'both': which side(s) to scrub
  • entities?: PiiEntity[]: entity types to detect (EMAIL, PHONE, CREDIT_CARD, SSN, IBAN). Defaults to all types when omitted.
  • customPatterns?: string[]: additional regex patterns to scrub (case-insensitive)
  • outputBufferSize?: number: (response scrubbing only) suffix buffer size in characters (10-500, default 30). Used to catch patterns spanning chunk boundaries when streaming. When multiple response policies are active, the largest value wins.

Matched values in message content are replaced with typed placeholders ([EMAIL], [PHONE_NUMBER], [CREDIT_CARD], [SSN], [IBAN]) before the request is forwarded to the provider and before the response is returned to the caller.

When a guardrail triggers or PII is redacted, the usage record gains guardrailTriggered (the rule type) and/or piiRedacted (the list of redacted entity types).

Guardrail judge calls and usage attribution​

Security rules that call a model (semantic embedding, topic judge, moderation judge) are tracked as separate usage records with callType: "guardrail". These records are attributed to the same project and token as the originating request and are subject to the same budget limits — an over-budget judge call fails the same as an over-budget completion. The records appear in GET /api/usage alongside completion and routing records and are broken out in the usage summary (see Query Usage Records).

Consumer Impact​

PII scrubbing modifies request content in-flight. The model receives placeholders instead of original sensitive values and responds based on the modified message. Disable specific entity types if your application requires the model to see the original values.

A rule with block: true returns a wire-faithful HTTP 200 response when it triggers. It does not return HTTP 400. See LLM Proxy: Guardrail block wire format.

A rule with log: true (and block unset) forwards the request to the model with no consumer-visible impact; the match is recorded on the usage record for audit purposes. block and log are independent, so a rule may do both: block the request and record the match.

Delete Project​

DELETE /api/projects/:slug

Project Tokens​

List Tokens​

GET /api/projects/:slug/tokens

Create Token​

POST /api/projects/:slug/tokens
{
"name": "production",
"tags": {
"environment": "prod",
"team": "backend"
},
"limits": [
{
"metric": "cost",
"limit": 10.00,
"window": "monthly",
"mode": "extend"
}
]
}

Fields:

  • name — token name (required)
  • tags — arbitrary key-value metadata attached to the token (optional). Tags are included in every usage record created with this token.
  • limits — array of per-token spending limits (optional)

Response includes the token value in plain text — returned once only. The response also includes the tags object.

Update Token​

PUT /api/projects/:slug/tokens/:tokenId
{
"tags": {
"environment": "staging"
}
}

Fields:

  • tags — replace the token's tags. Pass an empty object {} to clear all tags (optional).

Delete Token​

DELETE /api/projects/:slug/tokens/:tokenId

Project Members​

List Members​

GET /api/projects/:slug/members

Add Member​

POST /api/projects/:slug/members
{ "userId": "user-uuid", "role": "viewer" }

Update Member Role​

PUT /api/projects/:slug/members/:userId
{ "role": "editor" }

Remove Member​

DELETE /api/projects/:slug/members/:userId

Users​

List Users​

GET /api/users

Create User​

POST /api/users
{ "email": "[email protected]", "password": "password", "role": "operator" }

Get User​

GET /api/users/:id

Update User​

PUT /api/users/:id

Delete User​

DELETE /api/users/:id

Roles​

List Roles​

GET /api/roles

Create Role​

POST /api/roles
{
"name": "billing_reviewer",
"permissions": ["project:read", "report:read"]
}

Update Role​

PUT /api/roles/:name

Delete Role​

DELETE /api/roles/:name

Spend Groups​

Org- and team-level budget containers for the hierarchical spend-limit cascade (organisation -> team -> API key). A project belongs to a group via its spendGroupId; groups may nest via parentGroupId. When a request runs through a project that belongs to a group, the group chain's limits are enforced in addition to the per-model/project/token limits. Child limits cannot exceed parent limits (validated on create/update).

A group object:

{
"id": "uuid",
"name": "Engineering",
"limits": [{ "metric": "cost", "windowType": "period", "period": "monthly", "value": 1000 }],
"projectIds": ["proj-1"],
"tokenIds": [],
"parentGroupId": "org-uuid"
}

List Spend Groups​

GET /api/spend-groups

Requires report:read. Returns each group with a usage array (current and remaining consumption per limit, aggregating its own and descendant groups' projects) for the consumption tree view and end-of-period forecast.

Create Spend Group​

POST /api/spend-groups

Requires project:write. Body validated with Zod (name required; limits, projectIds, tokenIds, parentGroupId optional). Returns 400 if a child limit exceeds the parent's matching limit or the parent does not exist.

Update Spend Group​

PUT /api/spend-groups/:id

Requires project:write. Same validation as create. A group cannot be its own parent.

Delete Spend Group​

DELETE /api/spend-groups/:id

Requires project:write. Returns 409 if the group still has child groups.


Usage​

Query Usage Records​

GET /api/usage

Query parameters:

ParameterTypeDescription
fromISO dateStart of range
toISO dateEnd of range
projectstringFilter by project slug
projectIdsstringComma-separated project IDs to filter by
modelstringFilter by model ID
modelIdsstringComma-separated model IDs to filter by
callTypestringcompletion, routing, or guardrail. completion also matches legacy records with no callType field
outcomestringsuccess, error, budget_exceeded, timeout, blocked. error matches records that are neither success nor blocked
limitnumberMax records to return (default: 100)
offsetnumberPagination offset

All filters are applied server-side. projectIds and modelIds accept comma-separated values for multi-value filtering; they combine with (AND) the single-value project and model parameters when both are provided, narrowing the result to records that match every active filter.

Response summary object:

{
"summary": {
"totalCost": 0.1234,
"totalCalls": 200,
"successCalls": 188,
"blockedCalls": 3,
"errorCalls": 9,
"completionCalls": 180,
"routingCalls": 8,
"guardrailCalls": 12,
"completionCost": 0.1200,
"routingCost": 0.0011,
"guardrailCost": 0.0023
},
"byModel": {
"openai/gpt-5-mini": {
"calls": 120,
"cost": 0.08,
"success": 115,
"avgLatencyMs": 820,
"p95LatencyMs": 1540
}
},
"timeline": [],
"records": [],
"pagination": {}
}

The summary object breaks down calls and cost by sub-activity type:

FieldDescription
completionCalls / completionCostMain model inference calls
routingCalls / routingCostLLM policy routing calls (e.g. the llm routing policy)
guardrailCalls / guardrailCostModel calls made by security rules (semantic embedding, topic judge, moderation judge)
blockedCallsRequests blocked by a guardrail rule before reaching any model
errorCallsFailed calls -- does not include blocked calls

Each byModel entry includes:

FieldDescription
callsTotal requests for this model in the period
costTotal cost in USD
successNumber of successful calls
avgLatencyMsMean response time in milliseconds
p95LatencyMs95th-percentile response time in milliseconds

Guardrail judge call records appear in the records array with callType: "guardrail". Blocked request records appear with outcome: "blocked" and callType: "guardrail". The errorCalls counter excludes blocked requests -- a block is a normal guardrail outcome, not a model error.

The outcome filter on GET /api/usage accepts blocked in addition to success, error, and budget_exceeded.

Individual usage records for blocked requests carry guardrailTriggered (the rule identifier, e.g. regex:pattern or injection:dan-mode) and blockedBy (same value; present only when the outcome is blocked). Records where PII was redacted carry piiRedacted with an array of redacted entity types. Records where a guardrail triggered on the flag or log path carry guardrailTriggered but not blockedBy.

Wire format unchanged

The block response sent to the API client is standard and unchanged: HTTP 200, empty content, finish_reason: "content_filter" (OpenAI) or stop_reason: "refusal" (Anthropic). Only observability around the block changed — the usage record is now written and the summary counts it separately.

Get Routing Trace​

GET /api/traces/:id

Returns the routing trace ({ trace: [...] }). All trace entries are stored out-of-band in the trace store; the wire response sent to your API client is never modified.

EntryWhen emitteddetails shape
guardrail:evaluatedAfter every guardrail check on each target, whether or not any rule fires{ target: "request"|"response", rules: [{ rule, outcome, reason? }] }. One object per rule. outcome is passed, triggered, or skipped. reason is set on skipped rules (e.g. judge-failed) and on scoring rules (e.g. regex:<pattern>, semantic:82%). The built-in prompt-injection check appears as rule: "injection".
guardrail:triggeredEmitted whenever a rule triggers (block or log) on the request side{ rule, target, block, log, blockMessage }
guardrail:response-triggeredEmitted whenever a rule triggers (block or log) on the response side{ rule, target, block, log, blockMessage }
pii:evaluatedAfter every PII scrubbing pass, whether or not anything was redacted{ redacted: string[] }. Entity types found (e.g. ["EMAIL"]). Empty array on a clean pass. panel indicates "request" or "response".
pii:scrubbedWhen at least one PII entity was detected and replaced{ entities: string[] }. Entity types that were replaced. Also emitted alongside pii:evaluated on a hit.

Use the x-routerly-trace-id header from any LLM proxy response — present even on blocked responses — to look up its trace:

curl -s http://localhost:3000/api/traces/$TRACE_ID \
-H "Authorization: Bearer <jwt>"

End Users​

List End Users​

GET /api/end-users

Auth: Authorization: Bearer <jwt> (requires report:read)

Query parameters:

ParameterTypeDescription
projectIdstringFilter by project ID (optional)

Response 200:

{
"users": [
{
"userId": "user-123",
"projectId": "proj-uuid",
"firstSeen": "2026-06-15T10:30:00.000Z",
"lastSeen": "2026-06-25T14:45:30.000Z",
"requests": 142,
"totalTokens": 45600,
"totalCost": 0.0456
}
]
}

Response fields:

FieldTypeDescription
userIdstringEnd-user identifier (from body.user in the request)
projectIdstringProject ID this user is attributed to
firstSeenISO 8601Timestamp of first request
lastSeenISO 8601Timestamp of most recent request
requestsnumberTotal request count for this user
totalTokensnumberTotal tokens used (input + output)
totalCostnumberEstimated USD cost

Errors: 403 insufficient permissions


Settings​

Get Settings​

GET /api/settings

Response 200:

{
"port": 3000,
"logLevel": "info",
"defaultTimeoutMs": 30000,
"publicUrl": "https://routerly.example.com",
"providerRepos": [
{
"url": "https://raw.githubusercontent.com/Inebrio/Routerly-Providers/main/",
"enabled": true,
"channel": null
}
]
}

Update Settings​

PUT /api/settings
{
"port": 3000,
"logLevel": "info",
"defaultTimeoutMs": 30000,
"publicUrl": "https://routerly.example.com",
"providerRepos": [
{
"url": "https://raw.githubusercontent.com/Inebrio/Routerly-Providers/main/",
"enabled": true
},
{
"url": "https://your-org.com/catalog/",
"enabled": true
}
]
}

Fields:

  • port, logLevel, defaultTimeoutMs, publicUrl — service configuration (optional)
  • providerRepos — array of provider repository objects (optional)

ProviderRepo object:

  • url — repository endpoint (required)
  • enabled — whether the repo is active (optional, default true)
  • channel — named channel to prefer (optional, e.g. stable, latest)

Catalog​

Manage provider and model catalog repositories and cache.

Get Provider Catalog​

GET /api/providers

Auth: Authorization: Bearer <jwt> (requires model:read)

Returns the full provider catalog fetched from configured repositories.

Response 200:

{
"providers": [
{
"id": "openai",
"name": "OpenAI",
"models": [
{
"id": "gpt-5.2",
"name": "GPT-5.2",
"contextWindow": 128000,
"inputPrice": 1.75,
"outputPrice": 14.0,
"capabilities": ["functionCalling", "json", "vision"]
}
]
},
{
"id": "anthropic",
"name": "Anthropic",
"models": [
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"contextWindow": 200000,
"inputPrice": 5.0,
"outputPrice": 25.0,
"capabilities": ["functionCalling", "json"]
}
]
}
]
}

Errors: 403 insufficient permissions

Get Catalog Status​

GET /api/catalog/status

Auth: Authorization: Bearer <jwt> (requires settings:read)

Returns per-repository status information.

Response 200:

{
"repos": [
{
"url": "https://raw.githubusercontent.com/Inebrio/Routerly-Providers/main/",
"enabled": true,
"resolvedFile": "providers/providers.20260630120000.json",
"updatedAt": "2026-06-30T12:00:00.000Z",
"lastChecked": "2026-06-30T14:30:00.000Z",
"error": null
},
{
"url": "https://your-org.com/catalog/",
"enabled": true,
"resolvedFile": null,
"updatedAt": null,
"lastChecked": "2026-06-30T14:30:00.000Z",
"error": "HTTP 404: Not Found"
}
],
"cachedAt": "2026-06-30T14:30:00.000Z",
"expiresAt": "2026-06-30T14:35:00.000Z"
}

Fields:

  • url — repository endpoint
  • enabled — whether this repo is active
  • resolvedFile — filename of the last successfully fetched catalog (null if never fetched)
  • updatedAt — timestamp from the catalog registry (null if never fetched)
  • lastChecked — when Routerly last attempted to fetch from this repo
  • error — error message if the last fetch failed (null on success)
  • cachedAt — when the current catalog was loaded into memory
  • expiresAt — when the 5-minute cache expires

Errors: 403 insufficient permissions

Refresh Catalog Cache​

POST /api/catalog/refresh

Auth: Authorization: Bearer <jwt> (requires settings:write)

Invalidate the in-memory cache and fetch all enabled repositories immediately.

Response 200:

{ "ok": true, "message": "Catalog refreshed successfully" }

Response 200 (partial failure — some repos errored):

{
"ok": false,
"message": "Catalog refresh completed with errors",
"errors": [
{
"url": "https://your-org.com/catalog/",
"error": "HTTP 404: Not Found"
}
]
}

Errors: 403 insufficient permissions


Notifications​

List Channels​

GET /api/notifications/channels

Auth: Authorization: Bearer <jwt> (requires user:write)

Returns the configured notification channels array.

Create Channel​

POST /api/notifications/channels

Auth: Authorization: Bearer <jwt> (requires user:write)

Request body

{
"provider": "dashboard",
"name": "Budget Alerts",
"events": ["budget.*"],
"targets": {
"roles": ["admin"],
"permissions": [],
"users": ["user-uuid"]
}
}
FieldTypeRequiredDescription
providerstringyesChannel type: smtp, ses, sendgrid, azure, google, webhook, slack, teams, pagerduty, discord, dashboard
namestringnoFriendly label shown in the UI
eventsstring[]noEvent patterns routed to this channel (empty = all). Supports exact names, *, and prefix globs like budget.*
targetsobjectno{ roles, permissions, users } — who receives (empty = everyone). Controls inbox visibility for dashboard and recipient resolution for email channels; ignored for webhook/native channels

Provider-specific fields (e.g. host, apiKey, botToken) pass through alongside these base fields.

Response 201

{
"id": "abc-uuid",
"provider": "dashboard",
"name": "Budget Alerts",
"events": ["budget.*"],
"targets": { "roles": ["admin"] }
}

Errors: 400 invalid body (unknown provider, bad targets shape) · 403 insufficient permissions

Delete Channel​

DELETE /api/notifications/channels/:id

Auth: Authorization: Bearer <jwt> (requires user:write)

Response: 204 No Content

Errors: 404 channel not found · 403 insufficient permissions

Get Channel​

GET /api/notifications/channels/:id

Auth: Authorization: Bearer <jwt> (requires user:write)

Response 200

{
"id": "abc-uuid",
"provider": "smtp",
"name": "Admin Alerts",
"host": "smtp.example.com",
"port": 587,
"fromAddress": "[email protected]",
"fromName": "Routerly",
"events": ["provider.error", "budget.exhausted"],
"targets": { "roles": ["admin"] }
}

Secrets (API keys, passwords, tokens) are masked and shown as null when present in the channel config.

Errors: 404 channel not found · 403 insufficient permissions

Update Channel​

PATCH /api/notifications/channels/:id

Auth: Authorization: Bearer <jwt> (requires user:write)

Request body (all fields optional):

{
"name": "Updated Name",
"events": ["budget.*"],
"targets": { "roles": ["admin"], "permissions": [], "users": [] },
"host": "new-smtp.example.com",
"port": 587,
"password": "new_secret_password",
"apiKey": "new_api_key"
}
  • To clear event filters, send an empty array: "events": []
  • To clear targets, send an empty object: "targets": {}
  • Secret fields (passwords, API keys, tokens) are only updated when explicitly provided and non-empty. Omitting a secret field leaves it unchanged.

Response 200

{
"id": "abc-uuid",
"provider": "smtp",
"name": "Updated Name",
"host": "new-smtp.example.com",
"port": 587,
"fromAddress": "[email protected]",
"events": ["budget.*"],
"targets": { "roles": ["admin"] }
}

Errors: 404 channel not found · 400 invalid body · 403 insufficient permissions

Test Channel​

POST /api/notifications/channels/:id/test

Auth: Authorization: Bearer <jwt> (requires user:write)

Request body (optional)

{ "to": "[email protected]" }

Response 200

{ "ok": true, "message": "Test notification sent" }

Errors: 404 channel not found · 400 send failed (details in message)

Notifications Inbox​

The in-app notification inbox is per-user, available to any authenticated dashboard user (no special permission required). Returns only items for the current user (matched by the targets of the dashboard channel that created each item, or all items when no targeting was configured). Users can also dismiss items individually (soft delete), which removes them from their personal inbox only.

List Inbox Items​

GET /api/notifications/inbox?limit=50&page=1&pageSize=20&severity=all&event=&unreadOnly=false&from=&to=

Query params:

  • limit - max items in legacy flat-list response (1–200, default 50). When page is omitted, activates flat-list mode; presence of page switches to paginated mode.
  • page - page number for paginated response (1-indexed, default 1)
  • pageSize - items per page (1–100, default 20)
  • severity - filter by severity: info, warning, critical, or all (default all)
  • event - filter by event name substring (case-insensitive)
  • unreadOnly - when true, returns only items the current user has not read
  • from - start date (YYYY-MM-DD or ISO 8601 timestamp); when date-only, spans from 00:00
  • to - end date (YYYY-MM-DD or ISO 8601 timestamp); when date-only, spans to 23:59:59

Response 200 (flat-list mode when page omitted):

{
"items": [
{
"id": "8f3c…",
"event": "provider.error",
"severity": "critical",
"timestamp": "2026-06-24T12:00:00.000Z",
"details": { "modelId": "openai/gpt-4o" },
"read": false
}
],
"unreadCount": 5,
"enabled": true
}

Response 200 (paginated mode when page is provided):

{
"items": [ /* … */ ],
"pagination": {
"page": 1,
"pageSize": 20,
"totalRecords": 127,
"totalPages": 7
},
"unreadCount": 5,
"enabled": true
}
  • items - notifications newest-first
  • unreadCount - total unread count for the current user (independent of filters)
  • enabled - true when at least one dashboard-provider channel is configured; false when the inbox is not yet initialized
  • pagination - only in paginated mode (when page query param is present)

Get Single Notification​

GET /api/notifications/inbox/:id

Response 200:

{
"id": "8f3c…",
"event": "provider.error",
"severity": "critical",
"timestamp": "2026-06-24T12:00:00.000Z",
"details": { "modelId": "openai/gpt-4o", "latencyMs": 5000 },
"read": false
}

Errors: 404 notification not found (either does not exist or is not in the current user's inbox)

Mark Inbox Items as Read​

POST /api/notifications/inbox/read

Marks inbox items as read for the current user. Provide either ids or all:

{ "ids": ["8f3c…", "1a2b…"] }
{ "all": true }

Returns 400 if neither is provided.

Response 200:

{ "updated": 2 }

All read operations are logged to the audit log as notification:read actions.

Mark Inbox Items as Unread​

POST /api/notifications/inbox/unread

Clears the current user's read mark on one or more notifications. Inverse of /read. Provide either ids or all:

{ "ids": ["8f3c…"] }
{ "all": true }

Returns 400 if neither is provided.

Response 200:

{ "updated": 1 }

All unread operations are logged to the audit log as notification:unread actions.

Delete (Dismiss) Inbox Items​

POST /api/notifications/inbox/delete

Dismisses (soft-deletes) one or more items from the current user's inbox only. Other users' copies of the same notification remain unaffected. Provide either ids or all:

{ "ids": ["8f3c…", "1a2b…"] }
{ "all": true }

Returns 400 if neither is provided.

Response 200:

{ "deleted": 2 }

Important: Deletion is per-user only. Global deletion is never performed. All delete operations are logged to the audit log as notification:delete actions.

Test (legacy endpoint)​

POST /api/notifications/test
{ "channelId": "abc-uuid" }

Returns 200 OK on success or an error with details. Prefer POST /api/notifications/channels/:id/test.


Integrations​

Export Routerly metrics to external monitoring systems (Prometheus, OpenTelemetry, Datadog, Grafana Cloud, InfluxDB, Webhooks).

List Integrations​

GET /api/integrations

Auth: Authorization: Bearer <jwt> (requires settings:write)

Response 200

{
"integrations": [
{
"id": "int-uuid",
"type": "prometheus",
"enabled": true,
"name": "Prometheus",
"authToken": null
},
{
"id": "int-uuid2",
"type": "otel",
"enabled": true,
"name": "OpenTelemetry",
"endpoint": "http://localhost:4318/v1/metrics",
"protocol": "http",
"headers": {}
}
]
}

Secret fields (authToken, apiKey, token, secret) are masked and shown as null when present.

Errors: 403 insufficient permissions

Get Integration​

GET /api/integrations/:id

Auth: Authorization: Bearer <jwt> (requires settings:write)

Response 200 (example: Datadog integration)

{
"id": "int-uuid",
"type": "datadog",
"enabled": true,
"name": "Datadog Production",
"apiKey": null,
"site": "datadoghq.com"
}

Secret fields are masked and shown as null when present.

Errors: 404 integration not found · 403 insufficient permissions

Create Integration​

POST /api/integrations

Auth: Authorization: Bearer <jwt> (requires settings:write)

Request body — per-type examples:

Prometheus (pull, optional auth)

{
"type": "prometheus",
"name": "Prometheus",
"authToken": "secret-token-123"
}

OpenTelemetry (push)

{
"type": "otel",
"name": "OpenTelemetry Collector",
"endpoint": "http://localhost:4318/v1/metrics",
"protocol": "http",
"headers": {
"Authorization": "Bearer otel-token"
}
}

Datadog (push)

{
"type": "datadog",
"name": "Datadog",
"apiKey": "dd_api_key_123",
"site": "datadoghq.com"
}

Site options: datadoghq.com (US East), datadoghq.eu (EU), us3.datadoghq.com, us5.datadoghq.com, ddog-gov.com (GovCloud).

Grafana Cloud (push, Prometheus remote_write)

{
"type": "grafana",
"name": "Grafana Cloud",
"url": "https://prometheus-blocks-prod-us-central1.grafana.net/api/prom/push",
"username": "123456",
"apiKey": "glc_api_key_123"
}

InfluxDB (push, v2)

{
"type": "influxdb",
"name": "InfluxDB",
"url": "http://localhost:8086",
"token": "my-token",
"org": "routerly",
"bucket": "metrics"
}

Webhook (push)

{
"type": "webhook",
"name": "Webhook",
"url": "https://example.com/metrics",
"secret": "signing-secret",
"headers": {
"X-Custom-Header": "value"
}
}

Webhook requests are signed with HMAC-SHA256 using the secret field; the signature is sent in the X-Routerly-Signature header (format: sha256=<hex>).

Response 201

{
"id": "int-uuid",
"type": "otel",
"enabled": true,
"name": "OpenTelemetry Collector",
"endpoint": "http://localhost:4318/v1/metrics",
"protocol": "http",
"headers": {}
}

Errors: 400 invalid body · 403 insufficient permissions

Update Integration​

PATCH /api/integrations/:id

Auth: Authorization: Bearer <jwt> (requires settings:write)

Request body (all fields optional):

{
"name": "Updated Name",
"enabled": false,
"apiKey": "new-api-key"
}
  • To enable/disable: set "enabled": true or "enabled": false
  • Secret fields (authToken, apiKey, token, secret) are only updated when explicitly provided and non-empty. Omitting a secret field leaves it unchanged.
  • Per-type config fields (endpoint, protocol, url, etc.) can be updated individually

Response 200

{
"id": "int-uuid",
"type": "otel",
"enabled": false,
"name": "Updated Name",
"endpoint": "http://localhost:4318/v1/metrics",
"protocol": "http"
}

Errors: 404 integration not found · 400 invalid body · 403 insufficient permissions

Delete Integration​

DELETE /api/integrations/:id

Auth: Authorization: Bearer <jwt> (requires settings:write)

Response: 204 No Content

Errors: 404 integration not found · 403 insufficient permissions

Test Integration​

POST /api/integrations/:id/test

Auth: Authorization: Bearer <jwt> (requires settings:write)

Tests connectivity to the external system. For Prometheus (pull-based), the test is a no-op (always succeeds). For push-based integrations, sends a real metrics push and returns the result.

Response 200

{ "ok": true, "message": "Connection successful" }

Response 200 (on push failure)

{ "ok": false, "message": "HTTP 401: Unauthorized" }

Errors: 404 integration not found · 403 insufficient permissions

Metrics Pushed​

All push-type integrations (OpenTelemetry, Datadog, Grafana, InfluxDB, Webhook) send the following metrics every 60 seconds:

MetricTypeLabels/DimensionsDescription
routerly_requests_totalCounterproject, modelTotal request count
routerly_tokens_totalCountertype (input/output), project, modelTotal tokens consumed
routerly_cost_usd_totalGaugeproject, modelEstimated USD cost
routerly_request_duration_p50_msGaugeprojectMedian request latency
routerly_request_duration_p95_msGaugeproject95th percentile latency
routerly_budget_used_ratioGaugeprojectBudget consumption (0–1)

Each integration type sends these metrics in its native format (OTLP, Datadog Series API, Prometheus remote_write, InfluxDB line protocol, JSON webhook).


Audit Log​

List Audit Entries​

GET /api/audit

Auth: Authorization: Bearer <jwt> (requires audit:read)

Returns paginated audit log entries. Requires audit:read permission.

Query parameters:

ParameterTypeDescription
userIdstringFilter by user ID or email (substring match on email)
actionstringFilter by action substring (e.g. model:create)
resultstringFilter by result: success, forbidden, or error
fromstringStart timestamp (ISO 8601)
tostringEnd timestamp (ISO 8601)
pagenumberPage number (default: 1)
pageSizenumberEntries per page (default: 50, max: 200)

Response 200

{
"entries": [
{
"id": "uuid",
"timestamp": "2026-06-25T13:03:26.767Z",
"userId": "uuid",
"email": "[email protected]",
"endpoint": "POST /api/models",
"action": "model:create",
"result": "success",
"details": { "id": "my-model" }
}
],
"pagination": {
"page": 1,
"pageSize": 50,
"totalRecords": 142,
"totalPages": 3
}
}

Errors: 403 insufficient permissions


System​

Get System Info​

GET /api/system/info

No authentication required.

Response:

{
"version": "0.2.0",
"nodeVersion": "v22.0.0",
"platform": "darwin",
"configDir": "/Users/you/.routerly/config",
"dataDir": "/Users/you/.routerly/data",
"uptimeSeconds": 3600,
"channel": "stable",
"isDocker": false,
"updateInfo": {
"available": true,
"currentVersion": "0.2.0",
"latestVersion": "0.3.0",
"channel": "stable",
"releaseUrl": "https://github.com/Inebrio/Routerly/releases/tag/v0.3.0",
"checkedAt": "2026-06-09T10:00:00.000Z"
}
}

updateInfo is null if no check has completed yet (first 24 hours after boot). isDocker is true when the service is running inside a Docker container.


Force Update Check​

GET /api/system/update-check

Requires authentication. Forces an immediate check against the GitHub Releases API and returns the result. This also updates the cached value returned by GET /api/system/info.

Response: same shape as updateInfo above (UpdateInfo object).

{
"available": false,
"currentVersion": "0.2.0",
"latestVersion": "0.2.0",
"channel": "stable",
"checkedAt": "2026-06-09T12:34:56.000Z"
}

Trigger In-App Update​

POST /api/system/update

Admin only. Downloads and installs the latest version on the active channel and restarts the service. The response is returned immediately (202); the update runs in the background.

Constraints:

  • Returns 403 if the caller is not an admin.
  • Returns 403 if the service is running inside Docker (ROUTERLY_DOCKER=1). Pull the new image instead.
  • Returns 400 if the service is running on Windows. Run the installer manually.

Response 202:

{ "message": "Update started. The service will restart shortly." }

Poll GET /health to detect when the service has restarted. The CLI command routerly update run does this automatically.