npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@thg-agentic/hyper-personalisation-web

v3.35.0

Published

Browser SDK for the THG Hyper-Personalisation event specification: event tracking from a write key and remote settings, strict CMP-agnostic consent, a GA4 dataLayer bridge and in-session audience evaluation. Dependency-free ESM on the Segment wire protoco

Readme

@thg-agentic/hyper-personalisation-web

The browser SDK for THG Hyper-Personalisation, a customer data platform. It implements the event specification (spec 1.22, stamped on every event as context.spec.version) for any storefront: it sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads the GA4 dataLayer the site already pushes to, and has no runtime dependencies.

The core knows standards only: GA4 and Segment. A preset teaches it one commerce platform's conventions; none ships with the SDK, and you can write your own in about 20 lines.

npm i @thg-agentic/hyper-personalisation-web

Or, without a bundler, the CDN script.

Integrating in one call

import { bootstrap } from '@thg-agentic/hyper-personalisation-web/bootstrap';

const hp = await bootstrap(
  { writeKey: 'hp_pk_live_…' },
  {
    consent: { provider: 'onetrust' },
    identityCookies: { sessionId: 'session_id' },
    getCurrency: () => getCookie('currency'),
    pageType: 'product',
    identity: { userId: customer?.id, emailSha256: customer?.emailSha256 },
    dataLayer: {},
  },
);

The site configures one value: the key. writeKey is the source's publishable key (hp_pk_live_… in production, hp_pk_test_… elsewhere), safe in page source. It names the organisation, the property and test or live, so there is no host, property or switch to set per environment, as with Stripe's or LaunchDarkly's keys. apiHost is optional, only for a first-party proxy (a bare host or an https:// origin); without it the SDK uses the HP API (DEFAULT_API_HOST, https://hp.thgingenuity.com since 3.18, for test and live keys alike; see Default host). Everything tenant-level comes from GET {apiHost}/v1/projects/{writeKey}/settings, so switching a site on or off, or changing what it collects, is a settings change and never a deploy:

| From the settings (hp) | | |---|---| | enabled | Anything but true leaves the SDK off: no events, no cookie, nothing stored. | | propertyId | context.source.property_id: the source's default property. A propertyId the page passes wins over it (since 3.20); a key scoped to several properties with no default needs one. | | environment | context.source.environment. | | consent.purposes | The purposes the property asks for. Only those are reported; the rest read as unanswered. Strict is the only mode. | | queryParamAllowlist | Query parameters kept on page URLs (plus campaign parameters). The ingest gateway applies the same list to every URL that arrives, so an older SDK cannot store more. | | anonymousId | Where device ids come from, chosen per source in the console (since 3.6): { source: 'site_cookie', cookie } or { source: 'hp_cookie' }. See Device ids. | | apiHost | Where events go when the page set no apiHost (an https:// host only). A page's own apiHost is a proxy and carries events too (since 3.20). | | plan.track | Segment's tracking plan: an event with enabled: false is dropped at the source. The console's blocked events arrive here. | | sampleRate | The share of visitors events are sent for, by a hash of the anonymous id (since 3.18). Absent: 1. | | onsite | The live onsite forms ({ forms, capture }): triggers, pages, caps, consent texts (an SMS one for a form that collects texts), prize labels. Never creatives or odds. Used only by a page that passes onsite; see Onsite forms. | | debug | { enabled, tokenSha256 }: console logging for everyone, or for a session opened with ?hp_debug=<token> whose SHA-256 is tokenSha256 (since 3.18). ?hp_debug= ends it. |

The page configures only what the page knows (the second argument):

| Option | | |---|---| | consent | Required. The page's CMP: { provider: 'didomi' \| 'onetrust' \| 'cookie' \| 'forwarded' \| 'static', ...adapterOptions }, or a consent adapter of your own. A preset may supply defaults for a provider. | | identityCookies | { anonymousId, sessionId }: the names of your own first-party device and session cookies. sessionId is always read from here; without it the SDK keeps its own session (see Session ids). anonymousId is read only when the settings name no device cookie: see Device ids. | | autocapture | false, or per tracker { promotions, productLists, recommendations, exposures }, each false or its options. All on by default: they report only what the page tags. | | identity | The signed-in visitor, { userId, emailSha256, token }. userId goes on every event; the identify is sent once per session and user, and again once a token arrives. token is the identity token your server signs for the user on this page view (3.26, see Verified sign-ins). | | getCurrency, getShipToCountry, getLocale | Read per event; see below. | | preset | A platform preset, passed in by the host. | | pageType | context.page.type of this page's page view. | | dataLayer | { name?, ignore? }: connect window[name ?? 'dataLayer'] through the preset's mapping. Absent: no bridge. | | propertyId | Only for a key scoped to several properties, whose settings name none. | | consentCookieDomain | Domain for the consent cookie, e.g. .acme.com to share it with checkout. | | storageNames | Rename what the SDK stores; see Storage. | | debug | Log every event and delivery problem. Also on when the settings' debug says so. | | settings | Development and tests only: use this settings response, never fetch or cache one. | | extend | createAnalytics options that are functions, such as getRecommendations or getExtensions. | | onsite | true, or the onsite options: start onsite forms when the settings have a live one. Absent: the runtime is never loaded. |

What bootstrap does:

  • Resolves to the client, or null when the key is not enabled or the options are invalid. Invalid options log one console warning listing every problem. It never rejects, and a second call on the same page returns the first client.
  • Settings. The last good answer is cached in localStorage, only once analytics is granted, and forgotten on a refusal. A page with a cache starts from it at once and asks the API again for the next page; a first visit waits for the API (which answers Cache-Control: max-age=10 and an ETag, so the browser revalidates for a 304), for at most 3 seconds (3.24.2). A key the API switches off clears the cache, so the next page is off too. Onsite forms are the exception: they start from the fresh answer when it comes within a second, else from the cache, so a form set live, changed or stopped shows on the next page.
  • An API it cannot reach never turns collection off (3.25). A first visit whose settings fail (a 5xx, 408 or 429, a timeout, an answer that is not settings; a 4xx is the key refused, and the page is off) starts on built-in defaults: collection only, with no property (the ingest stamps the key's, so a key scoped to several properties needs the page's propertyId), campaign parameters only in URLs, the device id from identityCookies.anonymousId else the SDK's own cookie, no sampling, no tracking plan and no onsite forms. Consent is still the CMP's, strictly. They are never cached, so the next page asks the API again, and what the ingest cannot take waits in the retry buffer (see Choosing a delivery path).
  • Consent, identity, one page view, the dataLayer bridge and a once-per-session identify, as configured. A page restored from the back/forward cache runs no script again, so bootstrap reads consent again, then sends its back_forward page view on pageshow and reports its lists afresh (3.22.2). A host on createAnalytics sends that view itself: page({ navigation_trigger: 'back_forward' }) on a pageshow whose persisted is true.
  • Never waited for. Call hp (@thg-agentic/hyper-personalisation-web/hp) from anywhere, at any time, and never await or flush it: calls made before the client starts wait, each with the messageId and time it was made at, and are sent in order once it has. The page view is stamped when the page was navigated to, so none of them precedes it (3.18). A page that navigates first (a sign-in that redirects at once) carries them in sessionStorage, and the next page's bootstrap sends them once, before its own page view (since 3.9). The carry is a device write, so it is made only once this device has granted analytics, or unanswered with holdAcrossPages; the next page's consent decides whether they are sent, and a refusal drops them. Once started, the SDK hands its queue to sendBeacon at pagehide itself.

