Leagues
Weekly or daily leagues: tiers, cohorts, promotion and demotion, rank rewards in currency or items, standings, settlement and the league events.
A league puts players who are active in the same week (or day) into small groups, a cohort of up to 100, ranks them by score, and at the end of the period moves the top of each cohort up a tier and the bottom down. Rank rewards and a promotion reward pay out automatically. It is the Duolingo league: you join a group when you first play that week.
Operator routes need programs:read to read and programs:write to change. In the dashboard,
leagues are under Engagement.
Configuration (league/v1)
{
"key": "weekly",
"name": "Weekly league",
"config": {
"schema": "league/v1",
"tiers": [
{
"key": "bronze",
"name": "Bronze",
"promote_count": 10,
"rewards": [{ "rank_from": 1, "rank_to": 3, "currency": "gems", "amount": 50 }]
},
{
"key": "silver",
"name": "Silver",
"promote_count": 7,
"demote_count": 5,
"rewards": [
{
"rank_from": 1,
"rank_to": 1,
"reward": [
{ "currency": "gems", "amount": 100 },
{ "type": "item", "item": "golden_frame", "quantity": 1 }
]
}
],
"promotion_reward": { "type": "item", "item": "streak_freeze", "quantity": 1 }
},
{ "key": "gold", "name": "Gold", "demote_count": 5 }
],
"cohort_size": 30,
"period": { "kind": "week", "start_weekday": 1, "start_minute": 0, "timezone": "environment" },
"score": { "source": "direct" },
"matching": "none",
"settle_delay_minutes": 10,
"min_score_to_promote": 1
}
}| Field | Meaning |
|---|---|
tiers | 1 to 20, lowest first. promote_count (top N move up) and demote_count (bottom N move down); the top tier never promotes and the bottom tier never demotes. promote_count + demote_count is at most cohort_size. |
tiers[].rewards | Up to 20 rank bands that do not overlap. A band pays { currency, amount }, or a reward list of up to 5 entries mixing currency and items. |
promotion_reward | Optional, paid on promotion: one entry or a list of up to 5. |
cohort_size | 5 to 100 players per cohort. |
period | kind week or day; start_weekday (ISO, 1 is Monday) and start_minute in local time; timezone an IANA zone or environment. Boundaries are correct across daylight-saving changes. |
score | { "source": "direct" } (your backend or a rule contributes) or { "source": "leaderboard", "program_id": "prg_..." } (the sum of the player's valid contributions to that leaderboard program in the period). |
matching | none, or timezone to fill cohorts per 6 hour UTC offset band. |
settle_delay_minutes | 0 to 1440 minutes after the period ends before it settles; late scores inside this window still count. |
min_score_to_promote | A player in the promotion zone moves up only with at least this score. |
Reward entries. Every reward list in engagement (league bands and promotion rewards, friend
quests, friend streak milestones, team quests) takes the same two shapes: a currency entry
{ "currency": "gems", "amount": 50 } and an item entry { "type": "item", "item": "<item key>", "quantity": 1 } (at most 5 entries per list). An item entry is granted from the environment's item
catalogue in the same transaction as the currency entry beside it, with the same idempotency key,
so a retry never grants twice. An item that does not exist yet or is archived keeps the reward
pending (item_not_found, item_archived) until the item is fixed; an inventory that is full
grants what fits.
Lifecycle
POST /v1/leagues creates a league as a draft (at most 20 per environment). Then
POST /v1/leagues/{id}/activate, POST /v1/leagues/{id}/pause, POST /v1/leagues/{id}/resume
and POST /v1/leagues/{id}/archive, each with { "expected_revision": n } and an
Idempotency-Key. PATCH /v1/leagues/{id} with If-Match changes the name or configuration; a
new configuration applies from the next period, because each period pins the configuration it
started with. On activation every named currency must exist, and for the leaderboard source the
program must be a leaderboard.
In a live environment, an agent without auto autonomy proposes the same changes as change-set commands:
league.create (the create body), league.update (key, expected_version and the PATCH
fields), and league.activate, league.pause, league.resume, league.archive (each key and
expected_version). Archiving is irreversible. See Change sets.
Pausing a league freezes it. While it is paused no score is added (direct scores are refused with
league_not_active, and leaderboard contributions made during the pause never count), no new
period opens, and the current period does not settle; standings stay as they are. A period that
had already ended before the pause still settles and pays its rewards. When you resume, the
current period's end moves later by exactly the time the league was paused, so players get the
time they had left, and the next period starts where it ends.
Scoring
With the direct source, your backend adds score with POST /v1/leagues/{id}/contributions:
{ "customer_id": "cust_...", "amount": 25, "dedupe_key": "lesson:8812" }The dedupe_key makes a repeat count once (a repeat under a new Idempotency-Key answers
replayed: true; a retry with the same Idempotency-Key gets the stored first answer, with the
Idempotent-Replayed: true header). amount may be
negative; the score never goes below 0. The first contribution of a period creates the player's
entry and puts them in a cohort of their tier. A league that is not active, and a league scored
from a leaderboard, refuse direct contributions with invalid_state.
Anonymous customers take part in leagues under a pseudonym. Players are shown by their social
profile display name (PATCH /v1/customer/social_profile) or, without one, a stable pseudonym
such as "Player 4821".
Settlement
Some minutes after a period ends (settle_delay_minutes), each cohort is ranked by score, then by
who reached it first. The top promote_count move up (if their score reaches
min_score_to_promote), the bottom demote_count move down, everyone else stays. Each entry
records final_rank, outcome, the reward it earned and its reward_status; rewards are granted
exactly once. A cohort that never filled demotes nobody above its last real rank. Merged customers
rank once, as one player, with their summed score.
Reads
| Read | Who | Returns |
|---|---|---|
GET /v1/customer/leagues | Customer session | Per active league: tier, current period, time left, the entry (rank, score, zone) and the last result |
GET /v1/customer/leagues/{id}/standings | Customer session | The player's cohort, by display name only, with zone (promotion, safe, demotion) |
GET /v1/customer/leagues/{id}/friend_standings | Customer session | The player and their friends across cohorts |
GET /v1/leagues/{id}/periods | Operator | Periods with their pinned configuration |
GET /v1/league_periods/{id}/cohorts | Operator | Cohorts of a period |
GET /v1/league_cohorts/{id}/standings | Operator | A cohort's standings, with customer ids |
GET /v1/leagues/{id}/members | Operator | Members and their tiers |
GET /v1/customers/{id}/leagues | Operator | One customer's leagues |
POST /v1/leagues/{id}/members with { "customer_id", "status": "left" } removes a player from
future periods (an entry they already have this period still settles).
Events and notifications
Settlement and joining emit league.period_started, league.promoted and league.demoted
(data league_name, tier, previous_tier, rank), and reward.ready for a reward earned
without a promotion. Each is a webhook event and can become a message: see
Notifications. The hosted widget's league surface shows the
tier, the cohort standings and the time left (Hosted widget).
A complete setup is in the recipe Weekly league with item rewards.
AI drafting
Describe a campaign in plain language; get validated rule documents to review, simulate and publish.
Friends and teams
Friend codes, requests, blocks, nudges and high-fives, friend standings, friend quests, friend streaks, teams and team quests, with the privacy rules and what anonymous sessions can do.