Skip to content

Admin console ​

The admin console (apps/admin-console) is the web app for the people who run a tenant: analysts work cases, subjects and runs; authors define rules, rule sets, lists, workflows and collection flows; admins install apps, read the audit trail and manage the tenant's settings. This page covers signing in, the console's areas and the case-management API behind them; Admin console: Define covers authoring, versions and approvals. Every screen calls the documented API with the signed-in user's token: the API enforces every permission, and the console only hides what a role cannot use.

Packages:

  • apps/admin-console — the console (React, served by Vite on port 5176 in development).
  • @actanomos/aletheia-core — Case, CaseView (a case plus its derived slaState), caseSlaState, CaseFilter, CaseQueue, CaseNote, DirectoryUser, the list and batch shapes (CaseListItem, SubjectSummary, CaseCountsInput, ClaimNextCaseInput, BulkCaseInput), subjectDisplayName and the tenant setting cases.defaultSlaHours.
  • @aletheia-dev/auth — the UserDirectory interface, createZitadelUserDirectory and StaticUserDirectory; permissions cases:read, cases:decide, cases:assign, cases:claim, cases:notes.
  • @aletheia-dev/db — repos.cases (list/count/countMany over CaseFilter, guarded transition, claimNext with SKIP LOCKED), repos.caseNotes, repos.caseQueues (tables case_notes, case_queues).
  • @aletheia-dev/workflow-engine — createCase (sets dueAt), the SLA race in the interpreter, escalateCase, and the automatic close when a run completes.
  • apps/api — /cases, /cases/:id/notes, /case-queues, /users.

