NBA API Documentation

Service to scores a client's users for churn risk, segments and the two best next actions. The API key decides which client's model runs.

NBA API Documentation

One service scores a client's users for churn risk, segments and the two best next actions. The API key decides which client's model runs.

Updated 2026-10-22. Example values are illustrative; field names and wording templates are real.

Overview

Every model is trained offline per client, and explanations are written offline too, so no AI call happens when an endpoint is called.

EndpointMethodReturnsStatus
/predict_churnPOSTChurn score, band and reasons per userLive
/predict_user_segmentsPOSTSegment labels, history summary and games usedLive
/predict_nba_arcadePOSTTop 2 next actions with confidence, reason and expected effectLive
/save_resultsPOSTSaves the all-users result to a Redshift tablePlanned, not in the code yet
/jobs/{job_id}GETStatus and results of an all-users scoring jobLive
/nba_model_infoGETModel version, training date, signals, actions, test metricsLive
/health, /recommendGET, POSTThe earlier recommender, unchangedLive

How a client is chosen. The X-API-Key header holds the application's client secret. The service looks it up in Redshift (public.application) to find the client ID, then loads that client's folder and latest model version. Adding a client means adding a folder (feature_set.json, sql/, models/<version>/) with no change to the service code.

Authentication and request format

Every call needs an X-API-Key header. The three prediction endpoints take the same JSON body.

curl -X POST https://<host>/predict_churn \
  -H "X-API-Key: <client secret>" \
  -H "Content-Type: application/json" \
  -d '{"user_ids": ["0c1d…", "7a9e…"]}'
Body fieldTypeRequiredMeaning
user_idslist of stringsNoUp to 1,000 user UUIDs. Leave out to score every recently active user.
client_idstringNoMust match the client the key belongs to, otherwise 403.
model_versionstringNoA trained version such as v1-2026-10-13. Default is the latest.

Two ways to call

  1. With user_ids. Users are scored on the spot from fresh data and the rows come back in the reply.
  2. Without user_ids. The result for all recently active users is cached for one hour and shared by the three prediction endpoints. If it is ready, it returns at once with cached: true. If not, the call returns 202 with a job_id, and the job is polled at /jobs/{job_id}. "Recently active" means activity in the last 14 days. A user asked for by ID with no activity in that time comes back with band lapsed and no churn score; an ID with no data at all is listed in not_found.

Endpoints and example responses

The examples use one illustrative user: a brand-new fan three days after joining. Names and numbers are placeholders.

Reply envelope

Calls with user_ids return this envelope. The same results rows appear in cached and job replies.

{
  "count": 1,
  "not_found": [],
  "model_version": "v1-2026-10-13",
  "results": [ { "…": "one row per user, fields below" } ],
  "seconds": 2.4
}

Every row starts with prediction_date, user_uuid, client_id and model_version.

POST /predict_churn

How likely each user is to have no activity in the next 14 days.

FieldTypeMeaning
days_since_last_activenumberDays since the last event.
churn_scorenumber or null0 to 1 chance of no activity in the next 14 days. Null for lapsed users.
churn_bandstringlow, medium, high or lapsed. Cut-offs come from the active population, 80th and 50th percentile.
churn_reasonsstringUp to 3 signals that are unusually high or low for this user, in words. Empty when none stands out.
{
  "prediction_date": "2026-10-22",
  "user_uuid": "a3f1…",
  "client_id": "<client_id>",
  "model_version": "v1-2026-10-13",
  "days_since_last_active": 2.0,
  "churn_score": 0.73,
  "churn_band": "medium",
  "churn_reasons": "active days 28d is low (1); events 28d is low (4)"
}

A user with no activity in 14 days is not scored:

{
  "days_since_last_active": 19.0,
  "churn_score": null,
  "churn_band": "lapsed",
  "churn_reasons": "no activity for 19 days"
}

POST /predict_user_segments

Which segment buckets each user falls in, and a plain-language picture of how they use the app. The full bucket list is in the master list.

