Kombine Flex Portal API · v1 · Integration guide

Build your own portal or connect an AI agent

The API provides the same information used by the official portal. Send ordinary HTTPS requests and receive JSON. You do not need database access, a particular SDK, or a special agent protocol.

Getting started: Find the API address → log in as a manager → request /api/v1/session/me.

Available now: API availability, the public active-user count, and the signed-in manager’s name, icon, Tabs, bank/location access, and operation permissions. These are the data displayed on the portal’s current overview. Users2 lists are available through GetBankUsers. Other bank, location and unit records and editing operations are not implemented yet. A permitted Tab does not mean its business functionality already has an API endpoint.

Purchases in the last hour

GET /api/v1/public/statistics/purchases requires no login. Its Swagger operation is GetPublicPurchases; the JavaScript client provides client.getPurchases().

Counts rows in the site's A{tenant}.Log1Hour using UserId >= eUserId.Users AND UserId <= eUserId.UsersLast AND Text NOT LIKE '%E' AND Amount < 0. These are purchases, not distinct customers. E matching follows the table's collation. The shared enum defines the inclusive user range, currently 1001–99999. No bank filter is added. The response also includes amount = -SUM(Amount)/100 and currency = MAX(Currency). No purchases returns zero and null. MAX(Currency) assumes a common currency among purchases; no conversion is performed.

The database maintains Log1Hour with cleanup every minute. This operation uses its contents exactly as the agreed SQL does, so the actual window depends on that cleanup. sinceUtc is the nominal start one hour before measuredAtUtc; lookbackHours is 1. tenantKid identifies the site and count is the total. Clients cannot override the tenant or filters.

Results are cached for one minute per API instance. The card refreshes automatically every minute while the page is open. Concurrent requests share one database query. HTTP 503 with Retry-After: 60 means unavailable, not zero. The usual CORS policy applies to JavaScript on other sites.

const response = await fetch('https://localhost:7241/api/v1/public/statistics/purchases');
if (!response.ok) throw new Error('HTTP ' + response.status);
const purchases = await response.json();
console.log(purchases.count, purchases.amount, purchases.currency);

Public data without login

GET /api/v1/public/statistics/active-users — Active-user count for the site's tenant. Swagger: Public → GetPublicActiveUsers.

The count includes distinct bank/user pairs in the tenant's current Log7, with user IDs 1001 through 99999 inclusive, bank ID at least 1000, and MS2000 strictly newer than 100 days before the UTC measurement time. Multiple rows for one pair count once; the same user ID in two banks counts twice. Activity means a recent Log7 record, not necessarily a login. User Enabled and Deleted settings are not additional filters.

This aggregate is public and independent of manager permissions. No individual accounts or per-bank counts are exposed. The site chooses the tenant; this operation accepts no KID, date window, or other filters.

tenantKid identifies the site's tenant, count is the total, lookbackDays is 100, sinceUtc is the exclusive cutoff, and measuredAtUtc is the measurement time. Results are cached for up to 5 minutes per API instance. Concurrent visitors share one query, with no background polling. Database failures return HTTP 503 and Retry-After: 60; display unavailable, not zero. A successful response with count 0 means a genuine zero.

const response = await fetch('https://localhost:7241/api/v1/public/statistics/active-users');
if (!response.ok) throw new Error('HTTP ' + response.status);
const statistics = await response.json();
console.log(statistics.count, statistics.measuredAtUtc);

The JavaScript client also provides client.getActiveUsers(). For JavaScript on another site, configure the client's exact origin in the API's Cors:AllowedOrigins as described below.

Login and manager settings use the site's own A{TenantId:D4}.Log7, BankId 0. D4 pads the tenant ID to at least four digits: Team 166 uses A0166.Log7. The server's PortalSite:TenantId selects the tenant; there is no fallback to another tenant's manager database.

A manager must have Enabled = 1. An absent Deleted row (or SQL NULL) defaults to 0: not deleted. Present invalid Deleted values and positive deletion timestamps still block login.

1. From API address to your first login

Ask the deployment administrator for the API base address for your tenant site. A tenant is the organization to which the site belongs. Local development uses https://localhost:7241 for the API and Nortec, tenant 999. https://localhost:7242 is the official portal, not the API address.

The examples below use localhost. For a hosted installation, replace it with the provided HTTPS address. You cannot switch tenants by adding a field, query parameter, or header. The server determines the site and permissions.

