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
1. Purpose of this document
Section titled “1. Purpose of this document”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:
2. Base URLs
Section titled “2. Base URLs”https://api-v2.kisum.dev/api— Staginghttp://localhost:3099/api— Local
3. Required headers for protected routes
Section titled “3. Required headers for protected routes”Authorization: Bearer <JWT_FROM_AUTH_SERVICE>x-org: <COMPANY_ID>Content-Type: application/jsonAuthorizationmust contain a JWT issued by Auth Backendx-orgmust contain the active company context- tenant-scoped routes must reject missing or malformed
x-org - Guest / optional org:
GET /api/eventsusesoptionalRequirePermissions([]). Whenx-orgis missing or cannot be resolved, the route setsreq.isGuest = trueand returns200with an empty list (data: [],count: 0) — not500. Other routes (notifications, artists-network, etc.) still require resolved company context and return403without validx-org. - Package access guard: after JWT verify, Promoters checks the token
packageclaim (promoter|venue|artist). A non-empty value other thanpromoter→403{ code: "WRONG_PACKAGE", message: "This account does not have access to Promoters." }. Empty/missingpackagepasses through. Exception: shared profile BFF under/api/users/*skips this guard so Artists/Venues can proxy membership/profile routes with anartist/venueJWT (still Auth-verified). Promoter-domain routes keep the guard. Machine internal-key / service-token auth is exempt.
Machine auth (selected routes)
Section titled “Machine auth (selected routes)”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).
| Route | x-org for machine callers | Upstream |
|---|---|---|
GET /internal/events/:eventId | Required when resolving tenant context | Promoters Mongo |
GET /internal/admin/events* | Not required (cross-tenant platform staff feed) | Promoters Postgres events |
GET /news | Optional (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 |
4. Common content types
Section titled “4. Common content types”application/jsonfor most requestsmultipart/form-datafor uploads and attachmentsapplication/xmlmay appear in legacy Swagger definitions, but JSON is the primary format
5. Common response codes
Section titled “5. Common response codes”200/201/default— success400— invalid request / missing malformedx-org401— missing or invalid JWT403— authenticated but not allowed500— internal server error503— effective access cannot be resolved from Auth
6. Active route groups
Section titled “6. Active route groups”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/artistsforwardsiso2country 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*, nox-org) - User profile write —
PUT /api/users/profile(name/phonePrefix(+XX from countries) /phoneNumber→ AuthPATCH /auth/me);PUT /api/users/update-password→ AuthPUT /auth/me/password - Countries dial codes —
GET /api/countries/allincludesphone_prefix(+XX) for phone pickers (from ArtistsphonePrefix) - 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-vendorx-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-termstakes the event as:idand returns the flat term list (each with itsvendor;?vendorIdnarrows to one), andGET /api/event-ticket/:id/daily-salesreturns an array —?view=dailyTotalspages over days (skip/limit,sortBy,sortOrder,dateFrom,dateTo), anything else gives flat per-sale rows. Salediscountis 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
:idon GET/PATCH/DELETE accepts Mongo ObjectId, publicuuid, orslug(Mongo festival docs). Edition route accepts ObjectId or editionuuid. - 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.
| Route | Returns |
|---|---|
GET /internal/directory/promoters | Paged list. Params: q, country (iso2), page, limit, sort, order. |
GET /internal/directory/promoters/:coreCompanyId | Identity (name/logo/country) + staff + show/cancelled counts. No show list inline. |
GET /internal/directory/promoters/:coreCompanyId/shows | Paged past shows. Params: page, limit, country, artistId, from, to. |
List flow
Section titled “List flow”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.
Detail flow
Section titled “Detail flow”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
ListUsersInCompanyjoins the membership’sis_activebut not the user’sis_active/approval_status— filtered on this side, not fixed in Auth). Fields returned: name, company role, department, work email. Never phone —mapStaffMemberbuilds 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
isDepartmentHeadflag (migration000019— see Backend Auth §5.2b). Not inferred fromMANAGERor from Finance’sis_primary_approver.
Shows flow
Section titled “Shows flow”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 toGET /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 viaallSettled. - Venues — no upstream
idsfilter exists yet (follow-up, not built here), so this uses a boundedp-limitfan-out (≤ 10 concurrent) over the page’s distinct venue UUIDs plus a short-TTL name cache, instead of firing every id simultaneously.
Auth between hops
Section titled “Auth between hops”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.
| Route | Purpose |
|---|---|
GET /internal/admin/events | Paged list of all events. Params: q, status, type (all | today | upcoming | past), sort_by, page, limit. |
GET /internal/admin/events/:eventId | Single event (numeric id). |
PATCH /internal/admin/events/:eventId | Update date, venue, lineup only. |
DELETE /internal/admin/events/:eventId | Hard-delete event. |
Statuses: pending, confirmed, completed, cancelled. Default list includes every status unless status filter is set.
7. Deprecated legacy groups
Section titled “7. Deprecated legacy groups”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
8. Table of contents
Section titled “8. Table of contents”Active sections
Section titled “Active sections”- Agencies
- Agreements
- Ai
- Analytics
- Approval
- Artists
- Avails
- Cash Flow
- Countries
- Companies
- Profile companies (membership-scoped)
- 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
- Untagged
Deprecated legacy sections
Section titled “Deprecated legacy sections”Company workspace (profile BFF — restored 2026-07-14)
Section titled “Company workspace (profile BFF — restored 2026-07-14)”9. Endpoint family summary
Section titled “9. Endpoint family summary”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.
| Route | Ownership | Notes |
|---|---|---|
| `GET | PUT /access/policy` | Retired |
GET /access/preflight | Retired | Returns 410 Gone; browser rollout preflight is disabled |
GET /access/audit, /access/catalog | Auth | Access history and permission catalog |
/access/teams* | Auth | Team CRUD/archive, defaults, members, and scoped grants |
/access/members* | Auth | Effective access, direct allow/deny, BU membership, Finance role/limit and primary flags |
/access/business-units* | Core | BU CRUD/archive and promoter defaults |
POST /company-user-invitations | Auth | Invitation 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.
Authority tiers on the access routes
Section titled “Authority tiers on the access routes”| Tier | Source | May manage |
|---|---|---|
| full | company role OWNER / ADMIN / TENANT_SUPERADMIN | everything |
| delegated | delegation.canManageUsers | company-wide, clamped to grantableModules / grantablePermissions; cannot touch Owners or Admins |
| scoped | Auth business-unit role ADMIN | the 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.
| Direction | Modules allowed | Modules denied |
|---|---|---|
Read (GET) | directModuleAllows | directModuleDenies |
Write (PUT) | moduleAllows | moduleDenies |
Rules for any persona app calling this route:
- Send
moduleAllows/moduleDenies— neverdirectModuleAllows/directModuleDenies. - Send back everything you read, including denies and any namespace your UI does not display. Omission is deletion.
isPrimaryFinanceAdminis nested underfinanceon the read (finance.isPrimaryFinanceAdmin), not top level. Reading it from the wrong place yieldsundefined, which coerces tofalseand 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/init → access.deniedPermissionKeys.
When promoter.booking.agencies.view is denied:
| Layer | Behavior |
|---|---|
| FE | canViewAgencies() false even for owners; Team tab hidden; /booking/agencies shows unauthorized |
| BFF | requireAgenciesContactNotDenied() 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):
- Core
GET /internal/companies/{id}/entitlements→enabledModules(base package + add-ons, e.g.promoter,finance,ai). - Auth internal catalog union for each enabled module.
- Intersect with caller
GET /auth/me/access→delegation.grantablePermissions(fallback:permissionswhen delegation empty). - Frontend shows only Promoters-managed namespaces (
promoter.*,venue_marketplace.*, allowedai.*, allowedfinance.*, and relevantmiddle_agency.*). It must not showartist.*orvenue.*persona-app permissions in Promoters Users & Access. - 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):
| Layer | Status | Source |
|---|---|---|
| Route middleware | ✅ promoter.* | utils/constants/permissions.js, middleware/permission.middleware.js |
| Auth catalog SoT | ✅ | Backend-Kisum-Auth/cmd/migrate-permissions/seed.go |
| Frontend menu gates | ✅ ENTITY_PERMISSION_MAP | Frontend-Kisum-Promoters/src/hooks/use-permission.ts |
| Legacy Mongo migrations | historical only | migrations/migrate-expense-permissions.js — not runtime |
The active management contract is complete under /api/access/*; browser code never calls Auth/Core internal routes directly.
Agencies
Section titled “Agencies”Typical responsibilities:
- list agencies
- create agency
- update agency
- delete agency
- manage agency roster
- manage agency team members
Typical routes:
GET /agenciesPOST /agenciesGET /agencies/searchGET /agencies/{id}PUT /agencies/{id}DELETE /agencies/{id}GET /agencies/{id}/rosterPOST /agencies/{id}/rosterDELETE /agencies/{id}/roster/{rosterId}GET /agencies/{id}/teamPOST /agencies/{id}/teamPUT /agencies/{id}/team/{teamId}DELETE /agencies/{id}/team/{teamId}
Agreements
Section titled “Agreements”Typical responsibilities:
- retrieve agreements
- create agreement assets
- generate or delete agreement PDFs
Typical routes:
GET /agreementsGET /agreements/{id}PUT /agreements/{id}GET /agreements/{id}/{assets}POST /agreements/{id}/{assets}PUT /agreements/{id}/{assets}GET /agreements/{id}/{assets}/{extra}POST /agreements/createPOST /agreements/create/pdf/{offerId}DELETE /agreements/delete/pdf/{offerId}
Typical responsibilities:
- AI assistant
- Gemini route
- AI conversation/chat routes
Typical routes:
POST /ai-kisum/geminiPOST /ai-kisumPOST /ai-kisum/n8nGET /ai-kisum/conversation/{id}POST /ai-kisum/conversationPOST /ai-kisum/chat
Analytics
Section titled “Analytics”Typical responsibilities:
- analytics views exposed by Base
Typical routes:
GET /analytics/financials
Approval
Section titled “Approval”Typical responsibilities:
- approval queue
- approve/reject/cancel actions
- approval views by user
Typical routes:
GET /approvalGET /approval/by-userPOST /approval/{id}/approvePOST /approval/{id}/rejectPOST /approval/{id}/cancelGET /approval/{id}
Artists
Section titled “Artists”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):
| Route | Source of truth | Promoters behavior |
|---|---|---|
GET /artists | Backend-Kisum-Artists GET /api/v1/artists | Machine BFF after JWT/x-org; maps to legacy { data, pagination }; each row includes slug (artists.slug) for UI links |
GET /artists/search | same 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/artists | same as GET /artists | Delegates 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}/discography— Default: artist Spotify albums list + top tracks (unchanged). With?ids=id1,id2: batch album details (max 50); proxies MusicDataGET /spotify/albums/multiple(fallback: Promotersspotify.getAlbums). Responsedata:{ artistId, albums[], source }— full album objects includepopularity+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 readpopularityfor the popularity-ranked Top Albums bento.GET /artists/{id}/discography/{albumId}— Single album detail; proxies MusicDataGET /spotify/albums/{albumId}(fallback: Promotersspotify.getAlbum). Responsedata:{ artistId, albumId, album, source }. HTTP cache: global Promoters GET middleware; bypass chains to MusicData viax-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 MusicDataGET /spotify/tracks/multiple(fallback: Promoters Spotify client whenMUSICDATA_*unset). HTTP cache: global Promoters GET middleware (promoters:prefix, 7d TTL) — no per-route Redis keys. Bypass:?live=true,x-redis: bypass, or frontendNEXT_PUBLIC_REDIS_BYPASS=true; Promoters forwardsx-redis: bypassto MusicData on internal calls. Responsedata.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, fallbackstreamingPlatforms.netease/streamingPlatforms.qq). Skips a platform when its ID is missing. Proxies MusicDataGET /netease/artist/top/song?id={neteaseId}andGET /qq/artist/song?singermid={qqId}(requiresMUSICDATA_BASE_URL+MUSICDATA_INTERNAL_API_KEY; returns emptytrackswithsource: unavailablewhen unset). HTTP cache: same global GET middleware on Promoters and MusicData when bypass is not active. Responsedata:{ artistId, netease, qq }where each section is{ platform, platformId, tracks[], source, cached, cachedAt }and each track hasrank,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 MongochartsKworb collections (utils/kworb-chart-presence.utils.js). Resolves artistid/name/platforms.spotify.id; returnsdata.charts[]grouped by chart family. Same match semantics as MusicData analyst route (noMUSICDATA_*required). Frontend artist overview Chart Presence section.GET /artists/{id}/top-countries— Soundcharts social followerscountryPlotsviaGET /api/v2.37/artist/{uuid}/social/{platform}/followers/(defaultinstagram; optionalplatform=tiktok|youtube). RequiresSOUNDCHARTS_APP_ID+SOUNDCHARTS_API_KEY; resolvesdata_platforms.soundcharts_uuidfrom Spotify when missing. Query:limit(default 10, max 50), optionalstartDate/endDate. Responsedata:{ artistId, platform, soundchartsUuid, countries[], totalFollowers, snapshotDate, source }where each country hasrank,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 viaflagcdn.com.GET /artists/{id}/charts/song/ranks/{platform}— Soundcharts proxy for Get chart song entries:GET /api/v2/artist/{uuid}/charts/song/ranks/{platform}. Pathplatform= 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(or0) omitsitems[]from the Promoters response (aggregateinfostill computed from the upstream page). RequiresSOUNDCHARTS_APP_ID+SOUNDCHARTS_API_KEY; resolvesdata_platforms.soundcharts_uuidfrom Spotify when missing (may persist on Mongoartists_v2). Responsedata:{ artistId, platform, soundchartsUuid, source, related, items[], info, page, errors[] }where eachitems[]entry includes chart metadata,position,peakPosition,current,song, etc. (Soundcharts shape).info(Promoters aggregate over returneditems[]):{ peakPosition, timeonChart[] }—peakPosition= best (lowest)peakPositionacross items;timeonChart= perchart.frequency(daily/weekly/monthlywhen present) average oftimeOnChartas{ frequency, time }. HTTP cache: global Promoters GET middleware.GET /artists/{id}/popularity/{platform}— MusicData analyst proxy (GET /analyst/artists/{id}/popularity?platform=…). Pathplatform= Soundcharts platform code (spotify,instagram,apple-music, …). RequiresMUSICDATA_INTERNAL_BASE_URL(orMUSICDATA_BASE_URL) +MUSICDATA_INTERNAL_API_KEY; returnssource: unavailable/error: musicdata_not_configuredwhen unset. Optional query:refresh,startDate,endDate(passed through to MusicData Mongo read filters / forced refresh). MusicData owns Mongodata_analyst.artist_popularityand conditional Soundcharts backfill (60d rule). Promoters summarizes MusicDataitems[]intoyearly[]/peak/currentviautils/artist-popularity.utils.js. Responsedata:{ 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 Kisumartist_id(peakListeners,peakRank,listeners). Powers Rankings → Historical Insights “Peak Monthly Listeners”.GET /artists/{id}/statsGET /artists/{id}/demographicsGET /artists/{id}/eventsGET /artists/{id}/bandsintown/events— external artist concerts; failover order: Bandsintown → SoundchartsGET /v2/artist/{uuid}/events→ Last.fmartist.getPastEvents/artist.getEvents(env:LASTFM_API_KEY). Returns[]when all sources fail (not 500). Optional?date=all|past|upcoming. Items may includesource:bandsintown|soundcharts|lastfm. Enrichment setslocation.*, copiesvenue.country, and server-side geocodes missingvenue.latitude/venue.longitudeviaGOOGLE_MAPS_API_KEY(country-centroid fallback from Mongocountries).
Stats & demographics data sources (2026-06-06):
| Route | Storage (SoT) | Refresh trigger | External providers (on refresh only) |
|---|---|---|---|
GET /artists/{id}/stats | Mongo 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 + fallbacks | Per existing demographics pipeline | See 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}/bioPOST /artists/{id}/prediction-v2POST /artists/{id}/prediction-v3
Avails
Section titled “Avails”Typical responsibilities:
- avails list
- avails by user
- avails-linked shows
Typical route families:
GET /availsPOST /availsGET /avails/userGET /avails/{id}/showsGET /avails/{id}/shows-summary
Rankings
Section titled “Rankings”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,regionquery)GET /rankings/topartists— top artists listGET /rankings/topsong— top songs by year (rankings= year, includingall)GET /rankings/worldwide— Viberate worldwide chart (not Kworb)
Kworb routes (2026-06-03):
GET /rankings/current-charts— multi-platform #1 gridGET /rankings/global-artists?list=main|extended— Kworb composite leaderboard (use instead ofworldwidefor Kworb data)GET /rankings/itunes/charts/{countryCode}— top ~200 songs per countryGET /rankings/itunes/worldwide/{chart}—ww|europeGET /rankings/itunes/pop/{countryCode}— live popularity barsGET /rankings/itunes/sales/us— US digital sales estimatesGET /rankings/itunes/countries— market catalogGET /rankings/youtube/trending,GET /rankings/youtube/videos-24hGET /rankings/radio/usGET /rankings/spotify/listeners— optionalspotifyIdGET /rankings/spotify/songs?period=— notall(legacytopsongfor all-time)GET /rankings/spotify/albums?period=all|least_streamed_trackGET /rankings/spotify/country-charts—country,chartType,variantGET /rankings/spotify/countriesGET /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 distinctchart+meta), merged with optionalweekly-categories/year-end-categorieslabels. Optional?cat=filter. Rows:id(slug),label,chart,meta,category,year.GET /rankings/billboard/chart— latest snapshot forchart=weekly|year-end+meta(e.g.hot-100). OptionalweekOf,year. Normalizesdata[].artist[]→ flatartistlabel +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).
Additional active groups
Section titled “Additional active groups”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.
Profile companies (membership-scoped)
Section titled “Profile companies (membership-scoped)”Browser profile flows under /profile/companies. Do not send x-org. No Promoters module entitlement required.
| Method | Path | Who | Purpose |
|---|---|---|---|
GET | /users/companies | Authenticated member | List membership companies; rows include package, packageName, packages[], membership.role from Auth + Core subscription summary |
GET | /users/companies/eligible | Authenticated member | Promoters-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/:id | Member of company | Profile company detail (Core snapshot, TCompany-compatible shape) |
GET | /users/companies/:id/delete-preview | TENANT_SUPERADMIN, no package | Impact preview: memberships removed vs users deactivated; actorWillBeDeactivated for caller |
DELETE | /users/companies/:id | TENANT_SUPERADMIN, no package | Retire 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 (
requireCompanyWideAuthorityon the BFF). - Behavior: removes the target from this company. If they belong to only this company, Promoters calls Finance
POST /api/internal/users/{userId}/purgewithmode: "anonymize"(snapshot email + name on invoices/incomes), then AuthPOST /internal/users/{id}/hard-delete. If they belong to other companies, AuthDELETE /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, canonicalx-orgUUID).
10. Request examples
Section titled “10. Request examples”Generic GET example
Section titled “Generic GET example”curl -X GET 'http://localhost:3099/api/events?type=all&page=1&limit=20' -H 'Authorization: Bearer <JWT>' -H 'x-org: <COMPANY_ID>'Generic POST example
Section titled “Generic POST example”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": {} }'Generic upload example
Section titled “Generic upload example”curl -X POST 'http://localhost:3099/api/files/upload' -H 'Authorization: Bearer <JWT>' -H 'x-org: <COMPANY_ID>' -F 'file=@example.pdf'11. Route usage rules
Section titled “11. Route usage rules”- all protected routes require
Authorization - all tenant-scoped routes require
x-org - deprecated groups must not be used
Untaggedroutes must be categorized before release- active business routes should map to
promoter.*permissions (Auth catalog; see Permissions Catalog)
12. Deprecated legacy sections
Section titled “12. Deprecated legacy sections”Deprecated. Moved to Auth Backend.
Company Users
Section titled “Company Users”Deprecated. Moved to Auth Backend.
Company User Invitations
Section titled “Company User Invitations”Deprecated. Moved to Auth Backend.
Company Teams
Section titled “Company Teams”Deprecated. Moved to Auth Backend.
Packages
Section titled “Packages”Deprecated. Moved to Platform Core Backend.
Permissions
Section titled “Permissions”Deprecated. Moved to Auth Backend.
Deprecated. Moved to Auth Backend.
Subscription
Section titled “Subscription”Deprecated. Moved to Platform Core Backend.
Deprecated. Moved to Auth Backend.
Event list type filter (2026-08-06)
Section titled “Event list type filter (2026-08-06)”GET /api/events accepts type as a date window, not a status:
| Value | Meaning |
|---|---|
today | events dated today |
upcoming | today and later (the UI default) |
past | before today |
all | no 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:
| Resource | Identifier in responses |
|---|---|
| Event | id (plus uuid, publicEventId, promoterEventId) — no _id |
| Tax, income, expense, term, payment, event group, transaction | _id and id, same integer |
| Expense category | code (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.
Event income write responses (2026-08-07)
Section titled “Event income write responses (2026-08-07)”| Endpoint | Status | Body |
|---|---|---|
POST /api/event-income | 201 | the created row, including _id |
PATCH /api/event-income/{id} | 200 | the 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.
Where actual income lives
Section titled “Where actual income lives”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 (2026-08-07)
Section titled “Ticket vendors (2026-08-07)”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.
| Route | Purpose |
|---|---|
GET /api/ticket-vendor-term?country=<ISO2> | vendors available in that country |
GET /api/ticket-vendor-term/:id | one 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.
Daily sales movements (2026-08-07)
Section titled “Daily sales movements (2026-08-07)”GET /api/events/{id}/overview → ticketSalesOverview.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_by | quantity, 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.
Ticket sales CSV import (2026-08-07)
Section titled “Ticket sales CSV import (2026-08-07)”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.
Columns
Section titled “Columns”The default@1 mapping reads these (aliases in brackets):
| Column | Required | Aliases |
|---|---|---|
external_category_id | yes | category_id, ticket_type_id |
quantity | yes | qty, tickets |
unit_selling_price | yes | unit_price, price |
currency | yes | ccy |
sold_at | yes | purchase_date, date, created_at |
discount_amount | no | discount |
Matching
Section titled “Matching”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=truex-internal-api-key: <PROMOTERS_INTERNAL_API_KEY>| Request | Result |
|---|---|
DELETE /api/events/{id} | 409 while sales exist |
DELETE /api/events/{id}?force=true | 409 — the flag is not read here |
DELETE /internal/admin/events/{id} | 409 — the key alone is not consent |
DELETE /internal/admin/events/{id}?force=true | deletes 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.
Mapping a vendor category id (2026-08-09)
Section titled “Mapping a vendor category id (2026-08-09)”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.