Skip to main content
A checklist is the “getting started” panel in your product: a few steps, each one either done or not, with a completion funnel behind it that tells you where people stop. The unusual thing about it is what you cannot do: there is no endpoint that marks a step done.

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 in sessionStorage, 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 a scope, 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:
Without it, the second partner in a club sees “connect your payment provider” in grey — being asked, by us, for work a colleague already finished. Nothing is recorded for the team. The progress row still belongs to the person who did the work; this is the same row asked a different question, on the read, inside a request that has already resolved which team it is for — which is exactly 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 scope comes 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 completed becomes null, exactly as it already does for an entitlement step 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:
Nothing in that call mentions a checklist. If a live step is waiting on that name in that environment, the event is forwarded onto our bus and the step is satisfied for that person; if none is, it costs one map lookup and stays an ordinary analytics event.
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.
A name cannot be refused for not existing. Your event vocabulary is yours and open, so the API checks the shape and nothing else — which also means you can write the guide before instrumenting the product, in that order. What the panel does instead is tell you what it knows: seen 1,284 times, last today, or this environment has never received that event. A zero there is almost always a typo, and it is the only warning there will be. Anonymous visitors are skipped: progress belongs to an identified contact, and the widget mints a visitor per page load. And the fact can happen on your backend. Half of what an onboarding measures never happens in a browser — a payment a webhook confirmed, a contract countersigned, an export a worker finished. Those go through POST /v1/track with the environment’s secret key, and every line names the contact it belongs to:
Everything after that is the same machinery: the name matches a step, the step is satisfied for that person. The only difference is who sent it. 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:
Two texts, and the pair is the feature. The same line means different things before and after. Pending, the useful sentence is a reason or an instruction; done, an instruction is noise and what is useful is what it bought. One field would force you to write a sentence that is wrong half the time — and a sentence that is wrong half the time is one nobody writes at all. Both are optional and independent. 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 the track() 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:
Nothing about that is a write. The event travels the ordinary way, the API stores it, and the bus is still what satisfies the step — this only tells whatever is on screen that somebody did the thing, seconds before the truth arrives. The guide re-reads itself twice to replace the guess with the answer, and if the fact never lands the check is taken back: an event can be dropped under load, refused for its shape, or belong to a visitor nothing records progress for, and a check that stayed would be the widget lying for the rest of the session. The optimistic mark never decides anything either. 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:
The handler runs before the link and returning 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 with unlocks_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:
That reads with ?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.
An anonymous visitor gets 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

An entitlement step reports 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 is engagement: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.