# Run a leaderboard (/docs/build/leaderboards)



A leaderboard ranks your members by their points. For example:

* **"This week's top contributors"**, with prizes for the top three every Monday.
* **"All-time XP"**, so long-time members can see where they stand.
* **"Launch campaign race"**, between two fixed dates.
* **"Token holders"**, ranked by a score your own system measures.

Points from quests, referrals and your own server feed the board automatically. You never submit a score by hand.

For the model behind this guide, read [Leaderboards](/docs/concepts/leaderboards) and [Points and ledgers](/docs/concepts/points). See [Compose a campaign](/docs/composition) for a complete example.

```mermaid
flowchart LR
    Q["Quest awards"] --> L["Point ledger"]
    B["Trusted backend"] --> L
    R["Referral bonuses"] --> L
    L --> S["Score and window"]
    S --> T["Standings"]
    T --> A["Your app"]
    T --> F["Frozen settlement"]
    F --> O["Operator approval"]
    O --> P["Prize rewards"]
```

## What members see [#what-members-see]

* **The standings.** Each row shows a rank, a name and a score. Equal scores share a rank, such as `1, 2, 2, 4`, unless the board ranks whoever reached the score first.
* **Their own place.** A signed-in member sees their own row and the members just above and below them.
* **How they earned it.** Your app can list the point entries that make up a member's score.
* **A name, or an alias.** Members appear under a stable alias, or under their chosen name and avatar when your project shows [profiles](/docs/build/profiles).
* **Signed-in members only**, unless you make a board public.

## What you can do in Console [#what-you-can-do-in-console]

Boards themselves are defined in code. In Console you can:

| Without code                                            | Where                                                      |
| ------------------------------------------------------- | ---------------------------------------------------------- |
| Create, rename and archive point ledgers                | **Rewards → Point ledgers**                                |
| Watch standings and see how a board is scored           | **Leaderboards**, **About this board**                     |
| Exclude a member from a board, or reinstate them        | **Leaderboards**, select the member                        |
| Award, spend, adjust or reverse a member's points       | **Leaderboards** or the member's page                      |
| Review prize winners, then approve or reject the prizes | **Leaderboards**, **Prize settlements**                    |
| Take back one winner's prize                            | **Leaderboards**, **Revoke completion** beside the winner  |
| Get started with a coding agent                         | **Leaderboards**, **Copy prompt**, before any board exists |

## Choose a point ledger [#choose-a-point-ledger]

A ledger holds the balances a leaderboard ranks. Manage ledgers in Console under **Rewards → Point ledgers**:

| Action               | Effect                                                                                                            |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| **New ledger**       | Creates a ledger with a name and a description that explains what it tracks. Domino generates its fixed ID.       |
| **Edit ledger**      | Changes its name and description in a dialog without moving any points.                                           |
| **Archive ledger**   | Hides it from new reward choices, after you give a reason. Existing rewards, balances and history stay available. |
| **Unarchive ledger** | Offers an archived ledger to new rewards again, after you give a reason.                                          |
| Select its ID        | Copies the ID for use in code.                                                                                    |

Each ledger lists the published quests that pay into it. Two ledgers can both be named "XP" and still keep separate balances and histories.

* **Change ledgers in test.** Ledgers are definitions, so live refuses creating, editing or archiving them, from Console too. Make the change in test, or declare the ledger in code and deploy it to test, then promote test to live. See [test and live](/docs/concepts/environments).
* **Permissions.** Creating, editing and archiving a ledger need `publish` permission; reading the picker needs `read`.
* **Audit.** Console **Audit history** records creating and archiving a ledger. An edit appears as **Points or leaderboard changed**. Ledgers declared in `relay.json` appear as part of the release that publishes them.

Console member detail lists the member's balance in every ledger. Choose a ledger there to page through its history, award points, record a positive or negative adjustment, or reverse a positive award or adjustment. Point changes need `points` permission and always record the operator's reason.

### Reference ledgers in code [#reference-ledgers-in-code]