RequestPurposeAuthentication
GET /api/v1/public/statistics/purchasesPurchase count for the last hourNone
GET /api/v1/public/statistics/active-usersActive-user count for the site's tenantNone
GET /api/v1/statusCheck whether the API responds. Does not check MySQL.None
POST /api/v1/session/loginExchange a manager email and password for a temporary access token.Email and password in JSON
GET /api/v1/session/meRetrieve your own profile and current permissions together.Access token

Try it without writing a program

  1. Open Swagger and select Portal integrations v1. Swagger is a web page for reading about and trying API requests.
  2. Expand GetPortalStatus, select Try it out, then Execute. HTTP 200 means the request succeeded.
  3. Expand LoginManager. Replace the example email and password with your manager’s credentials and select Execute. All example credentials are fictitious and cannot log in.
  4. Copy only the accessToken value from the response, without quotation marks. Click Authorize, paste it under ManagerBearer, and authorize. Swagger adds the Bearer prefix.
  5. Run GetCurrentManager. Its response provides the information for your portal’s profile and access cards.

A manager must be enabled and not deleted. Missing Tabs or bank access do not prevent login itself; explain the missing access in your portal.

The HTTP requests

POST https://localhost:7241/api/v1/session/login
Content-Type: application/json
Accept: application/json

{"email":"[email protected]","password":"<your password>"}

Send the original password over HTTPS. The API verifies it; do not encode or hash it in the client.

{
  "accessToken": "<your access token>",
  "expiresIn": 3600,
  "tokenType": "Bearer"
}
GET https://localhost:7241/api/v1/session/me
Authorization: Bearer <your access token>
Accept: application/json

The token lasts one hour from login. Treat it as a secret string: it is not a JWT to decode. Reuse it for requests and log in again when it expires. There are no refresh tokens or single-token revocation endpoints yet. On logout, your client discards its token; an existing copy may remain usable until expiry or account/password revocation.

A complete PowerShell 7 example

Copy this block into PowerShell 7. The dialog asks for the manager email and password. It displays the profile without printing the password or token. For local development, trust the .NET development certificate using dotnet dev-certs https --trust.

$api = 'https://localhost:7241'
$credential = Get-Credential -Message 'Manager email and password'
$session = $null
$body = $null
$headers = $null
try {
    $body = @{
        email = $credential.UserName
        password = $credential.GetNetworkCredential().Password
    } | ConvertTo-Json -Compress

    $session = Invoke-RestMethod "$api/api/v1/session/login" `
        -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 15

    $headers = @{ Authorization = "Bearer $($session.accessToken)" }
    $profile = Invoke-RestMethod "$api/api/v1/session/me" `
        -Headers $headers -TimeoutSec 15
    $profile | ConvertTo-Json -Depth 6
}
catch {
    if ($_.Exception.Response) {
        Write-Warning "HTTP $([int]$_.Exception.Response.StatusCode). See the error table."
    } else {
        Write-Warning 'Could not reach the API. Check the address, connection, and certificate.'
    }
}
finally {
    $body = $null
    $headers = $null
    $session = $null
    $credential = $null
}

2. Understand the profile and permissions

This response is fictitious. Always use the actual values returned by the API.

