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).
  • @aletheia-dev/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:

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 approvaleveryone with definitions:approve
app_degradedthe workers open an app's circuiteveryone with apps:write
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 app'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, the overview's list of pending approvals, of apps disabled, missing secrets or failing in the last hour, 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 (one is installed and 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; app_disabled and app_unconfigured (the secrets missing) with apps:write; app_failing (calls, failures and timeouts in the last hour, or an open circuit) with apps:read; 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 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 @aletheia-dev/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, 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; 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.

Connect ​

Connect › Apps opens with apps:read, which admins and analysts hold; installing, configuring and testing an app 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 Installed and Not installed, each with its version, publisher, category and vendor, its state (Enabled, Disabled or Not installed), the last hour's calls, failures and p95, an open circuit, and tags for an available update, missing secrets or an upload not yet published. A search box matches the name, the vendor and the capabilities, 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) and publishes it, or opens an approval request when the organization requires approvals for apps. On a deployment without an app runner or object storage the page says apps are off.
  • An app's page heads with its state, publisher, health, an open circuit (with when it opened) and a module the operator blocked, then the last hour's figures. Install (or Installation once installed, apps:write) picks the version (installed, latest, and the publisher when the app has versions of both), lists the hosts that version calls to be approved (Approve these hosts; an upgrade marks the new ones), and holds Enabled for this organization, the Configuration form generated from the version's config schema and the Secrets of the version (Set value…, Rotate…, Revoke). One save installs, configures or moves the install (Install app →, Save changes →, Upgrade to vX →): for an upgrade the values entered for the new version's secrets are stored first, so the app stays enabled throughout, and a new install that needs secrets is created off, given them, then turned on. Below come Actions (each with its timeout, retries and whether it is idempotent or completes through a webhook, and Try, which calls it once with the stored configuration), the invocations a page at a time (GET /app-invocations?appName=), the newest callbacks (GET /app-callbacks?appName=), Versions (status, publisher, size and memory; the organization's own with Publish, Approve or Reject an open request, and Withdraw), Used by (GET /apps/:name/usage) and Uninstall, which says how many runs wait on the app's callbacks and offers to fail them. The rail has the vendor's documentation and pricing note, the capabilities, the approved hosts, the inbound webhook endpoint the vendor calls and the installation's details, with a link to the app's events in the audit trail (Apps).
  • 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 /apps/:name/test (apps:write) calls one action of the installed version once with the install's configuration, secrets and hosts, enabled or not: one attempt, no circuit, nothing recorded but the app.tested audit event. 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. POST /apps/:name/secrets/:secret (apps:write) seals { value } with SECRET_STORE_KEY (AES-256-GCM, bound to the tenant, the app and the secret's name) 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 page are listed in Apps: reference.

The workers keep a circuit per tenant and app: after five calls in a row that ran the app and failed or timed out, calls are refused at once (app_circuit_open, 503) for 30 seconds, then one trial call goes out; the first that succeeds closes the circuit. 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 them.

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), 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, 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 /apps/:name/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.