> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Points, levels and achievements

> Rules that pay when a fact lands, metrics that count what is not a point, levels that ask for all of it at once, and the ceiling the API refuses to choose for you.

Points are the other half of the [onboarding checklist](/en/guides/onboarding).
That list measures what somebody did once; this measures what they **keep**
doing — and it uses the same machinery, including the rule that gives it all its
value: nothing is ticked by anybody.

## A rule is a fact, an amount and a ceiling

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/points/rules?environment=live \
  -H "Authorization: Bearer $UK_SESSION" \
  -d '{
    "key": "invited_teammate",
    "name": "Invited a teammate",
    "kind": "event",
    "event_type": "customer_member.joined",
    "points": 25,
    "award_limit": "once"
  }'
```

It is created inactive. While you are arguing about what it is worth nobody is
credited — and turning it on is the moment counting starts, from then rather than
from what already happened.

### Two kinds of trigger

`event` is a fact the platform already publishes about one person: verified an
address, joined a team, left the waitlist. There is nothing to instrument. The
full list travels on the rules read itself, as `event_types`.

`product_event` is a name **your product** sends through
[`track()`](/en/guides/events). The vocabulary is yours, so nothing here can
refuse a name for not existing — what the panel does instead is say whether this
environment has ever seen it, which is the honest half of a check that cannot
fail.

<Warning>
  A `product_event` carries a weaker guarantee than an `event`, and it is the same
  difference the checklist already has. Analytics drops events when its buffer is
  full; a dropped event is a point nobody will ever pay, because the ledger has no
  idea it was owed. For anything that has to be exact, prefer a platform fact or a
  [grant from your own backend](#granting-points-from-your-backend).
</Warning>

**There is no "entitlement" trigger.** A point is an event and an entitlement is
a state. Paying for a state would need a clock asking "do they still hold it" and
a reversal when they stop — and the answer belongs to a *team*, of which one
person can be on several. A balance whose value depends on which tab resolved it
is not a balance.

### The ceiling has no default, and that is deliberate

`award_limit` is required:

| Value       | What it means                                               |
| ----------- | ----------------------------------------------------------- |
| `once`      | Once per person, forever                                    |
| `per_day`   | At most `limit_value` times per person per rolling 24 hours |
| `cooldown`  | At most once every `limit_value` minutes                    |
| `unlimited` | No ceiling — and you typed that                             |

The first rule anybody points at a high-frequency event with no ceiling is an
unbounded loop with a table at the end of it. The windows are **rolling** rather
than calendar days: nobody gets a fresh allowance at a midnight we picked for
them.

On the list read, every rule comes back with `awards` and `earners`. Many awards
over few people is the shape of this module's characteristic mistake: a ceiling
left at `unlimited` on a name the product sends on every page.

### What cannot change afterwards

`key`, `kind` and `event_type` are frozen. The ledger lines a rule already wrote
say "you earned this for X", so re-aiming it would rewrite everybody's history
without touching a row. Retire the rule and write another; the old lines keep the
name the rule had at the time.

Re-pricing is allowed and is **not** retroactive. Changing 10 to 5 changes what
the next award is worth, and nothing that already happened.

## Metrics: what you count besides points

While the only unit was the point, "gold is 4 courses" could only be written as
"a rule paying 1 point per course, and gold is 4 points" — and the unit died at
the door: by the time the fact reached the ledger it had stopped being a course.
A **metric** gives the unit back.

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/points/metrics?environment=live \
  -H "Authorization: Bearer $UK_SESSION" \
  -d '{"key": "courses", "name": "Courses completed", "unit": "course", "icon": "🎓"}'
```

A rule credits the metric instead of the balance — same ledger, same ceiling,
same idempotency, one more field:

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/points/rules?environment=live \
  -H "Authorization: Bearer $UK_SESSION" \
  -d '{
    "key": "course_completed",
    "name": "Completed a course",
    "kind": "product_event",
    "event_type": "course.completed",
    "metric_id": "'"$METRIC_ID"'",
    "points": 1,
    "award_limit": "unlimited"
  }'
