Skip to main content
A banner is what you say to somebody inside your product, where they already are: the maintenance notice, the trial ending in three days, the migration deadline, the sale strip. It is the shortest thing in this API — a title, sometimes a button — and almost all of its design is about where it draws and when it stops.

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:
In a Next app, import it from @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 highest priority 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

Every slot comes back in one call, so a page with a header strip and a billing notice costs one round-trip. Branch on 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.