Multi-currency and multi-destination tenants

One property can trade in several currencies and ship to many countries: a visitor on one site can pick EUR and a German delivery address halfway through a visit. So context.currency, context.ship_to_country and context.locale are read per event, never fixed per tenant.

  • Getters. Pass getCurrency, getShipToCountry and getLocale (to bootstrap, createAnalytics or cdpPlugins). They are called for every event. createAnalytics and cdpPlugins also take static currency / shipToCountry / locale, which a getter wins over whenever it returns a value.
  • Validation. Values are uppercased and validated on read: currency ^[A-Z]{3}$, ship-to country ^[A-Z]{2}$, locale a BCP-47 tag such as en-GB (a platform's en_GB is normalised to en-GB). An invalid value is dropped (sent as null), never emitted, so it cannot quarantine the event.
  • Prices keep their own currency. A GA4 push carries the currency its prices are in.
    • Cart Viewed and Checkout Started take it as properties.currency.
    • Every event mapped from that push also gets it as its context.currency, so a price pushed in EUR is never stamped with the site's current GBP.
    • A push with no currency falls back to the site's current one.
  • Country is not here. context.country (where the visitor is) is derived at the edge, so there is deliberately no getCountry.

The lower-level API

import { createAnalytics, getCookie, hashEmail } from '@thg-agentic/hyper-personalisation-web';
import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
import { connectDataLayer } from '@thg-agentic/hyper-personalisation-web/datalayer';

const analytics = createAnalytics({
  writeKey: 'hp_pk_live_…',
  propertyId: 'acme-gb',      // stable id of this site
  currency: 'GBP',
  locale: 'en-GB',            // the site's locale, not the browser's
  consent: didomiConsent(),   // strict: nothing is sent until analytics is granted
  consentCookieDomain: '.acme.com',
  queryParamAllowlist: ['colour', 'size', 'sort'],
  getAnonymousId: () => getCookie('device_id'),
  getSessionId: () => getCookie('session_id'),
});

analytics.page({ type: 'product' });
connectDataLayer(analytics);   // every GA4 ecommerce push becomes a spec event
analytics.identify({ userId: customer.id, email_sha256: await hashEmail(customer.email) });

createAnalytics takes everything as options and fetches nothing: it is what bootstrap calls once the settings have answered. Add the named methods (analytics.productViewed(...) and so on) wherever a page has something the dataLayer does not.

Since 3.1 (spec 1.2) there is a named method for every Segment Ecommerce V2 event, including the five spec 1.2 restored:

| Method | Event | Notes | |---|---|---| | wishlistProductAddedToCart | Wishlist Product Added to Cart | A wishlist conversion. Call productAdded (with surface: 'wishlist') for the cart add as well: every cart-add metric reads Product Added | | productShared / cartShared | Product Shared / Cart Shared | share_message and recipient are personal data; the ingest splits them off before anything is stored | | couponEntered | Coupon Entered | The attempt, before the outcome. The code goes in coupon_name (Segment's coupon_id is accepted too) | | couponApplied / couponDenied / couponRemoved | Coupon Applied / Denied / Removed | Sendable from the browser since spec 1.2. Report them from ONE producer per property, the storefront or the service that handles the code, or every code is counted twice |

Since 3.5 (spec 1.4):

| Method | Event | Notes | |---|---|---| | cartUpdated | Cart Updated | Pass the basket your cart API returned after the change: every line with its TOTAL quantity, value excluding tax, the codes, a reason and an initiator. Send it alongside productAdded / productRemoved, never instead. item_count is summed from the lines when omitted; value is never derived, because line prices include tax where the market shows them so | | productVariantSelected | Product Variant Selected | On the shopper's pick of a size, colour or flavour, not on page load. product_id the master, sku the variant now selected, changed_attributes what changed | | backInStockSubscribed | Back In Stock Subscribed | kind back_in_stock, price_drop or launch, for one product (the out-of-stock sku). Hash any email or phone with hashEmail() / hashPhone() | | newsletterUnsubscribed | Newsletter Unsubscribed | An opt-out made on the site. The ESP reports its own opt-outs as Email Unsubscribed | | checkoutFailed | Checkout Failed | stage and reason_kind are required. payment_method is the kind of instrument, never card data |

identityLinked and slotEngaged complete the set: every spec event a browser may send has a named method, and hp.track('<Spec Event>', …) is typed by the event's schema as the client's track is.

New optional fields on existing methods: search_request_id on productsSearched, productClicked and productAdded (your search engine's id for the result set, such as Algolia's queryID); initiator (shopper or system) on productAdded and productRemoved; products[].subscription on basket and order lines; channel and phone_sha256 on newsletterSubscribed. A search page sends productsSearched with its results and never a productListViewed for the same grid.

Since 3.19 (spec 1.12), a search box's suggestions are their own event:

| Method | Event | Notes | |---|---|---| | productsSearched | Products Searched | A submitted search and the results page it produced: query, the results in products, and pagination with total_results | | searchSuggestionsViewed | Search Suggestions Viewed | The products a search box suggested for a partly typed query, sent once per suggestion set with the products seen (position in the suggestions). Give each set its own search_request_id, and a click on one of its products carries that id on productClicked. Never productsSearched: suggestions and results are ranked by different models and are measured apart |

Options

| Option | Default | | |---|---|---| | apiHost | the HP API | Only for a first-party proxy. The settings and the events both go to it: events are POSTed to ${apiHost}/v1/batch (since 3.20 a host in the settings never overrides it). | | writeKey | 'web' | The source's publishable key, hp_pk_…. Sent in the batch body. | | propertyId | required | context.source.property_id. | | channel, market, environment | null | The rest of context.source. | | currency, getCurrency | null | ISO-4217 currency of this request; the getter is read per event and wins. A per-call override wins over both. | | shipToCountry, getShipToCountry | null | ISO-3166 alpha-2 destination, per event. | | locale, getLocale | null | The storefront locale served (BCP-47), per event. | | country | null | Visitor country; the ingest derives it at the edge and overwrites this. | | consent | none | A consent adapter (see below). Makes the SDK strict. Without one the SDK sends under your lawful basis (basis: 'host') and says so once in the console (3.19). | | consentTimeoutMs | 5000 | How long the CMP has to answer before the status is unknown. Events stay held, in memory, until it answers. | | consentCookieDomain | host only | Domain for the consent cookie. | | onConsentStatus | none | Called with the gate's status: granted, denied or unknown (timed out). | | storageNames | thg_hp_* | Rename what the SDK stores; see Storage. | | plan | none | A Segment tracking plan, { track: { [event]: { enabled } } }; __default covers unlisted events. | | sampleRate | 1 | The share of visitors to send for, by a hash of the anonymous id salted hp_sample; a visitor with no anonymous id is sent for. A consent withdrawal is always sent. Since 3.18. | | queryParamAllowlist | none | Keep only these query params (plus campaign params) in context.page, and in a clicked link's href, which leaves another site's link as origin and path only (3.22.2). Patterns are anchored and case-insensitive. | | queryParamBlocklist | [] | Strip these params. Ignored when an allowlist is given. | | sensitiveParamAllowlist | [] | Whatever else is configured, a param is always dropped from every reported URL when its name contains email, token, password, secret, auth, session, phone, address, card, customerid and the like (see SENSITIVE_PARAM_FRAGMENTS in src/context.js), or when its value is an email address or a card-length number — campaign params and the referrer included. This exempts names from the first rule, for a false positive such as an author filter; nothing exempts a value. | | identityToken | none | The identity token your server signed for the user getUserId names, read once: on each identify as context.identity_token and with each onsite decision, until reset(), a sign-out or another user's identify (3.26, see Verified sign-ins). | | getAnonymousId, getUserId, getSessionId | none | Identity accessors. Without getSessionId the SDK keeps its own session (see Session ids); a missing or empty device id gives way to the SDK's own (see Device ids). | | getExperiments, getAudiences, getDecisions, getRecommendations, getExtensions | none | Read per event into the matching context field. Without getDecisions, context.decisions is what recordExposure last recorded per slot. | | pageType | null | context.page.type before the first page() call. | | onEvent | none | Observer for every event that will be sent. | | debug | false | Logs every event and delivery problem to the console. | | validate, throwOnInvalid | none | Inject validateEvent from @thg-agentic/hyper-personalisation-spec in development. | | transport | built in | Replace delivery, for example in tests. |

The client has one method per browser event in the spec, plus track, page, identify, reset, signedOut, flush, servingHeaders, and the read-only consent and lastPageMessageId. Every method takes an optional last argument of context overrides, either as the overrides or as { context }. An event method returns the event, or null when it is not sent (the tracking plan disables it, the visitor is outside the sample, or beforeSend drops it). Declarations are in types/, and event properties are typed from the registry.

  • No method throws into your page (3.19). A call that fails, whatever it was passed, logs a warning and answers what a client that cannot decide answers, failing closed: an event method null, inHoldout() true, personalisationGroup() 'excluded', servingHeaders() and recommendationContext() {}, flush() a resolved promise. The one exception is throwOnInvalid, which you ask for in development.
  • One event is at most 55 KB of JSON (3.19), so that it can always travel in a keepalive request at unload. A bigger one is not sent and its method returns null; with debug the SDK logs it. Segment's limit is 32 KB; this one is higher so that a long product list still fits.
  • A sign-out forgets the visit (3.19). signedOut() (or track('Signed Out'), since 3.20), or reset() while a user is signed in, also forgets the SDK's session, the decisions in force and the recommendation context, so the next person on a shared device inherits none of them. reset() with nobody signed in changes nothing else, so a host that calls it on every page keeps its session. The SDK's own device id starts afresh too, as Segment's analytics.reset() does; a device id from the site's own cookie (getAnonymousId, or a site_cookie source) stays, because the site owns it. On one page, identify with a different userId than an earlier identify named is a sign-out first; the same user again, a first identify, a getUserId cookie or bootstrap's identity option of another form changes nothing (with debug, a mismatch with identity is logged). Across pages, call signedOut() or reset() at the sign-out.
  • A refusal forgets the visit at once (3.19): the kept recommendation context and the session signals go when consent is refused or personalisation withdrawn, even if beforeSend drops the Consent Updated event. Before the client starts, hp.recommendationContext() reads the kept visit only when the consent cookie grants personalisation.
  • Retries wait out Retry-After on a 429 or 5xx (3.19), as well as backing off exponentially with jitter, up to 30 seconds. Cross-origin, the HP ingest exposes the header to the page (Access-Control-Expose-Headers: Retry-After, from the ingest-gateway-v1.13.0 release); a same-origin proxy needs nothing. Where the header cannot be read the backoff alone applies.
  • A refusal sends only the withdrawal (3.20): what was collected under the grant and not yet sent is dropped, including batches already cut while one was on the wire.
  • identify sends only the spec's traits (3.20): email_sha256 and phone_sha256 (SHA-256 hex, or base64 sent as hex), accepts_marketing (boolean), gender (string) and created_at (RFC 3339 date-time). Anything else, a raw email or phone included, is not sent; with debug the dropped keys are logged.
  • A listing must name its own category (3.22): a Product List Viewed without one, and its facets, no longer count as browsing (listViews, browseViews, browseShare), with or without browse_categories, and product views after it credit no category page. Send category on every category listing. See Visit traits.
  • The tracking plan covers every call (3.20): it is keyed as the gateway keys it, a track's event name or otherwise the call's type (page, identify), so an allow-list that omits page sends no page views. A consent withdrawal is sent whatever the plan says.

Calling the serving API

Recommendations, decisions and audience membership personalise only for a visitor who granted personalisation. Every request to them carries the visitor's consent in the hp-consent header, in the consent cookie's serialisation (a=1&p=0):

const res = await fetch(`https://api.example.com/v1/recommendations?placement=pdp`, {
  headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
});

Without p=1 the API answers with the non-personalised response. servingHeaders(state) from ./consent builds the header from any consent state.

The visit, for a rail that reacts to it

Since 3.11, hp.recommendationContext() returns what this visitor did earlier in the visit, as the recommendations API's session parameters: viewed (products viewed, clicked or added), basket, q (searches) and impressed (products shown and not engaged with, as id:count), newest first and within the API's limits. Append it to the rail request, or send it to the server that makes it:

const visit = new URLSearchParams(hp.recommendationContext());
const res = await fetch(`https://api.example.com/v1/recommendations?recipe=similar_items&sku=${sku}&${visit}`, {
  headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
});

The SDK keeps the visit from the events it sends (Product Viewed, Clicked, Added, Removed, Added to Wishlist, Products Searched, and the products of a list viewed or filtered), in this browser's storage for the session, only while the visitor allows personalisation; withdrawing it forgets the visit. The call is synchronous and never throws. Before the client has started on a page it answers with the visit an earlier page kept (for 30 minutes from its last event), so a rail requested at page load, before the SDK has downloaded, still carries it. It returns {} without personalisation consent, and for a client whose host decided consent itself (no consent adapter), which the API would not personalise. createAnalytics clients have it as client.recommendationContext(); recommendationContextFrom(signals) from ./session builds the same from session signals you keep yourself.

Holdout and exposure

Since 3.2 (spec 1.3). A property can hold a share of its shoppers out of personalisation, so its uplift can be measured. The settings carry each property's holdout, and the edge, the public API and the SDK all hold out the same shoppers (by a hash of the anonymous id; spec/assignment/holdout.json has the vectors). A held-out shopper gets the default everywhere. Since spec 1.6 a property may also set a ramp, the share of all shoppers personalised; shoppers above holdout + ramp are excluded and get the default too, with no exposure. Personalise in the page only for the personalised group:

if (hp.personalisationGroup() === 'personalised') applySessionAudiences();

inHoldout() means "show the default": true for held-out and excluded shoppers alike, and before the client has loaded, so the older if (!hp.inHoldout()) gate stays safe under a ramp. personalisationGroup() gives the exact group.

A membership, decisions or recommendations response that personalised the page, or would have for a held-out shopper, carries an exposure block. Record it after rendering, so both sides of the holdout are counted on the same trigger. The SDK sends one Personalisation Exposed per token:

const body = await res.json();
render(body);
hp.recordExposure(body);

For a decisions response the exposure lists each slot's decision_id, which is what marks that decision shown (a click is a Slot Engaged with engagement: 'click' and the same decision_id). It is one event per response, so record it once the slots are rendered: a slot rendered below the fold counts as shown.

Since 3.7, recording a decisions response also puts its decisions in force. Every later event in the browser session carries them as context.decisions, latest per slot, so a basket is credited to the slot shown before it. Send the session id your events carry as session_id on /v1/decisions too: the reward joins a basket to a decision within one session. Pass getDecisions to supply context.decisions yourself instead.

Server-side assignments

A page your server rendered from POST /v1/assignments and cached per arm has no decision_id in it. Since 3.10, hp.assignmentDecisionId({ slot, arm }) derives the one HP logged, synchronously.

It reads the session cookie named in identityCookies.sessionId, else the visitor cookie (identityCookies.anonymousId), as your renderer forwards them to HP, so it answers once bootstrap has been called, before the client has started. It returns null before that, or with neither cookie. The id is UUIDv5(ASSIGNMENT_DECISION_NAMESPACE, "<session>:<slot>:<arm>"); assignmentDecisionId({ sessionId, slot, arm }) from the main entry computes it from values you pass, and the spec's assignment/decision-id.json has the vectors.

Such a page reports the property holdout's exposure itself, when the slot is on screen, for both groups, and a Slot Engaged click when a link in the slot is clicked. The answer's hash is holdout for a held-out visitor (cache that page apart from the "" one); on any other page with a hash the visitor was treated. The exposure token is the assignment's derived id for the arm shown, control included, and a held-out visitor's exposure names no decision. Since 3.13 the shares come from the property's settings when the exposure has none:

const token = hp.assignmentDecisionId({ slot, arm });
const holdout = hash === 'holdout';
// When the slot is on screen:
hp.recordExposure({
  exposure: { token, surface: 'decisions', holdout },
  decisions: [holdout ? { slot, holdout: true } : { slot, arm, decision_id: token }],
});
// When a link in the slot is clicked:
if (token && !holdout) hp.slotEngaged({ slot, arm, engagement: 'click', decision_id: token });

The exposure is what marks the decision shown; the click is its reward. Slot Engaged's view is deprecated since spec 1.16: it repeated the exposure.

A surface your server fetched from HP for the page, so the page never sees HP's answer, reports its exposure with surfaceViewed when it comes into view: a product rail your server asked HP for, which HP answers with its rail for a treated visitor and the site's own rail for a held-out one. Since 3.14 the SDK groups the visitor itself, with the holdout settings, salt and visitor id serving used (spec/assignment/holdout.json), so it is the group serving answered with. It records one exposure per session per surface, and nothing for a visitor outside the experiment: no holdout in the settings, outside the ramp, no visitor or session id, or a rail or audience without personalisation consent, which serving needs to personalise them:

hp.surfaceViewed({ surface: 'recommendations' });

Since 3.15 markup does the same without a call: bootstrap's autocapture calls surfaceViewed for an element tagged data-hp-exposure="<surface>" (recommendations, decisions or audiences) once it is viewable, half on screen for a continuous second (MRC), including one rendered after the SDK started. Tag what renders for both groups. trackExposures(client) from ./auto is the same for createAnalytics clients; autocapture: { exposures: false } turns it off.

<section data-hp-exposure="recommendations">…</section>

Bandit against the default

Since 3.16. A readout slot (the placement's Peak readout setting in the console) splits the shoppers it personalises 50/50, by a hash of the device id salted with the slot (banditSplit(slot, anonymousId), vectors in the spec's assignment/bandit-split.json): half get the bandit's arm, half the slot's default. The settings list each property's readout slots (holdouts.<property>.readoutSlots), so recordExposure adds the shopper's side to the slot's entry as variant: 'bandit' | 'default', for a cached server-rendered page as for a serving response (whose own variant wins). The default side is no decision: its decision_id is dropped and it is not put in force. Nothing changes in your calls.

Preview

Since 3.15. A trader's preview link from the console opens the site with ?hp_preview=<token>. The SDK keeps the token in the thg_hp_preview session cookie for the rest of the browser session, for your server to forward to /v1/assignments as preview (the query parameter on the first request, the cookie after), which then answers that link's arm and logs nothing. While a preview is on, recordExposure, surfaceViewed, data-hp-exposure and slotEngaged send nothing and put nothing in force: the trader is not a shopper, so must neither train a slot nor count in its readout. Every other event is sent as usual. ?hp_preview= with no value ends the preview. The SDK never checks the token: serving does, and ignores one that has expired.

Visit traits

A session audience's rules name fields the SDK keeps for the visit, from the events it sends: productViews, listViews, cartAdds, searches, browseViews.<path>, browseShare.<path>, categoryViews.<path>, categoryShare.<path>, since 3.18 basketValue and since 3.35 basketCodeCount. A field this build does not know fails its rule closed.

listViews, browseViews and browseShare count category pages, as the warehouse does. A listing must send its own category (Product List Viewed); one without is not recorded. Since 3.22, pass the datafile's browse_categories as the fifth argument to resolveAudiences or resolveAudienceMemberships, and a listing counts only when the catalogue files products under its category, so a merchandising list (best sellers, new in, a sale) is never browsing interest:

resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for, datafile.browse_categories);

Each trail entry is judged on the listing it came from, as the warehouse judges each event: two listings under one path, one a category and one a sale list, count once. Product views and adds credit the category page they were reached from, and a facet (Product List Filtered) counts only on the listing being browsed; the warehouse counts neither, so these two measures run a little ahead of it. Without browse_categories (a datafile that predates it, or a property whose catalogue is not served yet, when the field is absent), every listing that names a category counts. A visit recorded by an SDK before 3.22 has no listing categories, so with the list its earlier browsing drops out once.

basketValue is the value of the last cartUpdated (Cart Updated), in that event's currency: the basket as your platform priced it. It is never summed from productAdded lines, whose prices can include tax where value does not. It is null, so every rule on it fails, until the visit sends a Cart Updated, and again after one without a value. Send cartUpdated after every confirmed basket change, as the spec asks, or a basket audience goes stale.

basketCodeCount is how many discount codes the basket holds: those in the last cart event's discounts (else its coupon), on Cart Viewed or Cart Updated, with couponApplied and couponRemoved adding and taking them out between cart events (a code typed in another case is the same code). It is null, so every rule on it fails, until a cart event names either field, so a platform that sends no codes is never taken for a basket without one. eq 0 is a basket with none.

Since 3.27, campaign.source, campaign.medium, campaign.campaign, campaign.content and campaign.term are the campaign the visit arrived from: the utm_* tags of the first page of the visit that had any (a page view records them), kept for the visit. They are text, compared trimmed and in any case with eq, neq, in (a list) or contains. A visit that arrived untagged fails every rule on them, neq included, so "not from Facebook" never means "from nowhere".

Audiences only some integrations may target

Since 3.8, only audiences marked Targetable in the console are membership. Pass the membership response's audiences and qualified_for, and the datafile's session_audiences, to resolveAudienceMemberships exactly as before:

const memberships = resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for, datafile.browse_categories);