```

With `metric_id`, `points` is **how much of that metric** one occurrence is
worth: 1 course, 1 e-mail. The balance does not move, and a `points_total` badge
is not unlocked by it — a metric credit moved no balance.

Creating a metric does nothing on its own. With no rule crediting it, it stays
at zero for everybody and every rung asking for it stands empty — which is why
the list read carries `lines` and `holders`, the pair that says so before
somebody opens a ticket about the loyalty programme not working. The order that
works is **metric, then rule, then level**.

<Warning>
  A metric that has ever credited anybody **cannot be deleted**, and that is a
  decision rather than a limitation. A ledger line survives the rule that wrote it
  — it carries the frozen name, and nobody's balance moves. It cannot survive its
  metric: without it, the line would stop being a course and silently become a
  point. Deleting "e-mails" would hand a thousand points to everybody who ever
  sent a thousand e-mails, and move them up the ladder for it.
</Warning>

## A level is a function of what somebody holds

A rung is **a set of conditions, and all of them must hold**:

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/points/levels?environment=live \
  -H "Authorization: Bearer $UK_SESSION" \
  -d '{
    "name": "Gold",
    "icon": "🥇",
    "requirements": [
      {"kind": "points", "threshold": 400},
      {"kind": "metric", "metric_id": "'"$COURSES"'", "threshold": 4},
      {"kind": "metric", "metric_id": "'"$EMAILS"'", "threshold": 1000}
    ]
  }'
```

Five kinds of condition, each answered by a different machine:

| `kind`        | Condition                                                      |
| ------------- | -------------------------------------------------------------- |
| `points`      | The balance reached `threshold`                                |
| `metric`      | That metric's tally reached `threshold`                        |
| `rule_awards` | That rule has paid this person `threshold` times               |
| `achievement` | They hold that badge                                           |
| `segment`     | They are in that [audience](/en/guides/segments) **right now** |

The last is the only one that can stop being true: leaving the audience drops the
level. If you meant "whoever reached Pro" rather than "whoever is on Pro", use a
segment badge — that one is written down forever the moment it is earned.

A level is stored on nobody: it is derived on every read as the highest `rank`
whose every condition holds. That is what keeps it from ever disagreeing with the
ledger, the tallies and the badges it describes.

### The order became a field

The threshold was the order while a rung was one number. It stopped being able
to be: no arithmetic ranks "400 points and 4 courses" against "400 points and 10
courses". `rank` is the only thing that orders the ladder, and it is unique per
environment. Omitting it on create puts the rung above the last one — which is
what adding a level almost always is.

Two rungs at the **same balance** are now allowed, and that is exactly what this
exists for: gold can be 400 points and 4 courses while platinum is 400 and 10.

### Things worth reading before you need them

* **A rung with no conditions is reached by NOBODY**, not by everybody. The
  other reading would promote the whole environment the moment somebody creates
  a level and goes to lunch.
* **A negative line drops the level with the balance**, immediately and with no
  announcement — and so does leaving an audience a rung asks for. There is no
  `level_lost`: demoting somebody is not a message any product wants sent on its
  behalf.
* **The congratulation happens once.** Somebody who reaches three rungs at once
  — a large correction, a merge of two records, a badge that was the last
  condition of two levels — gets **one** `points.level_reached`, for the
  highest. Three facts in the same second would be three emails about one
  moment.
* **Editing the conditions moves who is in the level immediately**, with no
  ledger line anywhere. It is the difference between a level and a rule: a rule
  describes what happened, and a level describes what is true now.

## Achievements

Three shapes, each satisfied by a different machine:

| `kind`         | Condition                                     |
| -------------- | --------------------------------------------- |
| `event_count`  | A rule paid this person N times               |
| `points_total` | Their balance reached N                       |
| `segment`      | They entered a [segment](/en/guides/segments) |

The third is the cheapest to get right, because it reuses the audience engine you
already use — and the only difference between a badge and a segment is that **a
badge does not come off**. Somebody who was an enterprise trialist in March still
has the badge in June; re-deciding it from the definition would take it back.

`hidden` keeps a badge off the shelf of anybody who has not earned it. It is half
the pleasure of the mechanic for one boolean — and it works because **the
condition never reaches the browser**: the API sends a name, a picture and
whether it is theirs. Saying "unlock at 40 invites" hands somebody the rule to
farm.

The condition is frozen too. Whoever already holds the badge earned the old one,
and editing what it means would change what every existing unlock claims.

<Note>
  Deleting a rule that an `event_count` badge counts is refused, and the error says
  how many badges are in the way. A badge whose rule is gone is a badge on the
  shelf that nobody can ever earn.
</Note>

## Showing it to the person

