You choose the place, we fill it
There is no floating bar. Nothing is injected into your layout, and there is no option that would do it. You render the component where your page has room:@userkit/nextjs — the same component, and in
proxy mode the read and the dismissal are
forwarded by the handlers like the rest of the contact surface, with the session in
an httpOnly cookie on your own origin.
The banner names the same slot in the panel, and the two meet by exact string.
Lowercase letters, digits, hyphens and underscores — a name with a capital or a
space is refused rather than normalised, because this string is typed twice in
two different places and case is how that goes wrong invisibly.
The reason is not taste. A widget that placed a fixed strip in the viewport would
cover the header of every product that already has one, on production, the moment
somebody saved a row in a panel. A slot is a place the page’s owner declared has
room, which is the only kind of place a third party may draw in.
Nothing checks that your app still renders a slot — we cannot see your code, and a
warning based on a guess would be worse than no warning. The panel offers the slots
your own banners already use.
One banner per slot
A slot holds one banner at a time. When more than one is a candidate, the highestpriority wins and ties go to the newest.
That is decided in the database, in the same statement that decides everything
else, so the answer is identical for the React component, a fetch from a mobile
shell and the panel’s preview. A client that picked its own winner would be a
second opinion nobody asked for.
Two kinds
text is a title, prose under it, an optional icon and an optional button. The
widget draws it in your palette — your colours, your radius, your font — so it
looks like part of the product rather than something rented.
image is a picture you made, optionally linking somewhere. It has no title by
construction: a headline over an image designed with its own headline is two
headlines. You host the file; the API takes an absolute http(s) URL and stores
nothing else.
Where either one leads is cta_url — the button’s destination on a text
banner, the whole picture’s link on an image one. It takes a path rooted at /
(/plans) or an absolute http(s) URL, the same two shapes an
in-app notification takes, and the path is if anything
the more natural half here: a banner is drawn inside your own product. A scheme
that executes is refused, and so is the protocol-relative //host/path — it reads
as a path and names another host. image_url is the one that stays absolute: a
relative <img src> would resolve against whatever page the widget is drawn on.
The kind cannot change after creation. The two fill different columns, and the
database refuses a row carrying both — so turning one into the other would discard
what somebody wrote with no way back. Create the other kind instead, which also
keeps the dismissal count attached to the thing people actually saw.
body is plain text, not markdown. A banner that needs formatting is a
changelog post with a button pointing at it.
When it shows
Three things decide it, and they answer different questions:active defaults to false: a banner exists while its copy is being written, and
creating one never puts anything in front of anybody. It is also the off switch, and
it is the one to reach for when a strip you regret is already on somebody’s screen —
it takes effect on the widget’s next read, not on a cache expiry.
ends_at is what takes the sale strip down on Monday without anybody remembering.
It is the most valuable field here and the one most often left empty.
Both bounds are RFC 3339 with an offset, compared against the API’s own clock. A
bare date would be a moment in whichever timezone the reader assumed, so “the sale
ends at midnight” would close on Friday for some of your customers and on Saturday
for the others.
Who sees it
segment_id is who it is for, or null for everybody — the same
segment your feature flags, surveys and changelog posts are
aimed at, never a filter of its own.
An anonymous visitor is not a special case: they have a contact like anybody
else, so they see the banners aimed at everybody. That is half of what this module
is for — the maintenance notice on your signed-out landing page.
The audience never travels to the reader. “This banner is aimed at a segment” is
your own analysis of your users, and the person looking at it is not who that is
for.
Closing one
kind, never on a field being empty:
only the fields the kind carries are sent, which is what keeps “a picture with no
alt text” distinguishable from “this is not a picture”.
A dismissal belongs to the person and follows them to every device. It is
idempotent, and closing a banner that does not exist, belongs to another tenant or
was already closed all answer the same 204 — a 404 here would tell anybody
holding a publishable key whether a uuid exists.
dismissible: false is for the notices that are a state of the product rather
than news: “this account is read-only until you confirm your email”. Hiding that
would hide the explanation for what the person is about to run into. Closing one is
still recorded and then ignored, so a client never has to know the rule to avoid an
error — and a banner you later make dismissible is already closed for the people
who tried.
Re-activating a banner does not re-show it to the people who closed it. Saying the
same thing again is a new banner, which is also what makes “how many closed this
one” a number about one announcement.
Reach
The list shows four numbers beside each banner, over the last 30 days:
A dashboard somebody opens eleven times a day is eleven views and one viewer, so the
pair is the point: quoting the first as reach is how a banner looks successful to
nobody.
viewers counts every distinct contact including anonymous visitors, because
a banner is drawn for them too — dropping everyone who has not signed in would leave
out exactly the audience an announcement is usually aimed at.
An impression is the strip crossing the fold, not the component rendering. A
banner below the fold is drawn on every page load and seen by nobody, so <Banner />
reports it from an IntersectionObserver at 50% visibility, once per banner per page
load however many times a single-page app remounts the component.
The counts are null, never 0, when analytics could not be read — zero would say
the banner reached nobody. engagement.available on the response says which it is.
These are your own analytics events: banner.shown and banner.clicked, written
by the widget through the same endpoint your own track() calls use, carrying
banner_id, slot and kind and no copy. They show up in
the explorer like any other event, which is where the
unbounded history lives — the panel’s 30 days is a window, and a banner that ran for
a week in March would otherwise look like it is failing as today’s zeroes pile up.
There is no impressions table on our side, and that is deliberate: a counter written
on every page load belongs where every other page-load counter goes, with the
batching and the abuse policy that endpoint already has. The click-through is counted
beside your link rather than through a redirect of ours — routing every one of your
CTAs through this API would make your buttons stop working whenever we did. The
trade is stated rather than hidden: a click that navigates away before the queue
flushes is a click nobody counted, which is a number slightly low instead of your
product broken during our incident.
What is not here
Email. A banner is drawn inside your product and nowhere else. What you mail is campaigns.Permissions
engagement:manage — owner and admin by default, shared with checklists, the
changelog and surveys. Reads included: what you say to your own users is your own
copy, and no wider permission shows any of it.