{
  "kid": "3E7Q46o3B9ACA01h",
  "retentionDays": 30,
  "organisation": "Example organisation",
  "name": "Example manager",
  "icon": "house",
  "tabs": [4, 12],
  "hasBankAccess": true,
  "tabDetails": [{"id": 4, "name": "Bank1"}, {"id": 12, "name": "Dashboard1"}],
  "resourceGrants": [{"kid": "3E7Q14o2Ab", "scope": "Bank"}],
  "navigationBanks": [{"kid": "3E7Q14o2Ab", "name": "Example bank", "icon": "house"}],
  "operationPermissions": [
    {"resource": "Bank", "level": "Read", "canRead": true, "canWrite": false, "canCreate": false},
    {"resource": "Location", "level": "Write", "canRead": true, "canWrite": true, "canCreate": false},
    {"resource": "Unit", "level": "Create", "canRead": true, "canWrite": true, "canCreate": true},
    {"resource": "User", "level": null, "canRead": false, "canWrite": false, "canCreate": false}
  ]
}
FieldMeaning and use
organisationOptional organisation from the manager's eSetting.Organisation in A{TenantId:D4}.Log7, BankId 0. Empty string when absent. The portal displays it below the name when nonblank. Display only; grants no access. Read in the same query and included in the manager's cache of at most 60 seconds.
retentionDaysDays after deletion during which the manager may see a deleted bank, location, unit, user or reservation. Read from eSetting.RetentionDays in A{TenantId:D4}.Log7, BankId 0. Missing, negative or invalid values default to 0, hiding deleted objects. This does not expand Kids, Tab or operation permissions and does not schedule physical deletion. Cached with the manager for at most 60 seconds. GetBankUsers enforces this limit. Future details, counts and searches must enforce it too.
kidThe manager KID string contains this site's tenant, bank zero, and the manager's user ID. Derive the tenant from this KID using Kombine.Flex.Kid; no separate tenantKid field is returned. me only returns the signed-in manager. A KID does not itself grant access.
name, iconDisplay name and icon identifier. The name can be empty. house can be displayed from https://static.kombine.services/icon1/house.svg. Use a known valid icon name or a default icon; never insert raw markup or an arbitrary URL from the field.
tabs, tabDetailsPermitted pages, called Tabs. IDs correspond to eTab; names are stable enum identifiers, not translated page titles. Use IDs as keys and translate your own display text. Do not interpret unknown IDs as known permissions.
hasBankAccessWhether at least one bank or location grant applies on this site. This is not blanket access to data.
resourceGrantsThe specific areas the manager may access, explained below. Does not contain bank names or actual bank records.
operationPermissionsFour independent operation permissions: Bank, Location, Unit, User. Use the computed canRead, canWrite, canCreate flags to display relevant actions.

Which banks and locations?

Each entry in resourceGrants applies only to its tenant. Multiple entries grant access to multiple areas.

The API has already inserted the site's tenant where omitted from the stored Kids. The scope field explains the extent of access without requiring a decoder. This request does not retrieve bank or location names.

Working with KID strings

A KID is the system's shared object identifier. Store and pass the string unchanged; it is neither a number, a token, nor a permission. The API uses Kombine.Flex.Kid.ToString() with an explicit object type so tenant-only identifiers and managers in bank zero preserve their tenant. The examples above identify tenant 999, manager 1000000001, and bank 42.

In C#, read its parts with new Kombine.Flex.Kid(kidString) and use the same library to create KIDs. In JavaScript, use the KID directly as a string key; storing or forwarding an identifier does not require a decoder. Use encodeURIComponent(kid) when a documented route accepts a KID in its URL. Tabs retain their eTab numbers because they are enum values.

The manager KID always uses this site's tenant, even though authentication reads the shared manager database. A KID cannot switch the site's tenant or grant more access. Future object operations must validate the KID, object type, site, and permissions on the server.

This contract revision replaces the separate userId, tenantId, bankId, and locationId fields with KID strings. Update clients that used the old fields. Existing login and profile routes are unchanged.

What may the manager do?

levelReadModifyCreate
ReadYesNoNo
WriteYesYesNo
CreateYesYesYes
nullNoNoNo

A missing or empty permission setting becomes Read on the server. An invalid nonempty setting yields null and no operations. Operation permissions never expand access to Tabs, banks, or locations. Write/Create permissions do not imply a delete permission.

Show “You do not have access to any banks yet” when hasBankAccess is false. Show “You do not have access to any Tabs yet” when tabs is empty. Both messages may apply. A network error or HTTP 503 must be shown as a retrieval error rather than missing permissions.

Client controls help the user navigate. The API must enforce account state, site, Tab, the actual object’s scope, and the operation before returning or changing business records. Editing client JSON or a link must never grant more access.

3. Errors and client actions

Check the HTTP status first. Login can return HTTP 403 with a stable code, for example {"code":"disabled"}. Other errors normally use Problem Details with status, title, and possibly traceId; validation errors can also contain errors. A proxy or web server can return an empty body or HTML, so error handling must not require JSON.

Status / codeMeaningClient action
400Invalid JSON, email, or missing fields.Correct the input. Email is limited to 254 characters; password to 1,024.
401 on loginCredentials do not match a valid manager.Show “Incorrect email or password.” Do not retry automatically.
401 on meMissing, invalid, or expired token, or a changed account/password.Discard the token and return to login. This response does not reveal the exact account state.
403 / disabledThe manager is disabled.Explain that the manager is disabled and suggest contacting the administrator.
403 / deletedThe manager is deleted.Explain that the manager is deleted and suggest contacting the administrator.
403 / account-settingsEnabled is missing or invalid, or a present Deleted value is invalid.Explain that account setup is incomplete and needs administrator attention.
413 / 415Oversized login body / unsupported content type.Send only email and password as application/json. Login body limit: 8,192 bytes.
429Too many login attempts.Wait at least Retry-After seconds (currently 60). Default to 60 seconds if absent.
503Required data cannot be loaded.Display a temporary service error. Offer a retry after a pause.

