Skip to content

Promoters Module API Specification

Related documentation: Data ownership · Artist Source of Truth Registry (per-endpoint SoT + callers) · Redis GET response cache (global GET middleware + bypass chain)

Version: 1.0.0
Audience: backend developers, frontend developers, QA, integrators
Status: API specification


This document is the Promoters module API catalog.

It focuses on:

  • route groups
  • request headers
  • common request/response expectations
  • endpoint families
  • deprecation visibility for old groups

It intentionally avoids long architectural explanations.
For architecture and access rules, see:

For integration examples, see:


  • https://api-v2.kisum.dev/api — Staging
  • http://localhost:3099/api — Local

Authorization: Bearer <JWT_FROM_AUTH_SERVICE>
x-org: <COMPANY_ID>
Content-Type: application/json
  • Authorization must contain a JWT issued by Auth Backend
  • x-org must contain the active company context
  • tenant-scoped routes must reject missing or malformed x-org
  • Guest / optional org: GET /api/events uses optionalRequirePermissions([]). When x-org is missing or cannot be resolved, the route sets req.isGuest = true and returns 200 with an empty list (data: [], count: 0) — not 500. Other routes (notifications, artists-network, etc.) still require resolved company context and return 403 without valid x-org.
  • Package access guard: after JWT verify, Promoters checks the token package claim (promoter | venue | artist). A non-empty value other than promoter403 { code: "WRONG_PACKAGE", message: "This account does not have access to Promoters." }. Empty/missing package passes through. Exception: shared profile BFF under /api/users/* skips this guard so Artists/Venues can proxy membership/profile routes with an artist/venue JWT (still Auth-verified). Promoter-domain routes keep the guard. Machine internal-key / service-token auth is exempt.

Inbound Promoters PROMOTERS_INTERNAL_API_KEY is accepted on the same headers as other Base machine routes: X-Internal-API-Key, x-api-key, or Authorization: Bearer <PROMOTERS_INTERNAL_API_KEY> (legacy INTERNAL_API_KEY still accepted).

Routex-org for machine callersUpstream
GET /internal/events/:eventIdRequired when resolving tenant contextPromoters Mongo
GET /internal/admin/events*Not required (cross-tenant platform staff feed)Promoters Postgres events
GET /newsOptional (global feed)PUBLIC_FUNCTION_URL_NEWS (default https://api-v3.kisum.dev/api/news) — MUSICDATA_INTERNAL_API_KEY forwarded as x-auth / Authorization (not user JWT or Promoters PROMOTERS_INTERNAL_API_KEY)
GET /internal/directory/promoters*Not applicable (cross-tenant list; see §6a)Core, Auth, own events, Artists, Venues

  • application/json for most requests
  • multipart/form-data for uploads and attachments
  • application/xml may appear in legacy Swagger definitions, but JSON is the primary format

  • 200 / 201 / default — success
  • 400 — invalid request / missing malformed x-org
  • 401 — missing or invalid JWT
  • 403 — authenticated but not allowed
  • 500 — internal server error
  • 503 — effective access cannot be resolved from Auth

These are the active business-domain groups in Base Backend.

  • Agencies — 14 operations
  • Agreements — 10 operations
  • Ai — 6 operations
  • Analytics — 1 operation
  • Approval — 6 operations
  • Artists — 27 operations. List GET /api/artists forwards iso2 country filter to Artists Postgres (comma-separated OK).
  • Avails — 9 operations
  • Cash Flow — 1 operation
  • Countries — 9 operations (Artists Postgres SoT since 2026-08-06 — Promoters adapts /api/v1/countries + nested states/cities; not Mongo)
  • Companies — 11 operations (business metadata only — not membership/access)
  • Profile companies (membership-scoped) — 4 operations (GET/DELETE /users/companies*, no x-org)
  • User profile writePUT /api/users/profile (name / phonePrefix (+XX from countries) / phoneNumber → Auth PATCH /auth/me); PUT /api/users/update-password → Auth PUT /auth/me/password
  • Countries dial codesGET /api/countries/all includes phone_prefix (+XX) for phone pickers (from Artists phonePrefix)
  • Regions — 3 operations (Artists Postgres regions/subregions/countries-in-subregion; not Mongo)
  • Dashboard — 3 operations
  • Events — 26 operations
  • Event Expense — 17 operations (estimated only; actual expenses blocked)
  • Event Expense - Approval — 7 operations (source of truth for actual expenses in this service scope)
  • Event Group — 10 operations
  • Event Income — 14 operations
  • Event Tax — 6 operations
  • Event Tickets — 19 operations (Phase 8, PostgreSQL). Inventory, evidence, ingest, reconciliation and the Finance feed under /events/:id/tickets/*, plus the external ticket-company API under /ticket-api/* (per-vendor x-api-key, authMode: 'ticket_vendor_key'). The pre-migration surface /api/event-ticket/* stays alive for the current clients — storage moved, the contract did not. Two of its reads carry shapes worth stating: GET /api/event-ticket/:id/payment-terms takes the event as :id and returns the flat term list (each with its vendor; ?vendorId narrows to one), and GET /api/event-ticket/:id/daily-sales returns an array?view=dailyTotals pages over days (skip/limit, sortBy, sortOrder, dateFrom, dateTo), anything else gives flat per-sale rows. Sale discount is a fraction (0–1).
  • Event Intelligence Copilot — 3 operations
  • Kisum — 7 operations (legacy /kisum/* aliases)
  • Integrations - Finance — 11 operations
  • Exchange — 2 operations
  • Festivals — 6 operations. Path :id on GET/PATCH/DELETE accepts Mongo ObjectId, public uuid, or slug (Mongo festival docs). Edition route accepts ObjectId or edition uuid.
  • Files — 1 operation
  • Genres — 1 operation
  • Integrations — 2 operations
  • News — 1 operation
  • Nextcloud — 3 operations
  • Notifications — 6 operations
  • Offer — 16 operations
  • Rankings — 21 operations (5 legacy + 16 Kworb read APIs)
  • Reviews — 1 operation
  • Roster — 6 operations
  • Setlistfm — 8 operations
  • Tasks — 8 operations
  • Transactions — 2 operations
  • Trend — 1 operation
  • Vendors — 11 operations
  • Vendors System — 15 operations
  • Venues — 19 operations
  • Untagged — 2 operations (BLOCKER — must be categorized before production release)

Artist and venue master-data writes are explicitly blocked in Promoters: POST/PATCH/DELETE /api/artists* and POST/PATCH/DELETE /api/venues* return 410 Gone. Promoters may read those directories and create promoter-side booking/offers/requests through the Artists/Venues network BFFs; the owning services keep master-profile writes.


6a. Promoters directory feed (internal, for Backend-Kisum-Artists) — 2026-07-15

Section titled “6a. Promoters directory feed (internal, for Backend-Kisum-Artists) — 2026-07-15”

Read-only aggregation feed so Frontend-Kisum-Artists can browse Kisum promoter tenants (not the Artists industry directory). This is the platform’s first cross-tenant data disclosure — see Data ownership. Internal-key only (requireInternalApiKeyOnly); no browser ever calls these routes directly.

RouteReturns
GET /internal/directory/promotersPaged list. Params: q, country (iso2), page, limit, sort, order.
GET /internal/directory/promoters/:coreCompanyIdIdentity (name/logo/country) + staff + show/cancelled counts. No show list inline.
GET /internal/directory/promoters/:coreCompanyId/showsPaged past shows. Params: page, limit, country, artistId, from, to.

Core GET /internal/companies?audience=promoter&q=&country=&page=&limit= (one call — profile + addresses already batch-loaded by Core) → map the page’s Core UUIDs to this service’s own Mongo companies._id in one $in query → one aggregate for past-show counts per company. Zero per-row Core or Auth calls.

Core GET .../profile + .../addresses → Auth GET /internal/admin/companies/{id}/users (staff + businessUnitMemberships[].isDepartmentHead) → own event aggregate for show + cancelled counts.

  • Staff: active + approved users only (Auth’s ListUsersInCompany joins the membership’s is_active but not the user’s is_active/approval_status — filtered on this side, not fixed in Auth). Fields returned: name, company role, department, work email. Never phonemapStaffMember builds an allow-listed object field-by-field rather than spreading-then-stripping the upstream Auth payload, so a future Auth field addition cannot leak it.
  • Decision maker: the user whose company role is TENANT_SUPERADMIN.
  • Department head: surfaced per person from Auth’s real isDepartmentHead flag (migration 000019 — see Backend Auth §5.2b). Not inferred from MANAGER or from Finance’s is_primary_approver.

Own events where company = <mongoId> AND date < now AND effective status ∈ {confirmed, completed} — the same event_status || (status ? 'confirmed' : 'pending') reconciliation events.query.js already uses for legacy rows, because nothing ever transitions an event to completed — filtering on event_status === 'completed' alone would drop nearly all history. Cancelled shows are counted with the same date filter and event_status = 'cancelled', returned only on the detail payload’s showsCancelled count — never mixed into the shows list.

Fields per show: country / countryIso (from the event, no lookup), date, venue (resolved name; the raw UUID is omitted if unresolved, never shown raw), artists[] (resolved names — an unresolvable legacy Mongo-ObjectId lineup entry is dropped from the row, but the show itself is still returned).

Name resolution is batched per page, not per row:

  • Artists — one real ?ids= batch call to GET /api/v1/artists?ids=<csv> over the distinct numeric lineup ids on the page (skips legacy ObjectIds). Does not reuse the older chunked-10 N+1 helper, which silently drops failures via allSettled.
  • Venues — no upstream ids filter exists yet (follow-up, not built here), so this uses a bounded p-limit fan-out (≤ 10 concurrent) over the page’s distinct venue UUIDs plus a short-TTL name cache, instead of firing every id simultaneously.

Artists → Promoters: X-Service-Token (audience promoters) when S2S is enabled, else X-Internal-API-Key matching this service’s PROMOTERS_INTERNAL_API_KEY. Promoters → Core / Auth: existing X-Internal-API-Key machine keys.


6b. Admin events feed (internal, for Backend-Kisum-Admin) — 2026-08-03

Section titled “6b. Admin events feed (internal, for Backend-Kisum-Admin) — 2026-08-03”

Cross-tenant promoter events CRUD for platform staff. Internal-key only (requireInternalApiKeyOnly); Admin BFF proxies with PROMOTERS_INTERNAL_API_KEY. No x-org.

RoutePurpose
GET /internal/admin/eventsPaged list of all events. Params: q, status, type (all | today | upcoming | past), sort_by, page, limit.
GET /internal/admin/events/:eventIdSingle event (numeric id).
PATCH /internal/admin/events/:eventIdUpdate date, venue, lineup only.
DELETE /internal/admin/events/:eventIdHard-delete event.

Statuses: pending, confirmed, completed, cancelled. Default list includes every status unless status filter is set.


These groups must not be used in Base Backend anymore.

  • Auth — ❌ deprecated; moved to Auth Backend
  • Company Users — ❌ deprecated; moved to Auth Backend
  • Company User Invitations — ❌ deprecated; moved to Auth Backend
  • Company Teams — ❌ deprecated; moved to Auth Backend
  • Packages — ❌ deprecated; moved to Platform Core Backend
  • Permissions — ❌ deprecated; moved to Auth Backend
  • Role — ❌ deprecated; moved to Auth Backend
  • Subscription — ❌ deprecated; moved to Platform Core Backend
  • Users — ❌ deprecated; moved to Auth Backend

Company workspace (profile BFF — restored 2026-07-14)

Section titled “Company workspace (profile BFF — restored 2026-07-14)”

This section is intentionally high-level.
For junior developers, the goal is to understand what kind of routes live in each group.

Company workspace (membership profile BFF)

Section titled “Company workspace (membership profile BFF)”

Used by Frontend-Kisum-Promoters /profile/companies/[id] Users & Access. Auth: Bearer JWT + canonical x-org (Core company UUID). Owners/Admins manage Promoters-side access; delegated managers are restricted to grantable modules/permissions and cannot edit protected roles. The Promoters UI filters the Auth catalog to Promoters-managed namespaces only and preserves hidden cross-app grants on save.

RouteOwnershipNotes
`GETPUT /access/policy`Retired
GET /access/preflightRetiredReturns 410 Gone; browser rollout preflight is disabled
GET /access/audit, /access/catalogAuthAccess history and permission catalog
/access/teams*AuthTeam CRUD/archive, defaults, members, and scoped grants
/access/members*AuthEffective access, direct allow/deny, BU membership, Finance role/limit and primary flags
/access/business-units*CoreBU CRUD/archive and promoter defaults
POST /company-user-invitationsAuthInvitation with an atomically validated V2 access-policy snapshot

Legacy company-team routes remain rollback compatibility only and are not the Users & Access source of truth.

TierSourceMay manage
fullcompany role OWNER / ADMIN / TENANT_SUPERADMINeverything
delegateddelegation.canManageUserscompany-wide, clamped to grantableModules / grantablePermissions; cannot touch Owners or Admins
scopedAuth business-unit role ADMINthe people in those units only

GET /access/authority returns { canManageCompanyAccess, businessUnitScoped, businessUnitIds } and is deliberately not behind the management guard, so a member with no authority still gets an honest answer.

A scoped caller is refused — not clamped — on every company-wide route: teams, access audit, business-unit create/rename/archive, and PUT /access/members/{id} (module and permission grants). GET /access/members is filtered to members sharing one of their units, and POST /company-user-invitations clamps the invite to those units while stripping module, permission, and team grants.

PUT /access/members/{id}/business-units merges for scoped callers. Auth’s endpoint is a full replace, so the payload is combined with the member’s assignments outside the caller’s units before forwarding — without it, every save would silently delete them.

PUT /access/members/{membershipId} — read and write field names differ (trap)

Section titled “PUT /access/members/{membershipId} — read and write field names differ (trap)”

This route replaces the membership’s module and permission rules: Auth deletes every existing rule and re-inserts from the request body. Anything the payload omits is deleted, not left alone.

The read and write contracts do not use the same field names, and Go silently ignores unknown JSON keys — so sending the read shape produces a successful 200 that wipes the member’s grants.

DirectionModules allowedModules denied
Read (GET)directModuleAllowsdirectModuleDenies
Write (PUT)moduleAllowsmoduleDenies

Rules for any persona app calling this route:

  1. Send moduleAllows / moduleDenies — never directModuleAllows / directModuleDenies.
  2. Send back everything you read, including denies and any namespace your UI does not display. Omission is deletion.
  3. isPrimaryFinanceAdmin is nested under finance on the read (finance.isPrimaryFinanceAdmin), not top level. Reading it from the wrong place yields undefined, which coerces to false and silently demotes a Primary Finance Admin on the next save.

This is a real defect class, not a hypothetical: Artists and Venues both sent the read field names and wiped module grants on every save until 2026-07-21.

Org restriction overrides (2026-08-12):

Core deniedPermissionKeys on company entitlements lists Auth keys blocked by active restriction packages (negative org overrides). Promoters hydrates this via GET /users/initaccess.deniedPermissionKeys.

When promoter.booking.agencies.view is denied:

LayerBehavior
FEcanViewAgencies() false even for owners; Team tab hidden; /booking/agencies shows unauthorized
BFFrequireAgenciesContactNotDenied() returns 403 on agencies network routes, GET /artists/:id/team, and GET /artists/:id/contacts

Auth membership must still grant the key for positive access; restrictions subtract at org level for everyone.

Permission picker filtering (2026-07-14):

  1. Core GET /internal/companies/{id}/entitlementsenabledModules (base package + add-ons, e.g. promoter, finance, ai).
  2. Auth internal catalog union for each enabled module.
  3. Intersect with caller GET /auth/me/accessdelegation.grantablePermissions (fallback: permissions when delegation empty).
  4. Frontend shows only Promoters-managed namespaces (promoter.*, venue_marketplace.*, allowed ai.*, allowed finance.*, and relevant middle_agency.*). It must not show artist.* or venue.* persona-app permissions in Promoters Users & Access.
  5. Saving from Promoters merges visible choices with existing hidden module grants and company-level allows so Artists/Venues-owned access is not erased.

Runtime alignment audit (Promoters repo):

LayerStatusSource
Route middlewarepromoter.*utils/constants/permissions.js, middleware/permission.middleware.js
Auth catalog SoTBackend-Kisum-Auth/cmd/migrate-permissions/seed.go
Frontend menu gatesENTITY_PERMISSION_MAPFrontend-Kisum-Promoters/src/hooks/use-permission.ts
Legacy Mongo migrationshistorical onlymigrations/migrate-expense-permissions.js — not runtime

The active management contract is complete under /api/access/*; browser code never calls Auth/Core internal routes directly.

Typical responsibilities:

  • list agencies
  • create agency
  • update agency
  • delete agency
  • manage agency roster
  • manage agency team members

Typical routes:

  • GET /agencies
  • POST /agencies
  • GET /agencies/search
  • GET /agencies/{id}
  • PUT /agencies/{id}
  • DELETE /agencies/{id}
  • GET /agencies/{id}/roster
  • POST /agencies/{id}/roster
  • DELETE /agencies/{id}/roster/{rosterId}
  • GET /agencies/{id}/team
  • POST /agencies/{id}/team
  • PUT /agencies/{id}/team/{teamId}
  • DELETE /agencies/{id}/team/{teamId}

Typical responsibilities:

  • retrieve agreements
  • create agreement assets
  • generate or delete agreement PDFs

Typical routes:

  • GET /agreements
  • GET /agreements/{id}
  • PUT /agreements/{id}
  • GET /agreements/{id}/{assets}
  • POST /agreements/{id}/{assets}
  • PUT /agreements/{id}/{assets}
  • GET /agreements/{id}/{assets}/{extra}
  • POST /agreements/create
  • POST /agreements/create/pdf/{offerId}
  • DELETE /agreements/delete/pdf/{offerId}

Typical responsibilities:

  • AI assistant
  • Gemini route
  • AI conversation/chat routes

Typical routes:

  • POST /ai-kisum/gemini
  • POST /ai-kisum
  • POST /ai-kisum/n8n
  • GET /ai-kisum/conversation/{id}
  • POST /ai-kisum/conversation
  • POST /ai-kisum/chat

Typical responsibilities:

  • analytics views exposed by Base

Typical routes:

  • GET /analytics/financials

Typical responsibilities:

  • approval queue
  • approve/reject/cancel actions
  • approval views by user

Typical routes:

  • GET /approval
  • GET /approval/by-user
  • POST /approval/{id}/approve
  • POST /approval/{id}/reject
  • POST /approval/{id}/cancel
  • GET /approval/{id}

Full per-endpoint matrix (source of truth, Promoters handler, upstream path, Mongo stores, frontend callers, UI screens): Artist Source of Truth Registry. Typical responsibilities:

  • artist CRUD
  • discovery/search
  • stats
  • demographics
  • prediction
  • historical data
  • platforms
  • events
  • contacts
  • bios

Typical route families:

Artist directory (master data — Artists Postgres, 2026-06-07):

RouteSource of truthPromoters behavior
GET /artistsBackend-Kisum-Artists GET /api/v1/artistsMachine BFF after JWT/x-org; maps to legacy { data, pagination }; each row includes slug (artists.slug) for UI links
GET /artists/searchsame upstream (q)Legacy search array shape (includes slug when present)
GET /artists/{id}same upstream (:id + include=profile,genres,platforms,providers)bigint id, uuid, slug, or legacy Mongo gid ref
GET /v2/artistssame as GET /artistsDelegates to upstream (Mongo layer models removed)
GET /v2/artists/{id}same as GET /artists/{id}Delegates to upstream

Requires ARTISTS_INTERNAL_BASE_URL + ARTISTS_INTERNAL_API_KEY on Promoters (e.g. http://artists.local.kisum.io:3831). Not Mongo kisum_data.artists_v2 for any artist master read. Directory mapper (artists-directory.mapper.js) must pass through slug — Promoters UI prefers /artists/{slug} over numeric ids.

Source split (2026-06-07): Profile routes (GET /artists, /search, /{id}, /v2/artists*) and event lineup display fields → Artists upstream. Stats/analytics (/stats, /demographics, discography, Soundcharts/Viberate/Songstats/Spotify, predictions) → Mongo kisum_data.artists_v2 + data_analyst.artist_data (unchanged). Event lineup stats still load from Mongo via getStatsById.

For marketplace offers with createEventOnSubmit, offersheet.targets[].officialArtistId must be numeric PostgreSQL artists.id. Promoters validates it through Artists and stores it directly in the Event lineup; it is never a Mongo ObjectId.

Canonical marketplace offer write: POST /api/artists-network/marketplace/offers, with GET|PATCH /api/artists-network/marketplace/offers/:id. The request must include offerChannel: exclusive|middle_agent. Promoters preserves the resolved offersheet at the top level and forwards both fields to Artists, which validates the declared channel against the actual listing. Legacy /artists-network/middle-agent/enquiries routes are secondary Middle Agent compatibility routes only; they are not aliases for Exclusive agency offers.

Failed-submit rollback (2026-07-15): because the promoter event is created before the offer is sent to Artists, if anything after creation fails (tour/event-group creation, or the Artists offer call) the event(s) + any newly-created tour are deleted before the error surfaces — no orphan event, and a retry cannot duplicate. Only events created in that request are rolled back; editing an offer already linked to an event creates nothing (rollback is a no-op, the event is kept). This is distinct from decline/cancel, which never deletes the event.

Event ↔ offer back-reference (2026-07-15): the promoter event is still created/resolved before the offer is sent (Artists rejects an unresolved createEventOnSubmit). After the offer is created, the Promoters BFF appends the returned offer id to each linked event’s flat offerIds array and to a per-artist map offerIdsByArtist.<officialArtistId> (events.offerIdsByArtist, keyed by the lineup artist id from offersheet.targets[].officialArtistId), so the offer is linked to the exact artist it was sent to. GET /api/events/:id returns offerIds on each lineup artist (a single-artist event falls back to the flat list, so pre-existing links surface without backfill). Promoters frontend (2026-07-16): the event overview Create Offer control is hidden when any linked offerIds are present; event detail refetches on tab navigation so the control updates after sending an offer from the Booking tab. Best-effort and non-fatal: a link failure never fails the already-created offer. There is no delete cascade — declining or cancelling an offer never removes the event; the event and each offer own their own lifecycle (offers use their existing terminal-delete; events are deleted only by the user).

Structured payment plan (2026-07-15): the offersheet may carry paymentPlan.installments[] (each { label?, amount, dueDate, percent? }) captured in the offer wizard Fee step, alongside the free-text clauses.paymentSchedule. Installments must sum to the total fee (±0.02). When an exclusive offer with a payment plan is accepted, Artists auto-creates the agency’s receivable Finance invoice (POST /api/income) with one term per installment. Absent a plan, no invoice is generated.

  • POST /artists — (legacy write surface; directory creates belong in Artists long-term)
  • GET /artists/{id}/discographyDefault: artist Spotify albums list + top tracks (unchanged). With ?ids=id1,id2: batch album details (max 50); proxies MusicData GET /spotify/albums/multiple (fallback: Promoters spotify.getAlbums). Response data: { artistId, albums[], source } — full album objects include popularity + label. Albums must belong to artist when Spotify id is known (a non-owned id 404s the whole batch). Frontend Discography tab calls the default list, then re-fetches the artist’s own album-type ids via ?ids= (chunked, fault-tolerant) to read popularity for the popularity-ranked Top Albums bento.
  • GET /artists/{id}/discography/{albumId} — Single album detail; proxies MusicData GET /spotify/albums/{albumId} (fallback: Promoters spotify.getAlbum). Response data: { artistId, albumId, album, source }. HTTP cache: global Promoters GET middleware; bypass chains to MusicData via x-redis: bypass.
  • GET /artists/{id}/top-tracks — Kworb all-time top 10 songs for the artist (charts.kworb_spotify_top_artists.tracks), enriched with Spotify album art/metadata via MusicData GET /spotify/tracks/multiple (fallback: Promoters Spotify client when MUSICDATA_* unset). HTTP cache: global Promoters GET middleware (promoters: prefix, 7d TTL) — no per-route Redis keys. Bypass: ?live=true, x-redis: bypass, or frontend NEXT_PUBLIC_REDIS_BYPASS=true; Promoters forwards x-redis: bypass to MusicData on internal calls. Response data.tracks[]: rank, title, spotifyId, link, streams, daily, image, albumName, artists, popularity.
  • GET /artists/{id}/top-songs-regional — NetEase + QQ Music hot top 10 songs when the artist has platform IDs (platforms.netease.id / platforms.qq.id, fallback streamingPlatforms.netease / streamingPlatforms.qq). Skips a platform when its ID is missing. Proxies MusicData GET /netease/artist/top/song?id={neteaseId} and GET /qq/artist/song?singermid={qqId} (requires MUSICDATA_BASE_URL + MUSICDATA_INTERNAL_API_KEY; returns empty tracks with source: unavailable when unset). HTTP cache: same global GET middleware on Promoters and MusicData when bypass is not active. Response data: { artistId, netease, qq } where each section is { platform, platformId, tracks[], source, cached, cachedAt } and each track has rank, title, platform, platformTrackId, albumName, image, artists, popularity, link. Frontend artist overview Top Songs on NetEase / Top Songs on QQ Music sections (below Spotify Top Songs).
  • GET /artists/{id}/chart-presence — Local read against Mongo charts Kworb collections (utils/kworb-chart-presence.utils.js). Resolves artist id / name / platforms.spotify.id; returns data.charts[] grouped by chart family. Same match semantics as MusicData analyst route (no MUSICDATA_* required). Frontend artist overview Chart Presence section.
  • GET /artists/{id}/top-countries — Soundcharts social followers countryPlots via GET /api/v2.37/artist/{uuid}/social/{platform}/followers/ (default instagram; optional platform=tiktok|youtube). Requires SOUNDCHARTS_APP_ID + SOUNDCHARTS_API_KEY; resolves data_platforms.soundcharts_uuid from Spotify when missing. Query: limit (default 10, max 50), optional startDate / endDate. Response data: { artistId, platform, soundchartsUuid, countries[], totalFollowers, snapshotDate, source } where each country has rank, countryName, countryCode, followers, share (% of snapshot total). HTTP cache: global Promoters GET middleware; bypass chains per Redis GET cache. Frontend artist overview Top Countries section (flag list + share bars, audience by Instagram followers); country flags via flagcdn.com.
  • GET /artists/{id}/charts/song/ranks/{platform} — Soundcharts proxy for Get chart song entries: GET /api/v2/artist/{uuid}/charts/song/ranks/{platform}. Path platform = Soundcharts song-chart platform code (e.g. spotify). Query passthrough: currentOnly (0|1), offset, limit (max 100), sortBy (position|rankDate), sortOrder (asc|desc); includeItems=false (or 0) omits items[] from the Promoters response (aggregate info still computed from the upstream page). Requires SOUNDCHARTS_APP_ID + SOUNDCHARTS_API_KEY; resolves data_platforms.soundcharts_uuid from Spotify when missing (may persist on Mongo artists_v2). Response data: { artistId, platform, soundchartsUuid, source, related, items[], info, page, errors[] } where each items[] entry includes chart metadata, position, peakPosition, current, song, etc. (Soundcharts shape). info (Promoters aggregate over returned items[]): { peakPosition, timeonChart[] }peakPosition = best (lowest) peakPosition across items; timeonChart = per chart.frequency (daily / weekly / monthly when present) average of timeOnChart as { frequency, time }. HTTP cache: global Promoters GET middleware.
  • GET /artists/{id}/popularity/{platform}MusicData analyst proxy (GET /analyst/artists/{id}/popularity?platform=…). Path platform = Soundcharts platform code (spotify, instagram, apple-music, …). Requires MUSICDATA_INTERNAL_BASE_URL (or MUSICDATA_BASE_URL) + MUSICDATA_INTERNAL_API_KEY; returns source: unavailable / error: musicdata_not_configured when unset. Optional query: refresh, startDate, endDate (passed through to MusicData Mongo read filters / forced refresh). MusicData owns Mongo data_analyst.artist_popularity and conditional Soundcharts backfill (60d rule). Promoters summarizes MusicData items[] into yearly[] / peak / current via utils/artist-popularity.utils.js. Response data: { artistId, platform, soundchartsUuid, source: musicdata_analyst, peak, current, yearly[], lastCrawlDate, errors[], musicData?: { dataSource, soundchartsApiCalls, refreshed, soundchartsFetch } }yearly[] = { year, average, count } per calendar year (0-valued points excluded as gaps); peak/current = { value, date }. Powers the Rankings → Historical Insights trajectory chart. HTTP cache: global Promoters GET middleware. See MusicData analyst.
  • GET /rankings/spotify/listeners?artistId={id} — single-artist branch (beside ?spotifyId=): returns the one Kworb listeners doc matched by Kisum artist_id (peakListeners, peakRank, listeners). Powers Rankings → Historical Insights “Peak Monthly Listeners”.
  • GET /artists/{id}/stats
  • GET /artists/{id}/demographics
  • GET /artists/{id}/events
  • GET /artists/{id}/bandsintown/events — external artist concerts; failover order: Bandsintown → Soundcharts GET /v2/artist/{uuid}/events → Last.fm artist.getPastEvents / artist.getEvents (env: LASTFM_API_KEY). Returns [] when all sources fail (not 500). Optional ?date=all|past|upcoming. Items may include source: bandsintown | soundcharts | lastfm. Enrichment sets location.*, copies venue.country, and server-side geocodes missing venue.latitude / venue.longitude via GOOGLE_MAPS_API_KEY (country-centroid fallback from Mongo countries).

Stats & demographics data sources (2026-06-06):

RouteStorage (SoT)Refresh triggerExternal providers (on refresh only)
GET /artists/{id}/statsMongo data_analyst.artist_data keyed by artist_id (Kisum int32 kisum_data.artists_v2.id)On-demand when row missing, updatedAt older than 7 days, or ?bypass_cache=true / ?refresh=true. No crontab (2M+ artists).MusicData analyst (when hybrid env set): Viberate → Songstats → Soundcharts → native MusicData platform gap-fill (genius, lastfm, netease, qq, Spotify/Deezer/YouTube/Instagram/TikTok/Bandsintown). Local fallback: tiers 1–3 only via artist-stats-refresh.utils.js (no native tier duplication). Refresh failure → return stale artist_data if present.
GET /artists/{id}/demographics(unchanged) Soundcharts + fallbacksPer existing demographics pipelineSee prior demographics row

Promoters read path: Mongo artist_data if fresh (7-day rule) → else on-demand refresh. No handler-level Redis on stats; optional GET response cache via global middleware only. Auth /auth/me/access is always live (never cached in Promoters).

MusicData analyst writer (Phase 2+): When MUSICDATA_BASE_URL + MUSICDATA_INTERNAL_API_KEY are set on Promoters, stale/missing refresh is delegated to GET /analyst/artists/{artistId}/stats on Backend-Kisum-MusicData (sole writer to artist_data). Promoters keeps hybrid fast path (reads Mongo when fresh). See MusicData analyst module.

Local fallback (no MusicData env): Promoters writes artist_data via utils/artist-stats-refresh.utils.js + utils/artist-data-stats.utils.js.

Requires SOUNDCHARTS_APP_ID, SOUNDCHARTS_API_KEY, optional SOUNDCHARTS_BASE_URL. Public stats response shape unchanged (flat keys: spotify, youtube_views, …).

  • POST /artists/{id}/bio
  • POST /artists/{id}/prediction-v2
  • POST /artists/{id}/prediction-v3

Typical responsibilities:

  • avails list
  • avails by user
  • avails-linked shows

Typical route families:

  • GET /avails
  • POST /avails
  • GET /avails/user
  • GET /avails/{id}/shows
  • GET /avails/{id}/shows-summary

Data ownership: Chart snapshots live in MongoDB data_analyst (kworb_* collections + legacy spotify_top_streamed_*, rank_vibrate). Scrapers run outside Promoters (Kworb cron); Promoters is read-only.

Auth: Bearer JWT + x-org on all routes.

Kworb response shape (in data):

{ "meta": { "scrapedAt": "..." }, "rows": [], "pagination": { "page": 1, "limit": 50, "total": 0 } }

Legacy routes (unchanged):

  • GET /rankings/alltime — all-time streamed artists (Kisum enrich)
  • GET /rankings/cityranking — city listener rankings (rankings, region query)
  • GET /rankings/topartists — top artists list
  • GET /rankings/topsong — top songs by year (rankings = year, including all)
  • GET /rankings/worldwideViberate worldwide chart (not Kworb)

Kworb routes (2026-06-03):

  • GET /rankings/current-charts — multi-platform #1 grid
  • GET /rankings/global-artists?list=main|extended — Kworb composite leaderboard (use instead of worldwide for Kworb data)
  • GET /rankings/itunes/charts/{countryCode} — top ~200 songs per country
  • GET /rankings/itunes/worldwide/{chart}ww | europe
  • GET /rankings/itunes/pop/{countryCode} — live popularity bars
  • GET /rankings/itunes/sales/us — US digital sales estimates
  • GET /rankings/itunes/countries — market catalog
  • GET /rankings/youtube/trending, GET /rankings/youtube/videos-24h
  • GET /rankings/radio/us
  • GET /rankings/spotify/listeners — optional spotifyId
  • GET /rankings/spotify/songs?period= — not all (legacy topsong for all-time)
  • GET /rankings/spotify/albums?period=all|least_streamed_track
  • GET /rankings/spotify/country-chartscountry, chartType, variant
  • GET /rankings/spotify/countries
  • GET /rankings/scrape-runs — cron health (job, limit)

Billboard routes (2026-06-08): Mongo charts.billboard snapshots (synced by MusicData /crontab/billboard).

  • GET /rankings/billboard/categories — chart list built from snapshot documents (weekly-snapshot / year-end-snapshot, one row per distinct chart+meta), merged with optional weekly-categories / year-end-categories labels. Optional ?cat= filter. Rows: id (slug), label, chart, meta, category, year.
  • GET /rankings/billboard/chart — latest snapshot for chart=weekly|year-end + meta (e.g. hot-100). Optional weekOf, year. Normalizes data[].artist[] → flat artist label + artist_id (first linked Kisum id) for row click / insight rail.

Canonical field reference: Backend-Kisum-Promoters/rankings/RANKING.md.

Frontend (Frontend-Kisum-Promoters, 2026-06-08): /market_analysis/rankings adds a Billboard provider tab (/market_analysis/rankings/billboard/{chart-slug}). Chart list loads from billboard/categories; table uses billboard/chart with infinite scroll. Row click uses artist_id from data[].artist[].id when present. Legacy + Kworb tabs unchanged (Overview, iTunes, YouTube/Radio, Spotify, Ops).

The same pattern continues for:

  • Cash Flow
  • Countries
  • Companies
  • Dashboard
  • Events
  • Event Expense
  • Event Expense - Approval
  • Event Group
  • Event Income
  • Event Tax
  • Event Tickets
  • Event Intelligence Copilot
  • Kisum
  • Integrations - Finance
  • Exchange
  • Festivals
  • Files
  • Genres
  • Integrations
  • News
  • Nextcloud
  • Notifications
  • Offer
  • Rankings
  • Reviews
  • Roster
  • Regions
  • Setlistfm
  • Tasks
  • Transactions
  • Trend
  • Vendors
  • Vendors System
  • Venues

Each of those route groups remains active in Base unless explicitly marked deprecated.

Removed (2026-06-07): /api/xero/* and /api/integrations/xero/* — Xero is owned by Backend-Kisum-Finance, not Promoters.

Browser profile flows under /profile/companies. Do not send x-org. No Promoters module entitlement required.

MethodPathWhoPurpose
GET/users/companiesAuthenticated memberList membership companies; rows include package, packageName, packages[], membership.role from Auth + Core subscription summary
GET/users/companies/eligibleAuthenticated memberPromoters-eligible companies, enriched (logo + packageName). The O(companies) fan-out — one Auth access + one Core snapshot per membership. Lazy: call only when the company picker or the switcher dropdown opens. Must stay registered above /users/companies/:id or the :id route captures the literal eligible.
GET/users/companies/:idMember of companyProfile company detail (Core snapshot, TCompany-compatible shape)
GET/users/companies/:id/delete-previewTENANT_SUPERADMIN, no packageImpact preview: memberships removed vs users deactivated; actorWillBeDeactivated for caller
DELETE/users/companies/:idTENANT_SUPERADMIN, no packageRetire company: Auth membership/user cleanup → Core status: inactive → Promoters Mongo tenant purge

Delete rules

  • Only companies with empty commercial entitlements (no base package, no add-ons).
  • Members with other active companies: membership removed for this company only; account stays active.
  • Members with only this company: membership removed + Auth user soft-deactivated (sessions revoked).
  • Deleter with other companies stays logged in; sole-company deleter is signed out client-side after success.

Upstream (machine, BFF only — not browser)

  • Auth: GET /internal/admin/companies/{id}/memberships, GET /internal/admin/users/{id}/companies, POST /internal/admin/companies/{id}/memberships (isActive: false), DELETE /internal/admin/users/{id}
  • Core: PATCH /internal/companies/{id} { "status": "inactive" }

Design spec: Frontend-Kisum-Promoters/docs/superpowers/specs/2026-06-07-company-delete-no-package-design.md.


9b. Access management — delete company member

Section titled “9b. Access management — delete company member”

DELETE /api/access/members/{membershipId}

  • Auth: company-wide administrator or delegated company manager (requireCompanyWideAuthority on the BFF).
  • Behavior: removes the target from this company. If they belong to only this company, Promoters calls Finance POST /api/internal/users/{userId}/purge with mode: "anonymize" (snapshot email + name on invoices/incomes), then Auth POST /internal/users/{id}/hard-delete. If they belong to other companies, Auth DELETE /internal/admin/companies/{companyId}/memberships/{membershipId} only.
  • Guards: cannot delete yourself; cannot delete the last tenant super admin of the company.
  • Headers: same as other /api/access/* routes (Authorization, canonical x-org UUID).

Terminal window
curl -X GET 'http://localhost:3099/api/events?type=all&page=1&limit=20' -H 'Authorization: Bearer <JWT>' -H 'x-org: <COMPANY_ID>'
Terminal window
curl -X POST 'http://localhost:3099/api/artists/{id}/bio' -H 'Authorization: Bearer <JWT>' -H 'x-org: <COMPANY_ID>' -H 'Content-Type: application/json' -d '{
"resJson": {}
}'
Terminal window
curl -X POST 'http://localhost:3099/api/files/upload' -H 'Authorization: Bearer <JWT>' -H 'x-org: <COMPANY_ID>' -F 'file=@example.pdf'

  • all protected routes require Authorization
  • all tenant-scoped routes require x-org
  • deprecated groups must not be used
  • Untagged routes must be categorized before release
  • active business routes should map to promoter.* permissions (Auth catalog; see Permissions Catalog)

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Platform Core Backend.

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Auth Backend.

Deprecated. Moved to Platform Core Backend.

Deprecated. Moved to Auth Backend.

GET /api/events accepts type as a date window, not a status:

ValueMeaning
todayevents dated today
upcomingtoday and later (the UI default)
pastbefore today
allno date filter

Event status (pending, confirmed, cancelled, completed) is a separate filter. Passing a status as type returns 400 Bad Request naming the accepted values — it previously returned 500, which told the caller a request that can never succeed was worth retrying. See the error contract.

Identifiers on event sub-resources (2026-08-07)

Section titled “Identifiers on event sub-resources (2026-08-07)”

The Postgres migration changed the identifier of the event only. Everything that hangs off an event still carries _id as well as id, and _id is the integer primary key:

ResourceIdentifier in responses
Eventid (plus uuid, publicEventId, promoterEventId) — no _id
Tax, income, expense, term, payment, event group, transaction_id and id, same integer
Expense categorycode (its identifier has always been the code)

Clients address sub-resources by _id, unchanged from the Mongo era — PATCH /api/event-tax/{_id}, DELETE /api/event-income/{_id}. Dropping the alias made every edit and delete address …/undefined and answer 404.

EndpointStatusBody
POST /api/event-income201the created row, including _id
PATCH /api/event-income/{id}200the updated row

Both previously answered 200 with data: null regardless of outcome, so a client could neither read the new id nor tell a save that worked from one that did not.

Promoters owns income estimates. Finance owns actual income. GET /api/event-income/find-by-event/{id} reads estimates locally and asks Finance for the actual rows. POST /api/event-income with type: "actual" still writes to the Promoters table, so a row created that way does not appear in find-by-event until POST /api/integrations/finance/events/{id}/sync-income pushes it across. Read the row back from GET /api/event-income/{id} if you need to confirm the write itself.

Ticket vendors are ticketing companies (Megatix, Klook, BookMyShow…) with country coverage. They live in ticket_vendors, a different table from /api/vendors (service suppliers), and the Add Ticket form fills its vendor picker from them.

RoutePurpose
GET /api/ticket-vendor-term?country=<ISO2>vendors available in that country
GET /api/ticket-vendor-term/:idone vendor, by legacy _id or integer id

Permission: promoter.event.ticket.view. country must be ISO2 (MY), not a country name; anything else is a 400.

These routes were removed on 2026-07-01 as unused and restored on 2026-08-07 — they are called by TicketForm and TicketVendorCard, and without them no ticket category can be created. Do not remove them again without grepping the frontends for the URL.

Ticket posted_by is not persisted (2026-08-07)

Section titled “Ticket posted_by is not persisted (2026-08-07)”

POST /api/event-ticket accepts posted_by and stores it on the payment term it opens, but event_ticket_categories and ticket_allocations have no such column — so GET /api/event-ticket/* always returns posted_by: null. Clients must not treat it as required on read. Persisting it needs a migration on both tables.

GET /api/events/{id}/overviewticketSalesOverview.ticketSaleHistory[] is a list of daily movements. Its shape changed with the Postgres migration:

Before (Mongo)Now
ticketId: { _id, title } (populated)ticketCategoryId: number
ticketCategoryTitle: string | null
quantity, date, posted_byquantity, comps, date, grossAmount, netAmount, currency

ticketCategoryTitle was added on 2026-08-07 because nothing else in the response could name a movement — ticketSalesOverview.tickets[] carries a title but no id to join on. Clients grouping a chart by ticket must read ticketCategoryTitle; ticketId is gone and dereferencing it throws.

Deleting an event with ticket sales (2026-08-07)

Section titled “Deleting an event with ticket sales (2026-08-07)”

DELETE /api/events/{id} answers 409 Conflict when the event has recorded ticket sales:

{ "statusCode": 409,
"message": "This event has 10 recorded ticket sales and cannot be deleted. Clear the sales on each ticket category first." }

ticket_order_lines holds a RESTRICT foreign key on ticket_allocations, so the delete cannot succeed while sales exist — it previously surfaced as a 500 carrying the raw constraint name. To delete such an event, clear each category’s sales first (DELETE /api/event-ticket/{id}/sales-clear), then delete the categories, then the event.

Sales are not cascaded away with the event by design: they record money that was taken.

POST /api/event-ticket/{id}/sales-import — multipart, field file.

The {id} segment is overloaded:

{id}Behaviour
an integer (ticket allocation id)event and vendor derived from the allocation
anything else (ticket vendor id)treated as the vendor; eventId must be sent in the body

Sending a vendor id without eventId answers 400 eventId is required when the path does not identify an allocation.

The default@1 mapping reads these (aliases in brackets):

ColumnRequiredAliases
external_category_idyescategory_id, ticket_type_id
quantityyesqty, tickets
unit_selling_priceyesunit_price, price
currencyyesccy
sold_atyespurchase_date, date, created_at
discount_amountnodiscount

Rows are matched to a ticket on external_category_id — the vendor’s own stable id for the tier — which must be mapped onto the allocation first via PATCH /api/events/{eventId}/tickets/allocations/{allocationId} with { "external_category_id": "..." }. Title-and-price matching was retired because renaming a tier broke every later import.

A 200 does not mean the rows were accepted. The response body carries rows_total, rows_valid, rows_rejected and row_errors[]; a file whose every row was rejected still answers 200. Send dry_run=true to validate without writing.

Admin override: force-deleting an event with sales (2026-08-09)

Section titled “Admin override: force-deleting an event with sales (2026-08-09)”

DELETE /api/events/{id} refuses with 409 when the event has recorded ticket sales, for every promoter, always. There is no user-facing way past it — ?force=true on that route is ignored.

The override lives on the internal admin route:

DELETE /internal/admin/events/{eventId}?force=true
x-internal-api-key: <PROMOTERS_INTERNAL_API_KEY>
RequestResult
DELETE /api/events/{id}409 while sales exist
DELETE /api/events/{id}?force=true409 — the flag is not read here
DELETE /internal/admin/events/{id}409 — the key alone is not consent
DELETE /internal/admin/events/{id}?force=truedeletes the event and its sales

Forcing removes ticket_order_lines and ticket_daily_report_lines (the two RESTRICT keys on ticket_allocations); the rest cascades from events. Those rows record money that was taken, so the admin console asks the operator to type DELETE before sending it.

CSV rows match a ticket on external_category_id. Set it per allocation:

PATCH /api/events/{eventId}/tickets/allocations/{allocationId}
{ "external_category_id": "VIP-001" }

GET /api/events/{eventId}/tickets/categories returns each category with its allocations[], each carrying ticketVendorId and the current external_category_id — that is the pair the Promoters upload drawer reads to show which tiers are still unmapped.

An allocation may only be matched by the vendor that owns it; a row whose external_category_id belongs to another vendor is rejected as such.