FieldTypeMeaning
user_segmentsstringComma-separated bucket names, in a fixed order. A user can be in several.
user_historystringA short summary from the user's own numbers: tenure, recency, trend, games, streak, points and tier.
games_played_events_actionsstringGames played with counts, games not tried, sessions and events in 14 days, and other actions.
{
  "prediction_date": "2026-10-22",
  "user_uuid": "a3f1…",
  "client_id": "<client_id>",
  "model_version": "v1-2026-10-13",
  "user_segments": "never_played_a_game, new_user",
  "user_history": "A brand-new user in their first days who has not played a game yet and is the most likely to be lost. Joined about 3 days ago. Last active 2 days ago, with 1 active day(s) and 4 events in the last 28 days. Has not played an arcade game yet. Beyond games, has not answered a quiz, has not made a correct prediction. Has not earned any points yet.",
  "games_played_events_actions": "No arcade games played. 1 sessions and 4 events in the last 14 days."
}

POST /predict_nba_arcade

The two best next actions per user, with the reason and what similar users did after the same action. Fields come in pairs, _1 for the first pick and _2 for the second.

FieldTypeMeaning
recommended_action_1, _2stringThe action name from the client's action list.
confidence_score_1, _2numberShare of 300 simulated draws in which this action ranked best for the user. The two scores need not add to 1.
why_predicted_1, _2stringThe reason, built from the user's own numbers and the fan type's pre-written text.
what_it_would_do_1, _2stringExpected points and how often similar users stayed active for the next 14 days after the action.
recommended_game_id_1, _2string or nullThe arcade game to open. Null for quiz and prediction picks.
{
  "prediction_date": "2026-10-22",
  "user_uuid": "a3f1…",
  "client_id": "<client_id>",
  "model_version": "v1-2026-10-13",
  "recommended_action_1": "Play Game A – Short description",
  "confidence_score_1": 0.82,
  "why_predicted_1": "They have not played Game A yet. They have not played anything yet, so an easy first game is the quickest win. As a newcomer who has not played yet, a first success matters most. Their churn risk is moderate (0.73), so a timely nudge can keep them engaged. 11% of the 128 users with a similar activity pattern have already done it. It ranked first among the actions open to this user.",
  "what_it_would_do_1": "A quick arcade game that can earn points. Users like them who did this stayed active 64% of the time over the next 14 days. A visit in the next few days would keep their habit from fading.",
  "recommended_game_id_1": "G-<game id A>",
  "recommended_action_2": "Play Game B – Short description",
  "confidence_score_2": 0.16,
  "why_predicted_2": "They have not played Game B yet. They have not played anything yet, so an easy first game is the quickest win. As a newcomer who has not played yet, a first success matters most. 11% of the 128 users with a similar activity pattern have already done it. It is the next-best choice, right behind Game A.",
  "what_it_would_do_2": "Can pay out points. Among similar users who did this, 61% stayed active for the next 14 days. A visit in the next few days would keep their habit from fading.",
  "recommended_game_id_2": "G-<game id B>"
}

Each client has its own action list, set in its feature file. The list is returned by /nba_model_info.

POST /save_results

Writes the cached all-users result, with churn, segment and NBA fields joined into one row per user, to a Redshift table named in the request. Each run adds rows stamped with prediction_date and model_version, so history builds up.

{ "table_name": "nba_results" }
{
  "table": "nba_results",
  "rows_written": 10000,
  "prediction_date": "2026-10-22",
  "model_version": "v1-2026-10-13"
}

The table name would accept letters, digits and underscores only. Cost is about $0.05 per run for the write, plus storage.

GET /nba_model_info

What model the key's client is running, with its test results.

{
  "client": "<client name>",
  "client_id": "<client_id>",
  "model_version": "v1-2026-10-13",
  "trained_at": "2026-10-13T04:34:59+00:00",
  "feature_groups": { "core": true, "streaks": true, "game_stats": true, "rewards": true, "tiers": true, "quests": false, "leaderboard": false, "comments": false },
  "feature_count": 49,
  "segments": ["high_churn_risk", "never_played_a_game", "…"],
  "actions": ["Play Game A – Short description", "…"],
  "churn_definition": "no engagement event in the next 14 days",
  "metrics": { "model": { "auc": 0.90, "brier": 0.12 }, "old_formula": { "auc": 0.85, "brier": 0.33 } }
}