Login reveals a 403 reason only after verifying the password. Translate the codes in your own interface. Do not depend on English error titles or a particular traceId.

4. Your own portal

Complete JavaScript example

Open the working JavaScript example. It includes API address, email, and password fields and buttons for status, login, profile refresh, and logout. It uses ordinary fetch without npm packages or a framework.

To use it in your portal, copy portal-api.mjs into your JavaScript folder. It is a small optional example; direct fetch calls also work, as shown below. Create one client per user and API site:

import { createPortalClient } from './portal-api.mjs';

const portal = createPortalClient('https://localhost:7241');
// Obtain email and password from your login form.
await portal.login(email, password);
const manager = await portal.getProfile();
// Display manager.name, manager.tabDetails, and manager.operationPermissions.
// Call portal.getProfile() for manual refresh and portal.logout() for logout.

Load your own script with <script type="module" src="./app.mjs"></script>. Host the example files on your own web server; do not open them using file:// or import the module directly from another API origin. The same module works in Node.js with built-in fetch. Server-side JavaScript does not need CORS.

The client keeps only the token in memory, reuses it, clears it after HTTP 401, and provides errors with status, code, and retryAfter for HTTP 429. Network, certificate, timeout, and CORS failures may have no HTTP status. There are no automatic retries or polling. Do not share a client instance between users on a Node.js server.

A portal with its own server

Have your server call the API and keep the access token in each user’s protected session. This is also how the official Blazor portal works. Never share one manager session between users. The browser uses your portal’s own cookie/login while your server sends the bearer token to the API. This does not require CORS.

A browser calling the API directly

This is also supported. When your portal uses a different origin from the API, the API administrator must add its exact origin to Cors:AllowedOrigins. An origin is the scheme, hostname, and optional port, without a path or trailing slash. The default list is empty.

{
  "Cors": {
    "AllowedOrigins": ["https://my-portal.example", "https://localhost:5173"]
  }
}

This is server configuration and requires a restart. The environment variable for the first origin is Cors__AllowedOrigins__0. Only listed origins can read integration responses through a browser. Authentication and permissions still apply. CORS is a browser rule, not access control for server programs.

If your development site runs at http://localhost:5173, add exactly that address; https://localhost:5173 is a different origin. Use the API’s HTTPS address. The browser automatically sends OPTIONS before applicable requests; the API handles it. Do not use mode: 'no-cors': your code would be unable to read the JSON response.

Call this small browser function with the values from your login form. It returns the profile without persisting the token in localStorage or placing it in the URL.

async function loginAndLoadProfile(apiBaseUrl, email, password) {
  const base = apiBaseUrl.replace(/\/$/, '');
  const login = await fetch(`${base}/api/v1/session/login`, {
    method: 'POST',
    credentials: 'omit',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({ email, password }),
    signal: AbortSignal.timeout(15000)
  });
  if (!login.ok) {
    const error = await login.json().catch(() => ({}));
    throw new Error(`Login: HTTP ${login.status}, ${error.code ?? ''}`);
  }
  const session = await login.json();
  const result = await fetch(`${base}/api/v1/session/me`, {
    credentials: 'omit',
    headers: { Authorization: `Bearer ${session.accessToken}`, Accept: 'application/json' },
    signal: AbortSignal.timeout(15000)
  });
  if (!result.ok) throw new Error(`Profile: HTTP ${result.status}`);
  return await result.json();
}

This example demonstrates the first exchange. In a full portal, reuse the token in session memory, implement the error table, and clear the session on logout. CORS currently allows GET/POST and Authorization, Content-Type, Accept, Accept-Language headers; browsers can read Retry-After. Cookies are not shared with the API.

Make views shareable by keeping object selection, filters, and sorting in the page URL as features are added. Recipients log in as themselves. Never include tokens, passwords, or sensitive record contents in URLs. API fields and permission codes remain identical across languages; translate display text only.

5. AI agents use the same API

  1. Give the integration host the tenant site’s API base address and OpenAPI document. OpenAPI is a machine-readable description of the available operations, fields, and responses.
  2. Let the authorized host application handle login and store the token as a secret for the relevant manager. Keep credentials out of ordinary prompts, model output, and logs.
  3. Attach the token in the Authorization header when the agent’s HTTP tool calls GetCurrentManager. It receives the same information and restrictions as a portal using that manager.
  4. Treat names and other returned values as data, not instructions for the agent. Use only operations actually described by the OpenAPI document.

