Skip to content

Backend Admin API Contract

Related documentation: Backend Admin · Backend Core · Backend Core API · Backend Auth · Admin Platform Spec

Detailed Target Contract for Platform Admin API

Section titled “Detailed Target Contract for Platform Admin API”

Audience: backend engineers, frontend engineers, QA, product, operations
Status: docs-first target contract with runtime notes
Scope: this page describes the intended Admin API surface for the new system. It is more important than the legacy runtime shape when the two differ, but implemented runtime route families are called out where they already exist.

This page is the target Admin contract.

  • some of these endpoints do not exist yet in the current Backend-Kisum-Admin
  • some currently existing Admin endpoints are legacy and are not the final shape
  • where an endpoint is described below, its job is to provide the staff-facing control-plane surface, while Core or Auth remains the source of truth underneath
  • current runtime exception: Backend-Kisum-Admin now also proxies Market directory routes under /api/v1/admin/artists-directory/*
  • Base path: /api/v1/admin
  • Auth: Authorization: Bearer <access_token>
  • Caller must be platform staff
  • Admin orchestrates
  • Core stores company/commercial truth
  • Auth stores identity/access truth
  • Market stores artists-directory truth for artists, market companies, people, taxonomy, and related directory relations
  • /api/v1/admin/companies is reserved for Core tenant-company control-plane operations.
  • /api/v1/admin/artists-directory/companies is the Market directory-company namespace.
  • The same split applies conceptually to related people/contact directory endpoints.

All protected requests:

Authorization: Bearer <access_token>
Content-Type: application/json

Recommended Admin response envelope:

{
"success": true,
"data": {}
}

For lists:

{
"success": true,
"data": {
"items": [],
"total": 0,
"page": 1,
"limit": 20
}
}

For errors:

{
"success": false,
"error": {
"code": "validation_error",
"message": "Human-readable error message"
}
}

Every endpoint below should document three things:

  • what the endpoint does for platform staff
  • what backend truth it writes to
  • what downstream effect should happen

Packages are commercial catalog entities. Admin provides the staff-facing management surface, but Core stores the truth.

Purpose

List packages for platform administrators.

Target

  • read from Core package catalog
  • display package metadata and mapped modules

Request body

  • no body

Response shape

Admin returns the Core subscription-summary payload unchanged.

The response contains:

  • hasBasic
  • basePackage
  • items
  • entitlementVersion

items contains one row per active commercial assignment:

  • one package row when a base package is active
  • one addon row per active add-on
  • one restriction row per active restriction package (negative org override)

Each item includes:

  • id
  • key
  • name
  • description
  • status
  • startsAt
  • endsAt
  • priceMinor
  • currency
  • billingInterval
  • taxCode
  • taxInclusive
  • trialDays
  • regionPricing

Important:

  • priceMinor is stored as a decimal amount
  • example: 199.00 USD is sent/stored as 199.00

Example:

{
"success": true,
"data": {
"companyId": "fb8163f2-587f-4c7b-b5bc-628e7b121520",
"hasBasic": true,
"basePackage": "basic",
"items": [
{
"kind": "package",
"id": "255db6d9-c9bc-4fff-a08f-b97f4906507e",
"key": "basic",
"name": "Basic",
"description": "Basic subscription that enables the core application",
"isActive": true,
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z",
"priceMinor": 99.00,
"currency": "USD",
"billingInterval": "monthly",
"taxCode": "digital_services",
"taxInclusive": false,
"trialDays": 14,
"regionPricing": [],
"entitlementKind": "package",
"entitlementLabel": "Package"
},
{
"kind": "addon",
"id": "b8c37dfb-d154-408a-a7e8-fbfab9ff6a2c",
"key": "finance",
"name": "Finance",
"description": "Finance add-on",
"isActive": true,
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z",
"priceMinor": 49.00,
"currency": "USD",
"billingInterval": "monthly",
"taxCode": "digital_services",
"taxInclusive": false,
"trialDays": 7,
"regionPricing": [],
"entitlementKind": "addon",
"entitlementLabel": "Add-on"
}
],
"entitlementVersion": 7
}
}

Purpose

Create a new commercial package from the Admin control plane.

Target

  • create package in Core
  • optionally map modules during creation

Body

{
"key": "basic",
"name": "Basic",
"description": "Basic subscription that enables the core application",
"isActive": true,
"priceMinor": 99.00,
"currency": "USD",
"billingInterval": "monthly",
"taxCode": "digital_services",
"taxInclusive": false,
"trialDays": 14,
"regionPricing": [
{
"region": "SG",
"currency": "SGD",
"priceMinor": 129.00
}
],
"moduleKeys": ["basic"]
}

Field meaning

  • key
    • stable package identifier used across systems
  • name
    • display name shown in admin UI and potentially billing flows
  • description
    • internal or UI-facing commercial description
  • isActive
    • whether the package can be assigned to companies
  • priceMinor
    • commercial price as a decimal amount, for example 199.00
  • currency
    • 3-letter currency code
  • billingInterval
    • one of monthly, quarterly, yearly, one_time
  • taxCode
    • optional tax classification
  • taxInclusive
    • whether the stored price already includes tax
  • trialDays
    • free-trial duration in days
  • regionPricing
    • optional region-specific price overrides
  • moduleKeys
    • modules bundled in the package

2.3 GET /api/v1/admin/packages/{packageId}

Section titled “2.3 GET /api/v1/admin/packages/{packageId}”

Purpose

Read one package in detail.

Target

  • fetch package metadata and its module mappings from Core

Request body

  • no body

2.4 PATCH /api/v1/admin/packages/{packageId}

Section titled “2.4 PATCH /api/v1/admin/packages/{packageId}”

Purpose

Update a package without recreating it.

Target

  • patch package metadata in Core
  • optionally replace mapped modules

Body

{
"name": "Basic Plan",
"description": "Updated basic plan description",
"isActive": true,
"priceMinor": 119.00,
"currency": "USD",
"billingInterval": "yearly",
"trialDays": 30,
"moduleKeys": ["basic", "finance"]
}

Important rule

  • if moduleKeys is omitted, package-module mappings should remain unchanged
  • if moduleKeys is provided, it should replace the package’s module mapping set

2.5 PUT /api/v1/admin/packages/{packageId}/modules

Section titled “2.5 PUT /api/v1/admin/packages/{packageId}/modules”

Purpose

Replace the module mapping for a package explicitly.

Target

  • update package-module relationships in Core

Body

{
"moduleKeys": ["basic", "finance", "venue"]
}

2.6 DELETE /api/v1/admin/packages/{packageId}

Section titled “2.6 DELETE /api/v1/admin/packages/{packageId}”

Purpose

Delete a package from the platform catalog.

Target

  • delete package in Core

Important rule

  • delete must fail when the package is already assigned to companies

Modules represent commercial features/products in the platform catalog.

Purpose

List the module catalog.

Target

  • read module definitions from Core

Request body

  • no body

Purpose

Create a new module in the commercial catalog.

Target

  • create module in Core

Body

{
"key": "finance",
"name": "Finance",
"type": "addon",
"description": "Finance module",
"isActive": true
}

Field meaning

  • key
    • stable identifier
  • name
    • display name
  • type
    • commercial/module grouping type
  • description
    • human-readable explanation of the module
  • isActive
    • whether the module may be used in active catalog configurations

Purpose

Get one module definition.

Request body

  • no body

3.4 PATCH /api/v1/admin/modules/{moduleId}

Section titled “3.4 PATCH /api/v1/admin/modules/{moduleId}”

Purpose

Update module metadata or lifecycle state.

Target

  • patch module metadata in Core

Body

{
"name": "Finance Module",
"description": "Updated finance module description",
"isActive": true
}

3.5 DELETE /api/v1/admin/modules/{moduleId}

Section titled “3.5 DELETE /api/v1/admin/modules/{moduleId}”

Purpose

Delete a module from the commercial catalog.

Target

  • delete module in Core

Important rule

  • delete must fail when the module is still linked to packages or add-ons

Add-ons are commercial catalog entities sold or assigned on top of packages.

Purpose

List add-ons available in the platform catalog.

Request body

  • no body

Purpose

Create an add-on.

Target

  • create add-on in Core
  • optionally map modules during creation

Body

{
"key": "finance",
"name": "Finance",
"description": "Finance add-on",
"isActive": true,
"priceMinor": 49.00,
"currency": "USD",
"billingInterval": "monthly",
"taxCode": "digital_services",
"taxInclusive": false,
"trialDays": 7,
"moduleKeys": ["finance"]
}

Important rule

  • add-ons may only map modules whose module type is addon

Purpose

Get one add-on in detail.

Request body

  • no body

Purpose

Update add-on metadata and optionally its module mappings.

Body

{
"name": "Finance Add-on",
"description": "Updated finance add-on description",
"isActive": true,
"priceMinor": 59.00,
"currency": "USD",
"billingInterval": "yearly",
"moduleKeys": ["finance"]
}

4.5 PUT /api/v1/admin/addons/{addonId}/modules

Section titled “4.5 PUT /api/v1/admin/addons/{addonId}/modules”

Purpose

Replace the mapped modules for an add-on.

Body

{
"moduleKeys": ["finance", "market"]
}

Purpose

Delete an add-on from the platform catalog.

Target

  • delete add-on in Core

Important rule

  • delete must fail when the add-on is already assigned to companies

Restriction packages are negative catalog entries: they list Auth permission keys to deny for a company when attached. They are managed under Packages & Add-ons → Restrictions in Admin FE.

Admin proxies Core catalog CRUD under /api/v1/admin/restriction-packages (same auth as add-ons catalog).

4A.1 GET /api/v1/admin/restriction-packages

Section titled “4A.1 GET /api/v1/admin/restriction-packages”

List restriction packages in the platform catalog.

4A.2 POST /api/v1/admin/restriction-packages

Section titled “4A.2 POST /api/v1/admin/restriction-packages”

Create a restriction package in Core.

Body

{
"key": "agencies_contact",
"name": "Agencies & artist contacts",
"description": "Blocks agency directory and artist team/contact surfaces",
"isActive": true,
"permissionKeys": ["promoter.booking.agencies.view"]
}

4A.3 GET /api/v1/admin/restriction-packages/{restrictionPackageId}

Section titled “4A.3 GET /api/v1/admin/restriction-packages/{restrictionPackageId}”

Get one restriction package (includes permissionKeys).

4A.4 PATCH /api/v1/admin/restriction-packages/{restrictionPackageId}

Section titled “4A.4 PATCH /api/v1/admin/restriction-packages/{restrictionPackageId}”

Update name, description, active flag, or permissionKeys.

4A.5 DELETE /api/v1/admin/restriction-packages/{restrictionPackageId}

Section titled “4A.5 DELETE /api/v1/admin/restriction-packages/{restrictionPackageId}”

Delete from catalog. Fails when still assigned to companies.


Company operations are initiated by Admin but stored in Core.

Purpose

List companies from the platform control plane.

Target

  • list company records from Core
  • may aggregate profile and address summaries for UI convenience

Request body

  • no body

Purpose

Create a company through the Admin control plane.

Target

  • write company master/profile/address/social/document structure into Core

Body

{
"legalName": "Kisum Entertainment Group Pte Ltd",
"displayName": "Kisum Entertainment Group",
"status": "active",
"createdSource": "admin",
"metadata": {
"region": "SG"
},
"profile": {
"website": "https://group.kisum.dev",
"email": "group@kisum.dev",
"phone": "+65 6999 8888",
"timezone": "Asia/Singapore",
"industry": "Entertainment",
"description": "Regional artist management and touring group",
"metadata": {
"preferredLanguage": "en"
}
},
"addresses": [
{
"type": "primary",
"line1": "120 Orchard Road",
"line2": "Level 15",
"city": "Singapore",
"state": "Singapore",
"postalCode": "238888",
"country": "Singapore",
"isPrimary": true
}
],
"socialLinks": [
{
"platform": "instagram",
"label": "Official Instagram",
"url": "https://instagram.com/kisumgroup"
}
],
"documents": [
{
"type": "business_registration",
"name": "Business Registration Certificate",
"storageKey": "companies/company-id/business-registration.pdf",
"url": "https://files.kisum.dev/companies/company-id/business-registration.pdf",
"mimeType": "application/pdf",
"sizeBytes": 251004,
"metadata": {
"version": 1
}
}
]
}

Target system

  • Core

Important rule

  • Admin should not persist this company locally as source of truth

5.3 GET /api/v1/admin/companies/{companyId}

Section titled “5.3 GET /api/v1/admin/companies/{companyId}”

Purpose

Get one company in detail.

Target

  • read company bundle from Core

Request body

  • no body

5.4 PATCH /api/v1/admin/companies/{companyId}

Section titled “5.4 PATCH /api/v1/admin/companies/{companyId}”

Purpose

Update a company from Admin.

Target

  • patch company master and optional nested sections in Core

Body

{
"displayName": "Kisum Entertainment Group",
"status": "active",
"metadata": {
"region": "SG",
"tier": "enterprise"
},
"profile": {
"website": "https://group.kisum.dev",
"email": "group@kisum.dev",
"phone": "+65 6999 8888",
"timezone": "Asia/Singapore",
"industry": "Entertainment",
"description": "Regional artist management and touring group",
"metadata": {
"preferredLanguage": "en"
}
},
"addresses": [
{
"id": "0f4de9f7-6f87-4f8c-8209-7480ac8d2374",
"type": "primary",
"line1": "120 Orchard Road",
"line2": "Level 15",
"city": "Singapore",
"state": "Singapore",
"postalCode": "238888",
"country": "Singapore",
"isPrimary": true
}
],
"socialLinks": [
{
"id": "2c2f57c9-cce0-4a6e-8dc9-cf2d2e4e86c7",
"platform": "instagram",
"label": "Official Instagram",
"url": "https://instagram.com/kisumgroup"
}
],
"documents": [
{
"id": "698c5ef5-1c93-4d8f-b644-80d7d3a75af0",
"type": "business_registration",
"name": "Updated Business Registration Certificate",
"storageKey": "companies/company-id/business-registration-v2.pdf",
"url": "https://files.kisum.dev/companies/company-id/business-registration-v2.pdf",
"mimeType": "application/pdf",
"sizeBytes": 251004,
"metadata": {
"version": 2
}
}
]
}

Important partial-update rule

  • omitted collection keys mean no change
  • empty collection arrays mean replace with empty set

5.5 POST /api/v1/admin/companies/{companyId}/approve

Section titled “5.5 POST /api/v1/admin/companies/{companyId}/approve”

Purpose

Approve a draft company from the admin review workflow. Approval activates the company — there is no separate approve-then-activate step for signup drafts. For test_submission records, the same endpoint also repairs an older partially provisioned active company.

Orchestration (implemented)

  1. Load company state from Core (GET /internal/companies/{id}). Accept draft, plus active only for test_submission reconciliation.
  2. Resolve Auth memberships. If the signup membership is missing, recover the applicant from metadata.signupUserId and recreate the TENANT_SUPERADMIN membership.
  3. Approve/activate PENDING users, accept APPROVED users on retries, and activate membership rows. Rejected/deleted users fail closed.
  4. Test-submission signups: Core POST /internal/companies/{id}/activate-signup provisions or repairs the package/add-ons from metadata, then Admin reloads memberships and replaces owner/tenant-superadmin permission grants from enabled modules.
  5. Other draft companies: Core PATCH /internal/companies/{id} with { "status": "active" }.
  6. Return refreshed Core company bundle.

Body

Optional JSON (currently ignored); {} is fine.

5.6 POST /api/v1/admin/companies/{companyId}/reject

Section titled “5.6 POST /api/v1/admin/companies/{companyId}/reject”

Purpose

Reject a draft company signup.

Orchestration (implemented)

  1. Require status = draft (idempotent when already archived).
  2. Reject pending Auth users linked via company memberships (approvalStatus = REJECTED, isActive = false).
  3. Core PATCH /internal/companies/{id} with { "status": "archived" }.
  4. Return refreshed Core company bundle.

Body

Optional JSON (currently ignored).

5.7 POST /api/v1/admin/companies/{companyId}/activate

Section titled “5.7 POST /api/v1/admin/companies/{companyId}/activate”

Purpose

Re-activate a company that was deactivated (inactive or suspended) or archived (e.g. after Reject). Not used for draft signup approval — use Approve instead. The platform tombstone company (00000000-0000-0000-0000-000000000001) cannot be activated.

Orchestration (implemented)

  • inactive / suspended / archived → Core PATCH { "status": "active" }.
  • draft409 (use Approve for draft signup companies).
  • Already active → idempotent; returns current bundle.

Body

Optional JSON (currently ignored).

5.8 POST /api/v1/admin/companies/{companyId}/deactivate

Section titled “5.8 POST /api/v1/admin/companies/{companyId}/deactivate”

Purpose

Deactivate an active company (activeinactive).

Orchestration (implemented)

  • active → Core PATCH { "status": "inactive" }.
  • Already inactive → idempotent.
  • Other statuses → 409 conflict.

Body

Optional JSON (currently ignored).

Admin UI button rules (Frontend-Kisum-Admin company detail → Overview)

StatusApproveRejectActivateDeactivateHard delete
draftenabledenableddisableddisableddisabled
activedisableddisableddisabledenableddisabled
inactivedisableddisabledenableddisabledenabled
archived (rejected)disableddisabledenableddisabledenabled
deletingdisableddisableddisableddisabledenabled
suspendeddisableddisabledenableddisableddisabled

Platform staff hard-delete orchestration. Full matrix, tombstone rules, and ops runbook: Company hard-delete lifecycle.

GET /api/v1/admin/companies/{companyId}/delete-preview

Section titled “GET /api/v1/admin/companies/{companyId}/delete-preview”

Aggregated per-service row counts and blockers before confirm. Proxies Core + Auth + Promoters + Finance + Artists + Venues + Checkout machine previews.

POST /api/v1/admin/companies/{companyId}/delete

Section titled “POST /api/v1/admin/companies/{companyId}/delete”

Starts async purge. Body: { "force": boolean, "reason": string, "confirmName": string }confirmName must match Core legal or display name. Creates Core company_delete_jobs row (locks company status=deleting), returns { "jobId": "…" }. Admin orchestrator runs purge steps asynchronously.

GET /api/v1/admin/companies/{companyId}/delete-status/{jobId}

Section titled “GET /api/v1/admin/companies/{companyId}/delete-status/{jobId}”

Poll job status, per-step progress (Admin overlay + Core job row), and errorMessage.

Breaking: legacy DELETE /api/v1/admin/companies/{companyId} returns 405 — use POST …/delete.


These endpoints are staff-facing wrappers around Core commercial operations.

6.1 GET /api/v1/admin/companies/{companyId}/subscription

Section titled “6.1 GET /api/v1/admin/companies/{companyId}/subscription”

Purpose

Read the current company subscription summary.

Target

  • read from Core subscription-summary view

Request body

  • no body

6.2 POST /api/v1/admin/companies/{companyId}/package

Section titled “6.2 POST /api/v1/admin/companies/{companyId}/package”

Purpose

Assign or change the base package for a company.

Target

  • write to Core package/subscription assignment flow

Body

{
"packageKey": "basic",
"status": "active",
"startsAt": "2026-04-19T00:00:00Z",
"endsAt": "2026-05-19T00:00:00Z",
"source": "platform_admin",
"externalReference": "stripe_sub_123"
}

6.3 POST /api/v1/admin/companies/{companyId}/addons

Section titled “6.3 POST /api/v1/admin/companies/{companyId}/addons”

Purpose

Add an add-on to a company.

Target

  • write to Core company add-on assignment flow

Body

{
"addonKey": "finance",
"status": "active",
"startsAt": "2026-04-19T00:00:00Z",
"endsAt": "2026-05-19T00:00:00Z",
"source": "platform_admin",
"externalReference": "stripe_addon_finance_123"
}

Admin also accepts:

{
"addonId": "b8c37dfb-d154-408a-a7e8-fbfab9ff6a2c",
"status": "active"
}

When addonId is sent, Admin resolves the catalog add-on id to the Core addonKey before calling Core.

6.4 DELETE /api/v1/admin/companies/{companyId}/addons/{addonId}

Section titled “6.4 DELETE /api/v1/admin/companies/{companyId}/addons/{addonId}”

Purpose

Remove an add-on from a company.

Request body

  • no body

Important

  • {addonId} must be the catalog add-on id from the subscription items[] row
  • Admin resolves that id to the Core addonKey before deactivating the company add-on

6.4A POST /api/v1/admin/companies/{companyId}/restrictions

Section titled “6.4A POST /api/v1/admin/companies/{companyId}/restrictions”

Attach or update a restriction package on a company.

Body

{
"restrictionKey": "agencies_contact",
"status": "active"
}

Admin also accepts restrictionId (catalog UUID from subscription items[]) and resolves it to restrictionKey before calling Core.

6.4B DELETE /api/v1/admin/companies/{companyId}/restrictions/{restrictionId}

Section titled “6.4B DELETE /api/v1/admin/companies/{companyId}/restrictions/{restrictionId}”

Deactivate a company restriction. {restrictionId} is the catalog restriction package id from subscription items[] where kind is restriction.

6.5 GET /api/v1/admin/companies/{companyId}/entitlements

Section titled “6.5 GET /api/v1/admin/companies/{companyId}/entitlements”

Purpose

Inspect the resulting company entitlements.

Target

  • read entitlement result from Core

Request body

  • no body

6.6 GET /api/v1/admin/companies/{companyId}/history

Section titled “6.6 GET /api/v1/admin/companies/{companyId}/history”

Purpose

Inspect commercial history for audit, support, or troubleshooting.

Target

  • read from Core entitlement/subscription history

Request body

  • no body

These endpoints are staff-facing wrappers around Auth-side access governance.

Purpose

List the current delegation rules used by Auth.

Target

  • read from Auth delegation policy state

Request body

  • no body

Purpose

Create a new delegation rule set.

Target

  • write delegation policy into Auth

Body

{
"role": "ADMIN",
"grantableModules": ["basic", "finance"],
"grantablePermissions": [
"finance.read",
"finance.write",
"users.manage"
],
"canManageUsers": true,
"canBuyAddons": false
}

7.3 PATCH /api/v1/admin/delegation-rules/{ruleId}

Section titled “7.3 PATCH /api/v1/admin/delegation-rules/{ruleId}”

Purpose

Update delegation limits.

Target

  • patch Auth-side delegation policy

Body

{
"grantableModules": ["basic"],
"grantablePermissions": [
"finance.read"
],
"canManageUsers": false,
"canBuyAddons": false
}

These routes should exist in Admin as the platform-staff control surface, even when the underlying source of truth is Auth.

This distinction must be understood before implementing any Admin user-access screen.

A user is the person/account record stored in Auth.

It answers identity-level questions such as:

  • who is this person
  • what is their email
  • what is their full name
  • are they active
  • do they have a platform global role
  • what sessions do they currently have

Typical user fields:

  • id
  • email
  • fullName
  • isActive
  • globalRole
  • approvalStatus

A user exists independently of any company or business unit.

One user can exist even if they are not assigned to any tenant scope yet.

A membership is the relationship between a user and an organizational scope.

That scope can be:

  • a company
  • a business unit

A membership answers access-level questions such as:

  • is this user assigned to this company
  • is this user assigned to this business unit
  • what role do they have in that scope
  • is that scoped assignment active
  • which module grants apply in that scope
  • which permission grants apply in that scope
  • which delegation limits apply in that scope

Typical membership fields:

  • userId
  • companyId
  • optional businessUnitId
  • role
  • isActive
  • moduleGrants
  • permissionGrants
  • delegation

This is the core reason the two endpoint groups are different.

Example:

User
- id: U1
- email: marco@kisum.io
Company membership
- user_id: U1
- company_id: C1
- role: ADMIN
Business-unit membership
- user_id: U1
- company_id: C1
- business_unit_id: BU7
- role: APPROVER
Business-unit membership
- user_id: U1
- company_id: C1
- business_unit_id: BU9
- role: MEMBER

This is still one user, but multiple scoped memberships.

Use users endpoints when the screen is identity-centered.

That means the main question is:

  • who are the people/accounts in the system

Use users endpoints for:

  • global user directories
  • platform-user directories
  • user detail pages
  • user activation/deactivation
  • user context inspection
  • session inspection

Examples:

  • GET /api/v1/admin/platform-users
  • GET /api/v1/admin/users
  • GET /api/v1/admin/platform-users/{userId}
  • GET /api/v1/admin/platform-users/{userId}/context

Use memberships endpoints when the screen is scope-assignment-centered.

That means the main question is:

  • who belongs to this company or business unit, and with what scoped role/access

Use memberships endpoints for:

  • company access administration
  • business-unit access administration
  • role assignment in tenant scopes
  • module grant editing
  • permission grant editing
  • delegation configuration in tenant scopes

Examples:

  • GET /api/v1/admin/companies/{companyId}/memberships
  • POST /api/v1/admin/companies/{companyId}/memberships
  • DELETE /api/v1/admin/companies/{companyId}/memberships/{membershipId}

GET …/memberships accepts ?includeInactive=true to also return deactivated memberships (is_active = false), so the memberships screen can show and then delete them; the default and the /users directory list stay active-only.

  • GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships
  • POST /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships

Deactivate vs. hard-delete. POST …/memberships with isActive: false deactivates a membership (the row stays; access is revoked). DELETE …/memberships/{membershipId} permanently removes it — the company membership, the user’s business-unit memberships in that company, and all module/permission rules, grants, and delegations for that company, in one transaction. The user account and the user’s memberships in other companies are untouched. Admin proxies to Auth’s machine route; Auth refuses to remove a company’s last tenant super admin (409 last_tenant_admin) and writes an audit log. Prefer delete over an accumulating pile of deactivated rows — a deactivated membership that downstream code forgets to filter reappears where it should not (it surfaced in the Promoters company switcher).

Why both /users and /memberships may appear in one screen

Section titled “Why both /users and /memberships may appear in one screen”

Some Admin screens need both views at the same time.

Example:

  • a company page may show users
    • human directory view
    • useful for search, contact, status, and profile inspection
  • the same company page may also show memberships
    • access administration view
    • useful for role changes, grants, and delegation settings

So:

  • users = identity-oriented representation
  • memberships = access-oriented representation

Use this rule during implementation:

  • user = who the person/account is
  • membership = what access assignment that person has in a specific scope

Purpose

List users visible to platform staff.

Target

  • read through Auth user-administration APIs

Request body

  • no body

Typical query parameters

  • approvalStatus
  • globalRole
  • limit
  • offset

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/platform-users
  • Auth upstream: GET /internal/users?globalRole=true
  • Auth mode: forwarded Authorization: Bearer <token>

This route is for the platform-staff directory only.

Purpose

List all non-platform users across the system, not filtered by company.

Target

  • read tenant/non-platform users through Auth

Request body

  • no body

Typical query parameters

  • approvalStatus
  • limit
  • offset

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/users
  • Auth upstream: GET /internal/users?globalRole=false
  • Auth mode: forwarded Authorization: Bearer <token>

Important rule

  • this is the global tenant-user directory
  • this is not limited to one company
  • company-specific user views must use the company-scoped routes below

8.1B Test-submission signup requests (2026-07-08)

Section titled “8.1B Test-submission signup requests (2026-07-08)”

Purpose

Orchestrate approval/rejection for marketing test submission signups. Approval accepts a draft company or retries an active partial approval; other company states fail closed.

MethodAdmin routeUpstream
POST/api/v1/admin/signup-requests/{userId}/approveRetry-safe: Auth user PENDING or APPROVED; membership activate; Core activate-signup; owner permission grants reseeded
POST/api/v1/admin/signup-requests/{userId}/rejectAuth REJECTED + isActive=false; Core PATCH /internal/companies/{id} status=archived when draft test submission
PATCH/api/v1/admin/users/{id}Alias to PATCH /api/v1/admin/platform-users/{id} (Auth bearer user patch)

Frontend: Frontend-Kisum-Admin/users list (approvalStatus filter + column) and user detail approve/reject actions.

Purpose

Create a user from Admin.

Target

  • create user in Auth

Body

{
"email": "ops-admin@kisum.dev",
"fullName": "Ops Admin",
"password": "TemporaryPassword123!",
"globalRole": "PLATFORM_ADMIN",
"approvalStatus": "APPROVED",
"isActive": true
}

Admin backend mapping

  • Admin endpoint: POST /api/v1/admin/platform-users
  • Auth upstream: POST /internal/users
  • Auth mode: forwarded Authorization: Bearer <token>

8.3 GET /api/v1/admin/platform-users/{userId}

Section titled “8.3 GET /api/v1/admin/platform-users/{userId}”

Purpose

Get one user from the platform-admin perspective.

Target

  • read user detail from Auth

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/platform-users/{userId}
  • Auth upstream: GET /internal/users/{id}
  • Auth mode: forwarded Authorization: Bearer <token>

8.4 PATCH /api/v1/admin/platform-users/{userId}

Section titled “8.4 PATCH /api/v1/admin/platform-users/{userId}”

Purpose

Update a user from Admin.

Target

  • patch user in Auth

Body

{
"fullName": "Updated Ops Admin",
"globalRole": "PLATFORM_SUPERADMIN",
"approvalStatus": "APPROVED",
"isActive": true
}

Admin backend mapping

  • Admin endpoint: PATCH /api/v1/admin/platform-users/{userId}
  • Auth upstream: PATCH /internal/users/{id}
  • Auth mode: forwarded Authorization: Bearer <token>

8.5 DELETE /api/v1/admin/platform-users/{userId}

Section titled “8.5 DELETE /api/v1/admin/platform-users/{userId}”

Purpose

Deactivate a user from Admin.

Target

  • deactivate user in Auth

Request body

  • no body

Admin backend mapping

  • Admin endpoint: DELETE /api/v1/admin/platform-users/{userId}
  • Auth upstream: DELETE /internal/users/{id}
  • Auth mode: forwarded Authorization: Bearer <token>

8.6 GET /api/v1/admin/platform-users/{userId}/context

Section titled “8.6 GET /api/v1/admin/platform-users/{userId}/context”

Purpose

Read the full admin-visible user context, including memberships.

Target

  • read Auth user-context output

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/platform-users/{userId}/context
  • Auth machine endpoint: GET /internal/admin/users/{id}/context
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

8.7 POST /api/v1/admin/platform-users/{userId}/revoke-all

Section titled “8.7 POST /api/v1/admin/platform-users/{userId}/revoke-all”

Purpose

Revoke all sessions for a user.

Target

  • revoke sessions in Auth

Body

{}

Admin backend mapping

  • Admin endpoint: POST /api/v1/admin/platform-users/{userId}/revoke-all
  • Auth machine endpoint: POST /internal/admin/users/{id}/revoke-all
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

8.8 POST /api/v1/admin/platform-users/invitations

Section titled “8.8 POST /api/v1/admin/platform-users/invitations”

Purpose

Invite a new platform staff user.

Target

  • create invitation in Auth
  • optionally trigger invitation email flow

Body

{
"email": "ops-admin@kisum.dev",
"globalRole": "PLATFORM_ADMIN",
"name": "Ops Admin"
}

Current implementation note

Platform-user invitations still require a dedicated Auth-backed implementation contract. If that upstream invitation path is not present in the running Auth deployment, Admin must treat this action as unavailable rather than inventing a separate endpoint.

Purpose

List active sessions across the platform-admin scope.

Target

  • read active-session data from Auth
  • support “who is currently connected” views in Admin
  • support operational filtering by user, company, approval status, and platform-vs-tenant scope

Request body

  • no body

Typical query parameters

  • approvalStatus
  • globalRole
  • userId
  • companyId
  • limit
  • offset

Query behavior

  • approvalStatus
    • optional
    • when omitted, no approval-status filtering is applied
    • when provided, must match the Auth approval-status enum exactly
  • globalRole
    • optional
    • true means platform users only
    • false means tenant users only
    • a comma-separated role list can also be used for explicit role filtering
  • userId
    • optional UUID
    • when provided, restricts the result to sessions for a single user
  • companyId
    • optional UUID
    • when provided, returns sessions for users who currently have an active membership in that company
  • limit
    • default 50
    • upper bounded by Auth validation
  • offset
    • default 0

Definition

A session is considered active when all of the following are true:

  • is_revoked = false
  • expires_at > now()
  • the user row still exists

This route represents authenticated active-session presence, not websocket-style realtime presence.

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/sessions
  • Auth machine endpoint: GET /internal/admin/sessions
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

Response shape

{
"success": true,
"data": [
{
"sessionId": "8b4d1c3a-1a9e-4e93-b71f-9d8d8d7a7e90",
"userId": "9e280dc5-c1cf-4d62-bad9-0774e4a3b9e1",
"email": "ops-admin@kisum.dev",
"fullName": "Ops Admin",
"globalRole": "PLATFORM_ADMIN",
"approvalStatus": "APPROVED",
"isUserActive": true,
"ipAddress": "103.21.244.1",
"userAgent": "Mozilla/5.0 ...",
"deviceName": "Chrome on macOS",
"createdAt": "2026-04-19T02:00:00Z",
"lastUsedAt": "2026-04-19T08:41:00Z",
"expiresAt": "2026-05-19T02:00:00Z"
}
]
}

Operational use

This route should back:

  • connected-user dashboards
  • fraud or security review
  • support diagnostics
  • company-scoped activity checks

Purpose

Return aggregate counts for active sessions and connected users.

Target

  • power dashboard counters
  • separate platform-user activity from tenant-user activity

Request body

  • no body

Typical query parameters

  • approvalStatus
  • globalRole
  • userId
  • companyId

These filters use the same semantics as GET /api/v1/admin/sessions, but return counts instead of individual session rows.

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/session-stats
  • Auth machine endpoint: GET /internal/admin/session-stats
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

Response shape

{
"success": true,
"data": {
"activeSessions": 124,
"connectedUsers": 87,
"platformUsersConnected": 6,
"tenantUsersConnected": 81
}
}

Meaning of each field

  • activeSessions
    • total number of active session records matching the filter
  • connectedUsers
    • distinct users with at least one active session
  • platformUsersConnected
    • distinct connected users whose globalRole is one of the platform roles
  • tenantUsersConnected
    • distinct connected users whose globalRole is NONE

8.8C GET /api/v1/admin/companies/{companyId}/sessions

Section titled “8.8C GET /api/v1/admin/companies/{companyId}/sessions”

Purpose

List active sessions for users belonging to one company.

Target

  • inspect company-scoped user activity
  • support approval, security, and support operations for one tenant

Request body

  • no body

Typical query parameters

  • limit
  • offset

Behavior

  • this route is scoped by the company in the path
  • Auth returns sessions for users who currently have an active membership in that company
  • this is the company-scoped alternative to the global session list

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/sessions
  • Auth machine endpoint: GET /internal/admin/companies/{companyId}/sessions
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

Response shape

{
"success": true,
"data": [
{
"sessionId": "8b4d1c3a-1a9e-4e93-b71f-9d8d8d7a7e90",
"userId": "9e280dc5-c1cf-4d62-bad9-0774e4a3b9e1",
"email": "finance-user@kisum.dev",
"fullName": "Finance User",
"globalRole": "NONE",
"approvalStatus": "APPROVED",
"isUserActive": true,
"ipAddress": "103.21.244.1",
"userAgent": "Mozilla/5.0 ...",
"deviceName": "Chrome on macOS",
"createdAt": "2026-04-19T02:00:00Z",
"lastUsedAt": "2026-04-19T08:41:00Z",
"expiresAt": "2026-05-19T02:00:00Z"
}
]
}

8.9 GET /api/v1/admin/companies/{companyId}/users

Section titled “8.9 GET /api/v1/admin/companies/{companyId}/users”

Purpose

List company users from the platform-admin view.

Target

  • read through Auth company memberships

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/users
  • Auth upstream: GET /internal/companies/{companyId}/users
  • Auth mode: forwarded Authorization: Bearer <token>

8.10 POST /api/v1/admin/companies/{companyId}/memberships

Section titled “8.10 POST /api/v1/admin/companies/{companyId}/memberships”

Purpose

Create or update a company membership from Admin.

Target

  • write company membership in Auth
  • write module grants in Auth
  • write permission grants in Auth
  • write delegation constraints in Auth

Body

{
"userId": "9e280dc5-c1cf-4d62-bad9-0774e4a3b9e1",
"role": "ADMIN",
"status": "ACTIVE",
"moduleGrants": ["basic", "finance"],
"permissionGrants": [
"finance.read",
"finance.write",
"users.manage"
],
"delegation": {
"grantableModules": ["basic"],
"grantablePermissions": ["finance.read"],
"canManageUsers": false,
"canBuyAddons": false
}
}

Admin backend mapping

  • Admin endpoint: POST /api/v1/admin/companies/{companyId}/memberships
  • Auth upstream: POST /internal/companies/{companyId}/memberships
  • Auth mode: forwarded Authorization: Bearer <token>

8.11 GET /api/v1/admin/companies/{companyId}/business-units

Section titled “8.11 GET /api/v1/admin/companies/{companyId}/business-units”

Purpose

List Core-owned business-unit master rows for one company from the Admin control plane.

Target

  • read business-unit master data from Core
  • show company organizational structure owned by Core

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/business-units
  • Core upstream: GET /internal/companies/{companyId}/business-units
  • Core mode: X-Internal-API-Key using CORE_INTERNAL_API_KEY

8.12 POST /api/v1/admin/companies/{companyId}/business-units

Section titled “8.12 POST /api/v1/admin/companies/{companyId}/business-units”

Purpose

Create one Core-owned business-unit master row under a company.

Target

  • write business-unit master data in Core

Body

{
"name": "Finance Operations",
"code": "FINOPS",
"slug": "finance-operations",
"isActive": true,
"metadata": {
"description": "Finance team business unit"
}
}

Admin backend mapping

  • Admin endpoint: POST /api/v1/admin/companies/{companyId}/business-units
  • Core upstream: POST /internal/companies/{companyId}/business-units
  • Core mode: X-Internal-API-Key using CORE_INTERNAL_API_KEY

8.13 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}

Section titled “8.13 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}”

Purpose

Get one Core-owned business-unit master row in the scope of its parent company.

Target

  • read one business-unit master row from Core
  • keep business-unit reads explicitly scoped to the parent company

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}
  • Core upstream target: company-scoped business-unit read
  • Core mode: X-Internal-API-Key using CORE_INTERNAL_API_KEY

Important rule:

  • backend must verify the business unit belongs to the {companyId} in the path

8.14 PATCH /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}

Section titled “8.14 PATCH /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}”

Purpose

Update one Core-owned business-unit master row.

Target

  • update business-unit master data in Core

Body

{
"name": "Finance & Procurement",
"code": "FINPROC",
"slug": "finance-procurement",
"isActive": true,
"metadata": {
"description": "Updated business unit name"
}
}

Admin backend mapping

  • Admin endpoint: PATCH /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}
  • Core upstream target: company-scoped business-unit patch
  • Core mode: X-Internal-API-Key using CORE_INTERNAL_API_KEY

Important note:

  • this route patches the Core business-unit master row
  • it does not patch business-unit memberships
  • memberships remain Auth-owned
  • backend must verify the business unit belongs to the {companyId} in the path

8.15 DELETE /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}

Section titled “8.15 DELETE /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}”

Purpose

Delete or archive one Core-owned business-unit master row in the scope of its parent company.

Target

  • remove a business-unit master row from Core, or mark it inactive depending on final implementation policy

Request body

  • no body

Admin backend mapping

  • Admin endpoint: DELETE /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}
  • Core upstream target: company-scoped business-unit delete
  • Core mode: X-Internal-API-Key using CORE_INTERNAL_API_KEY

Important rule:

  • backend must verify the business unit belongs to the {companyId} in the path
  • delete policy must be explicit:
    • hard delete only if no dependent records exist, or
    • soft delete / deactivate if business-unit history must be preserved

8.16 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/users

Section titled “8.16 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/users”

Purpose

List users for one business unit.

Target

  • read business-unit memberships from Auth

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/users
  • Auth upstream: GET /internal/companies/{companyId}/business-units/{businessUnitId}/users
  • Auth mode: forwarded Authorization: Bearer <token>

8.17 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships

Section titled “8.17 GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships”

Purpose

List business-unit memberships for one business unit.

Target

  • read business-unit memberships from Auth

Request body

  • no body

Admin backend mapping

  • Admin endpoint: GET /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships
  • Auth upstream: GET /internal/admin/companies/{companyId}/business-units/{businessUnitId}/memberships
  • Auth mode: X-Internal-API-Key using AUTH_INTERNAL_API_KEY

8.18 POST /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships

Section titled “8.18 POST /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships”

Purpose

Create or update a business-unit membership from Admin.

Target

  • write business-unit membership in Auth

Body

{
"userId": "9e280dc5-c1cf-4d62-bad9-0774e4a3b9e1",
"role": "MEMBER",
"status": "ACTIVE"
}

Admin backend mapping

  • Admin endpoint: POST /api/v1/admin/companies/{companyId}/business-units/{businessUnitId}/memberships
  • Auth upstream: POST /internal/companies/{companyId}/business-units/{businessUnitId}/memberships
  • Auth mode: forwarded Authorization: Bearer <token>

These are privileged operational endpoints. Their persistence details must be explicitly defined during implementation.

Purpose

List payment-provider configuration visible to platform staff.

Request body

  • no body

9.2 POST /api/v1/admin/payment-providers/stripe

Section titled “9.2 POST /api/v1/admin/payment-providers/stripe”

Purpose

Configure Stripe for a deployment, country, or environment scope.

Body

{
"scopeType": "country",
"scopeValue": "SG",
"publishableKey": "pk_live_xxx",
"secretReference": "secrets/stripe/sg/live",
"webhookSecretReference": "secrets/stripe/sg/webhook",
"isActive": true
}

9.3 POST /api/v1/admin/payment-providers/xendit

Section titled “9.3 POST /api/v1/admin/payment-providers/xendit”

Purpose

Configure Xendit for a deployment, country, or environment scope.

Body

{
"scopeType": "country",
"scopeValue": "ID",
"apiKeyReference": "secrets/xendit/id/live",
"callbackTokenReference": "secrets/xendit/id/callback",
"isActive": true
}

10. Operational Admin surfaces that remain first-class

Section titled “10. Operational Admin surfaces that remain first-class”

These routes are not secondary. Even in the new architecture, they remain valid Admin responsibilities and should stay fully documented.

Purpose

List pending approvals.

Request body

  • no body

Purpose

Get one approval.

Request body

  • no body

Purpose

Approve an approval request.

Body

{
"approvalId": "approval_123"
}

Purpose

Reject an approval request.

Body

{
"approvalId": "approval_123",
"rejectionReason": "Insufficient documentation"
}

Artist management remains an Admin responsibility.

Purpose

List artists.

Request body

  • no body

Typical query parameters

  • active — boolean (1/0), independent of status
  • statusactive | inactive | archived | sleeping. Omit to get everything except sleeping (the Artists backend hides sleeping artists by default at the entity level, the same way it hides non-city rows for cities). Admin’s Sleeping Artists tab (/artists/sleeping) passes status=sleeping explicitly.
  • iso2
  • limit
  • page
  • search — artist name and/or Spotify artist id (raw id or Spotify URL/URI)
  • sort_by
  • sort_order

Purpose

Create a new artist.

Body

{
"name": "Artist Name",
"type": "solo",
"country": {
"name": "Singapore",
"iso2": "SG"
},
"genre": ["pop"],
"website": "https://artist.example",
"bio": "Artist biography",
"socials": {
"instagram": "https://instagram.com/artist",
"youtube": "https://youtube.com/@artist"
}
}

Purpose

Get one artist.

Request body

  • no body

Purpose

Update an artist. Partial — only fields present in the body are written.

Body

{
"name": "Updated Artist Name",
"bio": "Updated bio",
"website": "https://artist.example"
}

Sleep / wake an artist: PATCH with only {"status": "sleeping"} or {"status": "active"}. The Admin frontend’s Sleep/Wake toggle (artist detail page) and bulk sleep/wake (Sleeping Artists tab, and the main Artists list’s bulk-select bar) both call this — there is no dedicated sleep/wake or bulk-status endpoint; bulk loops this PATCH per id, the same way DELETE /api/v1/admin/artists bulk-delete is a per-id loop under the hood.

Purpose

Delete many artists (hard delete in Backend-Kisum-Artists Postgres). Blocked with a 409 if the artist has bookings, booking requests, or booking offers — remove/reassign those first. Available from the Admin artist detail page (single) and the Artists list bulk-select bar.

Body

{
"ids": ["artist_1", "artist_2"]
}

Purpose

Search artist genres from Backend-Kisum-Artists PostgreSQL (genres table). Used by Admin genre pickers (Add Artist form, Artists list filter).

Query

ParamRequiredNotes
qyes (min 2 chars)ILIKE search on genre name / slug; returns up to 50 matches ranked by exact name/slug match first, then prefix, then shorter names

Request body

  • no body

Response data

Array of genre name strings, e.g. ["Hip-Hop", "Hip Hop"]

Purpose

Reserve the next numeric artist id for the Admin Add Artist form so S3 image upload can run before final save.

Request body

  • no body

Response data

{ "id": 1616718 }

The add form loads this on open and sends the same id in POST /api/v1/admin/artists so the created row matches the S3 path prefix (images/artists/<id>_…).

Purpose

Get artist platform information from platform integrations.

Body

{
"platform": "music_brainz",
"name": "Artist Name",
"musicbrainz_id": "optional-mbid",
"search_by_name": false,
"spotify_id": "spotify-artist-id",
"iso2": "SG",
"type": "Person"
}

MusicBrainz behavior

  • No musicbrainz_id (or search_by_name: true) → search by name with optional limit / offset (20 per page in the form picker). Returns musicbrainz_candidates[], musicbrainz_count, and musicbrainz_offset for infinite-scroll picker UI.
  • With musicbrainz_id and search_by_name: false → fetch full MusicBrainz profile by MBID (country, city, genres/tags, sort name, active from, URL relations → platform links).
  • Admin UI: when a saved MusicBrainz ID exists, menu offers saved ID and search by name (for wrong-ID cases).

Purpose

Search artists by name prefix or Spotify artist id (raw id, spotify:artist:…, or open.spotify.com/artist/…).

Query

  • search — artist name prefix and/or Spotify id

Request body

  • no body

Purpose

Look up artist information from Spotify via Backend-Kisum-MusicData (not direct Spotify credentials on Admin).

Query

  • name — artist name search (MusicData GET /spotify/artists/search?q=…)
  • spotify_id — single artist by Spotify id (MusicData GET /spotify/artists/:id)
  • limit — page size (default 10, max 20; MusicData cap)
  • offset — Spotify search offset for pagination (default 0)
  • exclude_existing — when true, omit Spotify rows that already have a Kisum artist link (edit-form picker). Default list search omits this so Open / Add can both appear.
  • If name is a raw Spotify id or open.spotify.com/artist/… / spotify:artist:…, Admin treats it as spotify_id.

Behavior

  • Returns all Spotify matches (unless exclude_existing=true). Each item may include optional kisumArtistId when that Spotify platform id already exists in Kisum (legacy Mongo layers 1–5 + Backend-Kisum-Artists internal POST /internal/artists/resolve-spotify-ids). Admin does not use Artists public GET /artists?q= for this enrichment.
  • Admin UI: Add when kisumArtistId is absent; Open (artist detail) when present. Edit-form Spotify picker sends exclude_existing=true.
  • Response includes standard list meta (page, limit, total_items, total_pages) based on Spotify’s total hit count.

Upstream

  • Spotify catalog: Backend-Kisum-MusicData (MUSICDATA_INTERNAL_BASE_URL / MUSICDATA_BASE_URL, MUSICDATA_INTERNAL_API_KEY)
  • Kisum link resolution: Backend-Kisum-Artists (ARTISTS_INTERNAL_BASE_URL, ARTISTS_INTERNAL_API_KEY) — POST /internal/artists/resolve-spotify-ids with body { "spotify_ids": ["…"] }{ "matches": { "<spotifyId>": "<kisumArtistId>" } } (numeric artists.id, not uuid)
  • Admin does not connect to Artists Postgres directly.

Request body

  • no body

These routes proxy the current Backend-Kisum-Artists surface for non-artist artists-directory entities. Admin returns the Artists contract unchanged.

  • /api/v1/admin/artists-directory/*
  • GET /api/v1/admin/artists-directory/companies
  • POST /api/v1/admin/artists-directory/companies
  • GET /api/v1/admin/artists-directory/companies/{id}
  • PATCH /api/v1/admin/artists-directory/companies/{id}
  • DELETE /api/v1/admin/artists-directory/companies/{id}
  • GET /api/v1/admin/artists-directory/people
  • POST /api/v1/admin/artists-directory/people
  • GET /api/v1/admin/artists-directory/people/{id}
  • PATCH /api/v1/admin/artists-directory/people/{id}
  • DELETE /api/v1/admin/artists-directory/people/{id}
  • GET /api/v1/admin/artists-directory/genres
  • POST /api/v1/admin/artists-directory/genres
  • GET /api/v1/admin/artists-directory/genres/{id}
  • PATCH /api/v1/admin/artists-directory/genres/{id}
  • DELETE /api/v1/admin/artists-directory/genres/{id}
  • GET /api/v1/admin/artists-directory/subgenres
  • POST /api/v1/admin/artists-directory/subgenres
  • GET /api/v1/admin/artists-directory/subgenres/{id}
  • PATCH /api/v1/admin/artists-directory/subgenres/{id}
  • DELETE /api/v1/admin/artists-directory/subgenres/{id}
  • GET /api/v1/admin/artists-directory/platforms
  • POST /api/v1/admin/artists-directory/platforms
  • GET /api/v1/admin/artists-directory/platforms/{id}
  • PATCH /api/v1/admin/artists-directory/platforms/{id}
  • DELETE /api/v1/admin/artists-directory/platforms/{id}
  • GET /api/v1/admin/artists-directory/provider-sources
  • POST /api/v1/admin/artists-directory/provider-sources
  • GET /api/v1/admin/artists-directory/provider-sources/{id}
  • PATCH /api/v1/admin/artists-directory/provider-sources/{id}
  • DELETE /api/v1/admin/artists-directory/provider-sources/{id}
  • GET /api/v1/admin/artists-directory/regions
  • GET /api/v1/admin/artists-directory/regions/{id}
  • GET /api/v1/admin/artists-directory/subregions
  • GET /api/v1/admin/artists-directory/subregions/{id}
  • GET /api/v1/admin/artists-directory/countries
  • GET /api/v1/admin/artists-directory/countries/{id}
  • GET /api/v1/admin/artists-directory/states
  • GET /api/v1/admin/artists-directory/states/{id}
  • GET /api/v1/admin/artists-directory/cities
  • GET /api/v1/admin/artists-directory/cities/{id}
  • GET /api/v1/admin/artists-directory/companies/{id}/people
  • PUT /api/v1/admin/artists-directory/companies/{id}/people
  • GET /api/v1/admin/artists-directory/companies/{id}/locations
  • PUT /api/v1/admin/artists-directory/companies/{id}/locations
  • GET /api/v1/admin/artists-directory/companies/{id}/genres
  • PUT /api/v1/admin/artists-directory/companies/{id}/genres
  • GET /api/v1/admin/artists-directory/companies/{id}/roster
  • PUT /api/v1/admin/artists-directory/companies/{id}/roster
  • GET /api/v1/admin/artists-directory/companies/{id}/social-metrics
  • PUT /api/v1/admin/artists-directory/companies/{id}/social-metrics
  • GET /api/v1/admin/artists-directory/people/{id}/companies
  • PUT /api/v1/admin/artists-directory/people/{id}/companies
  • GET /api/v1/admin/artists-directory/people/{id}/platform-accounts
  • PUT /api/v1/admin/artists-directory/people/{id}/platform-accounts
  • GET /api/v1/admin/artists-directory/regions/{id}/subregions
  • GET /api/v1/admin/artists-directory/regions/{id}/countries
  • GET /api/v1/admin/artists-directory/subregions/{id}/countries
  • GET /api/v1/admin/artists-directory/countries/{id}/states
  • GET /api/v1/admin/artists-directory/countries/{id}/cities
  • GET /api/v1/admin/artists-directory/states/{id}/cities
  • These routes are for the market directory only, not for Core tenant-company administration.
  • Upstream source of truth: Backend-Kisum-Artists.
  • Admin → Artists auth (mandatory): machine-only via ARTISTS_INTERNAL_API_KEY (X-Internal-API-Key). Admin does not mint Auth service tokens and must not forward the staff browser Authorization or x-org on these proxies. Artists still accepts user JWT + x-org for persona apps; that path is unrelated to Admin.
  • Path IDs follow the upstream Market rules:
    • UUIDs for market entities such as companies, people, genres, subgenres, platforms, and provider sources
    • integers for geo/reference entities such as regions, subregions, countries, states, and cities

Platform staff can list and manage all promoter events across every company. No x-org header — Admin proxies to Promoters internal machine routes.

Source of truth: Backend-Kisum-Promoters (events table).

Flow: browser → Backend-Kisum-AdminBackend-Kisum-Promoters /internal/admin/events* (PROMOTERS_INTERNAL_API_KEY).

Purpose

Paginated list of all promoter events (every status: pending, confirmed, completed, cancelled).

Query parameters

  • q — search (name, venue label, numeric id)
  • status — filter by status (pending | confirmed | completed | cancelled)
  • type — date bucket (all | today | upcoming | past; default all)
  • sort_bydate (default) or created_at
  • page, limit

Purpose

Single event detail (no finance fields).

Path

  • eventId — numeric Promoters event id (int32)

Purpose

Staff edit of operational fields only: date, venue (Venues Postgres UUID string), lineup (array of Artists directory numeric ids).

Purpose

Hard-delete one event (platform staff only).

Venue management remains an Admin control-plane responsibility, but runtime venue truth now belongs to Backend-Kisum-Venues.

Current enforced integration path:

  1. browser -> Frontend-Kisum-Admin
  2. browser -> Backend-Kisum-Admin
  3. Backend-Kisum-Admin -> Backend-Kisum-Venues internal admin routes

Important rules:

  • Admin must not query Venue Postgres directly
  • numeric venue_id is the Admin-facing venue identity
  • external venue enrichment/search may still stay in Admin, but persisted truth is written through Venue backend

Purpose

List venues.

Runtime truth

  • read from Backend-Kisum-Venues
  • return both backend UUID id and numeric venue_id

Request body

  • no body

Typical query parameters

  • active
  • iso2
  • limit
  • page
  • search
  • sort_by
  • sort_order
  • type

Purpose

Create a venue.

Body

{
"name": "Kisum Arena",
"country": "Singapore",
"region": "Singapore",
"city": "Singapore",
"type": "arena",
"capacity": 12000,
"address": "120 Orchard Road"
}

Purpose

Get one venue.

{id} is the numeric venue_id.

Request body

  • no body

Purpose

Update a venue.

{id} is the numeric venue_id.

Body

{
"name": "Updated Kisum Arena",
"capacity": 15000,
"type": "arena"
}

Purpose

Delete many venues.

Body

{
"ids": ["1001", "1002"]
}

Purpose

Get all venue types.

Runtime truth:

  • read from Venue venue_type

Purpose

List sleeping imported venues that are still held by the Kisum holding company.

POST /api/v1/admin/venues/{id}/takeover-approvals

Section titled “POST /api/v1/admin/venues/{id}/takeover-approvals”

Purpose

Create a Venue-owned takeover approval record for a sleeping venue.

Purpose

Move a sleeping venue out of the Kisum holding company into the approved tenant company and mark it active.

Request body

  • no body

POST /api/v1/admin/venues/{id}/revert-takeover

Section titled “POST /api/v1/admin/venues/{id}/revert-takeover”

Purpose

Undo a previously approved venue takeover. Moves the venue back to the sleeping holding company, marks the originating approved takeover request revoked (with decision_notes = reason), and writes reconciliation + audit rows. Use case: falsified proof of ownership, or a takeover approved by mistake.

Request body

  • reason (required, min 10 chars): explanation surfaced to the tenant in the revoked request’s decision_notes.
  • actor_user_id, actor_email, actor_name — stamped by the handler from the JWT, not trusted from the client.

Errors

  • 409 VENUE_HAS_TENANT_DATA (with error.details carrying per-table counts for spaces, bookings, deposits, contracts, events, availability_blocks, checklists) — refuses when the tenant has created data on the venue. Admins must coordinate cleanup first.
  • 409 VENUE_NOT_ACTIVE — venue is not in ownership_status='active'.
  • 409 VENUE_ALREADY_SLEEPING — venue already belongs to the sleeping holding company.
  • 404 — venue not found.

Admin BFF queue for tenant-submitted venue requests. Proxies the /internal/admin/venue-requests* family on Backend-Kisum-Venues. Decision routes stamp the JWT actor (user_id / user_email / user_name) onto the payload before forwarding.

Purpose

List venue takeover + creation requests across all companies for the admin queue.

Query parameters

  • status (optional): pending | approved | rejected.
  • kind (optional): takeover | creation.
  • requestedCompanyId (optional): scope to a single tenant company.
  • page, limit: pagination (defaults from the validator).

Purpose

Read a single venue request including the kind-specific payload, the proofOfOwnership descriptor (if any), and any decision metadata.

GET /api/v1/admin/venue-requests/{id}/proof-url

Section titled “GET /api/v1/admin/venue-requests/{id}/proof-url”

Purpose

Return a short-lived presigned S3 GET URL for the proof-of-ownership file attached to the request, so the admin UI can open it in a new tab without making the bucket public.

Response shape

  • url: presigned GET URL.
  • expiresAt: ISO-8601 expiry (5 minutes from issue).
  • fileName, contentType, size: descriptor copied from the request row.

Errors

  • 404 when no proof was uploaded with the request.

POST /api/v1/admin/venue-requests/{id}/approve

Section titled “POST /api/v1/admin/venue-requests/{id}/approve”

Purpose

Approve a venue request. The Venue backend dispatches to the existing ActivateVenue (for takeover) or CreateVenue (for creation) flow, then marks the request approved.

Request body

  • decisionNotes (optional): free-form notes shown to the tenant.

POST /api/v1/admin/venue-requests/{id}/reject

Section titled “POST /api/v1/admin/venue-requests/{id}/reject”

Purpose

Reject a venue request. Persists decisionNotes and marks the request rejected.

Request body

  • decisionNotes (optional, but recommended): explanation surfaced to the tenant in /settings/venue.

Purpose

Search venues from Bandsintown.

Request body

  • no body

Purpose

Search venues from Viberate.

Request body

  • no body

Purpose

Generate or enrich venue information using Gemini.

Request body

  • no body

GET /api/v1/admin/venues/search-by-platform

Section titled “GET /api/v1/admin/venues/search-by-platform”

Purpose

Search venue-like records from an external source for admin auto-fill.

Request body

  • no body

Query parameters (representative)

  • name (required): search string (venue name).
  • platform (required): one of viberate, bandsintown, gemini, musicbrainz, openstreetmap.
  • page, limit: pagination.
  • type, country, region, city: optional refinements. For platform=gemini, type, country, and region are required (see handler validation). For platform=musicbrainz, only name is strictly required; the server maps musicbrainz to MusicBrainz Places (GET /ws/2/place) and may pass city / country as optional Lucene area filters. city is forwarded for all platforms when present.

Notes

  • musicbrainz: responses include platform_ids with type=musicbrainz and the place MBID, and url pointing at https://musicbrainz.org/place/<mbid>. The Admin BFF should send a descriptive User-Agent (config: MUSICBRAINZ_USER_AGENT) per MusicBrainz API policy.

  • openstreetmap (2026-07-28): only name is strictly required (city and country narrow the search and should be passed when the form has them — a bare venue name searched worldwide is mostly noise). Responses include platform_ids with type=openstreetmap and the OSM reference (way/123456), and url pointing at the record on openstreetmap.org unless the venue publishes its own website.

    This is the only provider that reliably returns country_iso2, region, and postal_code — the block the venue form marks required and that most imported rows are missing — which is the reason it needs just a name rather than the full Country/Region/City set the other providers demand.

    Implementation notes that are contract, not detail:

    • It talks to Nominatim, not Overpass. Overpass is a tag/area query engine; an unbounded nwr["name"~"..."] search is a global regex scan the public endpoint throttles or refuses. Same OpenStreetMap data.
    • OPENSTREETMAP_USER_AGENT is mandatory. The Nominatim usage policy bans anonymous clients and will block the source IP, so the client refuses to build without one and the endpoint answers 400 instead. One request per second is enforced in code. Point OPENSTREETMAP_BASE_URL at a self-hosted Nominatim to lift that cap.
    • Results are ODbL: the OpenStreetMap attribution is returned on each result and must remain attached wherever the data is displayed or stored.
    • Capacity is never mapped onto the venue. OSM’s capacity tag is usually a seated or sports-configuration figure, not the concert configuration a booking decision needs.
    • Only fields the provider actually resolved are returned, so applying a result never blanks data an admin already entered.

Venue enrichment — “Fetch all data” (2026-07-28)

Section titled “Venue enrichment — “Fetch all data” (2026-07-28)”

POST /api/v1/admin/venues/enrich — one venue, every public source, one call. Returns per-field proposals with provenance, not a merged venue.

Sources: OpenStreetMap tags · Nominatim reverse geocode · BigDataCloud reverse geocode · Wikidata · Wikimedia Commons · Wikipedia.

Nothing is written. The endpoint proposes; the admin form applies only ticked fields and only into fields that are currently empty.

Auto-apply variant (2026-08-11): POST /api/v1/admin/venues/enrich-apply uses the same enrichment pipeline but writes high-confidence proposals only into empty venue fields server-side (no manual tick). Used by SoccerWiki import Enrich imported after sleeping venues are created. Capacity and existing main image are never overwritten; publishable Wikimedia images upload to S3 when the venue has no image.

Priority is per field because no single ranking is correct for everything. The reasons are measured, not assumed (Istanbul sample, 2026-07-28):

FieldWinnerReason
Address / postcode / countryNominatim reverseDerived from map geometry, so it answers for any coordinate. Only ~27% of venues carry a street tag and 6% a capacity.
CityBigDataCloudNominatim frequently returns the district — the reason a venue in Fatih reads as “Fatih” rather than “Istanbul”.
Website / phoneOpenStreetMap tagsThe venue’s own record beats an encyclopedia entry.
Capacity / image / opening dateWikidataNothing else carries them.

Wikidata is never a source of addresses: measured 0% postal code, 8% street. It answers “what is this venue like”, not “where is it”.

Each proposal is high or hint:

  • A Wikidata item reached through the OSM wikidata tag is an exact cross-referencehigh.
  • The same item reached by searching the venue namehint, with a warning, because it may be a different venue entirely.
  • Capacity is always hint, whatever the source: it is normally a seated or sports-configuration figure, not a concert configuration.

Only high values are pre-selected in the UI, so nothing unverified is applied by accident. Disagreements between sources appear as alternatives rather than being silently resolved.

Returned separately from field proposals and never auto-applied. Commons files carry per-file licences and most require crediting the photographer; the licence, licence URL and attribution travel with every image. An image whose licence could not be determined is marked publishable: false — treat that as “do not publish”, never as permission.

One source failing never fails the request. Every source reports ok / skipped / failed / not_configured with a reason, so a blank field is explainable: “Wikidata had nothing” and “Wikidata timed out” are different answers.

Bulk was ruled out. Nominatim’s one-request-per-second policy alone turns a catalogue-wide run into hours, and every value here wants human confirmation.

The admin UI picks the city from the shared Market geo directory rather than accepting free text, so a swept city is spelled the same as everywhere else in Kisum and the Nominatim boundary lookup gets a consistent input. The city list is keyed by the numeric Market country id; the sweep itself needs the ISO2 code — the UI carries both from one country selection.

A city absent from the Market directory therefore cannot be swept by name; the country-wide sweep covers it instead.

The merge is deterministic. Every source already returns structured fields, so a model between structured input and structured output would cost provenance and risk corrupting exact values such as coordinates. AI is planned only for the Wikipedia-derived description (prose, no structured source) and for disambiguating which candidate is the right venue. The endpoint returns raw per-source payloads so that pass can reuse the same fetch.

Config: WIKIMEDIA_USER_AGENT (falls back to OPENSTREETMAP_USER_AGENT), WIKIDATA_BASE_URL, BIGDATACLOUD_BASE_URL.

Sweeps OpenStreetMap for every venue inside a city or a country, so the catalogue can be populated from an area rather than one venue name at a time. This is the discovery half of external venue data; search-by-platform is the lookup half.

EndpointPurpose
GET /api/v1/admin/venues/discovery/categoriesVenue kinds a sweep can cover
POST /api/v1/admin/venues/discoveryStart a sweep — countryCode required, city optional; returns 202 + job id
GET /api/v1/admin/venues/discovery/{jobId}Progress while running, candidates when complete
POST /api/v1/admin/venues/discovery/{jobId}/importCreate the selected candidates

Overpass here, Nominatim in search-by-platform. They answer different questions: Nominatim resolves a typed name, Overpass returns everything inside a boundary. Area discovery is the case where Overpass is the correct tool. For a city sweep both are used — Nominatim resolves the city’s boundary relation first, because area["name"="Istanbul"] inside Overpass is ambiguous across countries and administrative levels. A city sweep therefore needs OPENSTREETMAP_USER_AGENT and OVERPASS_USER_AGENT; a country sweep needs only the latter.

Contract points that are not implementation detail:

  • It is a job, not a request. A country sweep runs one Overpass query per venue category because a single all-categories country query exceeds the endpoint’s timeout. Callers poll. City sweeps use the identical path and finish sooner.
  • A failed category does not fail the sweep. Partial results are returned and the failure is recorded against that category.
  • Import carries external ids only. Venue data comes from the stored sweep, so the endpoint cannot be used to inject a hand-crafted venue.
  • Import is idempotent on (external_source, external_source_id): re-running returns the same venues with created: false rather than duplicating.
  • Imported venues are created sleeping + claimable under the holding company (identical to the China catalogue import), so the real operator can still claim them through the takeover flow.
  • Candidates already in Kisum are flagged, not filtered out — a re-sweep of a covered city must read as “nothing new”, not as an empty result.
  • “Already in Kisum” means the whole venue table, not just rows Kisum imported from OpenStreetMap. Two passes: an exact OpenStreetMap id match (certain), then a name-similarity-plus-proximity match via POST /internal/venues/match on the Venues service. Checking lineage alone answered “did this come from OpenStreetMap?” and reported every pre-existing venue as new.
  • A likely match stays importable, with the matched venue and the evidence shown, because fuzzy matching cannot be certain and hiding a genuinely new venue is the worse failure. Only an exact id match is locked.
  • Capacity travels as an unconfirmed hint and is never written onto a venue.
  • Results are ODbL; the attribution is returned on the job and must be shown.
  • Sweeps live in memory for an hour. The durable outcome is the venues created.

Venue import from external platforms (2026-08-11)

Section titled “Venue import from external platforms (2026-08-11)”

Staff choose a data source platform (SoccerWiki only today; more later), upload a platform export in the Admin UI at /venues/venue-import (parsed in the browser only), select rows, click Get data to stage in Venues Postgres and scrape when needed, review duplicates, then import only non-duplicated rows as sleeping + claimable venues.

Admin UI list filter (2026-08-11): after upload / Get data, the table defaults to Ready (not duplicate, not imported). Separate filters: Duplicates, Imported (already in venues — use for Enrich), and All. Import runs one row at a time with live progress and per-row errors. Staff can press Stop to cancel the remaining queue (already-created venues are kept).

Staging keys: venue_temp_rows is keyed by platform + platform_id (unique together). SoccerWiki uses platform=soccerwiki and platform_id=<StadiumData.ID>. One saved row per external record — re-uploads reuse cached scrape data unless staff click Force update on selected rows.

EndpointPurpose
POST /api/v1/admin/venues/venue-imports/lookup-saved{ platform, platformIds[] } — return existing staging rows (max 500 ids) so the UI can show Saved without re-scraping
POST /api/v1/admin/venues/venue-imports/stage-and-scrapePrimary flow. { platform, batchId?, filename, forceScrape?, stadiums: [{ id, name }] } (max 100) — upsert staging rows, scrape only when not saved (or when forceScrape=true), resolve geo, auto-label duplicates; returns { batch, rows }
POST /api/v1/admin/venues/venue-importsLegacy: create a full batch from JSON (avoid from UI; use stage-and-scrape instead)
GET /api/v1/admin/venues/venue-imports/{batchId}Batch metadata
GET /api/v1/admin/venues/venue-imports/{batchId}/rows?page&limit=100&q=Paginated staging rows
POST /api/v1/admin/venues/venue-imports/{batchId}/rows/scrapeRe-scrape existing staged { rowIds } (max 100) without adding new stadium ids
PATCH /api/v1/admin/venues/venue-imports/{batchId}/rows/{rowId}Manual { isDuplicated } — exact external-id matches stay locked
POST /api/v1/admin/venues/venue-imports/{batchId}/importImport selected row ids via Venues external intake (source=<platform>, e.g. soccerwiki, externalId=<platform id>). Fast DB path only: writes sleeping venue + capacity from staged row; does not download images (use Enrich imported / enrich-apply for hero image and other public fields).
POST /api/v1/admin/venues/venue-imports/{batchId}/rows/reconcileRe-run duplicate matching for already-scraped rows (e.g. after matcher fixes)
POST /api/v1/admin/venues/enrich-applyPost-import enrichment. { venueId, name, city, country, … } — same inputs as POST /venues/enrich; fetches public sources and writes only high confidence values into empty fields. Never overwrites existing capacity or main image. Publishable Wikimedia image uploads to S3 when the venue has no image. Admin UI Enrich imported runs this sequentially for selected imported rows.

Ownership split:

  • JSON upload stays in the browser until Get data; Postgres staging is written only for selected stadiums (temp tables venue_temp_batches, venue_temp_rows) — not Mongo.
  • HTML scrape for SoccerWiki runs in Admin (SOCCERWIKI_BASE_URL, SOCCERWIKI_USER_AGENT, ~1 req/s); Admin writes parsed fields to Venues staging via internal API. Other platforms will plug in the same BFF shape.
  • Geo resolves country (FIFA → ISO3 → Artists) and city name under country.
  • Duplicate rules mirror Discover: exact (platform, id) in catalog, then POST /internal/venues/match name similarity. Only non-duplicated rows import.

Venues internal mirror (machine key): /internal/admin/venue-imports/*.

Breaking rename (2026-08-11): paths and packages were renamed from soccerwiki-imports / soccerwikiimport to venue-imports / venueimport. The SoccerWiki platform id and scrape env vars stay soccerwiki / SOCCERWIKI_*.

Purpose

Upload file assets for admin workflows.

Target

  • operational upload only
  • not a replacement for Core company-document truth

Body

multipart/form-data

Required form field:

  • file

Venue uploads (existing):

  • venue_id — venue numeric id (Mongo)
  • optional kindmedia (default, images/venues/media) or logo (images/venues/logos)

Artist image uploads:

  • artist_id — artist numeric id
  • kindartist (stores under images/artists/<artist_id>_<n><ext>)

Max file size: 5 MB.

Purpose

Staff-only AI helper route.

Body

{
"prompt": "Summarize this admin review case"
}

If implementing the new Admin system, the recommended order is:

  1. package/module/add-on management
  2. company create/update and company lifecycle orchestration
  3. subscription/add-on assignment and entitlement inspection
  4. delegation-rule management through Auth
  5. payment-provider configuration
  6. cleanup or deprecation of legacy company proxy flows

Admin endpoints must always be documented with:

  • what the admin action does
  • which system stores the truth
  • what request body is required
  • what downstream effect should happen

If an Admin endpoint writes commercial truth, it should target Core.
If an Admin endpoint writes access truth, it should target Auth.