Hogsend is brand new.Try it
Hogsend
Client-side SDK

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.

EventEmitted byProps
inapp.preference_changedsetPreference / subscribe / unsubscribe{ categoryId, subscribed }
inapp.item_seenmarkAsSeen{ feedItemId, feedId }
inapp.item_readmarkAsRead{ feedItemId, feedId }
inapp.item_archivedmarkAsArchived{ feedItemId, feedId }
inapp.item_unseenmarkAsUnseen{ feedItemId, feedId }
inapp.item_unreadmarkAsUnread{ feedItemId, feedId }
inapp.feed_clearedmarkAllAsRead (once){ feedId }
inapp.item_clicked<NotificationFeed> item click{ feedItemId, feedId, actionUrl? }
inapp.feed_opened<FeedPopover> on open{ feedId }
inapp.toast_showntoasts().show{ toastId, type }
inapp.toast_dismissedtoasts().dismiss{ toastId }
inapp.toast_clickedtoasts().click{ toastId, actionUrl? }

Author banner journeys on these, not on the inapp.* events a banner:<slot> feed emits internally.

EventEmitted byProps
banner.shown<BannerView> first render of current (when autoCapture){ slot, bannerId }
banner.clickedbanners().click / <BannerView> click{ slot, bannerId, actionUrl? }
banner.dismissedbanners().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` // markAllAsRead

The 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 sessionStorage guard); the ingest idempotencyKey (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: false in 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: false in the config turns auto-capture off.
  • hogsend.captureRef() is the manual escape hatch for SPAs that route before init (optionally captureRef(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.