A step is satisfied, never ticked
Three kinds, and none of them takes a write. What each one is, and what each one guarantees, is further down — this section is about the rule they share. There is no write path against progress, and that absence is the design. A tick somebody’s client can send is a checklist that says “you have connected your domain” to a person who has not, because a request was retried or a button was clicked hopefully. A step that is satisfied by the fact that satisfies it is a step that cannot be wrong. It holds for the click, too. A step can carry a destination and somebody can press it — and pressing it satisfies nothing. The link is the door, not proof that anybody walked through it. The corollary: whatever you want to appear on a checklist has to be a fact first. A step names a fact about one PERSON — a contact or a membership, or one of your own events. A fact about a team or a subscription would have to be fanned out across a roster to be attributed, and “everybody completed set up billing because one person did” is not what a funnel is read as.It counts from the moment it is on
A fact published before the checklist existed does not count. The bus is not an event store, so there is nothing to replay — and a rule that backfilled some steps and not others would produce a funnel whose numbers cannot be compared to each other. The practical shape of this: create the checklist, turn it on, and read the funnel against the cohort that arrived afterwards. A checklist switched on today does not tell you what last year’s users did.active is a reversible off switch rather than an archive, because nothing outside
this API names a checklist by id.
Rendering it
position takes inline (default), center, bottom-right, bottom-center or
bottom-left — the
same five the survey and the
suggestion board take.
Two floating panels never share a corner. A guide and a <HelpWidget /> in the
same corner used to cover each other, and the only advice was to put one on the left
— a layout decision made on your behalf to route around our bug. Now the corner holds
one panel: opening help asks the guide to step aside, and closing help puts it back.
The guide steps aside by leaving, and what makes that safe is the return: a guide
that vanished when somebody opened help would read as help having closed their
onboarding, while one that is back the moment help closes reads as the corner being
shared. Collapsing was the obvious alternative and it does not do the job — a card in
the same corner is still in the corner.
The help panel does not come back the other way round: somebody opened it, something
they opened after it took the corner, and a support window that reappeared on its own
would be a window the person closed.
Floating, the header carries two gestures and they are genuinely different.
Collapse shrinks the guide to a card — the heading, a progress bar, and the one
thing to do next. It is not a smaller widget in the corner; it is the same widget
saying less, and it expands from its own control.
Close takes it off the screen and leaves nothing floating behind. A panel
that answers “close” with a smaller panel in the same corner has not closed. The way
back is a control you place:
<OnboardingLauncher /> draws the same progress the guide does and opens it. It
renders nothing until an <OnboardingChecklist /> on the page has said there is
something to open, so it never becomes a button that opens nothing — and nothing
while the floating guide is already open, because a panel over the page and a
pill in the header saying the same thing is one too many. Pass whileOpen to keep it
through both states, for a toolbar that would reflow visibly as it comes and goes.
An inline guide never hides it: inline lives in the page’s flow and may be three
screens down, so a pill that vanished for it would leave no way back.
From your own code, onboardingGuide.show() does the same thing.
What you can take off the pill
count is for a header that is already dense, or one where the pill sits beside
other numbers and a second one competes with them. The number still reaches a
screen reader — somebody who cannot see the ring is not left without it.
border={false} is for a bar that already has its own frame, where one more
outline is a box around something nobody asked to separate. The horizontal padding
goes with it, because it existed to hold the text off an edge that is no longer
drawn.
label is for when the dictionary would have to be two dictionaries: the same
product calling it “Configuração” in a header and “Primeiros passos” on a settings
page. If it is the name everywhere, the place is localization on the provider —
that reaches every surface at once.
The gestures, and how far they go
Minimising and closing are remembered for the visit. Both live insessionStorage, keyed by contact: a refresh does not undo either — that is the
same visit — and closing the tab does, because somebody coming back next week
deserves the guide rather than a card they collapsed and forgot. The key carries
the contact because sessionStorage is per origin and not per person; without it,
on a shared computer one person’s gesture becomes the next one’s state.
They are one gesture at two depths, and for a while only the smaller one survived a
refresh — the odder half to explain: a reload put the card back over the page of
somebody who had just taken it off. Opening the guide again from your own control
forgets the close immediately; the later answer is the one that counts.
Standing aside is not closing. When the help panel takes the same corner the
guide steps off screen and returns when help closes — our own retreat rather than
an answer from the reader, and a refresh in the middle of it does not cost them the
guide for the rest of the visit.
Neither gesture tells the API anything. The close that really persists is a
different one, just below.
The card’s title is the toggle — it is where the hand goes before the chevron.
The card’s line does the step, rather than opening the guide. The card exists to
name the one thing left to do; a line that names it and then answers with a list is
asking somebody to find it again in what they were just told. With a destination it
is a real link (middle click, copy address); with neither a destination nor an
onStepAction, it opens the guide, which is all that is left to offer.
The close that does persist lives in the goal menu, behind Editar — “hide all
for a while”. It dismisses every list on the guide, each for its own snooze_days,
and they come back on their own. It is there rather than in the header because it is
a different kind of act from the two beside it, and because that menu is already the
screen about what somebody wants on their guide. From your own code it is
userkit.dismissChecklist(id), one list at a time.
What satisfies a step
Three kinds, and the difference between the two event ones is who publishes.The person’s, or the team’s
Every step has ascope, and the default is contact: one person, one step.
That is what every step meant before this field existed.
scope: "customer" is for the work that belongs to the account rather than
to anybody in particular — connecting a payment provider, importing a catalogue,
setting up a domain. It is satisfied for everybody on the team the moment any
member does it:
entitlement’s arrangement, one kind along. That is why the argument against
fanning a fact across a roster still holds: nothing is fanned out.
Two consequences worth knowing:
- The funnel counts TEAMS for a customer-scoped step and people for the rest.
The
scopecomes back beside the number on the completion read, because 40 and 40 in different units is a table where two answers look like agreement. - The list’s own
completedbecomes null, exactly as it already does for anentitlementstep and for the same reason. The marker for “finished the list” is written by a consumer, which has no request and therefore no team; materialising one would mean picking a team on the person’s behalf and being wrong in every other one they belong to. The step keeps its number; the list answers “cannot say” rather than contradicting the widget. - With no team resolved it falls back to the person. A contact with no team is not a contact whose team has done nothing — there is no team. It is also where everybody is before they mirror their accounts.
scope is frozen after creation, like kind: flipping it while people hold
progress would change what their rows mean. On an entitlement step it is
refused — that kind is already a question about the team by construction.
A product_event step is how you express the steps an onboarding is actually
made of — “created their first project”, “published something”, “invited a
teammate through your own flow”. You send the event you already send:
Its guarantee is weaker than the other two, and it is worth knowing which.
Analytics drops events when its buffer is full, under load; our own bus does not.
Once an event is stored the rest of the path is the bus’s and the guarantee is
the same as everything else’s — but the front of that path can lose one, and a
lost event is a step that stays grey.
POST /v1/track
with the environment’s secret key, and every line names the contact it
belongs to:
POST /v1/events —
the door the browser’s track() uses — cannot name anybody, and an event with
no contact satisfies no step for anybody: if your step waits on a fact from your
server, this is the door.
Where a step sends somebody
A guide that names the work and hides the door makes the reader navigate twice. So a step carries its own destination:action_url takes a path in your own app (/settings/domain) or an
absolute http(s) URL — the same pair a
notification and a banner accept. The
protocol-relative form is refused: //evil.example/x starts with a slash, reads
as a path in every review, and is an absolute URL to somebody else’s host.
The widget renders that step as a real link, not a button that navigates —
middle click, copy address and open-in-a-tab are why a link is a link. The whole
row is the target and an arrow says so; an absolute URL opens in a new tab, a
path stays in the app.
action_label is not drawn on the line. A word on the right of every
actionable row is a column of verbs competing with the titles, and the arrow
already says the row opens. What it still does is name the action for a screen
reader, and declare that a step with no destination has one at all — the one
your own code handles.
A finished step keeps its door. Done is not gone: somebody who connected a
domain last week still wants that screen.
The sentence a line has no room for
A step is one line, and that is the point — a guide people read at a glance is a guide people read. But some steps need a second sentence: why this one is locked, what the thing actually is, what it bought. That sentence goes on hover, after a second:hint_pending alone is the common case and reads
correctly: nothing on hover once somebody has done the thing, which is the honest
amount to say about it.
A checklist takes a third, hint_locked, because a heading has a state a line
does not. The widget already draws a padlock and the name of what the group waits
on, and a name is a door rather than a reason:
A step has only two, and the absence is deliberate: a step has no locked state.
What looks like a locked step in other products is a step inside a locked group, and
a locked group is drawn closed — so a third field there would be configuration that
can never render. The near case is an entitlement step for a feature the team does
not hold, and that one is honestly pending: “available on Pro” belongs in
hint_pending.
At most 200 characters. Longer is a paragraph on hover, which disappears the moment
somebody moves the mouse to read it.
It goes green while they are looking at it
A step waits on a fact reaching our bus, which takes a couple of seconds from thetrack() call. The widget does not poll — a poll would race the drain
rather than read a live number — so without help the check would appear on the
next page load, long after the moment it meant something.
So track() also says so locally:
completed stays the
server’s answer, so no list congratulates anybody, hides itself, or records a
completion on a guess.
For the app that finishes something without calling track():
When the action is not a navigation
Half of what a step wants to do on click is open your own modal, start a product tour, or focus a field on the page somebody is already on. None of that has an address, so it does not live in the database — your code does:true cancels it. That is
what lets one handler intercept the two steps your app knows about and leave the
rest to navigate.
A step whose action is only ever a function has action_label and no
action_url — it declares that there is a door and that your page is what
opens it. On a page that never wired onStepAction, that step renders a control
that does nothing, which is why the panel says so beside the field.
Clicking never satisfies a step, in either shape. If the click is the
thing you are measuring, the composition is a
product_event step whose
track() call sits in the same handler:Many lists, one guide
Every checklist this person is on is drawn as a group of one guide: the first unfinished, unlocked one is expanded and the rest are folded. A guide that opened everything would be a wall of twenty steps, and the question somebody has on arriving is “what now”, not “what is there”. Order them withunlocks_after. A checklist that waits on another is drawn
closed, with a lock and the name of what it waits for — it stays ON the guide rather
than disappearing, because seeing what comes next is the difference between a guide
and a queue. Same environment only, and a cycle is a 400: two lists waiting on each
other is a pair neither of which could ever open.
Let people choose with selectable. A selectable checklist is off the guide until
the person puts it there, from the “what do you need to get started?” modal behind the
guide’s Edit. The segment still decides what is offered; this hands the last
step to them. The menu travels with the guide on one round trip, so the modal opens
with their own choices already ticked.
Saving sends the whole answer, never a diff — otherwise “I unticked this” and
“this was launched while my modal was open” would be the same request, and the second
would quietly undo your own release.
None of this touches what satisfies a step. Choosing decides which lists are on the
guide; a step is still satisfied by a fact landing on the bus or by an entitlement
being held, and no screen anywhere can tick one.
Inline belongs on a dashboard, in a place you cleared for it. The floating values are
for the product that has no such place: a corner card somebody glances at between
tasks. Those carry a close, which the inline one does not need — a panel over the page
that cannot be got rid of is a panel people learn to work around.
Closing is per checklist, through POST /v1/contact/checklist/{id}/dismiss, which
<OnboardingChecklist /> calls for you. An environment has many active checklists, so
a flag on the person would make the one you launch next month be born already hidden
for everybody who closed the last one — dismissing one says nothing about your others,
nor about the ones that do not exist yet.
And it is an adjournment, not an answer. snooze_days on the checklist decides
how long: seven days by default, and the card comes back afterwards. Somebody pressing
X on a setup guide almost never means “never help me again” — they mean “not now”,
and recording the strongest possible reading of the weakest possible gesture is how a
guide disappears from the account of a person who never finished setting up.
Set snooze_days to 0 for the checklist that really is a one-time offer; a close
then lasts forever. Between the two, longer periods are for the list somebody may
legitimately not want for a while — a person who will never buy the feature an
entitlement step asks about would otherwise watch the same card return for the life of
their account, and a nag on a schedule is still a nag.
Changing the number never moves a snooze already running. The end is computed when
somebody closes the card and stored, so a promise made to a person cannot be shortened
by an edit they never saw.
The dedicated setup page asks for the closed ones. A close says “not on my
dashboard”, and somebody who navigates to /getting-started has just said the
opposite about that screen:
?include_dismissed=true, and each checklist comes back carrying
dismissed so a page rendering its own UI can say so. The close button goes with
it — a control that hides something which is back on the next render is a control
that does not work.
It reaches past the dismissal and nothing else: a checklist that is off stays off,
the audience still decides, and a selectable one still has to have been chosen. Those
are facts about the checklist; a close is a preference about a screen.
Choosing a list again in the goal menu brings it back immediately, whatever is
left of its snooze. That screen is where somebody says “I want this on my guide”, so a
dismissal outliving it would be a refusal nobody said out loud.
It is not a decline and touches no progress: closing says “not on this screen”,
and the steps go on being satisfied by facts landing on the bus. The panel reads two
numbers beside the completion funnel — dismissals, how many ever closed it, and
hidden, how many it is closed for right now. The gap between them is the snooze
working. The two being equal is what a card people are actively pushing away looks
like.
In a Next app, import it from @userkit/nextjs — the same component, and in
proxy mode the read is forwarded by the handlers,
with the session in an httpOnly cookie on your own origin. The read alone: there is no
write half anywhere on this surface.
200 and an empty list, not a 401. The widget runs
on your landing page, and a console error is the wrong way to say “there is nothing
to show you here”.
satisfied_at is always null for an entitlement step: nothing was stored, so
there is no moment to report. completed is derived from the steps rather than
kept beside them, which is what makes it right for a list carrying an entitlement
step too.
The customer id travels with the read, because it decides the entitlement steps. It
identifies and never authorizes — a team the session does not belong to matches no
membership.
Who sees it
segment_id aims the checklist at an audience, or null
for everybody. It decides who is shown the list, not who is recorded against
it: progress is written for whoever produced the fact, so somebody entering the
segment later arrives with the steps they had already completed already ticked.
The funnel
null, not a zero it did not earn. Nothing is
stored for those steps, so there is no count to give — and 0 would be a number
somebody acts on. materialized says which of the two you are looking at, so a
chart can draw a gap instead of a bar.
completed is null for the same reason whenever the checklist carries an
entitlement step: a “finished the whole list” number computed over the steps it
can see would be a lower bound presented as a total. A reassurance is exactly what
stops somebody checking.
audience is the segment’s members, or every identified contact of the environment
when the checklist is aimed at everybody.
What it publishes
checklist.step_completed and checklist.completed are both deliverable as
webhooks — the second is the one worth subscribing to,
because “this person finished onboarding” is the moment a welcome sequence or a
sales alert hangs off.
checklist.completed fires at most once per person per checklist, held by a
marker row written in the same transaction. The bus is at-least-once, so a
consumer that re-ran without one would announce a graduation twice.
It fires only for a checklist made entirely of event steps. An entitlement step
is decided at read time, against a team, so the path that announces completion —
which runs when a fact lands, and knows only the person — cannot tell whether the
list is finished. A checklist you want an automation to hang off should be built out
of event steps alone.
The payloads carry ids, keys and titles: the step somebody satisfied and the list it
belongs to. Never the rule behind it — which fact or which feature a step waits on is
your configuration, and a subscriber does not need it to act on somebody having got
there. Editing or deleting a checklist is not delivered.
Permissions
The whole staff surface isengagement:manage — owner and admin by default. One
name covers checklists, changelog and surveys: they are the same act, which is
operating what your users are shown inside your product.