> [!DEV]
> - **Authored resources use handles.** Define a ledger with `defineLedger({ key, name })`, then pass the returned handle to `points`, `defineLeaderboard`, referral bonuses and balance prerequisites. Don't copy a Console ID into authored definitions.
> - **Wire values use IDs.** Member and management reads, point commands and Console selections use the environment's opaque ledger ID, or a key where that API documents it. Metadata and points belong to one project and environment.
> - **Settings get a picker.** Quest settings declared with `settings.pointLedger` show a searchable picker in Console. Search by name, description or ID; each option shows its ID so duplicate names stay distinguishable.
> - **Live refusals.** Live refuses ledger changes from every credential with 422 `promotion-required`.
> - **Management client.** `management.pointLedgers.list()` returns `archivedAt` with each ledger. Use `save({ id, name, description })` to update metadata and `archive(id)` to archive it. Saving never renames the accounting key or merges balances.
> - **Member apps.** Reads return ledger IDs. Match them to your keys with `relay.pointLedgers.list()`, as described in [Match ledgers in your app](/docs/concepts/points#match-ledgers-in-your-app).

## Publish a leaderboard [#publish-a-leaderboard]

Define a leaderboard in your project, add its module to `relay.json`, and deploy the project bundle. Leaderboards rank the project's existing point ledgers.

> [!NOTE] Scoped to one project and environment
> Test and live data stay separate. Organization-wide balances and rankings across projects are not supported.

Use your hosted project's test environment while you build. Test points and rankings persist between `domino dev` sessions; restarting the app doesn't reset them. Publish the test configuration and check the rankings with real member accounts. The offline runtime has been removed.

### 1. Define the calculation [#1-define-the-calculation]

```text
import { defineLeaderboard, points } from "@domino-sdk/relay/authoring";
import { community } from "../ledgers";

export default defineLeaderboard({
  key: "weekly",
  title: "This week's contributors",
  ledger: community,
  score: {
    kind: "earned",
    window: { kind: "calendar", period: "week" },
  },
  settle: {
    at: "window-end",
    ties: "shared",
    prizes: [
      { ranks: [1, 1], rewards: [points(community, 100)] },
      { ranks: [2, 3], rewards: [points(community, 50)] },
    ],
  },
});
```

This board ranks the points members earned this week. When the week ends, first place wins 100 points and second and third win 50, once an operator approves. See [Choose what counts](#choose-what-counts) for scores and windows.

The week is the [project calendar](/docs/concepts/calendar)'s. Set the project timezone once, in Console **Settings → General** or with `"calendar": { "timeZone": "Europe/Budapest" }` in `relay.json`.

### 2. Add the module to the manifest [#2-add-the-module-to-the-manifest]

```json
{
  "leaderboards": [{ "entry": "src/weekly.ts" }]
}
```

Keep the existing connection and quest fields in `relay.json`.

> [!DEV]
> An entry can select a named export with `exportName`. The build bundles the module without running it locally; Domino evaluates its definition in an isolated runtime without network access.

### 3. Preview and publish to test [#3-preview-and-publish-to-test]

```sh
domino deploy --preview
domino deploy
```

The preview shows the definition and the first page of proposed standings, with the time it calculated them. Points keep arriving after the preview, so it doesn't freeze any results. The second command publishes to test.

### Live publication [#live-publication]

Publish to test directly with `domino deploy --environment test`. For live, the CLI prepares a request, and a person with publish permission approves it in Console. See [reviewed live publication](/docs/build/publishing#publish-behavior-to-live).

### Change a published leaderboard [#change-a-published-leaderboard]

> [!IMPORTANT] Leaving a board out doesn't unpublish it
> A board left out of a later bundle stays published. Change boards in source and publish another bundle.

Every version of a board is kept. Console **Leaderboards** shows the standings and member controls for the selected project and environment.

Before any board is published there, the page offers ready-made requests instead, such as a weekly board or all-time standings. Choose **Copy prompt** and paste it into your coding agent; the prompt includes the project, environment and setup steps.

## Choose what counts [#choose-what-counts]

| Score     | Ranks by                                                                                                                             |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `balance` | The member's whole balance. Spending lowers their rank. This is the default.                                                         |
| `earned`  | Points earned in the window: awards, adjustments up or down, and reversals. Spending doesn't count. You can limit it to some quests. |
| `net`     | Every change in the window, including spending.                                                                                      |

Earned and net scores take a window:

| Window     | Example                                                             | Meaning                                                                                                                                                                                                                  |
| ---------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `all-time` | `{ "kind": "all-time" }`                                            | The default.                                                                                                                                                                                                             |
| `fixed`    | `{ "kind": "fixed", "start": 1790812800000, "end": 1793491200000 }` | Between two dates, in UTC epoch milliseconds; the start is included and the end isn't.                                                                                                                                   |
| `rolling`  | `{ "kind": "rolling", "days": 7 }`                                  | The last seven 24-hour periods, including entries accepted at the moment of calculation.                                                                                                                                 |
| `calendar` | `{ "kind": "calendar", "period": "day" }`                           | The current `day`, `week` or `month` of the [project calendar](/docs/concepts/calendar); weeks start Monday. Boundaries follow the project timezone, including daylight-saving changes, and match the project's budgets. |

> [!DEV]
> `earned({ quests })` takes quest handles to choose which awards count.

### Ranking rules [#ranking-rules]

* **Quest awards count automatically.** They count when they pay into the board's ledger. For example, a quest award of 100 `community` points adds 100 to the weekly board above when it falls in the current week. You don't submit a separate leaderboard score.
* **Points are whole numbers.** Custom formulas and outside measurements become points before they reach Domino. Use a dedicated ledger when an outside system owns the total; see [Rank an external score](#rank-an-external-score).
* **Ties share ranks by default.** Equal scores share a rank, for example `1, 2, 2, 4`, and tied members keep a stable order.
* **Or first to reach the score ranks higher.** A board can instead order equal scores by when each member reached their score: their latest qualifying entry, earliest first, then by member ID. Every member who scored gets their own rank, such as `1, 2, 3, 4`. On a balance or net board, a spend or a negative adjustment is a qualifying entry too, so it moves that time.
* **No points, no rank, by default.** Members without a qualifying entry aren't ranked. A member who spends their balance down to zero stays ranked on a balance board.
* **Or list everyone.** A board can also list members with no qualifying entry, at a score of 0. They share one rank after everyone who scored, even someone whose entries add up to 0 or less, and they never win prizes.
* **Points on hold still count.** Points held for a redemption reduce what a member can spend, but they aren't spending.
* **Everyone is eligible by default.** Every member of the project is eligible. A board can rank only the members of one [group](/docs/concepts/groups), such as those who enrolled. Group membership and points are separate records.

> [!DEV]
> Rank first-to-reach with `ties: "earliest"` on the board; omitted or `"shared"` keeps shared ranks. It is independent of `settle.ties`, so set both on a board that settles. List every member with `includeUnscored: true`. Unscored members are ordered by member ID, share one rank under either `ties`, and count in `total`. The board's `group` and exclusion group still apply. They appear in standings, `me`, `nearby` and `relay.leaderboards.placement`, but never in a [settlement](#settle-prizes-after-a-window). Rank only a group's members with `group: "enrolled"`.

## Settle prizes after a window [#settle-prizes-after-a-window]

A board can hand out prizes when its window ends, such as every week or at the end of a campaign. Domino freezes the final standings, an operator checks them, and prizes go out only after approval.

Add `settle` to an `earned` or `net` board with a `fixed` or `calendar` window. `at` is `"window-end"`. Prize ranges are inclusive, in order, and can't overlap.

```ts
import {
  defineLeaderboard,
  defineLedger,
  defineTieredReward,
  points,
  staffHandover,
  untracked,
} from "@domino-sdk/relay/authoring";

const community = defineLedger({ key: "community", name: "Community" });
const podium = defineTieredReward({
  key: "podium",
  title: "Podium prize",
  tiers: [{ key: "gold", title: "Gold" }],
  inventory: untracked(),
  fulfillment: staffHandover(),
});

export default defineLeaderboard({
  key: "weekly",
  title: "This week's contributors",
  ledger: community,
  score: {
    kind: "earned",
    window: { kind: "calendar", period: "week" },
  },
  settle: {
    at: "window-end",
    ties: "shared",
    prizes: [
      {
        ranks: [1, 1],
        rewards: [podium.tier("gold"), points(community, 100)],
      },
      { ranks: [2, 3], rewards: [points(community, 50)] },
    ],
  },
});
```

* **Prizes are points and tiered rewards.** A prize can't repeat the same point ledger or tiered reward. Badges, group memberships and integration rewards, such as Discord roles, aren't supported: a badge or group prize makes `defineLeaderboard` throw, and a deployment refuse it, with a message naming the reward kind. Give those from a quest instead.
* **Only boards with an end.** Balance, rolling and all-time boards can't settle.

Choose how ties are paid before you publish:

| `ties`     | Result                                                                                                                                       |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `shared`   | Equal scores share a rank. Every member whose shared rank is in a prize range gets that prize.                                               |
| `earliest` | Equal scores are ordered by who reached their score first (their last qualifying entry), then by member ID. Every position has its own rank. |

The standings members see before the cutoff follow the board's own `ties`, not `settle.ties`. Set `ties: "earliest"` on the board as well so the live order matches what the settlement freezes. When the two differ, including a board that sets only `settle.ties: "earliest"`, `defineLeaderboard` prints a warning and `domino check` reports a `LEADERBOARD_TIES_DIFFER` warning that names the file. Neither stops the deploy.

At the end of the window, Domino freezes the board version active at that moment, every eligible ranked row, the exclusions, the ranks and the prizes each row wins. Points, group or exclusion changes and new board versions after that don't change what you review.

### Split a pool by score [#split-a-pool-by-score]

Add `pool` to `settle` to split a fixed amount over the eligible members in proportion to their scores. A settlement needs rank prizes, a pool, or both: with a pool, `prizes` can be empty, and `defineLeaderboard` lets you leave it out.

```ts
import { defineLeaderboard, defineLedger } from "@domino-sdk/relay/authoring";

const views = defineLedger({ key: "views", name: "Clip views" });

export default defineLeaderboard({
  key: "clip-contest",
  title: "Clip contest",
  ledger: views,
  score: {
    kind: "earned",
    window: { kind: "fixed", start: 1767225600000, end: 1769904000000 },
  },
  settle: {
    at: "window-end",
    ties: "shared",
    pool: {
      amount: "10000000000",
      unit: { code: "USDC", decimals: 6 },
      minScore: 100,
    },
  },
});
```

| Field      | Meaning                                                                                                                                                                 |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`   | The whole pool in base units, as a decimal string. `"10000000000"` is 10,000 USDC at 6 decimals. `parseUnits("10000", 6).toString()` from `@domino-sdk/relay` converts. |
| `unit`     | `"points"`, or `{ code, decimals }` for a token or currency (code up to 32 characters, 0–36 decimals).                                                                  |
| `ledger`   | The point ledger a points pool pays into: a ledger handle or key. Required for `"points"`, refused for any other unit.                                                  |
| `minScore` | Optional. Members scoring at or below it get no share. Members scoring 0 or less never get one.                                                                         |

> [!DEV]
> A published leaderboard names two ledgers. `balance` is the one it ranks, which `defineLeaderboard` takes as `ledger`; `settle.pool.ledger` is the one a points pool pays into, and can be another. New fields that name a ledger are called `ledger`; the older ones, such as a leaderboard's `balance` and a points reward's `balance`, keep their name through SDK 1.x.

A points pool holds at most 2,147,483,647 points. Each pool share rounds down, and the leftover base units go one each to the largest remainders, earlier positions first, so the shares sum exactly to the pool. See [Pool prizes](/docs/concepts/leaderboards#pool-prizes).

The settlement reads (`settlementReview`, `settlement`, and `domino leaderboard-settlements get`) carry the frozen `pool` with its `allocated` and `unallocated` totals, and each row's `share` in base units. Console shows each winner's pool share, formatted with the unit's decimals, before you approve.

### Export the payout list [#export-the-payout-list]

Relay pays a points pool on approval. For any other unit it only records the shares, and you pay them. In Console **Leaderboards**, select the window under **Prize settlements** and select &#x2A;*Download payout list (CSV)**. The button appears for a pending or approved settlement whose pool is not points. From the CLI, export the same list:

```sh
domino leaderboard-settlements export SETTLEMENT > payouts.csv
domino leaderboard-settlements export SETTLEMENT --json
```

The CSV has one row per frozen member, with columns `member`, `score`, `rank`, `share` (base units), `amount` (whole units), `unit`, and `wallets`: the member's verified linked wallets as `chain:address`, separated by spaces, with the CAIP-2 network in place of the chain for Cosmos wallets. Wallets a member import attached without proof are left out. `--json`, and `management.leaderboards.settlementExport(id)` from a server, return the same rows with each wallet as `{ chain, network, address }`. `settlementPayoutCsv(result)` from `@domino-sdk/relay/server` turns that result into the CSV the CLI prints. Reading the export requires `read` permission. A pending settlement's export shows what approval would make owed. A rejected settlement allocates nothing, so its export lists every share as 0 and the whole pool as unallocated, while its review keeps the frozen shares.

### Review and approve delivery [#review-and-approve-delivery]

Open Console **Leaderboards**, select the board and the window under **Prize settlements**, and compare the frozen winners and prizes. Enter a reason and select **Approve and deliver**. A confirmation states how many members the prizes reach, the points they pay per ledger, and the environment; select **Approve and deliver** again to deliver them. This needs `review` permission, and delivers every listed prize together.

Approval gives each winner one completion for their prize. Console shows the approval reason with who approved it and when.

Server operators can do the same review:

```ts
import { createManagementClient } from "@domino-sdk/relay/server";

declare const management: ReturnType<typeof createManagementClient>;

const windows = await management.leaderboards.settlements("weekly");
const latest = windows.at(0);

if (!latest) throw new Error("No ended window to review");

const frozen = await management.leaderboards.settlement(latest.id);

const approved = await management.leaderboards.approveSettlement(frozen.id, {
  actionId: `approve-${frozen.id}`,
  reason: "Eligibility and final scores verified",
});
```

> [!DEV]
> Approval creates one completion per member who wins a rank prize or a points pool share (a member owed only a token or currency share gets none), and sends a `completion.created` [webhook event](/docs/reference/webhooks) for each, with `source.kind` set to `leaderboard`, the board ID in `source.id` and `quest`, and the settlement ID in `key`. Each point prize also sends `points.awarded` with the same `source`. The settlement keeps the approval reason, the approver and the approval time, and Console shows all three. An exact retry with the same action ID returns the first result without delivering the prizes twice.

### Correct one winner [#correct-one-winner]

An approved settlement stays as the record of the review. To take back one winner's prize, use **Revoke completion** beside that winner in Console.

* **Points come back as far as they can.** Domino reverses each point prize up to what the member hasn't spent or put on hold in that ledger, and shows what it couldn't reverse. It records no debt, so points the member already spent or reserved stay with them. Console keeps that remainder visible after a reload. Someone with `points` permission can open the linked point adjustment and record an explicit correction with its own reason.
* **Tiered prizes are corrected too.** The winner's tiered-reward entitlement is updated.
* **No second payout.** A leaderboard prize can't be freed and awarded again. Console keeps the winner in the settlement and marks the completion revoked.

A trusted backend can revoke the row's completion instead:

```ts
import { createManagementClient } from "@domino-sdk/relay/server";

declare const management: ReturnType<typeof createManagementClient>;

const approved = await management.leaderboards.settlement(
  "leaderboard_settlement_example",
);
const winner = approved.rows.find((row) => row.member === "member_alice");

if (winner?.completion) {
  await management.completions.revoke({
    actionId: `revoke-${winner.completion}`,
    completion: winner.completion,
    reason: "Member was not eligible at cutoff",
    freeOccurrence: false,
  });
}
```

> [!DEV]
> The result reports any unreversed remainder per point ledger. Its `source` is `{ kind: "leaderboard", id }` with the board ID, as on the webhook.

## Read standings in your app [#read-standings-in-your-app]

Your app reads the boards and their standings, one page at a time:

```ts
import { createRelayClient } from "@domino-sdk/relay";

const domino = createRelayClient({ baseUrl: "/relay" });
const boards = await domino.leaderboards.list();
const page = await domino.leaderboards.standings("weekly", { limit: 25 });
const { items, me, nearby, period, calculatedAt, lastEntryAt } = page;
if (page.nextCursor) {
  const nextPage = await domino.leaderboards.standings("weekly", {
    cursor: page.nextCursor,
    limit: 25,
  });
}
```

| Field         | Contains                                                                                                                                                                                      |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `items`       | Ranked rows. Render each item's `rank`, `name` and `score`, and use `participant` as its stable row key for this board. `profile` holds the name and avatar where the project shows profiles. |
| `me`          | The signed-in member's row, or `null` when unranked or signed out. With `includeUnscored`, an eligible member always has a row.                                                               |
| `nearby`      | Up to two competitors on either side, plus that member.                                                                                                                                       |
| `total`       | The number of ranked members.                                                                                                                                                                 |
| `lastEntryAt` | When Domino received the latest balance entry, not when an outside provider last synced.                                                                                                      |
| `nextCursor`  | The cursor for the next page, when there is one.                                                                                                                                              |

Relay may add fields to a page and its rows in later releases, so read the ones you use and ignore the rest.

Show the returned rank, not the row's position, so ties display correctly. On a board with `ties: "earliest"` the rows come back already in first-to-reach order, each with its own rank, so one paged read gives the tie-broken board. Show an empty state when `items` is empty, and an unranked state when a signed-in member's `me` is null.

> [!DEV] Newer score kinds and paging
> To read definitions that may include score or window kinds newer than your SDK, use `domino.response("/v1/leaderboards", leaderboardListResponseSchema)` with the schema from `@domino-sdk/relay/schema`. Each definition has a `supported` or `unsupported` result, and `unsupportedCount` says how many your SDK can't interpret. Keep unsupported boards visible as unavailable instead of guessing their rules. See [Read evolving responses](/docs/reference/participant-api#read-evolving-responses). Response readers accept added fields and larger server windows or quest lists without applying authoring limits; authoring definitions still enforce them.
>
> If points, group membership, exclusions or the definition change, an earlier cursor returns HTTP 409. On a board with `includeUnscored`, so does a member joining the project or a test-member reset. Restart at the first page. Cursors expire after 15 minutes, with the same 409. A cursor from another project or board, or one that isn't a cursor, returns HTTP 400 `invalid-cursor`. A rolling window keeps the first page's calculation time while paging.

### Show how a member earned their score [#show-how-a-member-earned-their-score]

Your app can list the point entries that make up a signed-in member's score on one board, so they can see where it came from. Domino picks the entries with the board's own rules: spending is left out of an `earned` board, and imported opening balances count only on a `balance` board. Together the pages add up to the member's score, as long as they aren't excluded or removed from the board's group while you page.

```ts
import { createRelayClient } from "@domino-sdk/relay";

const domino = createRelayClient({ baseUrl: "/relay" });
const page = await domino.me.leaderboardHistory({ board: "weekly" });
const { items, period } = page;
if (page.nextCursor) {
  const older = await domino.me.leaderboardHistory({
    board: "weekly",
    cursor: page.nextCursor,
  });
}
```

> [!DEV]
> `relay.me.leaderboardHistory({ board })` takes the board by key or ID and applies its ledger, window, `quests` and calculation. In React, `useLeaderboardHistory(board)` returns the same entries with `loadMore()`.
>
> - **The point history's entries.** Each item has the same shape as an entry from [`relay.me.pointHistory()`](/docs/concepts/points): `id`, `amount`, `kind`, `at`, `quest`, `reverses` and `imported`, without the operator's reason. Pages hold up to 100 entries, most recently recorded first.
> - **One window across pages.** `period` is the window the first page counted, and the cursor keeps it, so a rolling or calendar window that moves while you page doesn't change which entries count. Republishing the board invalidates a cursor with HTTP 400 `invalid-cursor`; restart at the first page.
> - **The member's own entries only.** It needs a signed-in member on every board, public ones included, and takes no member. A member the board excludes, or one outside its `group`, gets no entries, matching their `null` rank. An unknown board returns 404 `not-found`, and an archived one 404 `leaderboard-archived`.

### Access and privacy [#access-and-privacy]

* **Signed-in members by default.** Only signed-in members can read a board, unless you make it public. Signing out hides private boards.
* **Aliases unless you show profiles.** Every row has a stable alias for that board. Where an operator makes [member profiles](/docs/build/profiles) visible, the row shows the member's chosen name and avatar instead; members who hid their profile keep the alias.
* **No private details.** Rows never show internal member IDs, emails, account IDs or wallet addresses.
* **A member's place on a profile page.** Your app can show one member's place on a board, applying the board's access and the profile's visibility. See [compose a profile page](/docs/build/profiles#compose-a-profile-page).

> [!NOTE] Why rows say "Participant 1a2b3c4d"
> Profiles are private until you turn them on, so a new project shows every member under a generated name that nobody recognises, even members who linked Discord or X. To show names, make profiles visible and choose X or Discord as default names in **Settings → Profiles**. See [show member profiles](/docs/build/profiles#turn-profiles-on). The change applies in test right away, and in live once you [promote](/docs/concepts/promotion) it.

> [!DEV]
> Boards default to `access: "participants"`; set `access: "public"` to allow anonymous reads through the project's configured participant endpoint. The alias is in `participant`, and `profile` is always present, `null` when no profile is visible; `name` is the profile name when one is visible. Management responses also include member IDs, and the profile whatever the settings, for staff work. Read one member's place with `relay.leaderboards.placement(board, profileId)`. The participant proxy and existing-app identity bridge support these read-only routes. Never put a management credential in member-facing code.

## Supply points from a trusted backend [#supply-points-from-a-trusted-backend]

Your own server can award points for things Domino doesn't see, such as purchases or event check-ins, and correct them. Those points count on leaderboards like any other. It writes with a management credential scoped to the project and environment:

| Where the code runs              | Credential                                                                                                                                                                |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A Domino-hosted app's own Worker | The app's key, already bound as `DOMINO_MANAGEMENT_KEY`. See [Call the management API from your server](/docs/build/hosted-app#call-the-management-api-from-your-server). |
| Any other backend                | A [service key](/docs/start/existing-app#5-call-domino-from-your-backend) created in Console, kept in that backend's secret store.                                        |

In a hosted app's Worker, build the client from its bindings:

```ts
import { hostedManagementClient } from "@domino-sdk/relay/server";

export function management(env: Record<string, string | undefined>) {
  return hostedManagementClient(env);
}
```

Elsewhere, pass your own key to `createManagementClient`, as in the example below. Both clients have the same methods.

| Operation              | Required permission |
| ---------------------- | ------------------- |
| Point updates          | `points`            |
| Reads                  | `read`              |
| Custom tracker records | `ingest`            |

Editors and hosted-app keys have all three. `ingest` allows custom tracker records and nothing else. Reviewers and redemption staff can't change points. Every update needs an existing member of the project, an `actionId` and a reason. Reuse the same action ID and payload after a timeout; a changed payload with the same ID is rejected.

```ts
import { createManagementClient } from "@domino-sdk/relay/server";

const management = createManagementClient({
  baseUrl: "https://api.example.com",
  organization: "my-organization",
  project: "community",
  environment: "test",
  getToken: async () => "SERVER_SIDE_MANAGEMENT_KEY",
});

const award = await management.points.record({
  kind: "award",
  actionId: "external-activity-42",
  member: "member_existing",
  balance: "community",
  amount: 100,
  reason: "Verified external activity 42",
  effectiveAt: Date.parse("2026-09-01T12:00:00Z"),
});

await management.points.record({
  kind: "reverse",
  actionId: "activity-42-correction",
  member: "member_existing",
  balance: "community",
  entry: award.entry.id,
  amount: 20,
  reason: "Twenty units were attributed incorrectly",
});
```

| `kind`    | Takes                                                                  | Behavior                                                                                                                        |
| --------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `award`   | A positive amount                                                      | Adds points.                                                                                                                    |
| `spend`   | A positive amount                                                      | Spends points. Spending can't increase a balance.                                                                               |
| `adjust`  | A nonzero signed amount                                                | Changes earned scores.                                                                                                          |
| `reverse` | A positive amount and the `entry` ID of a positive award or adjustment | Can't exceed what's left unreversed. It takes the original entry's effective time and quest, so it corrects past earned scores. |
| `set`     | `total`, `expectedTotal`, and `classification: "earned"` or `"spend"`  | Replaces the total. See [Absolute updates](#absolute-updates).                                                                  |

Every entry also records when it was received and who made it.

### Absolute updates [#absolute-updates]

Read the current total with `management.points.balance(member, balance)` first. If another write changes it in between, the update fails with HTTP 409. Read again, decide again, and send a new action ID. An exact retry of a successful command returns its original result even if the balance has changed since.

> [!WARNING] `set` replaces the whole balance
> An absolute update replaces the entire balance; it doesn't separate one source's share.

### Timing, limits, and history [#timing-limits-and-history]

* **Effective time.** Without `effectiveAt`, an entry takes effect when it's received. Backdated entries are allowed; future dates are rejected.
* **No negative balances.** A deduction can't make a balance negative or use points on hold. Sort out the conflicting spending or hold before applying a correction.
* **Journal.** Read the append-only journal with `management.points.history(member, balance, { cursor? })`, 100 entries a page; pass a page's `nextCursor` back as `cursor` for the next.

## Rank an external score [#rank-an-external-score]

To rank something measured outside Domino, such as a rating from your game server or tokens held in members' wallets, give it its own ledger. Your server keeps that ledger equal to the score with absolute updates, every change is recorded, and a `balance` board ranks it.

```ts
import { defineLedger } from "@domino-sdk/relay/authoring";

export const tokenBalance = defineLedger({
  key: "token-balance",
  name: "Tokens",
  description: "Tokens held across linked wallets, in whole tokens. Never spent.",
});
```

Register this export under `pointLedgers` in `relay.json`, then publish a `balance` board over the handle: `defineLeaderboard({ key: "token-balance", title: "Token holders", ledger: tokenBalance, score: { kind: "balance" }, access: "public" })`. A score your server already knows needs only the `set` loop at the end of the example below.

For a balance, keep the samples in a [wallet tracker](trackers.md#wallet-accounts) and set the ledger from the tracker's current total. Reading the chain is up to you: supply `readBalance(account)`, and the example samples every linked wallet through it.

```ts
import { caip10 } from "@domino-sdk/relay/schema";
import { hostedManagementClient } from "@domino-sdk/relay/server";

type Management = ReturnType<typeof hostedManagementClient>;

/**
 * Supplied by your app: one account's balance in base units, or null for an account you do not
 * read, such as one on another chain. `account` is a CAIP-10 account ID.
 */
declare function readBalance(account: string): Promise<{ amount: string; decimals: number } | null>;

export async function refreshBalance(env: Record<string, string | undefined>, member: string) {
  const management = hostedManagementClient(env);
  const accounts = (await management.members.get(member)).wallets
    .filter((wallet) => wallet.status === "linked")
    .map((wallet) => caip10(wallet));
  const observedAt = Date.now();
  const samples = await Promise.all(
    accounts.map(async (account) => ({ account, balance: await readBalance(account) })),
  );
  const records = samples.flatMap(({ account, balance }) =>
    balance ? [{ id: account, author: { id: account }, occurredAt: observedAt, observedAt, data: balance }] : [],
  );

  if (records.length) {
    await management.trackers.ingest("token-balance", {
      actionId: `token-balance-${member}-${observedAt}`,
      records,
    });
  }

  await setScore(management, member);
}

async function setScore(management: Management, member: string) {
  for (let attempt = 0; attempt < 2; attempt++) {
    // Sum what the tracker holds now, never the values just read: a slower refresh cannot win.
    const page = await management.trackers.records("token-balance", { member, limit: 100 });
    // Sum in base units at the finest precision any wallet reports, then convert to whole tokens once.
    const decimals = Math.max(0, ...page.items.map((record) => Number(record.data.decimals)));
    const units = page.items.reduce(
      (sum, record) =>
        sum + BigInt(String(record.data.amount)) * 10n ** BigInt(decimals - Number(record.data.decimals)),
      0n,
    );
    const total = Number(units / 10n ** BigInt(decimals));
    const watermark = Math.max(0, ...page.items.map((record) => record.observedAt));
    const { total: expectedTotal } = await management.points.balance(member, "token-balance");

    if (total === expectedTotal) {
      return;
    }

    try {
      await management.points.record({
        kind: "set",
        classification: "earned",
        member,
        balance: "token-balance",
        total,
        expectedTotal,
        actionId: `score-token-balance-${member}-${expectedTotal}-${total}-${watermark}`,
        reason: `token-balance total at ${new Date(watermark).toISOString()}`,
      });
      return;
    } catch (error) {
      // 409: another refresh changed the balance first. Read again once; the next refresh converges.
      if ((error as { status?: number }).status !== 409) {
        throw error;
      }
    }
  }
}
```

* **Your app chooses the chains.** `caip10(wallet)` names Ethereum mainnet for an EVM wallet and Solana mainnet for a Solana wallet; pass the network you read, such as `caip10(wallet, "eip155:8453")`, to name another. A smart-contract wallet is named on the chain that proved it (`provedOn`), and `caip10` throws for any other network: skip it when you read another chain. A Cosmos wallet's chain ID is the one it was linked under, which proves nothing: check the address prefix in `readBalance` and return `null` for chains you don't read.
* **The tracker is the source of truth.** The score sums the tracker's current records for the member's linked wallets, so a refresh that finishes out of order still computes from the latest samples, and an unlinked wallet stops counting at the next refresh.
* **Compare-and-set.** `set` with `expectedTotal` refuses a concurrent change with 409. The action ID names the inputs, so an exact retry replays and a new total gets a new entry.
* **Whole units.** Scores are whole numbers, so the example sums base units and converts the total to whole tokens once, rounding down.
* **Earned, never spent.** A decrease becomes a negative adjustment, which a `balance` board ranks correctly. Don't price rewards in a score ledger or pay it from quests; a Console adjustment is overwritten by the next refresh.
* **Current as of the last refresh.** Refresh after sign-in, after a wallet link, and from a [scheduled hosted-app route](/docs/build/schedules#feed-a-custom-tracker) that pages through members. The scheduled route verifies Domino's signature and sends custom tracker records with the hosted app's key, so refreshes continue when members don't come back.

A hosted app's key belongs to one environment: the staged build's key works on test data and the live app's on live data. To refresh from any other server, use a Console management key with `ingest`, `points` and `read`.

## Moderate participation [#moderate-participation]

In Console **Leaderboards**, **About this board** beside the standings names the scoring method, **Current balance**, **Points earned** or **Net points**, and shows the board's ledger, period, audience, eligibility and ID.

Standings stay visible while the next page loads. **Previous** and **Next** appear once a board has more than one page, and wait until that page has loaded.

Select a member's row to open them beside the standings. To reach a member who isn't ranked, search by name, email or ID in **Find a member** above the standings and choose them from the results.

| Where                                | Action                                                                                         | Effect                                                                                                                                                                                                                                             |
| ------------------------------------ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Eligibility**                      | **Exclude from this board** / **Reinstate on this board**                                      | Needs a **Reason for eligibility change**. Adds them to, or removes them from, the board's [exclusion group](/docs/concepts/leaderboards#exclusions). Their points don't change, and they stay listed under **Excluded members** until reinstated. |
| **Excluded members**                 | **Change exclusion group**, choose an **Exclusion group** and give a **Reason**, then **Save** | Points the board at another group, such as one several boards share, or back to **This board's own group**. Later standings and settlements leave out that group's members.                                                                        |
| **Eligibility**, boards with a group | **Add to group** / **Remove from group**                                                       | Changes membership of the board's [group](/docs/concepts/groups), with the same reason. Publishing the board created the group if it was missing.                                                                                                  |
| **Change points**                    | **Award**, **Spend** or **Adjustment**, then **Apply points**                                  | Needs the points and a **Reason for change**. Console shows the new balance before you apply it and blocks a deduction larger than the available points.                                                                                           |
| **Points history**                   | **Reverse** beside a positive award or adjustment, then **Reverse points**                     | Takes back up to that entry's points, with a reason, in the entry's original period. Entries are newest first; **Load older entries** reads the next page.                                                                                         |

A [banned member](/docs/use/member-bans) is left out of every board's standings, and of any settlement that freezes while they are banned, without being added to an exclusion group. Unbanning them brings them back.

Exclusions and group membership can change in live. Changing which group a board excludes is a definition change, so live refuses it, like other definition changes: make it in test and promote.

> [!DEV]
> Backends with `publish` permission can call `management.leaderboards.exclude(board, { actionId, member, excluded, reason })`, and change a board's group with `management.groups.addMember(group, { member, reason })` and `removeMember(group, member, { reason })` (see [Groups](/docs/concepts/groups)). These work in live with a personal API key, so automation can moderate a live board. **Audit history** records each change under the key's owner. A CLI sign-in cannot make these changes in live.
>
> Read definitions with `list()`, definition versions with `history(board)`, and exclusions with `exclusions(board)`. `exclusionGroup(board)` returns `{ leaderboard, group, generated, boundAt }`, the group whose members the board leaves out; `boundAt` is `null` until the first exclusion creates the board's own group. Change that group's members with `management.groups` as well, and bind another one with `bindExclusionGroup(board, { group, reason })`. Binding is a definition change, so live refuses it with `promotion-required`, as it does **Change exclusion group** in Console.

### What leaderboards do not do [#what-leaderboards-do-not-do]

A board without `settle` never awards prizes or finalizes results; only a board with `settle` freezes its window and pays [prizes](#settle-prizes-after-a-window), after a reviewer approves them. A "top ten" reward built elsewhere must decide how it treats ties and when results become final, and can read standings through an authorized backend. No board finds members' outside accounts or collects activity from other services on its own: points reach it from quests, referrals and your server. Boards never pay tokens or money either: a pool in another unit is recorded for you to [pay](#export-the-payout-list).

### Referral bonuses [#referral-bonuses]

[Referral programs](/docs/build/referrals) can add inviter bonuses to the same ledger. Those points count on boards like other earned points. Awards from your own server earn bonuses only when they opt in, and reading a leaderboard never triggers a referral payout. Prize points earn no referral bonus.

> [!DEV] IDs
> Point entry IDs are opaque prefixed strings. New IDs have 22 random alphanumeric characters after the prefix; keep the full returned ID when reversing an entry. Existing IDs stay valid.

## Reject an incorrect settlement [#reject-an-incorrect-settlement]

Open the frozen settlement in Console and choose **Reject settlement** with a **Rejection reason** before anyone approves it. The window stays in history as rejected and pays no prizes. Rejection is final for that window: it can't create a new freeze. To correct a prize that's already approved, revoke that winner's completion instead.

> [!DEV]
> Scripts read `management.leaderboards.settlementReviews(board)` or `settlementReview(id)`, then call `rejectSettlement(id, { expectedCreatedAt: settlement.createdAt, reason })` with `review` permission. The CLI provides `domino leaderboard-settlements list BOARD`, `get SETTLEMENT` and `reject SETTLEMENT --expected-created-at TIME --reason TEXT`.

## Compose achievement displays [#compose-achievement-displays]

Leaderboard prizes grant points and tiered rewards. Badges come from quests or reviewers. Your app can show a member's badges next to their leaderboard results. See [badges](/docs/concepts/rewards#badges).

> [!DEV]
> Read a member's badges with `relay.me.badges()`, or a visible profile's with `relay.profiles.badges(profileId)`.