```tsx theme={null}
import { Points, PointsPill } from "@userkit/react";

<Points />;                    {/* balance, level and shelf */}
<Points history />;            {/* with the statement already open */}
<Points achievements={false} />;

<PointsPill />;                            {/* the same balance in a header */}
<PointsPill border={false} />;           {/* inside a toolbar that has its own frame */}
<PointsPill level={false} ring={false} />; {/* the densest: the number alone */}
<PointsPill onClick={() => router.push("/rewards")} />;
```

`<PointsPill />` is `<OnboardingLauncher />`'s twin — same pill, same ring — for
the places a card does not fit. It opens nothing: there is no floating points
panel, so `onClick` is for the product that has its own page. In a product with
no gamification configured it draws nothing at all.

Or through the client, if you draw your own screen:

```ts theme={null}
const { balance, metrics, level, next_level, points_to_next, achievements } =
  await userkit.getPoints();

// What the next rung is still waiting for, condition by condition.
for (const requirement of next_level?.requirements ?? []) {
  if (!requirement.met) {
    console.log(requirement.kind, requirement.have, "of", requirement.need);
  }
}

const { lines, total } = await userkit.getPointsStatement({ limit: 20 });
```

`level` is `null` when no rung is reached — **including the ordinary case of a
product with no levels at all**. Render the null: a zero would be a rank nobody
configured.

`points_to_next` is still only the distance in **points**, and it is zero when
the next rung asks nothing about points. On a ladder with metrics, draw the bar
from `next_level.requirements`: "60 points to go" on a rung that also wants four
courses is a bar that fills and promotes nobody.

<Note>
  A **level's** conditions travel to the browser and a **badge's** do not, on the
  same response, and it is the same principle twice. A badge is a surprise your
  product is entitled to keep; a rung is a goal it is **asking** somebody to
  reach. The one thing withheld is the name of an audience — that is your own
  internal copy about your users, and it was never written for them to read.
</Note>

An anonymous visitor is answered zeros rather than a 401: the widget runs on your
landing page. What they earn after signing up follows them — the merge of the
visitor into the account moves the whole ledger.

### There is no write on this surface

No endpoint on the customer plane moves a balance, and there is not going to be
one. It is the checklist's rule for the checklist's reason: the moment a page can
ask for points, that page's network tab is the game.

## Granting points from your backend

The exception, and it lives on the machine surface — a program you control, not a
page you ship:

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/points \
  -H "Authorization: Bearer $UK_SECRET_KEY" \
  -d '{
    "external_id": "user_123",
    "amount": 500,
    "reason": "Support compensation",
    "request_key": "ticket-4711"
  }'
```

`request_key` is **required**, unlike the optional handle on
[`/v1/usage`](/en/guides/entitlements). A retried grant with no key is a second
gift, and the caller that most wants this endpoint is a script somebody runs
twice. A replay answers `200` with `duplicate: true` and the balance as it
stands: the state you asked for is true, so an error would be a retry to handle
for a correct outcome.

With `metric`, the same endpoint credits a **tally** instead of the balance —
the door for what happened outside your product:

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/points \
  -H "Authorization: Bearer $UK_SECRET_KEY" \
  -d '{
    "external_id": "user_123",
    "metric": "courses",
    "amount": 3,
    "reason": "Imported from the LMS",
    "request_key": "lms-import-2026-08"
  }'
```

It is a field rather than a second endpoint because it is the same act: the same
recipient, the same required `request_key`, the same line in the same ledger.
The answer's `balance` becomes that metric's tally, and `metric` beside it says
which one moved.

The line lands with `source: "manual"`, which is the column to group by when a
balance looks impossible. An anonymous contact is refused: a balance on a visitor
belongs to a page load rather than to a person.

## The facts that leave

Three, and all three reach your [webhooks](/en/guides/webhooks):

| Fact                   | When                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `points.awarded`       | Every ledger line, carrying the balance **after** it — and `metric_key`, when the line credited a tally instead of the balance |
| `points.level_reached` | A threshold crossed, at most the highest per line                                                                              |
| `achievement.unlocked` | A badge, once per person and forever                                                                                           |

`points.awarded` is the busiest fact in the catalogue by construction — it fires
per line. What bounds it is the rule's `award_limit`, which is exactly why that
field has no default.

**A rule may not listen to these three.** Paying for being paid is a loop that
runs at the speed of the queue, and the API refuses the rule at the moment you
create it.

## What is erased when somebody asks to be forgotten

The whole statement, the badges, and the record of which levels were announced to
them. The panel's numbers — what each rule paid, how many people hold each badge
— are aggregates over whoever is present, so they move rather than break. That is
the correct arithmetic: the person who earned those points is no longer here.