The models

Each client has its own models, trained only on that client's data and chosen at request time by the API key. Every trained version is saved under models/<version>/ and models/latest.txt points at the live one.

Churn model

A gradient-boosted classifier (scikit-learn HistGradientBoostingClassifier) that predicts the chance a user has no engagement event in the next 14 days. It trains on weekly snapshots of users who were active in the 14 days before each snapshot, and is tested on the most recent snapshot, which the model never saw. Users with no activity in the last 14 days are labelled lapsed and are not scored.

Bands are cut from today's active users: the top slice is High, the next is Medium, the rest Low. The cut-offs move a little with each retrain.

Each trained version records these test results in metrics.json, and /nba_model_info returns them:

ResultWhat it shows
Training dataNumber of weekly snapshots and rows used
Test setThe most recent week, held out from training
AUCHow well the score ranks leavers above stayers (1.0 is perfect, 0.5 is a coin flip)
Brier scoreHow close the percentages are to what happened (lower is better)
BaselinesThe same results for an older formula and for recency alone, so the model's lift is visible
Band cut-offsThe scores where Medium and High start, taken from today's active users
Feature countNumber of signals the model reads

A client's churn rate can swing between periods, for example after a user spike. That is why bands are quantiles of the current active users and not fixed numbers.

Signals. Both models read recency, activity counts over 7, 14 and 28 days, active days, sessions, play and completion counts per game, streaks, reward balance and earnings, days since the last reward, tenure and win rate. Clients with quizzes, predictions or status tiers also get signals from those. The strongest signals are active days in the last 14 to 28 days, days since the last reward and recent event counts.

User segmentation

Rule-based and multi-label: a user gets every label whose rule they meet, with no clustering. Each rule compares a user's numbers with a threshold saved in segmentation.joblib, so the labels stay the same until the next training.

Next best action

A contextual bandit using Thompson sampling. For each action it learns how often users in a similar situation stayed active over the following 14 days, then draws 300 simulated outcomes per user and ranks actions by how often each came out best (the confidence_score).

  • Context. 12 groups, from recent activity (under 5, 5 to 19, or 20 or more events in 14 days), whether the user has played any game, and whether they joined in the last 21 days.
  • Sparse groups. Groups with little data are pulled toward the action's overall retention rate (prior strength 20), so one lucky user cannot decide a pick.
  • Adjustments. Games get a 1.10 boost and an action the user has never done gets a further 1.10. The game they played last is down-weighted to 0.7, so it is not recommended again straight away.
  • Caveat. The model is observational. It learns from who stayed active after doing an action, not from a randomized test, so users who choose an action may differ from those who do not. Treat the picks as a ranked suggestion, not proof of cause.

Each client has its own action list, defined in its feature file. An action is either an arcade game or a reward action such as answering a quiz, making a prediction, watching a video, joining a chat room or linking an account. Reward actions carry the points they pay out, and one-time actions are offered once.

Explanations

No AI call is made when a request arrives. The explanation text is prepared when the model is trained:

  1. Users are grouped into fan types (archetypes) with a small decision tree, each with a name and a size.
  2. Claude writes the wording for each fan type and action once, offline, and it is saved in explanations.json.
  3. At request time the API picks the fan type, then fills the text with the user's own numbers: churn score, the share of similar users who did the action, and the retention of those who did.

This keeps replies fast, cheap and the same every time for the same inputs.

Training and versioning

Training runs per client and writes a new version folder. The API reads latest.txt, so a new version goes live by updating that one file, and an older version can be restored by pointing it back.

File in models/<version>/What it holds
churn_model.joblibThe trained churn classifier and its feature list
segmentation.joblibThe thresholds for the segment rules
explainer.joblibThe decision tree that assigns fan types
explanations.jsonFan-type names and the pre-written text fragments
nba_bandit.jsonRetention counts per context and action, prior strength, game boost
metrics.jsonTraining data summary, test results, band cut-offs, segment sizes, library versions