A targetable visit audience may require an audience nobody may target. The server sends that base in qualified_for (never in audiences), and the datafile ships it with targetable: false if the browser evaluates it. From 3.8 the resolver uses both to meet the requires, and never returns them, so context.audiences and exposures carry targetable audiences only. Before 3.8 such an audience matches nobody, and a shipped untargetable visit audience the visitor meets may be reported as membership. Every other audience resolves as it did.

Social proof has a client of its own, which batches and caches, and never throws once created (creating it without a writeKey or with an invalid apiHost throws):

const proof = createSocialProof({ writeKey, getConsent: () => hp.consent });
const badges = await proof.forProducts(['123', '456']); // GET /v1/social-proof, Bearer writeKey

It sends the key and hp-consent itself; propertyId is needed only for a key scoped to several properties.

Onsite forms

Sign-up popups, sheets, bars, teasers, inline forms and spin-to-win (ADR 0064). Built dark: nothing shows until a form is set live in the console. One line on the host:

const hp = await bootstrap({ writeKey: 'hp_pk_live_…' }, { consent: { provider: 'onetrust' }, onsite: true });

With onsite set and a live form in the settings' hp.onsite, bootstrap loads ./onsite as a separate chunk and starts it; otherwise it is never fetched. A page on createAnalytics starts it itself: import { startOnsite } from '@thg-agentic/hyper-personalisation-web/onsite' then startOnsite(client, options). On the CDN, hp.min.js loads onsite.min.js (and that loads onsite-render.min.js once a trigger fires, onsite-wheel.min.js for a wheel, onsite-answers.min.js for a date or number question, onsite-products.min.js for a products block, onsite-audiences.min.js for a form limited to audiences, onsite-values.min.js for a form whose text has shopper values) from beside itself; a self-hosted copy keeps all seven beside hp.min.js. The runtime watches triggers, caps and consent and asks for the decision; the renderer and its stylesheet are fetched only when a trigger fires, alongside the decision request, so a page whose forms never fire never downloads them. If the renderer cannot load, the form is not shown (hp.onsite.debug() says the renderer did not load) and the page goes on. A bundler splits ./onsite, the renderer, the wheel and the answers the same way.

