Script tag
No bundler, no React: your engine serves @hogsend/js as one script at /hogsend.js. Paste the tag and get capture, identity, attribution, feed, preferences, flags, banners and toasts on any page.
Every Hogsend engine serves the browser SDK at GET /hogsend.js. It is the same @hogsend/js client the React layer uses, built as one self-booting file (about 11 KB gzipped) with two vanilla DOM renderers on top. Webflow, Framer, Astro, WordPress, a static HTML page: one tag.
Install: the loader (recommended)
Paste this in <head>. It defines window.hogsend immediately with queued capture, identify, group, setUserToken, reset, resetGroups, flush, connect and captureRef, then loads the SDK asynchronously. Calls made before the script arrives replay in order on boot, so any inline script or tag manager can call hogsend.capture(...) from the first byte.
<script>
!function(w,d,c){if(w.hogsend&&w.hogsend._q)return;var q=[],s={_q:q};
["capture","identify","setUserToken","reset","group","resetGroups","flush","connect","captureRef"]
.forEach(function(m){s[m]=function(){q.push([m].concat([].slice.call(arguments)))}});
w.hogsend=s;var e=d.createElement("script");e.async=!0;e.src=c.host+"/hogsend.js";
for(var k in c)k!=="host"&&(e.dataset[k]=c[k]);d.head.appendChild(e)}
(window,document,{host:"https://api.example.com",key:"pk_..."});
</script>The config object takes the same keys as the tag attributes below (key, userId, userToken, pageview, connect, datalayer, datalayerWatch) in camelCase, e.g. {host, key, pageview: "true"}.
hogsend.capture("pricing_viewed", { plan: "team" });
hogsend.group("company", "acme.com");Methods that return a value (getDistinctId, feed(), preferences(), ui.*) are not stubbed: they exist once the SDK has booted. Wait for hogsend:ready for those:
<script>
document.addEventListener("hogsend:ready", ({ detail: hogsend }) => {
hogsend.ui.banner("#announcement");
});
</script>Install: the plain tag
One tag, no loader. Simplest when nothing on the page needs to call the SDK before it loads.
<script
src="https://api.example.com/hogsend.js"
data-key="pk_..."
defer
></script>data-key is a pk_ publishable key (mint one as in the Quickstart, with the page's origin in allowedOrigins). The engine host is read from the script's own src, so there is nothing else to configure. On load the script assigns the client to window.hogsend and dispatches hogsend:ready on document.
With the plain tag, code that must run before load can still queue by hand: window.hogsend = window.hogsend || { _q: [] }; window.hogsend._q.push(["capture", "landing_viewed"]);.
Unknown queued methods and rejected calls log a console.warn; nothing throws into your page. A second copy of the tag on the same page is ignored with a warning, so a duplicate embed never doubles your events.
Attributes
| Attribute | Default | Effect |
|---|---|---|
data-key | required | Publishable key. Without it the script logs a warning and does nothing. |
data-host | origin of src | Engine URL. Set it when the script is proxied or copied to another host. |
data-user-id | Known user id for a logged-in page. | |
data-user-token | Server-minted proof for data-user-id (see Identifying users). | |
data-pageview | off | "true" (or bare) captures $pageview on boot with $current_url, $pathname, title, referrer. |
data-connect | off | Open the realtime channel on boot so feed items and toasts arrive live. ui.toasts() opens it for you. |
data-datalayer | off | "push" mirrors every captured event to window.dataLayer as { event: "hogsend.<name>", hogsend: { event, properties } }. |
data-datalayer-watch | Comma-separated allowlist of dataLayer event names to ingest as Hogsend events. See dataLayer. |
window.__HOGSEND__ = { publishableKey, apiUrl } set before the tag is also honoured; data-* (and the loader config) wins over it.
Realtime is polling. A browser cannot set an Authorization header on EventSource, so the SSE transport is not selected here.
What window.hogsend can do
It is the full @hogsend/js client plus a ui namespace. Everything below works with no React on the page.
Identity
Anonymous works out of the box: the SDK mints and persists an anonymous id, and every event and feed read is scoped to it. To act as a known user, your backend mints a short-lived userToken for the id its own session resolved (generateUserToken from @hogsend/engine; the scaffold ships a reference route at POST /v1/example/user-token), the page fetches it after login, and hands both to the SDK:
const { userToken } = await fetch("/api/hogsend-token").then((r) => r.json());
hogsend.setUserToken(userToken);
hogsend.identify("user_123", { plan: "team" }); // traits are optionalOr on the tag / loader config for a page that already knows the user: data-user-id + data-user-token. hogsend.reset() on logout returns to a fresh anonymous id.
identify() without a userToken is refused. A pk_ key is public, so the engine will not let a browser claim an arbitrary user id: the identify returns 403, and because the client now believes it is that user, every capture after it is refused too until reset() or a token arrives. Mint the token first, or stay anonymous. This is the same rule as the React provider; the reasoning is in Identifying users.
getDistinctId(), getContactKey() and isIdentified() read the current state. Anonymous history is folded onto the contact server-side once the identified events arrive.
Events, groups, attribution
hogsend.capture("checkout_started", { cart: 3 });
hogsend.flush(); // force-send the queue
hogsend.group("company", "acme.com"); // associate later events
hogsend.getAttributionFields(); // utm_*, click ids, referrerArrival attribution runs on boot: utm_* parameters, click ids (gclid, gbraid, wbraid, fbclid, msclkid, ttclid, li_fat_id, twclid), the landing page and the referrer are stored in localStorage and stamped on every event, and an attributed landing fires campaign.arrived once per (signature, visitor, UTC day). A landing page on a page builder gets attribution with no extra code. Every capture goes through POST /v1/events and can trigger a journey.
Feed, preferences, flags
const feed = hogsend.feed(); // or hogsend.feed("in_app")
const { items, metadata } = await feed.fetch(); // metadata.unreadCount, ...
feed.on("items", () => render(hogsend.getSnapshot().feeds));
await feed.markAsRead([id]); await feed.markAllAsRead();
await feed.markAsArchived([id]); await feed.fetchNextPage();
hogsend.connect(); // live updates (poll)
const prefs = hogsend.preferences();
await prefs.get(); await prefs.lists();
await prefs.setPreference("newsletter", false);
await prefs.subscribe(listId); await prefs.setUnsubscribedAll(true);
hogsend.getFlag("new-pricing"); hogsend.flags();subscribe(listener) + getSnapshot() expose the reactive store (identity, groups, flags, feeds, banners) for your own rendering.
Vanilla UI: hogsend.ui
Two DOM renderers ship in the drop-in for the surfaces that need one. Styles are injected once and read the same --hs-color-* tokens as @hogsend/react theming, so overriding a token themes both.
<div id="announcement"></div>
<script>
document.addEventListener("hogsend:ready", ({ detail: hogsend }) => {
hogsend.ui.banner("#announcement", { slot: "top" }); // current banner for the slot
hogsend.ui.toasts(); // fixed toast stack, bottom-right
});
</script>ui.banner(target, { slot = "top", autoCapture = true, dismissLabel })renders the highest-priority undismissed banner intotarget(selector or element), with the action link and a dismiss button. Emitsbanner.shownonce per banner,banner.clickedandbanner.dismissedthrough the client. Returns{ destroy() }.ui.toasts(target?, { position = "bottom-right" })renders the toast stack; with notargetit appends its own fixed container tobody. Opens the realtime channel so server-sent toasts arrive. Returns{ destroy() }.
Classes: .hs-banner, .hs-banner__title/__body/__action/__dismiss, .hs-toasts[data-position], .hs-toast, .hs-toast__title/__body/__action/__dismiss. Not in the drop-in: the notification bell and feed list. Render those from hogsend.feed() or use @hogsend/react.
The same renderers are importable for bundled non-React apps:
import { mountBanner, mountToasts } from "@hogsend/js/ui";Caching
The engine sends Cache-Control: public, max-age=300, stale-while-revalidate=86400 and a content-hash ETag. A page revalidates at most every five minutes and gets a 304 when nothing changed. Deploying an engine with a newer @hogsend/js reaches every page within minutes with no cache-busting URL. Set HOGSEND_JS_PATH to serve a file from another location.
The response carries Cross-Origin-Resource-Policy: cross-origin so the tag loads from any site, and the SDK talks back to the engine with the pk_ key over CORS. The engine is a first-party host for your marketing site: api.example.com, not a third-party CDN.
When the route 404s
/hogsend.js is served when @hogsend/js is installed next to @hogsend/engine. New scaffolds include it. On an older app:
pnpm add @hogsend/jsNothing is bundled into your API: the engine reads node_modules/@hogsend/js/dist/hogsend.js at boot, the same way it serves the Studio.