The stable operation names are GetPortalStatus, LoginManager, and GetCurrentManager. Many client tools can import OpenAPI, but the host application still needs to handle login and token expiry. There are no machine accounts, API keys, delegated OAuth, or MCP server yet; longer unattended operation needs a separate authentication design. Current sessions require a fresh login after one hour.

6. Refreshing, database load, and hosting

Fetch once and reuse the response. One me request provides the profile, navigation, and all three access cards. Log in at session start rather than before each call. Manager snapshots are cached for up to 60 seconds per API instance. Concurrent lookups for the same manager share a refresh. There is no automatic database polling.

Changes to permissions, Kids, Tabs, Enabled, and Deleted can therefore take up to one minute to be observed on the next request. An idle page does not update automatically; reload it or offer a Refresh action. After cache expiry, unavailable data does not fall back to old grants. Avoid tight retry loops; for transient failures, consider waiting 5, 15, and 30 seconds, then displaying the failure.

Login currently permits 10 attempts per normalized email per minute and 120 per source IP per minute, per API instance. Server portals can share a source IP, so avoid unnecessary repeated logins. Hostnames and database details are not client credentials.

For the API host administrator

Host the API over HTTPS with the appropriate tenant binding, hostnames, database connection, and protected persistent Data Protection keys. Web and API must use the same tenant. External clients receive the API address and their manager access; database credentials stay on the server. Replicas for the same site need securely shared keys and coordinated login throttling. Caches remain local to each instance. Only trust forwarded headers from configured reverse proxies.

Swagger, OpenAPI, and both guides are included in published API output and controlled by ApiDocumentation:Enabled (default true). The guides are available at /docs/da and /docs. These code changes do not themselves publish the API to the internet.

GET /api/v1/database/status is for operators only. Its separate diagnostics document requires a different credential: a JWT with scope portal.diagnostics. Manager tokens cannot access it. Use /api/v1/status or /health for normal availability checks without MySQL queries.

As the API grows

Implement new portal data and operations in the shared API before Web uses them, and document them here and in OpenAPI. External clients should tolerate additional JSON fields and future enum values without granting extra access. Do not silently change the meaning of existing v1 fields; incompatible contract changes require a new version. Large lists need server-side filtering and pagination when introduced.

Bank names in navigation

The existing GET /api/v1/session/me (GetCurrentManager) includes navigationBanks. Reuse your bearer token and read profile.navigationBanks in JavaScript. Each item has kid, name, and icon; no numeric identifiers or extra request are needed. Use the KID as the item identity and copy that exact string when sharing an identifier.

Labels require an active account, at least one permitted Tab, Bank Read, and a bank or location grant in this site. A location grant allows its parent bank's navigation label only, never all bank data. The array is empty for all-bank access (enumeration is deferred), no Tabs, no scopes, or invalid Bank permission. Each child Tab still requires its own authorization for actual operations.

Names and icons come from the site's Log24, EntryType Settings (2), LocationId 0, UnitId 0, using eSetting.Name (99) and Icon (37). Missing values are empty strings: show an unnamed-bank label and a local icon fallback. Render text as text, never HTML. For a safe icon identifier such as house, use https://static.kombine.services/icon1/house.svg. The portal shows the bank KID on hover and offers a copy action on right-click.

Labels are cached for one minute per bank per API instance, shared between managers; cache misses are batched in groups of at most 64 banks. Authorization is rechecked independently with the bounded manager snapshot. No bank enumeration occurs for tenant-wide grants. A storage failure returns 503: do not interpret it as an empty permission list. Retry later; invalid/expired sessions return 401. Navigation labels do not implement bank detail or deleted-record listings.

tabDetails[].icon comes from AttributeMetaIcon on the corresponding eTab, for example bank_building. Use the same safe icon URL pattern as banks. An empty string means no icon; display a local fallback. Icons come from the shared enum without database queries.

userKid selects exactly one resident in the bank: await client.getBankUsers(bankKid, { userKid: residentKid }). Use a canonical User KID from a previous response. It must belong to the site tenant and specified bank and cannot be combined with filter or cursor (400). The same manager, Tab, User Read, location and RetentionDays checks apply. The response has zero or one item and no continuation cursors. Missing or invisible users return an empty list without revealing whether they exist. The portal shareable view uses /banks/{pageKid}, with both Tab=Users2 and the resident UserId in its bank page KID. Legacy ?resident={userKid} links redirect; browser workspace bookmarks grant no permissions.