Layout stability. A popup's next step takes its own height with its top where the last step's was, so only the card's bottom edge moves: nothing on it shifts, and a short step (the thanks after a spin) never sits in a tall step's empty space. A step too tall for the room below that top is centred again. A bar, sheet or inline form keeps at least its size, its content centred, so its top edge (or the page under it) never moves. An image with widths (the smaller copies the console's upload made, any widths, at -<w>w.webp) is a srcset with the original at its width, and sizes for where it shows: the whole screen under 640px, else the card's width (46% of it beside a split).

Pictures. Once a form is decided, its first step's pictures (its media, image blocks and a video's poster) are fetched and decoded with the same srcset and sizes the form will use, and the form opens when they are ready, or after 300 ms at most, so it never opens on an empty picture. The first step's images load eagerly at fetchpriority="high"; while a step shows, the pictures of the steps it leads to are warmed once the page has loaded and the browser is idle (requestIdleCallback, two seconds at most), and later steps' images load lazily. A video plays muted and inline only without reduced motion; under it only the poster shows and the video is never downloaded (preload="none"). A video with other encodings in sources offers WebM first, then MP4.

Keyboard. A popup or full-screen form is modal: focus starts on its heading, and Tab and Shift-Tab cycle through its controls without leaving it; Esc closes it.

