Events & the closed loop
The inapp.* and banner.* events each client interaction emits, how they reach journeys via /v1/events, and the sendFeedItem / sendBanner server sends that deliver.
Every client-side interaction emits a first-party event through POST /v1/events. The engine runs it through ingestEvent(): it stores the event row, pushes to Hatchet for journey routing, evaluates exit conditions on active journeys, and fans the event to PostHog. The browser SDK and your server share a delivery API, so a feed item sent from a journey shows up in the bell, and a click on that item triggers the next journey.
The source is stamped "inapp" server-side by the key class — a pk_ publishable key resolves to scope ingest-public, which the ingest pipeline records as source: "inapp". You do not set it from the client.
/v1/events is the same endpoint documented in Data API → Events. The client-side SDK calls it with a pk_ publishable key over the browser-reachable subset; server-side ingest uses an ingest-scoped secret key. Same pipeline, same journey routing.
Event names
inapp.* — feed, preferences, toasts
These fire on feed marks, preference writes, and toast lifecycle. They are journey-usable, but the per-item mark events double as internal bookkeeping.
| Event | Emitted by | Props |
|---|---|---|
inapp.preference_changed | setPreference / subscribe / unsubscribe | { categoryId, subscribed } |
inapp.item_seen | markAsSeen | { feedItemId, feedId } |
inapp.item_read | markAsRead | { feedItemId, feedId } |
inapp.item_archived | markAsArchived | { feedItemId, feedId } |
inapp.item_unseen | markAsUnseen | { feedItemId, feedId } |
inapp.item_unread | markAsUnread | { feedItemId, feedId } |
inapp.feed_cleared | markAllAsRead (once) | { feedId } |
inapp.item_clicked | <NotificationFeed> item click | { feedItemId, feedId, actionUrl? } |
inapp.feed_opened | <FeedPopover> on open | { feedId } |
inapp.toast_shown | toasts().show | { toastId, type } |
inapp.toast_dismissed | toasts().dismiss | { toastId } |
inapp.toast_clicked | toasts().click | { toastId, actionUrl? } |
banner.* — consumer-facing banner triggers
Author banner journeys on these, not on the inapp.* events a banner:<slot> feed emits internally.
| Event | Emitted by | Props |
|---|---|---|
banner.shown | <BannerView> first render of current (when autoCapture) | { slot, bannerId } |
banner.clicked | banners().click / <BannerView> click | { slot, bannerId, actionUrl? } |
banner.dismissed | banners().dismiss / <BannerView> dismiss | { slot, bannerId } |
A banner is a feed item in category banner:<slot>. Clicking or dismissing one also fires inapp.item_read / inapp.item_archived as internal feed bookkeeping (keyed inapp:banner:<slot>:<id>:<type>). Never trigger journeys on those — trigger on the banner.* events.
Mark dedup
Feed marks are optimistic: the SDK patches the local store first, persists via POST /v1/feed/mark, then captures the inapp.* event. To stop the client capture and the server's own emit from producing two events, both use the same idempotency key:
// packages/js/src/feed/index.ts
`inapp:${feedId}:${id}:${eventType}` // per-item marks
`inapp:${feedId}:all:inapp.feed_cleared` // markAllAsReadThe server emits the same event with the same key, so POST /v1/events dedups one of them. markAllAsRead emits exactly one inapp.feed_cleared for the whole batch (not one per item).
Server sends
Two standalone helpers deliver in-app content. Call them from a journey or a route — they are siblings of sendEmail / sendConnectorAction and import from @hogsend/engine, not from JourneyContext.
sendFeedItem
import { sendFeedItem } from "@hogsend/engine";
const result = await sendFeedItem({
recipient: { userId: "user_123" }, // or { email } / { anonymousId }
type: "release_note",
title: "v2 is live",
body: "Realtime feeds now ship in @hogsend/react.",
actionUrl: "https://acme.com/changelog",
blocks: [{ type: "button", label: "Read more", url: "https://acme.com/changelog" }],
category: "in_app", // default "in_app"
});
// result: { feedItemId, recipientKey, suppressed, createdAt }The pipeline resolves the recipient to a canonical key, checks in_app suppression (governed by the in_app list key and unsubscribedAll), inserts the feed_items row, and publishes to Redis feed:<recipientKey> so a connected client sees it. feedItemId is null when the send was suppressed or deduped. In a journey the send is replay-safe; pass idempotencyLabel to disambiguate divergent branches that send the same type.
sendBanner
import { sendBanner } from "@hogsend/engine";
await sendBanner({
recipient: { userId: "user_123" },
slot: "default", // → category banner:default
title: "Trial ends in 3 days",
actionUrl: "/billing",
metadata: { priority: 10 }, // banner ordering: priority desc, then createdAt desc
});sendBanner is a thin wrapper over sendFeedItem that pins type: "banner" and category: "banner:<slot>". Same result shape (SendFeedItemResult). Banner priority is read from metadata.priority.
Bridging existing GTM events
If the site already runs Google Tag Manager, the dataLayer bridge pipes an allowlist of existing window.dataLayer events (sign_up, purchase, …) into this same POST /v1/events pipeline — so instrumentation you already have can trigger journeys with no new page code — and can mirror every captured event back out to GTM as hogsend.<name>.
Closing the loop
A client interaction emits an event; a journey triggers on it; the journey sends the next in-app message. This journey reacts to a feed click:
import { days, hours } from "@hogsend/core";
import { defineJourney, sendFeedItem } from "@hogsend/engine";
export const onChangelogClick = defineJourney({
meta: {
id: "changelog-followup",
name: "Changelog follow-up",
enabled: true,
trigger: { event: "inapp.item_clicked" },
entryLimit: "once_per_period",
entryPeriod: days(7),
suppress: days(7),
},
run: async (user, ctx) => {
await ctx.sleep({ duration: hours(1) });
await sendFeedItem({
recipient: { userId: user.id },
type: "followup",
title: "More where that came from",
actionUrl: "https://acme.com/changelog",
});
},
});inapp.item_clicked carries { feedItemId, feedId, actionUrl? } as eventProperties, so a trigger.where can scope to a specific feed. The same event also fans to PostHog under source: "inapp".
Realtime delivery runs over polling today (GET /v1/feed every 12s). SSE is a seam: EventSource cannot send Authorization: Bearer pk_…, so the publishable gate rejects it. Setting realtime: "sse" silently falls back to poll. See Provider for config.
Identity-asserting calls (acting on a concrete userId) need a userToken minted server-side via generateUserToken. It signs an HMAC over { userId, exp } with BETTER_AUTH_SECRET — server-only (node:crypto). Never call it in a browser or mount it as a route. Anonymous capture works without it.
A pk_ publishable key is fail-closed on origin: the requirePublishableOrIngest gate requires the key's allowedOrigins to list the request Origin. No allowlist, no Origin header, or an unlisted origin returns 403.
Ad-click attribution (campaign.arrived)
When a visitor lands with a click ID (fbclid, gclid, ttclid,
msclkid, li_fat_id, rdt_cid, …) or utm_* params in the URL, the SDK
fires campaign.arrived automatically on init, carrying the click IDs,
UTMs, landing page, and referrer as event properties — the touchpoint the
revenue loop later recovers as click evidence for
conversion feedback (e.g. Meta's fbc needs the
fbclid and the real click timestamp this event records).
- Fires once per landing signature per session (a
sessionStorageguard); the ingestidempotencyKey(campaign-arrival:<anon>:<sig>:<UTC day>) backstops multi-tab races server-side. - Every attributed landing overwrites the persisted last-touch set, even when the event itself dedups.
hogsend.getAttributionFields()returns the current set as a flat map (hs_anonymous_id, click IDs,utm_*,hs_landing_page,hs_captured_at) — built for lead-form hidden fields.captureAttribution: falsein the config turns auto-capture off.- Inert on unattributed landings (no click ID, no
utm_*).
Arrival capture (hs_ref)
When a visitor lands from a tracked link or QR code whose link opted into
arrival attribution, the URL carries an hs_ref param. The SDK captures it
automatically on init: it POSTs the ref to /v1/t/arrive with the session's
identity (the userToken when held — a known contact arrival — else the
anon id) and strips the param from the URL. This powers "did an existing user
scan the door QR?" and the link.arrived journey trigger.
captureRef: falsein the config turns auto-capture off.hogsend.captureRef()is the manual escape hatch for SPAs that route before init (optionallycaptureRef(ref)with an explicitly extracted value).- Inert when the URL carries no
hs_ref; the beacon never throws.
See the link-tracking guide for the engine-side model (opt-in, trust tiers, per-hit stamps).
See Hooks for the client-side calls that emit these events and Components for the UI that wires them.
Theming & customization
Style the @hogsend/react components with CSS variables, classNames, data-* state attributes, asChild, and render props — no Tailwind or CVA dependency.
Video watch-depth tracking
@hogsend/video — a standalone analytics-first player for YouTube, Vimeo, and native HTML5 that emits normalized video.* watch-depth events to Hogsend, PostHog, GA4, or any custom sink.