Wildcards in filter: * matches zero or more characters and ? matches one character. Examples: filter=1568-*002* or filter=Anna?*. Matching remains substring-based in Number OR Name, including plain text without wildcards. Other characters, including SQL characters % and _, are literal. URL-encode the filter with URLSearchParams or encodeURIComponent. Matching runs in API memory against the existing cached index; no wildcard SQL is generated.

filter matches a substring in number OR name using culture-independent case-insensitive comparison. Maximum 200 characters; surrounding whitespace is trimmed. An empty filter returns the full authorized list. Filtering precedes paging and reuses the shared cached sorting index, including identity mode. Keep filter with the cursor; changing it requires a request without a cursor, otherwise 400 is returned. Permissions and RetentionDays still apply. Example: await client.getBankUsers(bankKid, { sort: 'name', direction: 'asc', filter: 'anna', pageSize: 25 }). URL-encode filter text. Shared links contain the search text.

icon contains the user setting eSetting.Icon (37), normalized to a valid eIcon name. Missing, empty, unknown values and none default to user. Stored enum names and numeric enum values are supported. Display it from https://static.kombine.services/icon1/{icon}.svg. The icon is fetched with the page settings under the same authorization and cache; it adds no separate database query per user.

Set sort=number|name|location|deleted and direction=asc|desc. Ordering applies to the entire authorized list, including progressively loaded pages. Omitting sort preserves legacy identity order (asc only). Numeric numbers sort numerically before text numbers; names and text numbers use language-independent ordinal, case-insensitive comparison. Location means the lowest visible Access/NoAccess location number. Empty values and non-deleted users come first in ASC and last in DESC. Deleted users sort by deletion time. User identity breaks ties.

const page = await client.getBankUsers(bankKid, {
  pageSize: 25, sort: 'name', direction: 'asc'
});
const next = page.nextCursor
  ? await client.getBankUsers(bankKid, {
      pageSize: 25, sort: 'name', direction: 'asc', cursor: page.nextCursor
    })
  : null;

Keep sort, direction and pageSize during traversal. When changing order, restart without a cursor. A cursor for another sort or direction returns 400. Sorted cursors are positions in the current authorized list; data or permission changes can shift positions between requests. Shared links never grant the sender's permissions.

Sorting uses a shared four-setting index cached for up to 60 seconds across managers and sort choices. The API filters permissions and sorts in memory; only the selected page fetches the remaining details. There is no COUNT, SQL OFFSET or query per user. A cold cache scans the bank's ordinary users; very large banks may hit the timeout and return 503. Avoid immediate automatic retries. Identity mode retains its bounded 1,000-candidate scan and scanLimitReached behavior.

The official portal uses GetBankUsers for progressive loading while scrolling. External portals can do the same: request one nextCursor at a time, reuse the result and stop at null. Stop automatic loading on errors and offer an explicit retry. Every request is authorized, including cursors received in a shared link from a colleague.

Users in a bank (Users2)

GET /api/v1/banks/{bankKid}/users?pageSize=25 · operation GetBankUsers. Take the bank KID from navigationBanks or a Bank scope in resourceGrants. Send the manager bearer token. No separate tenant, bank or user IDs are accepted.

const page = await client.getBankUsers(bankKid, { pageSize: 25 });
for (const user of page.items) console.log(user.kid, user.name, user.number);
if (page.nextCursor) {
  const next = await client.getBankUsers(bankKid, {
    pageSize: 25, cursor: page.nextCursor
  });
}

The response contains items, previousCursor, nextCursor and scanLimitReached. Each user has kid, name (99), number (1824), deletedAt (1996, UTC or null), locations (1809, KID and Access/NoAccess), tags (2977, KID and eTagState) and attributes (2978, eUserAttribute name and value; negative means no numeric value). Missing text/lists are empty; missing Deleted means not deleted.

Requires an active manager, Users2 (53), User Read and a matching resource scope. Tenant/bank grants include ordinary bank users. Location grants include only users associated with at least one permitted location, in either Access or NoAccess state; other locations are removed from the response. Name, number, tags and attributes are shared bank-level user data. RetentionDays limits deleted-user visibility; malformed deletion settings hide the user. The inclusive eUserId.Users–UsersLast range excludes manager and service accounts.