Questions. A choice (one, many) shows as buttons, a list of radio buttons or checkboxes, a dropdown, or picture cards, as its question block's display says (a multiple choice is never a dropdown, and the console offers picture cards for a single choice only). Picture cards show each option's picture from the block's pictures above its label, the whole card a radio button, two a row in a narrow form and up to four in a wide one; they come with the date and number fields' chunk, loaded when a step shows them (3.29 or later). A date question is a birthday's day and month selects and, when asked, a year field, in the order and month names of the form's locale (the page's lang when the form has none); or the browser's own date field for any other date. A number question is a text field with a numeric keyboard and its unit. A half-filled date, or a number out of the question's bounds or the gateway's limits (ten digits, at most a billion either way), is the shopper's to correct before the step moves on; the gateway checks every answer again. Answers are stored as option values, YYYY-MM-DD or --MM-DD, and digits. A form that asks a date or a number loads their fields once, as it mounts; if they cannot load, the question says it is not available and the form goes on without that answer (every question is optional). A phone field's country code starts on the block's country, else the region of the form's locale.

How a form shows. The browser judges the trigger; the server decides the rest. Each live form's trigger is watched: entry (delay_ms), exit, scroll (depth), time and idle (seconds), inline (rendered into selector) and manual (hp.onsite.open(slot)). Exit intent on desktop arms after delay_ms (5 s) and once the pointer has been below the top tenth of the viewport, and fires when it leaves through the top edge, moving up. On mobile it is a fast upward flick (120px at 0.5 px/ms) after 30% of the page, or a return to the tab after 10 s away. Pages (include/exclude globs on the path, * matching anything, in any case), devices (mobile is a coarse pointer or under 768px), the campaign the visit arrived from (arrived_from), what the page says (page_facts), dates and caps are checked first; then POST /v1/decisions is asked for that one slot. A held-out visitor, or no decision, sees nothing. One overlay per page view: of the forms that fire within 50 ms, the highest priority; once one has shown (or the visitor was held out of it), closing it lets no other form in on that page view. A manual form, which the site opens on the shopper's click (its own sign-up button), is the shopper asking: it opens whatever its caps, the gap and the page view's allowance say, as Klaviyo's custom-triggered forms do, though never over a modal on screen (a popup, or a teaser opened from its tab); its pages, devices, campaign, dates and audiences still hold. Asked for twice while it opens, it opens once. With the property's overlay_gap_minutes, no popup, sheet or full-screen form shows until that long after the last one this session (1440 is once per session); bars, teasers and inline forms are never held back. A form with audiences needs personalisation consent.

Answers (3.31). Under personalisation consent the client keeps what the shopper answered this property's forms, thg_hp_answers:<property> in localStorage: the latest answer to each choice question (its key and the option values chosen; never a date, a number, typed text or contact data), each for 90 days from when it was given, written from a quiz's Preferences Stated (so only with analytics consent too) and a sign-up form's answers once the gateway took them. A form limited to an early audience, one whose every rule is about the visit or an answer the shopper gave (is one of, includes all of), is then decided from those answers with the visit, so it shows on the next page view without waiting for the warehouse. An audience with an "is none of" answer is never early: a device that keeps no answer cannot tell, so the warehouse decides it. The datafile ships an early audience as a MIXED audience (rules its visit rules) with early: true and its answer_rules: answer.<question> with in (is one of) or all (includes all of) and a list of answers. A datafile written before 2026-11 may still carry not_in (is none of), which holds with no answer kept. The runtime sends the early audiences that hold on the kept answers as answered_audiences. The answers go when personalisation is refused or withdrawn, at a sign-out and when another user signs in. Anywhere else (the page's own ./session, which carries no answers, an answer given on another device, an earlier SDK) an early audience is MIXED: met where the warehouse qualified the shopper on its answers and its visit rules hold, from the next profile update.