The library versions used for training are recorded in metrics.json. The server must run the same versions to load the models.

Master list of segmentation buckets

Nine buckets, returned in user_segments as a comma-separated list. A user can be in several at once, and a user who meets none gets an empty list. The numbers in each rule (shown as N below) are set per client from that client's own users when the model is trained, so the same name can mean a slightly different number for each client.

BucketMeaningRule
high_churn_riskLikely to go quiet in the next 14 daysChurn score of 0.75 or more
never_played_a_gameHas not played any arcade gameNo plays of any game
game_enthusiastHas played many gamesN or more games played (about the top quarter of users)
high_reward_earnerHas earned a lot of pointsN or more points earned in total
new_userRecently joined21 days or fewer since joining
power_userVery frequent recent activityN events and N active days in the last 28 days, both at a high level
low_activityEstablished user who is barely activeN or fewer events in the last 28 days, and joined before the median join date
high_status_tierReached an upper status tierTier level N or higher (only where the client has tiers)
lapsed_userNo activity in the last 14 daysNo activity in 14 days
  • high_churn_risk is based on the churn score, so it changes when the model is retrained. Lapsed users have no score and are not placed in it; they get lapsed_user instead.
  • lapsed_user is the only bucket not tied to a per-client number. It is the same 14-day cut-off the churn model uses.
  • low_activity and power_user cannot apply to the same user, and neither can new_user and low_activity.

Fan types

Separate from the buckets, each user also belongs to exactly one fan type. It decides the wording of the NBA explanation and is not returned as a field. Fan types are found from each client's own users at training time and named for the behaviour they describe, for example a group of highly engaged regulars, a group of newcomers who have not played, or heavy users who have gone quiet. A client typically has around ten, and metrics.json and explanations.json record the name, size and average churn score of each.

Errors, limits and configuration

Errors come back as JSON with a detail message.

CodeWhendetail (shortened)
400user_ids is an empty listuser_ids is empty; omit it to score all active users
400More than 1,000 user_idsAt most 1000 user_ids per call; omit user_ids to score everyone
400view on /jobs/{job_id} is not churn, segments or nba_arcadeview must be one of [...]
403No X-API-Key headerMissing X-API-Key header
403Key not recognisedInvalid API key
403A client_id is sent that does not belong to the keyAPI key does not belong to this client_id
404The client has no model folder or no trained versionNo NBA model is set up for this client
404job_id unknown, for example after a restartUnknown job
500The client's feature_set.json does not match the saved modelfeature_set.json does not match the saved model's features
502Scoring failed, for example a data pull errorPrediction failed: ...
503The key lookup could not be reachedAuth service unavailable

A user asked for by ID with no data goes in not_found; that is not an error.

Limits

LimitValue
user_ids per call1,000
All-users result cache1 hour, shared by the churn, segment and NBA endpoints
Key lookup cache5 minutes
Draws per user in the NBA model300
Job storageIn memory, so a job is lost if its pod restarts

An all-users call within an hour of the last one returns the stored result (cached: true) without a new data pull. A key added or removed can take up to five minutes to take effect.

Configuration

Set as environment variables on the service. Adding a client needs none of them.

VariableDefaultPurpose
NBA_V2_MAX_USERS1000Most user_ids accepted per call
NBA_POP_TTL3600Seconds the all-users result is kept
NBA_ROOTthe service folderWhere the client folders are found
AUTH_APPLICATION_TABLEpublic.applicationRedshift table the API keys are looked up in
REDSHIFT_HOST, _PORT, _DBNAME, _USER, _PASSWORDnoneRedshift connection for data pulls and key lookup
POSTGRES_HOST, _PORT, _DBNAME, _USER, _PASSWORDnoneOptional; tier data where a client has it

Credentials belong in the service's environment, never in a request or in the client folders.

Adding a client

Create a folder named with the client's ID containing feature_set.json, a sql/ folder, and models/<version>/ with models/latest.txt. The service finds it at the next request, and the X-API-Key of that client's application selects it. No code changes are needed.