A rule is a fact, an amount and a ceiling
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(). 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.
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:
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.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.
A level is a function of what somebody holds
A rung is a set of conditions, and all of them must hold:
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:
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.
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.Showing it to the person
<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:
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.
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.
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:request_key is required, unlike the optional handle on
/v1/usage. 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:
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: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.