Shopper values in text (3.32). A heading or paragraph may hold a value node, { v, fallback, format? }, filled in the browser as a text node (never markup): trimmed, without control or invisible characters, at most 60 characters with an ellipsis. v is one of basket_value (the basket this visit, as a basket bar reads it, as money in the Cart Updated's own currency, else the client's), basket_items (the items its lines hold: each line's whole quantity, a line without one or with a fractional one once, zero or less none), first_name (what the page gave, below) or answer.<question> (the label of the shopper's answer in the form's language: one given in this form, else the one kept under personalisation consent; none without it). format.style is number, money, date, relative or text, formatted with Intl in the form's locale, and format.plural ({ one, other }) follows a number with its word ("3 items"). A value the page does not know shows its fallback; with the block's missing: "hide" the block is not shown instead. The page gives what only it knows with hp.onsite.values({ first_name: 'Alex' }) (or startOnsite's values): kept in the page's memory only, drawn as text, never sent, stored or logged; each call replaces the last, values({}) forgets it. The formatting is its own chunk (onsite-values.min.js), loaded only for a form whose text has a value; if it does not load, the form is not shown (values-failed). The public API serves such a form only to 3.32 or later.

Daily cut-off. A form's window.daily (3.33 or later) is a cut-off that comes back each day, such as "order by 1 AM for next-day delivery". The server works out when it falls, in the shop's time zone on the days it is set for, and the settings list those moments: next, epoch milliseconds, earliest first, for the next 8 days. So the browser compares numbers and carries no time-zone code, and every shopper counts to the same moment, whatever their own clock. It is targeting, checked with the form's pages and dates before any decision is asked for, so held-out and shown visitors are filtered alike: the form is not eligible while none of the moments is still ahead (no cut-off ahead) or, with within_hours, while the next is further away than that (outside its daily window). A countdown with daily: true, on any step and as many as the form has, counts to the first ahead: at 23:00 a 01:00 cut-off is two hours away, and as one passes the countdown moves on to the next. A preview shows the form whatever the time. The public API serves a form with a daily cut-off, or a daily countdown, only to 3.33 or later.