Running it ​

  • Development. pnpm dev serves the console on http://localhost:5176. The browser only talks to that origin: Vite forwards /api/* to the API (ADMIN_CONSOLE_API_TARGET, default http://localhost:4000) without the page's Origin, Referer or cookies, so the API needs no CORS entry for it. Sign-in reads the VITE_ZITADEL_* values in .env (Get started).
  • Deployment. The web image serves the console on port 5176 behind the console. host and forwards /api/* to ADMIN_CONSOLE_API_UPSTREAM (default API_URL) (Deployment). For attachments, STORAGE_CORS_ORIGINS must list its origin.

Signing in ​

The sign-in page asks for the organization domain (one Zitadel organization is one tenant; the device remembers up to five) and continues to Zitadel's own login page, which pnpm auth:seed gives the console's look (its colours, the Archivo font and the Aletheia wordmark): an authorization-code flow with PKCE, scoped to that organization. Tokens are kept in memory only; a reload goes back through Zitadel, silently while the Zitadel session lasts. Switching organization means signing out first. Once signed in, the console reads GET /me for the principal, the tenant and the permissions. After 30 minutes without input in any tab, the console drops its tokens and Zitadel asks for credentials again; unsaved note and decision drafts stay on the device. Sign out (avatar menu) deletes them and ends the Zitadel session in every tab.

Layout and permissions ​

The top bar holds the areas, the search palette (⌘K or Ctrl+K), the health indicator, the notifications bell and the avatar menu. An area shows when the role can open one of its tabs; a page the role cannot open shows a 403 page that names the missing permission.

AreaTabsPermission
Homenoneany signed-in user
OperateCases, Subjects, Runs, Approvalscases:read, subjects:read, runs:read; Approvals: definitions:approve or definitions:write
DefineRules, Rule sets, Workflows, Collection flows, Listsdefinitions:read; Lists: lists:read
ConnectApps, Webhooks, API keysApps: apps:read; Webhooks: webhooks:read; API keys: users:manage
AuditTrail, Exportsaudit:read; Exports: audit:export
SettingsGeneral, Team, Roles, SLA, Queues, Reason codes, Tenanttenants:settings; Queues also opens with cases:assign

Admins hold every permission. Analysts work cases (every cases:* permission and documents:write for attachments), read subjects, runs, definitions, lists and the audit trail, and run backtests: they see Define read-only and reach Settings › Queues from the workbench. The integration role is for machines and has no use for the console. Settings › Roles shows the whole matrix; "What can I do?" in the avatar menu lists your own permissions.

Notifications ​

The bell in the top bar (notifications:read, which admins and analysts hold) shows how many notifications are unread and opens the ten newest; clicking one marks it read and opens what it is about, and Mark all read clears the count. It asks again every minute. The worker raises notifications from the same events as outbound webhooks, a few seconds after they happen, and nobody is told about what they did themselves but a call a quota or a budget refused, which is told once per limit and period:

KindWhenWho
assigneda case is assigned to someone other than the assignerthe assignee
sla_breacheda case passes its due date while open or in reviewits assignee, or everyone with cases:assign while it has none
approval_requesteda definition version waits for approval, a save of an install that adds hosts, or an install's automatic upgrade that adds hosts or capabilitieseveryone with definitions:approve; for an install, everyone with apps:publish
app_upgradedthe platform moves an install that upgrades automatically, up its line or back off a withdrawn or blocked version, or an administrator approves the move it asked foreveryone with apps:write
app_degradedan install's circuit openseveryone with apps:write
app_quota_exceededa call through an install is refused because one of the operator's quotas of the organization or one of the install's budgets is used up, once per limit and periodeveryone with apps:write, whoever made the call
app_blockedthe operator blocks the module an install runs, with the reasoneveryone with apps:write
app_review_decidedthe operator approves or rejects one of the organization's app versions, or turns its reviews offeveryone with apps:publish
webhook_exhausteda webhook delivery is given up after five attemptseveryone with webhooks:write

GET /notifications?unread=&limit= answers { items, unread }: the caller's notifications newest first (limit 20 by default, at most 100), each with text, href (a console path) and readAt, and the unread count (up to 1000). POST /notifications/read takes { ids } or { all: true } and answers how many were marked; read marks are per person.

The palette sends what is typed to GET /search?q=&scope=&limit= (any of subjects:read, cases:read, runs:read, definitions:read or lists:read) and adds the console's actions, filtered here; > first searches actions only. The API searches every scope the caller may read at once, five results each by default (limit, at most 20, and repeated scope to narrow):

  • subjects by external id (exactly or as a prefix) or by name or legal name (as a prefix);
  • cases by the start of their id or by their subject, runs by the start of their id, their correlation id or their subject;
  • rules, rule sets, workflows, collection flows (definitions:read) and lists (lists:read) by key or name anywhere, newest version only, exact and leading matches first.

A full id, with or without #, looks the subject, case or run up directly. A scope that fails or takes over a second comes back empty with partial: true (the palette says "some sources failed"). Each result carries a title, a subtitle and the console path it opens (href). Records opened from the palette are remembered on the device; the text typed before opening one is kept with the person's preferences (recentSearches, ten at most), and both are offered while the input is empty.

Health ​

The indicator reads GET /health/summary (anyone signed in but applicants) every minute: the database, Temporal and object storage (ok, error, or off when the deployment has none); with apps:read, the apps (off when the deployment runs none, otherwise the apps with failed calls in the last hour and the open circuits); with webhooks:read, the webhook deliveries given up in the last 24 hours and to how many endpoints. state is down when the database is unreachable, degraded when anything else is failing and ok otherwise. While it is not ok, a banner under the sub-tabs names what is failing, with links to the install's page or to Connect › Webhooks (webhooks:read). The public GET /health/ready stays for load balancers.

Home ​

Home opens after sign-in unless a device preference (Settings › General) picks My cases or Runs. With cases:read it shows four figures from GET /stats/overview, refreshed every 30 seconds: Open cases (with the unassigned count), Breached SLA (with how long the longest-overdue case is past due), Due today (open cases due before local midnight, overdue ones included, and how many are yours) and, for admins, Runs · 24h (with the share decided automatically and the failures). Below them come My cases (the six of yours due first) and Recent activity (GET /activity, below). Next case → (cases:claim) claims the oldest free case of All open and opens it. Admins also get the Setup checklist (GET /setup/checklist, below), hidden once every step is done or always, as Settings › General says, and Needs your attention: first the installs whose module the operator blocked, with the operator's reason and since when (GET /apps/health, with apps:read), then the operator's quotas of the organization and the budgets of its installs whose period's use reached them, with when the period ends (their calls fail with app_quota_exceeded until then; GET /apps/metering, with apps:read), then the overview's list of pending approvals, of install changes waiting for a decision (an automatic upgrade's among them, with what it adds), of automatic upgrades waiting for a secret, a configuration or the definitions that call an install, of installs disabled, missing secrets or failing in the last hour (each linking to the install's page), of webhook endpoints with deliveries given up and of scheduled exports failed in the last day; a tenant with no case and no published workflow gets a first-run page with Import a policy pack →.

GET /activity?limit= (cases:read; 5 by default, at most 50) answers the newest case.decided and case.sla.breached events and, with definitions:read, the publishes and the approval requests, approvals, rejections and withdrawals, each as a sentence (text, the actor named from the reviewer directory) with a console path (href) and a tone (alert for a breach or a rejection). Payload values beyond the outcome, keys and versions are left out.

GET /setup/checklist (tenants:settings) computes six steps on each call: identity (always done), team (someone besides the first admin holds a role, from the reviewer directory), app (an install is enabled), workflow (one is published), sla (a default SLA is set) and api_key (an enabled service user holds a key). A step the API cannot tell (no directory or management credential) has done: null; the card says "not tracked".

GET /stats/overview?dueBefore= (cases:read) answers { cases, runs24h?, attention }:

  • cases: open, unassigned, breached (past due, or stamped breached, while open or in review), dueToday (due before dueBefore, overdue ones included; the console sends its local midnight, the API defaults to the next midnight UTC), dueTodayMine and oldestBreachMs.
  • runs24h (with runs:read): the runs started in the last 24 hours, total, completed, waiting, failed, p95Ms and autoDecidedPct, the share of the decided ones decided without a reviewer.
  • attention: what the caller may act on. approvals (count and the oldest three) with definitions:approve; install_change_pending (count and the oldest three, each with the install, its origin, save or auto_upgrade, and the hosts, needsAdded and handlesAdded it adds) with apps:publish; install_disabled and install_unconfigured (the required secrets missing; a new install waiting for its first approval shows as its change only) with apps:write; install_upgrade_waiting (an install that upgrades automatically, the version it moves to and what the move waits for: secrets, config or actions; one waiting for an approval shows as its change) with apps:write; install_failing (calls, failures and timeouts in the last hour, or an open circuit) with apps:read, each naming the install; webhook_exhausted (an endpoint, how many of its deliveries were given up in the last 24 hours and the newest one's event) with webhooks:read; export_failed (a scheduled export whose newest run in the last 24 hours failed, with the error) with audit:export.

Every figure is computed on the request.

Cases ​

A case is a manual-review item the workflow opens (create_case step) when a run needs a human decision. Operate › Cases is the workbench where reviewers find, claim and decide cases; each case has a page with the evidence, the decision controls, notes and attachments.

The workbench ​

  • Queues (left rail). Every tenant has the system queues: All open (oldest first), Unassigned, My cases (the default, by due date), Breached SLA, High priority (high and critical) and Decided, awaiting close, plus All cases (every status, newest first). The first five hold open and in-review cases only. Team queues lists the tenant's queues everyone (or, for admins, admins only) sees, and My queues the caller's personal ones. Counts refresh every 30 seconds.
  • Filters narrow the current view and never change the queue: search (the start of the case id, or the subject's external id or name), status, priority, type (with the types in use as suggestions), country, routing label, reason code (picked from the catalogue when the tenant keeps one), waiting for the applicant, SLA state, assignee and sort. They live in the URL (queue, status, priority, sla, type, country, label, reason, waiting, assignee, subjectId, dueBefore, search, sort, order, offset), so a view can be bookmarked. Save as queue, + New queue and Edit queue open Settings › Queues with the editor filled in (cases:assign).
  • Table: case, subject, type, priority, status, assignee, SLA and created, 25 rows a page. The SLA column reads "Breached · 6h over", "Due in 1h 20m", "Due" and a date, or "No deadline". Assignee names come with the rows (expand=assignee).
  • Bulk actions (cases:assign) on the selected rows: Assign to… a reviewer or Unassigned, and Claim (cases:claim), through POST /cases/bulk.
  • Claim next → (cases:claim) claims the next free case by the queue's own filter, not the on-screen one, when the queue is one "Claim next" uses; otherwise, and from Home's Next case, it draws from the queues marked for it, in their order, and opens the case.

The case page ​

The header names the subject, the case's priority, status and type, and the workflow version that opened it. Its actions: Reassign (cases:assign; a reviewer or Unassigned), Claim (take over a case assigned to someone else), Request info, and Claim to decide while the case is unassigned or Decide → once it is claimed.

The page reads, top to bottom: Why it's here (the rules that failed, then every rule result), Who (the subject's attributes), Requests for information (once there is one: what was asked, where it stands and, once answered, which answers the applicant changed; needs submissions:read), Documents, Vendor checks (the run's app calls, with apps:read, each naming the install it went through, linking to the install's page, with a result a reviewer can read, such as "Match found" or "No match"; the raw response needs runs:context), Workflow run (the run and case context, key by key), Events, Decisions on this subject and History (the case's audit events). Documents, vendor checks and events come in one request, GET /cases/:id/evidence. A block the role cannot read names the permission it needs. The rail holds the SLA line, the decision buttons, the assignee, queue, run and first response, and the notes. Note and decision text is kept as a draft on the device until it is saved or emptied.

  • Decide takes the outcome, optional reason codes and a note, which is required when the outcome differs from the subject's latest automated decision or a chosen code requires one. With a catalogue the codes are picked from it, those that apply to the outcome; without one a code and a message are typed. & next → decides and opens your next case in the queue, claiming one when you have none.
  • Request info (cases:decide) asks the applicant for more information without deciding. You write what they should change or add; POST /cases/:id/request-info sends the form the case's run collected again, as a follow-up with their answers (files included) filled in and your message on top, and the dialog shows the link to send them. The case stays in_review, marked Waiting for applicant, with its SLA paused; the rail shows when it was asked, until when, and (submissions:write) Copy applicant link and Withdraw request. When the applicant sends the form, the run repeats the checks it made after that form (its call_app and evaluate_rules steps) on the new answers and the case comes back with the rest of its SLA. A request still unanswered when its link expires (the form's link lifetime) rejects the case with the reason info_not_provided; deciding or closing the case meanwhile withdraws it. The request needs a run waiting on the case that collected a form (409 otherwise).
  • Close case closes a decided case; Close without deciding closes an undecided one with an optional reason (see the status model).

Case routes ​

RoutePermissionNotes
GET /casescases:readCaseFilter + queueId, sort, order, limit, offset, expand; { items, total }
GET /cases/:id/evidencecases:readrule hits and score, documents, vendor checks, events, decisions; a section is null without its permission
GET /case-typescases:readthe types published workflows open and open cases have, with their workflows and open counts
GET /reason-codes, PUT /reason-codescases:read, tenants:settingsthe reason-code catalogue; PUT replaces it (audited reason_codes.updated)
POST /cases/countscases:read{ filters: { <name>: CaseFilter } } (20 at most) → { counts }
POST /cases/claim-nextcases:claim{ queueId }, { filter, sort?, order? }, or {} for the claim-next queues; 404 nothing_to_claim
POST /cases/bulkcases:assign{ ids, action, assigneeId?, reason? } → { updated, failed }
GET /cases/:idcases:read{ case, subject, run, decisions }
POST /cases/:id/assigncases:assign{ assigneeId } (null unassigns)
POST /cases/:id/claimcases:claimassigns the caller
POST /cases/:id/decidecases:decide{ outcome, reasons?, note? }; manual decision, signals the run; 409 case_not_assigned on someone else's case; reasons checked against the catalogue
POST /cases/:id/request-infocases:decide{ message } → { case, submission, link, expiresAt }; the run waits
POST /cases/:id/closecases:decide{ reason? }; rejects first when undecided
GET /cases/:id/notescases:readoldest first, paged
POST /cases/:id/notescases:notes{ body } (10 KB max)
PATCH /cases/:id/notes/:noteIdcases:notesauthor within 24 h, or an admin
DELETE /cases/:id/notes/:noteIdcases:notesauthor or an admin; 204
GET /case-queuescases:readthe queues the caller sees, by position
GET /case-queues/countscases:read{ items: [{ queueId, count }] }, me resolved
POST /case-queues/previewcases:read{ filter, sort, order } → { total, items } (the first five)
PUT /case-queues/ordercases:assign{ ids }: the new order
POST /case-queues, PUT /case-queues/:id, DELETE /case-queues/:idcases:assignnames unique per tenant (409 queue_name_taken); a system queue keeps its name and filter and cannot be deleted
GET /users?role=cases:assign or cases:claimthe reviewer directory; empty without a PAT

A decision's note is stored as one more reason, with the code manual.note. Admins hold every permission above; analysts hold cases:read, cases:decide, cases:assign, cases:claim, cases:notes and documents:write (for attachments); the integration role holds none of the case permissions. A route may declare several permissions, in which case holding any one of them is enough.

Status model ​

open ──assign / first note──▶ in_review ──decide──▶ decided ──close / run completes──▶ closed
  │                                                                                     ▲
  └──────────────────────── close (reject with code case_closed) ──────────────────────┘
  • open -> in_review on the first assignment or the first note. Both stamp firstResponseAt once; later assignments and notes leave it alone.
  • in_review -> decided (also open -> decided) on POST /cases/:id/decide: the decision is recorded (source: manual), decidedAt and decisionId are set and the waiting run receives the manualDecision signal. Only the assignee decides: a case assigned to someone else answers 409 case_not_assigned until the caller claims it, and an unassigned case is assigned to the caller with the decision (audited as case.assigned), as the console's claim-then-decide does.
  • Waiting for the applicant is not a status: POST /cases/:id/request-info keeps the case in_review (an open one moves there) with infoRequestedAt set and dueAt empty, and the run sends the infoRequested signal's follow-up through its checks once answered, then clears infoRequestedAt and sets dueAt to now plus what was left of the SLA. An unanswered request moves the case to decided with an automated reject (info_not_provided) when it expires.
  • decided -> closed by POST /cases/:id/close or automatically when the run completes (closedReason: run_completed).
  • open|in_review -> closed by POST /cases/:id/close { reason? }: the case first receives a manual reject decision with reason { code: 'case_closed', message: reason ?? 'closed without decision' } so the run never waits forever, then closes with closedReason = reason ?? 'closed'.
  • open|in_review|decided -> closed without a decision when its run is cancelled (POST /workflow-runs/:id/cancel, closedReason: run_cancelled): nothing waits for it any more.
  • A closed case conflicts (409) with assign, claim, decide and close. Reopening is deferred.

Transitions run through repos.cases.transition(ctx, id, from[], patch), an UPDATE ... WHERE status = ANY(from), so two reviewers racing on the same case cannot both win.

Queues and filters ​

Queues are saved filters, not an ownership model: case_queues { name, filter, sort, order, position, createdBy, systemKey, visibility, claimNext }, unique by name per tenant. Every tenant has the six system queues (systemKey all_open, unassigned, mine, breached, high_priority, decided), made on its first GET /case-queues; a queue of the tenant's that already had one of their names becomes it. Their name, filter and sort are fixed; their position, visibility and claim-next choice are not. A queue's visibility is everyone, admins (callers with tenants:settings) or personal (its creator): a queue the caller does not see is left out of the lists and is a 404 by id. claimNext marks the queues POST /cases/claim-next {} draws from, in position order. A case carries assigneeId and, when its run gave it one, a routing label (queue, see SLAs) that queue filters select on.

GET /cases takes the same CaseFilter a queue stores:

ParamValues
statusopen, in_review, decided, closed (repeatable)
prioritylow, medium, high, critical (repeatable)
assigneea reviewer id, me (the caller) or unassigned
typethe case type (kyb_review, ...)
subjectIdone subject
slaStatenone, ok, due_soon, breached (repeatable)
dueBeforeISO-8601 instant
queuethe routing label, exactly
reasonCodea reason code of the case's decision
countrythe subject's country, as GET /subjects?country= reads it
waitingtrue: waiting for the applicant; false: not
searchthe start of the case id, or the subject's external id (exactly or as a prefix) or the start of its name
sortcreatedAt (default, desc), dueAt (undated last), priority
orderasc, desc

Repeated params build the arrays (?status=open&status=in_review). assignee: me is resolved to the caller on every request, which keeps stored queue filters user-independent; GET /case-queues/counts runs each queue's filter through repos.cases.count the same way.

queueId applies a queue the caller sees: its filter, with any filter given alongside replacing the queue's of the same name, and its sort unless one is given.

The response is { items, total, totalIsEstimate }: total counts the cases matching the filter, ignoring limit and offset (one count(*) with the same conditions, no ordering), and stops at 100,000, where totalIsEstimate turns true and the console shows "100,000+". expand=assignee adds assignee: { id, name, email } from the reviewer directory (name null for someone it no longer lists, null for an unassigned case). expand=subject adds the subject of each case as subject: { id, externalId, kind, country?, displayName }, read for the whole page in one extra query; displayName follows subjectDisplayName in @actanomos/aletheia-core (name, legalName, fullName, displayName, then firstName lastName, then the external id) and country follows subjectCountry. Nothing else from the subject data is returned, and names are not masked.

POST /cases/counts counts several filters in one request: the body names up to 20 filters, each the CaseFilter above as JSON (assignee: me resolved the same way), and the answer has a count under each name. The console reads its system queues, the Cases badge and the Home figures this way, one request every 30 seconds.

Assignment and the reviewer directory ​

assigneeId is the Zitadel user id. Names come from the directory: GET /users lists the human users of the tenant's organization that hold a role on the Aletheia project, as { id, name, email?, roles[] }, read live from Zitadel's user and authorization APIs and cached in memory for 60 s per organization (a failed refresh serves the stale entry and logs a warning).

The directory needs ZITADEL_DIRECTORY_PAT: the PAT of the machine user aletheia-directory, which pnpm auth:seed creates with the organization role ORG_OWNER_VIEWER (reads users and grants, writes nothing) and writes to docker/zitadel/bootstrap/directory.pat. Without it the API logs "reviewer directory disabled", GET /users is empty and assignees are not validated.

  • POST /cases/:id/assign { assigneeId } (cases:assign) requires the user to be in the directory when one is configured (404 not_found "user" otherwise), sets the assignee, stamps firstResponseAt on the first assignment and moves an open case to in_review. { assigneeId: null } unassigns without touching the status.
  • POST /cases/:id/claim (cases:claim) does the same for the caller, without the directory check (the caller is authenticated already; service users with the admin role may claim too).
  • POST /cases/claim-next (cases:claim) claims for the caller the next free case of a stored queue ({ queueId }: the queue's filter and sort) or of { filter, sort, order } (oldest first by default). Only unassigned open and in_review cases qualify, so a filter that names an assignee or only decided cases finds nothing. The case is picked and assigned in one transaction with SELECT ... FOR UPDATE SKIP LOCKED: two reviewers asking at once get two different cases. Bookkeeping and audit are those of /claim; when no case is free the answer is 404 with the code nothing_to_claim, which the console shows as "Nothing to claim." rather than as an error.
  • POST /cases/bulk (cases:assign) applies assign (with assigneeId, null unassigns), claim (also needs cases:claim) or close (with an optional reason; also needs cases:decide) to up to 100 cases. Each case goes through the single-case logic in order, with its own audit events; an assigneeId outside the directory fails the whole request with 404. A case that cannot change (closed, not found) is listed in failed with its id, error code and message while the others still change: the answer is 200 with { updated, failed }.

SLAs ​

  • The create_case step takes an optional slaHours; the tenant setting cases.defaultSlaHours (PUT /tenants/me/settings, Settings › SLA in the console) applies when the step sets none. Either way the activity sets dueAt = createdAt + hours; without both the case has no deadline.
  • The step may also take priorityFrom, a context path whose value (low to critical) replaces the step's priority when it is one, and queueFrom, a context path whose text (trimmed, 100 characters at most) becomes the case's routing label queue.
  • The interpreter races the manualDecision signal against the deadline. On expiry it calls escalateCase, which bumps the priority one level (low -> medium -> high -> critical), stamps slaBreachedAt, appends case.sla.breached, and keeps waiting for the decision. The escalation is idempotent (guarded by slaBreachedAt) and replay-safe.
  • slaState is derived at read time by caseSlaState(case, now) (SQL twin in case-query.ts for filters and counts):
    • none: no dueAt;
    • breached: slaBreachedAt set, or dueAt passed while the case is open/in_review;
    • due_soon: within the case's slaWarnPct % of the SLA window before dueAt (the tenant's cases.warnAtPct when the case opened), or without one the larger of 20 % of the window and two hours, capped at half the window so a one-hour SLA is due_soon only in its last 30 minutes;
    • ok: otherwise, including every decided or closed case with a deadline.
  • A breach notifies the case's assignee, or everyone with cases:assign while it has none (notifications), and goes to the webhook endpoints subscribed to case.sla.breached (outbound webhooks); reviewers also see the SLA column, the Breached SLA queue and the priority bump. No email is sent.
  • GET /stats/sla?days= (cases:read, 1 to 90 days, default 30) measures the window: decided cases, withinPct (of the decided cases with a due date, the share decided by it and never stamped breached), breached (cases stamped breached in the window, decided since or not), medianDecideMs (opening to decision), openPastDue (open and in-review cases past due now), and byWorkflow, the window's figures per workflow of the case's run (workflowKey: null for cases opened without one), busiest first. Settings › SLA shows them.

Reason codes ​

Settings › Reason codes (admins) keeps the tenant's catalogue: each code (lower case letters, digits, _, . and -) with a label, the outcomes it may justify (any when none is ticked) and whether it needs a note. GET /reason-codes (cases:read) lists it in order and PUT /reason-codes (tenants:settings) replaces it, audited as reason_codes.updated. With codes in it, POST /cases/:id/decide accepts only those codes, each for the outcomes it allows, and a code that needs a note only with the decision's note (400 validation_error otherwise); an empty catalogue lets reviewers type their own codes. The codes the API gives itself (case_closed, info_not_provided, manual.note) are not checked.

Notes ​

case_notes { caseId, authorId, body, createdAt, editedAt? }, plain text or Markdown, 10 KB max, listed oldest first. A first note is a first response (firstResponseAt, open -> in_review). The author may edit a note within 24 hours and delete it at any time; an admin (a principal holding tenants:settings) may edit or delete any note. Notes are addressed through their case: a note id under another case's path is a 404. Mentions and rich text are deferred.

Attachments ​

Attachments reuse documents: the case page's Upload attachment (JPEG, PNG, WebP or PDF up to 20 MB; analysts hold documents:write) calls POST /documents { subjectId, caseId, fileName, contentType, sizeBytes }, then the presigned upload and POST /documents/:id/finalize as for any other file; the worker scans it the same way. GET /documents?caseId= lists a case's attachments. The case page's Documents block lists every document of the subject, applicant uploads included; a clean image or PDF shows the grayscale thumbnail the scan rendered (GET /documents/:id/thumbnail), and preview and download (a fresh 60-second link each time) work once a file is clean. See Documents.

Audit actions ​

Every transition appends an audit event on resourceType: case:

ActionPayload
case.createdby the workflow activity
case.assigned{ assigneeId, previousAssigneeId, status } (assign, claim, claim-next, bulk assign and claim)
case.unassigned{ assigneeId: null, previousAssigneeId, status }
case.note.added{ noteId, length }
case.note.edited{ noteId, authorId }
case.note.deleted{ noteId, authorId }
case.decided{ decisionId, outcome, reasons, subjectId, workflowRunId }
case.sla.breachedby the workflow activity
case.closed{ reason, decisionId, workflowRunId } (API; reason: run_cancelled when the run is cancelled) or { caseId, reason: run_completed, workflowRunId } (worker)

GET /audit-events?resourceType=case&resourceId=<id> returns them newest first; the case page's History block reads this.

Subjects ​

Operate › Subjects lists every subject, 25 a page, with search (name or external id), kind, status, risk-score and country filters; with cases:read each row adds its open cases and last decision, and the list filters on the last decision too. Export CSV downloads every subject matching the filters (up to 10,000, from GET /subjects/export). New subject → (subjects:write) creates one with POST /subjects. A subject's page shows when it was first and last seen, risk over time, its open and past cases, its decisions, 20 a page (tick two, on any pages, and Compare → to see how the score, the rule results and the reasons changed), runs, submissions, events and the timeline, with the attributes, tags, flags and linked subjects in the rail. Start run ▾ (runs:start) starts a published workflow for the subject and opens the run. Edit attributes (subjects:write) changes the status, the tags and the attributes (as JSON); it sends only what changed, so values your role reads masked stay as stored unless you edit them.

Flags are derived on each request (GET /subjects/:id/flags): a score of 90 or more, each rule that failed on the newest decision (not shadow rules), a new device (the newest event came from a device none of the earlier events used), a device or card shared with other subjects, and a case waiting for the applicant; rule hits and the waiting case need cases:read. Linked subjects (GET /subjects/:id/links) are those sharing a device id, card fingerprint, IP address or email with the subject's newest 200 events, the same data.address object, or a referral (data.referredBy holding the other's external id), most recent first, at most 50; the shared values are not returned.

Submissions (submissions:read) lists every collection submission of the subject, 20 at a time, newest first: when it started, its id, flow and version, how far the applicant got (the current step, or when it was submitted), the case it answers (a request for information), the run it belongs to or "no run", and its status (in progress, submitted, withdrawn or expired). One still in progress offers Copy link (submissions:write). GET /collection-submissions takes subjectId, workflowRunId, caseId, status (repeated), limit and offset and returns { items, total }; without subjects:pii the personal data in each submission's data is masked. Applicant tokens cannot list. POST /collection-submissions/:id/link (submissions:write) issues a new applicant link to one in progress, never outliving a request's expiry, and POST /collection-submissions/:id/withdraw withdraws one the applicant no longer needs (not the form a run is waiting for); its link then opens a "no longer needed" screen.

GET /subjects takes search, kind, status, riskScoreMin, riskScoreMax, country, lastDecision, limit and offset. search matches externalId exactly (case-insensitive) or as a prefix of externalId, data.name or data.legalName; the expression indexes on those columns cover the prefix form, a contains match would scan. country matches the subject's country (the first non-blank of data.country, countryCode, nationality, countryOfResidence and address.country) case-insensitively, on an expression index; lastDecision (approve, reject, manual_review or none) the outcome of its newest decision. The response is { items, total, totalIsEstimate } (total ignores paging and stops at 100,000, where totalIsEstimate turns true). expand=openCases adds the number of open and in_review cases of each subject and expand=lastDecision its newest decision as { outcome, createdAt } (null when there is none); each is one extra query for the whole page, both are repeated params, and both, like the lastDecision filter, need cases:read (403 otherwise). GET /subjects/export takes the same filters and returns the matches as CSV (X-Export-Rows, X-Export-Truncated), masked like the list and audited as subject.exported.

GET /decisions?subjectId= (cases:read) pages the subject's decisions newest first: from/to bound createdAt (inclusive), limit (default 50, max 500) and offset page, and the response is { items, total }.

GET /subjects/:id adds lastSeenAt, the time of the newest event about the subject. PATCH /subjects/:id (subjects:write) takes data as a JSON merge patch (RFC 7396: objects merge, null removes a key), tags (the whole list) and status, and writes subject.updated with the paths that changed (data.address.city, tags, status), never their values.

The subject timeline ​

GET /subjects/:id/timeline (subjects:read) merges the subject's history into one stream of TimelineEntry ({ id, kind, at, title, summary?, resourceType, resourceId, actor?, data? }) newest first, from the entity tables and the audit log; nothing is stored. Query: from, to (bounds on at), kinds (repeated), limit (default 50, max 500) and cursor. The response is { items, nextCursor }: pass nextCursor as cursor for the entries after the page, until it is null. Entry ids are <kind>:<resourceId>[:<suffix>]. The subject page filters it by kind and loads 50 entries at a time, as far back as the history goes.

KindNeedsEntries (title)data
decisioncases:readDecision: <outcome> (+ (manual)), summary = score and reason count, actor = decidedBythe decision
casecases:readCase created, Picked up (firstResponseAt), Decided, Closed: <reason>, SLA breachedthe CaseView (with slaState)
notecases:readNote by <authorId>, summary = first 120 charsthe note (has caseId)
runruns:readRun started: <definitionKey> and, once terminal, Run <status> at finishedAtthe run with context: { keys } and caseIds
submissionsubmissions:readSubmission started: <flowKey>, Submission submitted: <flowKey>the submission
documentdocuments:readUploaded <fileName> and, once clean/infected/rejected, Document <status>the document (has caseId)
eventevents:readEvent: <type> at occurredAt, summary = amount and currencythe event
auditaudit:readthe action, for the subject and its cases, runs, submissions and documents (100 refs at most)the payload, as on GET /audit-events

A kind appears only when it was requested (all kinds when kinds is absent) and the caller holds its permission; the integration role, for instance, gets runs, submissions and events only. Run data never carries context values, only the key names, and caseIds is filled only for callers with cases:read. Decision, submission and event data is masked and redacted as on the entity routes, and audit data as on GET /audit-events (see below). Audit actions that an entity entry already represents (case.created, case.decided, case.closed, case.sla.breached, decision.*, document.created/uploaded/scanned/rejected, collection.submission.created/submitted, workflow.run.*) are left out of the audit kind so nothing appears twice; workflow.rules.evaluated is not one of them. Each source reads a page of rows at or before the cursor before the merge. When a source fills its page, the entries from the instant of its oldest row on wait for the next page, so rows sharing one instant (audit events of one transaction) are never split; when that leaves the page short, the sources are read again with a larger fetch, up to 500 rows each.

Runs ​

Operate › Runs lists the tenant's workflow runs, newest first, with search (run id prefix, correlation id, subject name or external id), status, workflow, outcome, trigger and time range filters (the last 24 hours by default) under the completed, waiting and failed counts and p95 duration of the runs the filters match, with the share of the decided ones decided automatically in the header. Live reloads it every 5 seconds; Start run → (runs:start) runs a published workflow for one subject, with an optional correlation id, and records the trigger console.

A run's page lists the steps in definition order from the trace the worker records: each step's status, duration, a summary (score 40 · 2 hits → review, waiting for the applicant, → review) and its input and output, from its latest visit (a step the checks repeated after a request for information shows its round). Without definitions:read the page lists the steps the run visited; a run started before the trace existed shows steps derived from its evaluations, app calls and context. The rail holds the trigger, the correlation id, the Temporal ids, app attempts (each linking to its install's page), the error, the run's Forms (its collection submissions and request follow-ups, with Copy applicant link for one still in progress, submissions:write), a timeline (admins also see the Temporal history), the run's cases and decision, the run it replayed and its replays, the webhook deliveries of its events (webhooks:read), and for admins (ops:read) a link to the trace of its start when TRACE_URL_TEMPLATE is set (environment).

Admins (runs:write) operate on a run from its page:

  • Cancel run, while the run is active, asks for a reason. The workflow stops without a decision, the run becomes cancelled with error cancelled: <reason>, its open cases close with run_cancelled and its forms in progress (its own and request follow-ups) are withdrawn.
  • Replay from step ▾, once the run has finished, lists the steps its trace recorded. A replay is a new run (trigger replay) of the same workflow version for the same subject and correlation id, starting at the chosen step with the context the run had when it last started it; the steps after it run again, so forms are asked for again, cases open again and the replay emits its own decision. Cancel an active run before replaying it.

GET /workflow-runs (runs:read: admins, analysts and the integration role, for every run of the tenant) lists runs newest first by startedAt as { items, total, totalIsEstimate } (the count stops at 100,000, where totalIsEstimate turns true). Every filter is optional: subjectId, status and outcome (repeated params), definitionKey, and from/to (inclusive bounds on startedAt). outcome is the outcome of the decision the run emitted, read through workflow_runs.decision_id, so only completed runs match it. search matches a prefix of the run id in its hyphenated form (51a3b2c0, or the full id) or the run's subject the way GET /subjects?search= does; both are index lookups, a contains match is not offered. Each item adds durationMs (finishedAt, or the time of the request for an active run, minus startedAt) and outcome (null until the run emits a decision), and expand=subject adds the subject summary as on GET /cases. Every run carries trigger (api, console or replay), filtered with trigger (repeated params), correlationId (the caller's own id from POST /workflow-runs, at most 200 characters, which search also matches exactly), and replayOf ({ runId, stepId }) on a replay. With subjectId alone the route returns that subject's newest runs, which the API client's workflowRuns.listBySubject returns as an array. GET /workflow-runs/summary (runs:read) takes the list's filters and summarises the runs they match: { total, byStatus, p95DurationMs, autoDecidedPct }, with every status in byStatus, the 95th percentile (percentile_cont) of finishedAt - startedAt over the runs that finished (null when none has) and the share of the runs with a decision decided without a reviewer (null when none has one). Both are computed in SQL on each request.

The worker records a run's trace in workflow_run_steps, one row per visit of a step (a loop brings a step back as visit 2; a request for information repeats the checks as round 2), with the run context the visit started from, which a replay starts with and no route returns. The routes on one run:

RoutePermissionWhat it does
GET /workflow-runs/:id/stepsruns:readThe trace in start order as { items, total } (500 at most): status, timings, summary, input and output (values redacted without runs:context), the error of a failed visit and refs to the evaluation, invocation, callback, submission, case or decision it wrote.
GET /workflow-runs/:id/relatedruns:readcases and decision (cases:read), replayOf, replays, traceUrl (ops:read) and deliveries (webhooks:read), the newest 50 webhook deliveries of the run's events.
POST /workflow-runs/:id/cancelruns:write{ reason }; cancels the workflow, closes the run's open cases, withdraws its forms, fails its app sessions still waiting for a webhook (run_cancelled); audits workflow.run.cancelled. 409 run_finished once the run has finished.
POST /workflow-runs/:id/replayruns:write{ fromStepId }; 201 with the new run. 409 run_active before the run has finished, step_not_reached for a step its trace does not hold.

Approvals ​

Operate › Approvals, the inbox of definition versions waiting for approval (definitions:approve to decide, definitions:write to follow your own requests), is described with the approval settings and four-eyes in Admin console: Define. The organization's app versions waiting to be published and the install changes waiting for a decision appear there too; deciding them needs apps:publish. An app version's card says who signed its module, as the operator's policy judges that signer now (Provenance: Trusted: <signer> · <repository>@<commit>, Signed by <identity> (not trusted) or Unsigned). An install change's card names the install, marks a new one, and says who asked, when and why and which hosts it adds, with Approve change, Reject, Withdraw on your own and Open install →. Four-eyes is the setting in force when the change was requested: under it the requester's card offers Withdraw only, even after four-eyes is turned off, and a change asked for without it can be approved by its requester (Apps: reference). A change the platform's automatic upgrade asked for is tagged Automatic upgrade, requested by the automatic upgrade, and says under It also what the new version does beyond calling hosts (reads the organization's documents, takes the vendor's webhooks, hands applicants off to the vendor); any administrator decides it, as four-eyes never applies to it, and rejecting it keeps the install where it is: the platform does not ask for that version again.

Connect ​

Connect › Apps opens with apps:read, which admins and analysts hold; adding, configuring, testing and removing installs need apps:write and uploading, publishing and approving versions apps:publish, which admins hold. Webhooks needs webhooks:read and API keys users:manage.

  • Apps lists the catalogue (GET /apps): the platform's apps and the organization's own, under In use (apps with at least one install) and Available, with a filter for either and a summary such as "1 app in use · 6 installs · 1 with an update · 1 degraded · 1 blocked · 3 available". Each card names the app by the title its latest version gives, if any, with the name beside it ("ChatGPT (OpenAI API)", chatgpt), and has the version (the latest by number), publisher, category and vendor, how many installs the app has, the last hour's calls and failures over its installs with the highest p95 among them, an open circuit, and tags for an install whose module the operator blocked, an available update, an automatic upgrade waiting on an administrator (Upgrade waiting), missing secrets, disabled installs, a change awaiting approval, or an upload not yet published or waiting for the operator's review. A search box matches the name, the title, the vendor, the capabilities and the installs' names, and a filter the publisher. Upload app (apps:publish) takes a module file, shows the manifest the platform read from it (actions, secrets and the hosts it calls), takes a Sigstore bundle that signs the module when there is one (Signature or attestation (.sigstore.json): from cosign sign-blob --bundle or a GitHub build provenance attestation, 64 KiB at most) and shows what the platform verified of it (the signer and its issuer or pinned key, the source repository, ref and commit, the transparency log entry, when it was signed and whether the operator's policy trusts the signer) or why it refused it (not a bundle, other bytes than the module stored, a signature that does not verify, or a deployment that takes no signatures), then publishes it, or opens an approval request when the organization requires approvals for apps. Where the deployment publishes only versions signed by a signer it trusts (APP_SIGNATURES=required, which GET /apps answers as signatures), the dialog says so before the upload, and publishing an unsigned or untrusted version is refused, the dialog saying which; where signatures are optional it says a bundle can be attached once the module is uploaded. When the operator reviews the organization's apps (Settings › Tenant), the version then waits for that review (Waiting for the operator's review) and is published once the operator approves it. Below the cards, once the organization has an install, Use (GET /apps/metering) gives a UTC month's calls, HTTP requests, webhooks handled and hand-offs, this month unless the month picker says another; the operator's quotas of the organization, each with a bar of what its UTC day or month used and when that ends (Reached once used up); and a row per install with its month and its spend at its prices (with apps:write; — without a price), marked Budget reached when one of its budgets is used up, removed installs kept and marked so. On a deployment without an app runner or object storage the page says apps are off.
  • An app's page (/connect/apps/:app) heads with the app's title and name as its card shows them, its publisher, its number of installs and its latest version, with Add install (apps:write, once a version is published) and, for the organization's own apps, Upload version… (apps:publish). Installs lists the app's installs: each with its version (an available update, and whether it upgrades automatically), Enabled or Disabled, missing secrets, a change awaiting approval, an automatic upgrade waiting on an administrator, an open circuit, a module the operator blocked (with the operator's reason and since when), its last hour and when it last changed, each linking to its page. Add install opens the install form with a name proposed: the app's own for its first install, <app>-2 and on when that name is taken; /connect/apps/:app?install=<name>, where a policy pack's import links to, opens it under that name. The name is checked as it is typed against the rules of an app's name and the names the organization's installs hold, the former names they still answer to included, and an organization has at most 50 installs. Below come Actions (each with its timeout, retries and whether it is idempotent or completes through a webhook) and Versions, the highest number first (status, publisher, provenance, size and memory, "Used by" with the installs that run each, and a module the operator blocked with the reason; a platform version the operator manages rather than a release says Published outside a release or Withdrawn by the operator). Provenance reads Trusted: <signer> · <repository>@<commit>, Signed by <identity> (not trusted), Platform release · <repository> or Unsigned; whether a signer is trusted is the operator's policy as it is now, so a signer the operator stops trusting is trusted nowhere. The organization's own versions have Attach signature… while they are uploaded and no request or review of them is open, where the deployment takes signatures (the bundle dialog of Upload app, which replaces what the version carried), Publish (held back, with the reason, while the deployment requires a trusted signer the version lacks), Approve or Reject an open request, and Withdraw; when the operator reviews the organization's apps, a version sent for review says Waiting for the operator's review with who sent it and when (it cannot be published again meanwhile), and one the operator turned down says Rejected by the operator with the operator's comment, after which Publish sends it for review again. The rail has the vendor's documentation and pricing note, the capabilities, the hosts the latest version calls and the config fields whose URLs add an install's own, its secrets and the module, with where the latest version comes from when the operator published it outside a release (Apps).
  • An install's page (/connect/installs/:install) heads with the install's name, its state, an available update, an automatic upgrade waiting on an administrator (Upgrade waiting), missing secrets, a change awaiting approval, its health, an open circuit (with when it opened) and a module the operator blocked, with the operator's reason and since when (its calls, test calls and webhooks fail with app_blocked, and its sessions hand no applicant off, until the operator lifts the block: Try is off, and only switching the install off is saved meanwhile), says which app it is an install of, with Rename… (apps:write), then shows the last hour's figures. While a change waits for an approval, a banner (Awaiting approval) says who asked, when and why, which hosts it adds and what the install does meanwhile (it serves as it was; a new install waits switched off), with Approve and Reject (apps:publish, hidden from the requester when the change was asked for under four-eyes) and Withdraw for the requester; the form below shows the state the change asks for. A change the automatic upgrade asked for reads Automatic upgrade awaiting approval, requested by the automatic upgrade, and says what the new version adds that nobody approved: the hosts it calls, reading the organization's documents, taking the vendor's webhooks or handing applicants off. For an install that upgrades automatically, a banner says where the platform moves it next and what that waits for: an approval of what the new version adds, a secret it requires (a Set button per secret stores it, and the move follows), a configuration it refuses (with the issues; upgrade by hand), an action the published workflows or rules call that it lacks, or answers asynchronously where an app rule calls it, or an administrator's rejection (it waits for a later version); or that the platform moves the install within a few minutes, down to the highest version left in its range when the one it runs was withdrawn or blocked. The form picks the version (current, latest by number, a module the operator blocked, and the publisher when the app has versions of both); for a platform app, Upgrade automatically · within 1.x (within 0.1.x on 0.x: the same major from 1.0.0 on, the same minor before) lets the platform move the install to each release of that range, never to a prerelease. While it is on, a save does not stay on an older version: turn it off in the same save. After an automatic move, Move back to vX points the form at the version the move left, withdrawn since or not (not one the operator blocked), with automatic upgrades off, and the save (Move back to vX →) moves the install there to stay. The form also holds Enabled for this organization, the Configuration form generated from the version's config schema, the Hosts and the Secrets, both worked out again as the configuration changes. Hosts lists the app's own and the host of each URL the configuration names, with its port when it is not the default (api.vendor.example:8443: another port of a host is another host to approve) and the field it comes from, marks the ones the save adds (New) and drops (Removed), and names a URL the platform would refuse with the reason (http, an IP address, localhost, or a port browsers refuse such as 22 or 25); a save that adds hosts needs Approve these hosts (Approve the new hosts on an existing install), which the API receives as approveHosts, with a warning when the install holds secret values the app could send to them. Secrets lists the version's secrets and the configuration's secret variables, each Stored, Stored on save (a value entered in the form), Missing, Not needed under this configuration or Not used (Set value…, Rotate…, Revoke), with the places steps may use a secret variable in ("Steps may use it in the path and the body"); a value keeps its line breaks, so a PEM private key (a jwt auth's RS, PS and ES algorithms) pastes as it is, and stays masked until Show the value is checked. A model app's install shows the keys its connection needs with its configuration instead (below), and Secrets lists only the values stored for secrets the configuration does not need, such as the key of a connection switched away from. For an install of the platform's http-api the configuration form names its fields, edits the query parameters and the headers as rows, gives each secret variable Steps may use it in with Path, Query, Headers and Body, and shows the chosen auth type's own fields with a Send this auth switch. A secret variable with no place checked stays in the install's own query, headers and auth; one that steps may use is required whether or not the configuration uses it, and a variable made non-secret drops its places (Secrets in a step). Paste from Hoppscotch… takes a Hoppscotch request, collection or environment and fills the endpoint, the method, the query parameters, the headers, the auth and the variables: a collection gives its auth, headers and variables but not its requests, an environment its variables, secret ones without a value. It lists what it could not take (scripts, OAuth 2.0 whatever the grant, auth types the host does not build, GraphQL, and a request's body, which each step gives). Akamai EdgeGrid comes over with its client secret as a secret variable (akamaiClientSecret, unless Hoppscotch names one) and without its nonce and timestamp, which the host makes afresh for each request; Hoppscotch's own EdgeGrid signature does not follow Akamai's scheme, so only the fields carry over. A credential field of the auth (a password, a token, an API key's value, a secret key, a private or signing key, an EdgeGrid client secret) that does not name one secret variable carries a warning as it is typed, as does a secret variable in a JWT's claims, an HMAC's prefix or suffix or an EdgeGrid host: the configuration is readable by analysts, so a credential is a secret variable whose value is stored here (App catalogue). Steps may send the organization's documents is the install's documents switch (documents), off until checked: on, a step's multipart or binary body may name a clean document of the run's subject, which the host sends to the host the switch names; off, no call through the install reads a document. It needs no approval of its own, and turning it off applies at once even when the save waits for one, for a host it adds; each change is audited (app.install.documents.enabled, app.install.documents.disabled). Webhook is the install's webhook, absent until Set up a webhook and taken out by Remove the webhook. Beside its settings is the URL the vendor posts to, with Copy, once the install is saved with a webhook (a start step may also send it with its request, as <<$callbackUrl>>), or why there is none yet. Each request is checked by picks how the vendor signs, each with its own fields: HMAC over the body (the header, the algorithm, the secret and how its text becomes bytes, the signature's encoding and any text before it, such as sha256=), HMAC with a timestamp (a t=…,v1=… header and how far its timestamp may be from now), Standard Webhooks (the secret as the vendor gives it, whsec_ and base64, and the same tolerance) or A token in a header, which signs nothing and never comes in the query. The secret or the token is one secret variable (<<webhookSecret>>, <<webhookToken>>), required and stored under Secrets like a credential. Then where the vendor's request carries The session's external id: under a scheme that signs, only what it signs, the JSON body at a pointer or, for Standard Webhooks, one of its id and timestamp headers, with a line saying why (a signed request sent again could otherwise name another session); under a token, which proves only who sent the request, the JSON body, a header, its name in lower case, or a query parameter. A source the scheme does not sign, left from another scheme, stays shown but refused, and the save waits until it changes. Then The event id (by default a digest of the external id and the body, so a delivery of the same body for the same session counts once), The outcome, with the values that complete the session, fail it or keep it waiting (separated by commas, a number or true and false as JSON; a rejection by the vendor completes the session for the rules to decide on, and any value not listed is ignored), The error text a failed session keeps, and Keep only, the body paths the run gets as the step's output. As the webhook is edited, warnings name a secret that is not one secret variable, an external id, an event id or an outcome read from a header or the query its scheme does not sign (the app refuses them: a replay could point a signed body at another session, count again or change the outcome), no outcome mapped (every verified request then completes its session) and a value listed twice. For an install of one of the platform's model apps (AI models) the configuration form starts with the connection: a choice of its two types where the app has two (Anthropic API or Amazon Bedrock for claude, OpenAI API or Azure OpenAI for chatgpt), then the type's own fields, named and explained (Connections). Switching the type keeps nothing of the connection before, and a key entered is stored only if the connection still needs it on save. A Bedrock connection's Endpoint follows its Region, the region's own endpoint, unless a proxy's stands in its place, and its host waits for approval like any host the configuration adds. For an OpenAI-compatible server, Key header shows only for a key sent In a header of its own, and Fill in a known server, above the form, fills in the base URL, the name and how the key goes for OpenRouter, Groq, Mistral, Together AI or Ollama (on a deployment that allows private networks), keeping the connection's headers, its query parameters and whether the server holds a model to a JSON Schema, which decisions need. Model, Decision model and Other models a step may name suggest the vendor's current models, say what the chosen one is and when it takes no temperature, and link to the vendor's list (The vendor's models); any other the vendor serves may be written, and for Azure, Bedrock and an OpenAI-compatible server the field says what it holds (Models). Instructions and Answers (Most tokens an answer takes, Temperature) follow. The keys the connection needs show under Keys, at the foot of the section, each set there as under Secrets, and follow the connection and its authentication: a Bedrock connection that signs with an IAM access key needs awsAccessKeyId and awsSecretAccessKey. The save waits, saying why, while an Azure connection gives both or neither of its resource's name and its endpoint, a key sent in a header of its own names no header or one the connection's headers also set, or a Bedrock endpoint is another region's than the connection's: requests are signed for the region. These forms come with the platform's versions of http-api and the model apps only: a version of the organization's own gets the forms generated from its schemas, whatever the app's name. One save carries the whole requested state, enabled included (Add install →, Save changes →, Upgrade to vX →, or Request approval → with a Note for the approver when it adds hosts and the organization requires approvals for apps), and stores the values entered with it: those the API takes already go first, so an enabled install stays enabled through an upgrade, and a new install that needs them is switched on once they are stored. A save that waits keeps enabled for the approval, which switches the install on if its secrets are stored by then. Below come Actions with Try, which calls one through the install once with its stored configuration (a call refused for a quota or a budget says which, and until when; for an install whose calls read documents, the dialog says whether a document the input names can be read, which takes documents:read as well), Budgets and use, the install's invocations a page at a time (GET /app-invocations?installId=; a call it answered as a step's or a rule's fallback reads Fallback for the install it stood in for), its newest callbacks (GET /app-callbacks?installId=, those the deployment's retention still keeps), Used by (GET /app-installs/:install/usage, marking a definition that calls the install by a former name) and Remove install…, which says which published definitions name the install and how many runs wait on its callbacks, and offers to fail them. Budgets and use (GET /apps/metering) shows the install's month in UTC (calls, HTTP requests, webhooks handled and hand-offs, which are never limited, and its spend with apps:write), its budgets and the operator's quotas of the organization, each with a bar of what its UTC day or month used and when that ends (Reached once it refuses calls: a spend budget once what is left is below the price of one of the install's actions), and with apps:write the prices of its actions (GET /app-installs/:install/limits) and Edit limits…: the currency (ISO 4217, such as EUR), a price per call for each action of the version it runs (an amount such as 0.40; an action without one costs nothing) and up to six budgets, one per metric (calls, HTTP requests or spend) and period (a UTC day or month). A price or a spend budget needs a currency, which changes only in a UTC month the install has spent nothing in; the save replaces the install's limits (PUT /app-installs/:install/limits, audited app.limits.updated), and the prices of actions the version lacks go with it. The rail has the inbound webhook endpoint the vendor calls (the install's own webhookUrl, with a copy button, or why it has none: an http-api install takes webhooks once its configuration sets one up), the approved hosts (each with its port when it is not the default: the runner refuses any other host or port), the Circuit breaker (its state shared by every worker, since when it is open, the outages counted against the threshold that opens it, and its two settings, the install's own or the deployment's; with apps:write, Settings… and Reset circuit), Cached values while the install's app keeps any between its calls (each name with when it expires, never the value, how much of the deployment's budget per install they take, and what empties them; with apps:write, Clear…, which asks for a confirmation), the installation's details (with whether a platform app's install upgrades automatically), with a link to the install's events in the audit trail, its former names (Also called) and the provenance of the version it runs: verified and trusted or not, the signer, the source, the log entry and when it was signed, or that nobody signed it. Rename… takes a new name, checked as it is typed like a new install's (a former name of this install is free for it: renaming takes it back), keeps the current name working as a former name unless unchecked (at most five per install; without one, steps, rules and runs that name it fail with unknown_install, which the API refuses while published definitions or runs in flight still name it and the dialog then lists them), lists the published workflows and rules that name the install and by which name, and can update them: leave them, create a draft of each that calls the new name (definitions:write), or publish a new version of each, or request its approval (definitions:publish), skipping those whose newer draft waits. The install keeps its secrets, configuration, history, changes and webhook URL, and its page opens under the new name; the page of a former name, such as an older notification's link, opens the install under its name. Remove… beside a former name frees it; while published definitions or runs in flight still call it, the dialog lists them and removes it only on Remove anyway, after which they fail with unknown_install.
  • Webhooks lists the organization's outbound webhook endpoints with their events, status (Active, Paused or Failing) and last delivery. New endpoint takes the URL, a description and the events from the catalogue, then shows the signing secret once. Opening an endpoint shows its settings with Send test ping (the answer in a banner), Edit, Rotate secret (the new secret once; the old one signs too for 24 hours) and Delete, and its deliveries newest first: time, event, the resource (linking to the run), the state (queued, 502 · retrying, given up after 5…) with the last error, the next attempt, the body on demand and Retry for a failed or given-up one. A filter shows all, failing, pending or delivered ones. The page opens on an endpoint from /connect/webhooks?endpoint=<id>, the link a notification or Home carries.
  • API keys lists the organization's service users (GET /service-users, users:manage): purpose, role, keys with their expiry and the last call the API answered for each. New service user → creates one (integration role by default) and offers its first key; New key issues a personal access token shown once, with 30, 90 or 365 days to live; a key can be revoked and a user disabled. Without ZITADEL_MANAGEMENT_PAT on the API, or without users:manage, the page links to the Zitadel console and lists recent machine callers from the audit trail instead.

POST /app-installs/:install/test (apps:write) calls one action of the installed version once with the install's configuration, the secrets it requires and its approved hosts, enabled or not: one attempt, no circuit, nothing recorded but the app.tested audit event. It counts toward the organization's quotas and the install's budgets like a run's call, and is refused with 429 app_quota_exceeded once one of them is used up. Its answer is { status, output?, externalId?, error?, durationMs, usage? }: success, pending for an asynchronous action (its webhook will find no run), error or timeout. An action the version does not declare is a 400, and a required secret without a value is a 409 install_not_configured. A test call of an install whose calls read documents (its version needs them and, for http-api, its documents switch is on) reads a document only when the caller also holds documents:read; having no run, it may name any clean document of the organization, where a run's call reads only its subject's. The input is data: text in it that reads like a secret placeholder reaches the action as written, and the host never fills it (Secrets). POST /app-installs/:install/secrets/:secret (apps:write) seals { value } with SECRET_STORE_KEY (AES-256-GCM, bound to the tenant, the install and the secret's name, so another install of the same app cannot open it) and answers { secret, storedAt }; the value is never returned. DELETE on the same path removes it. Without the key both answer 503 secret_store_unavailable. The routes behind the pages are listed in Apps: reference.

Each install has one circuit, shared by every worker: after the vendor's outages in a row (timeouts, and errors the app calls retryable, never a 4xx it calls final; five unless the install or the deployment says otherwise), calls through the install are refused at once with a 503 app_circuit_open for as long as it stays open (30 seconds unless set otherwise), then one trial call goes out, which one worker claims; its success closes the circuit. Another install of the same app keeps calling. The install's administrators set the two settings (Settings…, empty for the deployment's APP_CIRCUIT_FAILURE_THRESHOLD and APP_CIRCUIT_OPEN_SECONDS; PUT /app-installs/:install/circuit, audited app.circuit.configured) and close a circuit by hand (Reset circuit, POST /app-installs/:install/circuit/reset, audited app.circuit.reset), both at once for every worker. A step or a rule that names a fallback calls it while the circuit refuses, or once the vendor's outage outlasted the call's retries (Define). Changes are recorded and audited (app.circuit.opened, app.circuit.closed), GET /apps/health lists the open and half-open circuits as circuits, and Home's attention list names the install.

An install's cached values are what its app keeps between the install's calls with ctx.cache, such as a vendor's token (Apps: reference): sealed with SECRET_STORE_KEY and bound to the install like its secrets, each kept a day at most, within the deployment's budget per install (APP_CACHE_ENTRIES values and APP_CACHE_TOTAL_BYTES bytes, the values written longest ago leaving first), and never shown: a value a call reads or sets shows as [cache:NAME] in its answer, its logs and its records. A deployment without SECRET_STORE_KEY keeps none. GET /app-installs/:install/cache (apps:read) lists the live ones by name, with when each expires, when a call last wrote it and its size, and the budget; DELETE on the same path (apps:write) empties them, audited app.cache.cleared. Storing or revoking a secret of the install empties them too, as does a change of its configuration or its version, whether saved, approved or made by an automatic upgrade; each is audited app.cache.cleared with the reason when a live value went. They go with the install. Each clear moves the install's cache generation, and a call that read the values before it stores nothing it wrote: a token minted with a replaced secret is never kept.

The service-user routes (users:manage) proxy Zitadel with the management user's PAT: GET /service-users (the machine users of the tenant's organisation holding a project role, with their keys and lastCallAt), POST /service-users ({ name, purpose?, role }), PATCH /service-users/:id ({ purpose?, enabled? }), POST /service-users/:id/keys ({ expiresAt }, within 730 days; answers { id, token, expiresAt }, the only time the token is returned) and DELETE /service-users/:id/keys/:keyId. Machine users without a project role (the directory and management users) are never listed or touched. The API checks a key against Zitadel at most once a minute, so a revoked key or a disabled user is refused within a minute. lastCallAt comes from principal_activity, which the auth hook writes at most once a minute per caller and API instance; the same table records when people were last active.

Audit ​

  • Trail (audit:read) pages through GET /audit-events, 50 events at a time, filtered by action prefix, actor, resource and time range (all in the URL). Saved views ▾ names the current filters for you (/audit-views, on every device). An event opens in a side sheet (/audit/<eventId>) with the request that wrote it (request id and route; IP and agent for admins), its payload, the other events of the same resource and the case, decision, run and subject it concerns; ↑ and ↓ step to the next event under the trail's filters, across pages.
  • Exports (audit:export) previews the row count and size of an export (GET /audit-events/count), downloads GET /audit-events/export as CSV or JSON Lines under the default or a chosen file name, checks the file's SHA-256 against the manifest line, and lists past exports with their digests. Scheduled exports lists the schedules with their last run and next run; Schedule this export saves the form's filter (without its time range) on a preset (hourly, daily or weekly at 02:00 UTC) or a cron, and Runs lists each run with a download link for its file. The API redacts and masks payloads for the caller (Audit trail).

Settings ​

Settings needs tenants:settings (admins), except Queues, which reviewers holding cases:assign reach from the workbench. General, SLA, Tenant and Branding read the current settings, then save the whole object with PUT /tenants/me/settings, which writes a tenant.settings.updated audit event with the dotted paths that changed (see the approval settings and the origin allow-list).

PageWhat it holds
GeneralYour preferences (also under Preferences in the avatar menu, for everyone: time zone, date format, landing page and table density) and the workspace defaults (console): workspace name, time zone, date format, landing page, setup checklist and the idle lock (5 to 480 minutes, 30 by default); and what people see: run context for admins only or for everyone who opens it (showRunContextTo), and the kinds of personal data masked for analysts (maskForAnalysts).
TeamThe organization's people holding a role (GET /team/users, users:manage): role, status (active, invited, deactivated), last activity and open cases. Invite person sends an invitation through Zitadel; each row changes the role, deactivates or reactivates, or resends or cancels an invitation. Your own row has no actions.
RolesThe permission matrix of the admin, analyst and integration roles from GET /roles, read-only, with a CSV export.
SLAThe default case SLA (cases.defaultSlaHours, up to 2160 hours), business hours (days, opening hours and time zone the SLA clock runs in), when a case turns due soon (cases.warnAtPct, the share of its SLA left), the at-breach switches (escalate priority, notify the assignee, notify who assigns cases), the SLA hours the published workflows' create_case steps set, and the last 30 days' figures from GET /stats/sla, for the tenant and per workflow (SLAs).
QueuesThe system queues (fixed name and filter; "Claim next" on or off) and the tenant's queues: create, edit, delete and reorder (/case-queues). The editor takes a name, a default sort, filter conditions, who sees the queue (everyone, admins or only you) and whether "Claim next" uses it, and previews what the filter matches now. Not audited.
Reason codesThe reason-code catalogue reviewers pick from when they decide (Reason codes).
TenantIdentity and state (GET /tenants/me: provisioning date, region, status, counts), Approvals required, Four-eyes and the kinds approvals apply to (approvals), Operator review of apps (appReview, read-only: whether the organization's own app versions wait for the operator's review before they are published, which only the deployment's operator sets), the embed origins (embedOrigins, at most 20), the tenant's collection flow URL (collection.flowUrl, https; links point there instead of the deployment's) the lifetime of links for flows that set none (collection.defaultLinkTtlSeconds) after how many days forms nobody saved expire, cancelling a run that waits for one (collection.abandonAfterDays), and the Languages applicants may read forms in (collection.locales, language tags; the first is the one flows are written in), data and retention (the counts, and how long the deployment keeps app sessions and form drop-off events: retention), and the danger zone: suspend or resume the organization and revoke collection links.
BrandingHow the collection terminal looks for the tenant's applicants and whom they contact (branding): display name, logo (and one for the dark theme) and its text alternative, brand color, color scheme, corner radii, border width, heading weight, font, support email and page, privacy notice, terms and the return address after an approval, with https addresses only. Empty values take the deployment's defaults; a name or logo here hides the deployment's name and logo, and either support contact hides both of the deployment's. A preview shows the header and a button.

The preferences in effect are yours over the workspace defaults over the built-in ones; the console fetches them after sign-in (GET /me/preferences, GET /tenants/me) and keeps a copy in the browser so they apply as it loads.

Operator actions ​

POST /tenants/me/suspend { reason }, POST /tenants/me/resume and POST /tenants/me/revoke-links need tenants:settings and a sign-in under five minutes old: the console sends the ID token of the current session in X-Aletheia-Reauth, and the API checks its signature, its subject and its auth_time (401 reauth_required otherwise). The danger zone asks you to sign in again when your sign-in is older than that. While a tenant is suspended every route but GET /me, GET /me/preferences, GET /roles, GET /tenants/me, GET /health/summary and resuming answers 403 tenant_suspended with the reason, and collection links do not open; runs already under way keep running. Revoking links makes every link issued until then answer 401 ("this link was revoked"); forms stay in progress and new links can be sent. Other API instances apply a change within five minutes. They are audited as tenant.suspended, tenant.resumed and tenant.links.revoked.

Masking and redaction by caller ​

The API decides what a response shows from the caller's permissions; the console renders what it receives. Stored data and request bodies are never changed.

PermissionHeld byWithout it
subjects:piiadminPersonal data is masked by key, at any depth: emails (priya.n@…mail.com), phone numbers (+91 98•• ••• 412), identity and account numbers (Z61••••3), dates of birth (1990-••-••) and street address lines (••••). Names of people and organizations, countries and cities stay.
runs:contextadminEvery value becomes "[redacted]" (keys and array lengths stay) in: run context; case context; app invocation input and output; app callback output; the output of an app's test call; rule evaluation data; the details of rule results on decisions and evaluations; every value under a context, data, details, input or output key of an audit payload. The error text of app invocations, app callbacks and test calls becomes "[redacted]"; a run's error stays readable, because the engine records only the step's app and action there and keeps the vendor's reply on the callback record. Outcomes, scores, reasons, statuses and timings stay.

Masking applies to data of subjects (POST /subjects, GET /subjects, GET /subjects/:id, the subject of GET /cases/:id), to attributes and the email column of events (ip, deviceId and cardFingerprint are returned as stored), to data of collection submissions read by anyone but the applicant, and to the same records inside timeline entries. Redaction applies to POST/GET /workflow-runs (every row of the list), GET /workflow-runs/:id, GET /workflow-runs/:id/evaluations, GET /app-invocations, GET /app-invocations/:id, GET /app-callbacks, POST /app-installs/:install/test, GET /decisions, and the run and decisions of GET /cases/:id. The run context holds the signed collection-flow link, which would let a reader act as the applicant. Error text of app invocations and callbacks is redacted like their payloads, as it can quote what the vendor answered about the subject; GET /apps/health returns none.

Audit payloads get both rules by key, at any depth, on GET /audit-events, GET /audit-events/:id, GET /audit-events/export and the audit entries of the subject timeline: masked without subjects:pii, and redacted under context, data, details, input and output without runs:context. Among today's writers that redacts the details of every rule result in workflow.rules.evaluated; ids, keys, versions, outcomes and counts stay, so the trail can still be linked and filtered. Audit trail lists each action's payload and how its fields are classed.

A case's context is a copy of the run context taken when the create_case step opened it, so it carries the same submission data, app output and signed links, and gets the same rule (redacted without runs:context, masked without subjects:pii) on every route that returns a case: GET /cases, the case of GET /cases/:id, POST /cases/:id/assign, /claim, /decide and /close, POST /cases/claim-next, and the case entries of the subject timeline. search still matches context.subjectName on the server. A client that needs the subject's name reads expand=subject on GET /cases or the subject of GET /cases/:id, not the context; the console shows redacted and masked values as hidden for the role.

GET /workflow-runs/:id/history stays admin-only. Free text is returned as stored: decision and rule reasons (a list lookup's reason quotes the value it found), case notes, approval comments, close reasons and file names, including where they appear inside audit payloads.

Smoke ​

scripts/smoke-cases.sh (step 16, sourced from smoke-verification.sh) sets cases.defaultSlaHours = 1, runs kyb-onboarding to manual review, checks the directory (or warns and skips when GET /users is empty), asserts slaState = ok and dueAt about one hour after createdAt, claims the case, checks queues and counts, adds and edits a note, attaches a document, decides, waits for the close with run_completed, verifies the audit trail, closes an undecided case explicitly and restores the settings. Step 17 approves a named merchant through hello-world, rejects its kyb-onboarding case by hand, then checks subject search, the timeline (order, kinds, paging, no case.decided duplicate, the integration role's reduced view) and that the integration role sees every run context value as [redacted] while the admin sees the link.

Released under the Apache-2.0 License.