pageSize is 1–100 (default 25). The default identity mode orders by ascending user identity. Copy each returned cursor unchanged; null means no continuation in that direction. Cursors can be shared, but never grant access. Recipients use their own permissions and may see different contents. Concurrent changes mean pages are not a frozen snapshot.

In identity mode, to limit MySQL load, batches are cached for at most 60 seconds, without a full count or OFFSET. At most 1,000 candidates are examined per request. With scanLimitReached=true, a page can be short or empty: follow its continuation cursor. Do not automatically poll every page.

400: invalid KID, cursor or page size (restart at the first page). 401: sign in again. 403: missing Tab, scope or User Read. 503: temporary storage failure; show an error and retry later, not an empty list. Tokens are never included in shareable portal URLs. Browser JavaScript requires an allowed CORS origin as described above.

Users2: Each locations item also includes icon, the location eIcon name from eSetting.Icon in Log24. Missing or invalid icons default to house. Icons are cached for up to one minute. state remains Access or NoAccess. The portal displays the icon with state as a data attribute and location number/state/KID in its tooltip.

Bank overview locations

GET /api/v1/banks/{bankKid}/locations (operationId: GetBankLocations) returns an array of {kid,name,icon}. Use a plain bank KID and your manager bearer token.

const response = await fetch(`${api}/api/v1/banks/${encodeURIComponent(bankKid)}/locations`, {
  headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error(`Location request failed: ${response.status}`);
const locations = await response.json();

Requires an active manager, at least one assigned Tab, Location Read (also included in Write/Create), and matching site/bank/location access. Location-only grants return only those locations. Deleted locations respect RetentionDays. 400: invalid/wrong-site KID; 401: sign in again; 403: insufficient access; 503: retry later. Data is cached for up to 60 seconds; authorization is checked on every request. Results are ordered by location number, without pagination. Only locations with a Name, Icon or Deleted setting in Log24 are discoverable. Missing or invalid icons default to house. The portal displays Kid.ToLocationId derived from kid; the API does not return separate numeric identifiers.

Location overview and units

GET /api/v1/locations/{locationKid}/units, operationId GetLocationUnits. Returns {location:{kid,name,icon},items:[{kid,name,icon}]}. Use a canonical location KID from the bank location list or a user's locations; user location entries now also include the location name.

const response = await fetch(`${api}/api/v1/locations/${encodeURIComponent(locationKid)}/units`, {
  headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error(`Unit request failed: ${response.status}`);
const { location, items } = await response.json();

Requires an active manager, at least one Tab, Location Read and Unit Read (Write/Create include Read), plus access to this site and bank or exact location. Every request rechecks access. Both location and unit deletion follow RetentionDays; missing Deleted means not deleted. 400: invalid KID/site; 401: sign in again; 403: insufficient rights; 404: location missing or no longer visible; 503: retry later. At most 255 units, ordered by unit number without pagination. Empty items is a valid result.

Log24 data is cached for up to one minute. Objects need Name, Icon or Deleted to be discoverable. Missing names are empty; missing/invalid icons use house. Web uses the shareable route /locations/{locationKid} and displays Kid.ToUnitId. Workspace shortcuts are stored per manager and browser tab and never grant access; the cross only removes the shortcut.

Unit overview uses the shareable route /units/{unitKid}. The portal reuses GetLocationUnits and selects the exact unit KID from the API-authorized results. Missing or invisible units reveal no details. Additional unit functionality is not yet implemented. Unit workspace shortcuts are nested under their location; removing an open shortcut returns to the location and never deletes the unit.

Unit name language

Send Accept-Language: en-GB on each request. Language is not bound to login or token. Known eLocalization placeholders such as [455] in unit names are resolved from shared resources; surrounding text and unknown IDs are preserved. Content-Language reports the selected language. The ten portal languages and regional variants are supported; no/nn map to Norwegian Bokmål and pt-BR to pt-PT. Missing, malformed or unsupported language defaults to en-GB. Quality preferences are honored and q=0 excluded. Raw data is cached before translation, so languages never mix across users and require no extra SQL.

const response = await fetch(`${api}/api/v1/locations/${locationKid}/units`, { headers: { Authorization: `Bearer ${token}`, "Accept-Language": "en-GB" } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const overview = await response.json();

Current eCycle

GetLocationUnits now returns cycle (stable eCycle name) and cycleText (Accept-Language) per unit. Both are null for missing/invalid values. The newest MS2000 from eSetting.Cycle (1619, Settings) and eState.Cycle (20, States) wins; States wins ties. An invalid newest value never falls back to an older value. Unit data is now cached for at most 10 seconds; location labels and manager permissions for up to one minute. Both SQL branches are bank/location scoped. The portal refreshes every 10 seconds, pauses in hidden tabs, prevents overlapping requests and hides stale details on failure. External clients can repeat this GET with the same bearer token and Accept-Language every 10 seconds.

Settlement: viewing, history and downloads

Every call requires Authorization: Bearer TOKEN, the Settlement2 Tab, Bank Read, User Read and access to the entire bank. Location-only access cannot expose bank-wide settlement data. Permissions are checked on every request with a manager snapshot cached for at most one minute. These GET operations never close, undo or modify a settlement.

OperationGET path
GetBankSettlements/api/v1/banks/{bankKid}/settlements?beforePeriod=123
GetBankSettlementPeriod/api/v1/banks/{bankKid}/settlements/{period}
DownloadBankSettlement/api/v1/banks/{bankKid}/settlements/{period}/download?format=XLS

Omit beforePeriod initially. History returns up to 25 closed periods, nextSettlement and nextBeforePeriod. Pass the next cursor unchanged; null means the end. Unknown dates and values are null. Dates use UTC. Period 0 is the provisional current period, available through details and downloads; it can change until settlement.

Details contain sourceEntries, includedEntries, groups and formats. Each group has group, currency, entries and amountMinor. Amounts are signed database values in minor units, not formatted currency amounts. Different currencies are never added together. Stored history totals may differ from export totals.

ChargePoint_58/TimeNew power consumption is excluded; cash banks also exclude Month and Transfer. Group priority is ETest, EInstaller, EGuest, configured number masks (U), EDate, then LR. Masks use % for multiple characters and _ for one character. Valid legacy entries classified as Unknown retain their recorded amounts. Current user numbers, names, tags and attributes are used, so regenerating a historical file may differ from its original version. Settlement is exempt from RetentionDays: deleted users remain included in period details and downloads regardless of deletion age. Normal manager, Tab, bank and read permissions are still enforced.

The ZIP contains one file per group and currency plus a reconciliation manifest.json. Empty periods contain only the manifest. Text formats may omit groups with no exportable amounts; group totals remain in the manifest. XLS produces real .xlsx workbooks with Number, Amount and UserId following the export convention; UserId in this compatibility file is numeric, while HTTP object identifiers are KIDs. Other formats use UTF-8 text with CRLF. Excel keeps the database amount sign; NAVISION, for example, reverses it.

Formats: XLS, ATB, BL, DEAS, FRUEHØJGAARD, HEIMSTADEN, LEJERBO, MD90_1, MD90_3, MD90_3_minus, MD90_3_plus, MD90_3_AABKBH and NAVISION. MD90_3 is defined for banks 1001 and 1068 only. NIRAS and ROBERT are obsolete. HUMAN, KMD, LYKKEBO and MD90 are not offered because the reviewed shared code contains no implemented export for them. Format identifiers are case-sensitive; use the list returned in the details.

const headers = { Authorization: `Bearer ${token}` };
const base = `/api/v1/banks/${encodeURIComponent(bankKid)}/settlements`;
const details = await fetch(`${base}/12`, { headers });
if (!details.ok) throw new Error(`HTTP ${details.status}`);
const period = await details.json();
const download = await fetch(`${base}/12/download?format=XLS`, { headers });
if (!download.ok) throw new Error(`HTTP ${download.status}`);
const url = URL.createObjectURL(await download.blob());
const link = document.createElement('a');
link.href = url; link.download = 'settlement-12.zip'; link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);

This example assumes the same origin. External browser portals use the full API base address and an allowed CORS origin. 400 means invalid KID/period/format, 401 requires login, 403 means denied permissions, 404 means an unknown closed period, 422 means data cannot be exported safely, and 503 means temporarily unavailable data. Causes of 422 include invalid transaction codes, invalid numbers/currency, duplicate export numbers across users, field width overflow or more than 100,000 entries/10,000 users. No partial files are returned. Correct data/format instead of retrying 422. Back off before retrying 503.

History and period sources are cached for at most one minute, coalescing concurrent reads. Details load only when a period is selected. No background database reads or MySQL writes occur. Legacy readiness calculations and automatic jobs have not been migrated; this API does not claim a bank is ready to close.