Arrived from. A form with arrived_from shows only to a visit that landed from a matching campaign: [{ field, match, values }], field one of source, medium, campaign, content, term (the link's utm_* tags), match is or contains, compared trimmed and in any case. Every rule must hold; any of a rule's values meets it. The campaign is the landing page's, as GA4's session source is: the client keeps the first page of the visit with utm_* tags in one store, thg_hp_visit_campaign:<property> (localStorage, so every tab of the visit reads it; the tags, when the visit was last active and its session id once kept on the device, never a device or person id, the same strictly necessary basis as the onsite counters). Later pages without tags, or with others, keep it until the visit ends as the SDK's session does: 30 minutes without a page or an event, or a new session id. Each tag is read up to 100 characters. The tags are read when the client is created, so a later replaceState cannot lose them. A sign-out or a withdrawn consent forgets it; a first refusal does not, as the forms judge it without consent. The session id is written only while analytics is granted, and without localStorage the campaign holds for the page. It needs no consent; a visit with no campaign meets no rule (not eligible: not from its campaign), and an arrived_from this SDK cannot read never matches. The decision request for such a form carries it as visit_campaign (never logged), and the public API answers no form unless the rules hold, so an SDK older than 3.26 never shows one. A preview link reads its own URL's tags: the console's Preview on site link adds them. Click ids (fbclid, gclid) are not a campaign.

Page facts (3.34). A form with page_facts shows only on a page that says so: [{ fact, op, values }], op one of is, is_not (any of values meets it, or none does), at_least and at_most (one number). The page gives its facts with hp.onsite.facts({ next_day_eligible: true, delivery_tier: 'express' }): keys of lower-case letters, digits and _ (up to 40), values true, false, a number or text (up to 100 characters), at most 20. Each call replaces the last; facts({}) forgets them. They hold for the page path they were given on only, so a single-page app's next page starts with none, and are never stored. The rules are judged when the form's trigger fires, so facts the page gives after it loads (or again for another variant) count for a form that waits on one (exit intent, a delay, a scroll, time or idle). An inline form, or one that shows on arrival with no delay, is judged as onsite starts: give its facts before then (before startOnsite, or as the page renders). A preview shows whatever the page says. Every rule must hold, a fact the page did not give meets no rule, and a rule this SDK cannot read never matches (not eligible: the page does not say so). The decision request for such a form carries the facts as page_facts (never logged), and the public API answers no form unless the rules hold, so an SDK older than 3.34 never shows one.

Products. A form whose settings carry products: true shows only on the page of a chosen product: the request carries the page's product, the product_id of the last Product Viewed on this page, as visit_context.product_id, and the public API checks it against the form's product filters in the catalogue. A page with no product does not ask (not eligible: not a product page).

| Option | | |---|---| | audiences() | The visitor's session audiences, when the page evaluates them: a form targeting none of them is skipped. Without it HP's server decides, from the visitor's stored audiences and the in-visit audiences whose rules hold in this visit (the runtime evaluates them from GET /v1/audiences/datafile and sends them with the decision). | | subscribed() | True for a visitor the page knows is subscribed, for forms with exclude_subscribed. | | servingHost, gatewayHost | Where decisions and claims go (default: the host the SDK was pointed at) and capture goes (default: where events go). | | debug | Log why each form did or did not show. Also on with ?hp_debug=onsite. | | settings | Development and tests: these forms instead of the settings'. | | values | What the page knows of the shopper for a form's text, { first_name }, as hp.onsite.values (3.32). |

A form with exclude_code_applied is not shown while the basket holds a discount code, read from the cart events the page sends (see basketCodeCount under Visit traits); once the code is removed it is tried again.

hp.onsite.debug() returns each form's state and reasons (armed, not eligible: per_session cap, held out, shown, …), and since 3.29 a code, which stays the same across releases while the reasons may be reworded: armed, waiting-for-open, deciding, shown, closed, held-out, invalid, not-eligible, overlay-gap, awaiting-consent, another-showing, allowance-spent, lower-priority, no-element, empty-basket, suppressed, or one of nothing's codes below. Match on it, not on the reasons. code is always present, so a trace built by hand needs one.

| hp.onsite | | |---|---| | open(name), show(name) | Show a form now, past its trigger; resolves with whether it showed. name is a form's slot, else one of a form's aliases (the settings' names for it: footer_signup for the site's own sign-up button), matched exactly; a slot wins over an alias. A manual form, or one opened by an alias, shows each time (see above); any other keeps to its caps. From there it is that form, by its slot, in every event. | | on(name, fn) | shown, submitted, apply, closed, held or nothing, with { slot, arm, format } (nothing: { slot, format, code }); submitted and apply add coupon ({ code, prize } or null), closed adds reason. held is a held-out visitor whose trigger fired (a form the site opened says so by open resolving false instead), so a site replacing its own popup with HP's shows its own there and the holdout compares the two. nothing (since 3.29) is a covering form (any but inline) whose own trigger fired and for which HP will show nothing on this page view; the site shows its own there too. code says why: decision-failed (a network error, any status but 200, a 200 without a list of decisions, or no answer within 3 s), no-creative, no-code (a reminder with no code to remind of), renderer-failed, products-failed or values-failed (the chunk did not load within 3 s), or error (the form failed while it was being opened). It comes at most 3.05 s after the trigger: the 50 ms in which forms that fire together compete, then one 3 s deadline they share; an answer after it is never shown, so the site needs no timer of its own. It is not said when another covering form that fired with it shows, or the visitor is held out (held is said instead); for a form the site opened (open resolves false); on a suppressed page (suppress: the site is keeping its own screen clear); when its caps, the gap between popups, the consent banner or a form already on screen keep it away; or for an empty basket, which is tried again when the basket changes. Of the forms that fire together, only the highest HP has nothing for says it: the slot whose place the site takes. Like a holdout, it spends the page view's allowance, so no other HP popup, sheet, bar or teaser follows it on that page view (a later trigger is allowance-spent; a form the shopper asks for, manual or by an alias, still opens). A form with prizes says submitted once, when its claim settles: with the code, or null if the claim failed. Returns a function that stops listening. A throwing listener is logged, never breaks the form. | | ready() | Resolves once the page knows whether onsite forms run on it: each form's state, as debug(), once the runtime has started, or [] once none will (not opted in, no live form, the runtime failed to load). A site keeping its own popup where HP has none for the visitor reads it. | | suppress(reason), unsuppress(reason?) | Take any form off the screen quietly (not a dismissal) and keep forms away while any reason holds; unsuppress() lifts all. | | values(given) | What the page knows of the shopper for a form's text, { first_name } (3.32): in memory only, never sent; each call replaces the last. |

Formats. A teaser is a tab (its first step) that opens the rest as a popup; the tab is kept on the session's later pages until opened or dismissed, and never traps focus. A bar with a progress block is a basket bar: free-delivery progress from the basket value the SDK keeps from cartUpdated, not shown for an empty basket. A code block on a first step is a promo reminder, shown only with a code to remind of (the decision's offer.code, or the code this browser claimed, kept 14 days); its apply button emits apply. inline renders into the trigger's selector, a tile among products. Steps take a layout template (stack, split, overlay, image-only) with image or muted, looping video media; see ADR 0064.

