> ## 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.

# Onboarding checklists

> A list whose steps are satisfied by facts rather than by ticking, and a funnel that says which number it cannot give you.

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](#what-satisfies-a-step) — 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](#where-a-step-sends-somebody) 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

```ts theme={null}
const checklists = await userkit.getChecklist();
```

```tsx theme={null}
import { OnboardingChecklist } from "@userkit/react";

<OnboardingChecklist />;                          {/* on the dashboard */}
<OnboardingChecklist position="bottom-right" />;  {/* a card in the corner */}
```

`position` takes `inline` (default), `center`, `bottom-right`, `bottom-center` or
`bottom-left` — the
same five the [survey](/en/guides/surveys) and the
[suggestion board](/en/guides/feedback) 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:

```tsx theme={null}
<header>
  <Logo />
  <OnboardingLauncher />
</header>

<OnboardingChecklist position="bottom-right" />
```

`<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

```tsx theme={null}
<OnboardingLauncher
  count={false}              // hides the "6 de 9"; the ring still draws the fraction
  border={false}           // no outline, no ground
  label="Primeiros passos"   // different wording, here only
/>
```

`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**.

| Kind            | Satisfied by                                                                              | Guarantee                 |
| --------------- | ----------------------------------------------------------------------------------------- | ------------------------- |
| `event`         | A fact UserKit publishes about a contact — `contact.identified`, `customer_member.joined` | The outbox: at-least-once |
| `product_event` | An event **your** product sends through `track()`                                         | Weaker — see below        |
| `entitlement`   | A feature the contact's team holds, decided on every read                                 | Nothing recorded          |

## 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:

```json theme={null}
{ "key": "billing", "title": "Connect your payment provider",
  "kind": "product_event", "event_type": "billing.connected",
  "scope": "customer" }
```

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](/en/api-reference/customer-teams/create-or-mirror-a-team).

`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:

```ts theme={null}
userkit.track("project.created", { id: "prj_1" });
```

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.

<Note>
  **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.
</Note>

**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`](/en/api-reference/analytics/track-events-from-your-backend)
with the environment's secret key, and every line **names the contact** it
belongs to:

```bash theme={null}
curl -X POST $API/v1/track \
  -H "Authorization: Bearer uk_sk_live_…" \
  -H "Idempotency-Key: nightly-2026-08-02" \
  -d '{"events":[{"id":"0197a3a6-…","name":"invoice.paid",
       "external_id":"usr_42","occurred_at":"2026-08-02T14:07:31Z"}]}'
```

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:

```json theme={null}
{
  "key": "domain",
  "title": "Connect your domain",
  "kind": "product_event",
  "event_type": "domain.connected",
  "action_url": "/settings/domain"
}
```

`action_url` takes a **path in your own app** (`/settings/domain`) or an
absolute `http(s)` URL — the same pair a
[notification](/en/guides/notifications) 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:

```json theme={null}
{
  "title": "Connect your domain",
  "hint_pending": "Tell us about your company to unlock this task.",
  "hint_done": "Your charges now go out on your own domain."
}
```

**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:

| State   | Field          | Reads as                                             |
| ------- | -------------- | ---------------------------------------------------- |
| Pending | `hint_pending` | Why it matters, or what to do                        |
| Locked  | `hint_locked`  | Why the door is there — the row shows only a padlock |
| Done    | `hint_done`    | What it bought — or nothing                          |

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**:

```ts theme={null}
userkit.track("content.published");
// the step waiting on that name goes green now; the fact confirms it in a
// couple of seconds
```

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()`:

```ts theme={null}
userkit.signalChecklist();   // asserts nothing; just asks again
```

### 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:

```tsx theme={null}
<OnboardingChecklist
  onStepAction={(step) => {
    if (step.key === "invite") {
      openInviteModal();
      return true; // handled — don't follow the link
    }
    // anything else: let the step's own action_url happen
  }}
/>
```

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.

<Note>
  **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:

  ```tsx theme={null}
  onStepAction={(step) => {
    if (step.key !== "tour") return;
    startTour();
    userkit.track("tour.started");   // this is what turns the step green
    return true;
  }}
  ```
</Note>

## 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:

```tsx theme={null}
<OnboardingChecklist alwaysShow />
```

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](/en/customer-auth/session-tokens) 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.

```json theme={null}
{
  "checklists": [
    {
      "id": "…",
      "name": "Get started",
      "completed": false,
      "steps": [
        { "key": "verify", "title": "Verify your email", "kind": "event", "position": 1, "satisfied": true, "satisfied_at": "2026-07-30T10:12:00Z" },
        { "key": "invite", "title": "Invite a teammate", "kind": "event", "position": 2, "satisfied": false, "satisfied_at": null }
      ]
    }
  ]
}
```

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](/en/guides/segments), 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

```http theme={null}
GET /v1/organization/checklists/{id}/completion
```

```json theme={null}
{
  "audience": 812,
  "completed": null,
  "steps": [
    { "key": "verify", "kind": "event", "satisfied": 640, "materialized": true },
    { "key": "domain", "kind": "entitlement", "satisfied": null, "materialized": false }
  ]
}
```

**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](/en/guides/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.