Preview. ?hp_preview=<token> asks POST /v1/decisions for the previewed version and counts nothing. For an editor, import { mountPreview } from '@thg-agentic/hyper-personalisation-web/onsite/preview'; mountPreview(host, creative, { step, device: 'desktop' | 'mobile', consentText, smsConsentText, questions, prizes, basketValue, values }) draws the creative (with values, a returning shopper's sample values; without, every fallback) inside host with no request, event or counter, and returns { update(creative, options), destroy(), step() }. It is not part of the runtime chunk.

Rendering. A shadow root on an <hp-onsite> element. Popup and fullscreen are a native <dialog> opened with showModal() (focus trap, inert page, Esc, focus restore); sheet, bar and teaser are non-modal (focus stays on the page); inline renders into the page's element and never takes focus or scrolls the page. The close button is always there, and every control is at least 44px to touch. Button text is dark or white, whichever contrasts more with the accent. Text is the creative's RichText, built with DOM APIs only (text, bold, italic, https links), never innerHTML. Under 640px a popup is a bottom sheet with a drag handle (drag down to close) and a split stacks; prefers-reduced-motion stops the animation, video autoplay and the wheel's spin; a video pauses while the page is hidden.

Capture. On submit the form fetches GET /v1/capture/challenge (started on the field's first focus), solves the ALTCHA proof of work in a Worker made from a Blob (the main thread, yielding, where a CSP refuses one), and posts POST /v1/capture to the ingest gateway with the address, the consent text's id and version (sms_consent_text for a phone number, consent_text otherwise; a consent block shows the same one), any answers and the decision. The field is emptied; only the receipt is kept, in memory, for a spin's POST /v1/offers/claim (with the decision id when there is one; a 503 is retried twice, after 0.5 s and 1.5 s). A question answered on a later step is posted to POST /v1/capture/answers with the email step's address and receipt, for the destinations and Preferences Stated; the address and receipt are kept only while the form is open. When every prize's codes are gone the form shows the creative's fallback step, else a plain thanks, with no countdown and nothing to copy. Allow blob: in worker-src for the Worker.

Events, each only with analytics consent, and none while previewing:

| When | Event | |---|---| | The form renders, or a held-out visitor's trigger fires | Personalisation Exposed (surface decisions, from the decisions response) | | A sign-up succeeds | Slot Engaged { engagement: 'submit', step }. The gateway sends Newsletter Subscribed, and Preferences Stated for answers sent with it | | A question is answered after the sign-up | Sent by the gateway with POST /v1/capture/answers after an email step; otherwise Preferences Stated { source: 'onsite' } from the page | | Closed, Esc or declined before a sign-up | Slot Engaged { engagement: 'dismiss', step } | | A link button is clicked | Slot Engaged { engagement: 'click' } |

The sign-up itself is sent whatever the analytics answer: it is the visitor's marketing consent.

Privacy. No contact data is kept in the browser. The caps are opaque counters per slot under storageNames.onsite (thg_hp_onsite): shows this session (sessionStorage), and shows ever, last close and submitted, plus one subscribed flag and the last claimed code (localStorage), and a kept teaser tab (sessionStorage).

Prizes that cannot be claimed. A form with prizes claims as soon as the sign-up is taken. If the claim fails (the offers API unreachable, an error, or no answer in 8 seconds), the sign-up still counts: a spin offers one retry ("You're signed up, but we couldn't load your prize. Please try again."), then thanks the shopper and says the prize could not be loaded, as does a code block. The shopper stays subscribed and the form is done with them (already submitted), so they are never asked for their address twice. No code was drawn, so the destinations get the sign-up without one (ADR 0064, decision 5).

Testing a live form again. A browser that has seen a form keeps to its caps. To see it as a new visitor, open a private window, or delete thg_hp_onsite:<property> from both localStorage (shows ever, last close, submitted, subscribed, the last code) and sessionStorage (shows this session, the gap between popups, a teaser's tab, the A/B version), then reload; thg_hp_settings:<writeKey> in localStorage is the cached settings. ?hp_debug=onsite logs why each form did or did not show, and a preview link (?hp_preview=) counts nothing.

Budgets, enforced by tests/onsite-chunks.test.mjs, each about 5% over its chunk: onsite.min.js under 6.75 KiB gzipped (about 6.6 KiB), onsite-render.min.js under 15.75 KiB (about 15.7 KiB), so a page that shows a form loads about 22.3 KiB of onsite code and one whose forms never fire about 6.6 KiB; onsite-wheel.min.js under 8 KiB (about 1.8 KiB), onsite-answers.min.js under 2 KiB (about 1.5 KiB), onsite-products.min.js under 2.6 KiB (about 2.5 KiB), onsite-audiences.min.js under 1.63 KiB (about 1.61 KiB), onsite-values.min.js under 1.5 KiB (about 1.35 KiB). The core carries only the loader.

Products in a form. A creative's products block (ADR 0064 as amended 2026-11-02) is filled by the public API in the decision that serves the form, as the block's served. For a form whose settings carry visit_context: true, and only for it, the decision request carries visit_context: page_type, the page's product_id (the last Product Viewed on the page) and, with personalisation consent, up to 20 basket product ids (the last Cart Updated or Cart Viewed lines, moved by Product Added and Product Removed) and 20 viewed. The cards are reported as a rail's: Recommendations Viewed once they meet the viewability standard, Product Clicked with the block's recommendation_request_id, and the form's Slot Engaged (click, with the product and its position). A form whose channels is empty collects nothing and never calls capture.

Try it locally: node tools/local/serve.mjs, then http://localhost:8099/onsite.html?hp_debug=onsite (offline mocks by default, ?mode=live for the local gateway and public API). ?example=<name> shows one example creative from tools/local/testpage/onsite-examples.js (hero, split, gif, spin, teaser, bar, sheet, gate, sms, exit, inline, basket, promo, fullscreen), with local images under testpage/media/.

Consent

With a consent adapter the SDK is strict:

  • Before the CMP answers, events are held in memory: not sent, not stored. They wait up to consentTimeoutMs.
  • Analytics granted: held events are sent, each carrying the answer in context.consent.
  • Analytics refused: held events are dropped, and so is everything after. Nothing goes into local or session storage.
  • No answer by the timeout: the status is unknown, and held and later events stay held, in memory, while the SDK keeps listening for the answer. Strict is the only mode: an unanswered CMP is never a grant, however long it takes.
  • Page closed before an answer: nothing is sent. Switching to another tab is not closing the page: held events wait for the visitor to come back.
  • A navigation before an answer (a visitor who answers the banner on their second page, a sign-in that redirects) loses what the page held, landing page included, unless you opt in with holdAcrossPages: true (since 3.5). Then held events are also kept in sessionStorage under thg_hp_held:<writeKey> and sent when a later page's answer is a grant, stamped with it. A refusal deletes them, and so does a tab left unanswered for 30 minutes. This writes event data to the device before the visitor answers, which strict mode otherwise never does, so turn it on only where your consent policy allows that.
  • The retry buffer: a batch is kept in localStorage only while analytics is granted, and one kept on an earlier page is replayed only once this page's answer is a grant. A refusal clears it.
  • Consent changes: each change is recorded as a Consent Updated event. A decision that matches the last one this device reported (read from thg_hp_consent) is not a change. A withdrawal of analytics is also sent, once, stamped with the withdrawn state: the raw event table is where a refusal is shown to have been received, and the warehouse's consent filter keeps that event out of every model. After that, nothing more is sent.
  • Changes nothing announces: after the first