@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +825 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +476 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/decisions.js +90 -0
  22. package/src/device-storage.js +201 -0
  23. package/src/experiments.js +220 -0
  24. package/src/ga4.js +124 -0
  25. package/src/hash.js +138 -0
  26. package/src/hp.js +135 -0
  27. package/src/index.js +818 -0
  28. package/src/preview.js +44 -0
  29. package/src/recommendation-context.js +101 -0
  30. package/src/segment.js +375 -0
  31. package/src/session/category.js +40 -0
  32. package/src/session/record.js +163 -0
  33. package/src/session/rules.js +195 -0
  34. package/src/session/signals.js +146 -0
  35. package/src/session-id.js +62 -0
  36. package/src/session.js +28 -0
  37. package/src/settings.js +211 -0
  38. package/src/shape.js +111 -0
  39. package/src/social-proof.js +139 -0
  40. package/src/spec-version.js +4 -0
  41. package/src/storage-scope.js +159 -0
  42. package/src/transport.js +436 -0
  43. package/src/version.js +2 -0
  44. package/types/auto.d.ts +51 -0
  45. package/types/bootstrap.d.ts +154 -0
  46. package/types/consent.d.ts +126 -0
  47. package/types/datalayer.d.ts +76 -0
  48. package/types/events.d.ts +680 -0
  49. package/types/hp.d.ts +64 -0
  50. package/types/index.d.ts +610 -0
  51. package/types/segment.d.ts +68 -0
package/README.md CHANGED
@@ -1,3 +1,826 @@
1
- # Temporary Holding Version
1
+ # @thg-agentic/hyper-personalisation-web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The browser SDK for THG Hyper-Personalisation, a customer data platform. It implements the event
4
+ specification (spec 1.6, stamped on every event as `context.spec.version`) for any storefront: it
5
+ sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads
6
+ the GA4 `dataLayer` the site already pushes to, and has no runtime dependencies.
7
+
8
+ The core knows standards only: GA4 and Segment. A **preset** teaches it one commerce platform's
9
+ conventions; none ships with the SDK, and you can write your own in about 20 lines.
10
+
11
+ ```sh
12
+ npm i @thg-agentic/hyper-personalisation-web
13
+ ```
14
+
15
+ Or, without a bundler, the [CDN script](#cdn-script).
16
+
17
+ ## Integrating in one call
18
+
19
+ ```js
20
+ import { bootstrap } from '@thg-agentic/hyper-personalisation-web/bootstrap';
21
+
22
+ const hp = await bootstrap(
23
+ { writeKey: 'hp_pk_live_…' },
24
+ {
25
+ consent: { provider: 'onetrust' },
26
+ identityCookies: { sessionId: 'session_id' },
27
+ getCurrency: () => getCookie('currency'),
28
+ pageType: 'product',
29
+ identity: { userId: customer?.id, emailSha256: customer?.emailSha256 },
30
+ dataLayer: {},
31
+ },
32
+ );
33
+ ```
34
+
35
+ **The site configures one value: the key.** `writeKey` is the source's publishable key
36
+ (`hp_pk_live_…` in production, `hp_pk_test_…` elsewhere), safe in page source. It names the
37
+ organisation, the property and test or live, so there is no host, property or switch to set per
38
+ environment, as with Stripe's or LaunchDarkly's keys. `apiHost` is optional, only for a first-party
39
+ proxy (a bare host or an `https://` origin); without it the SDK uses the HP API
40
+ (`DEFAULT_API_HOST`; in 3.17 an interim nonprod host for test keys only, see
41
+ [Default host](#default-host-until-production-is-live)). Everything tenant-level comes from
42
+ `GET {apiHost}/v1/projects/{writeKey}/settings`, so switching a site on or off, or changing what it
43
+ collects, is a settings change and never a deploy:
44
+
45
+ | From the settings (`hp`) | |
46
+ |---|---|
47
+ | `enabled` | Anything but `true` leaves the SDK off: no events, no cookie, nothing stored. |
48
+ | `propertyId` | `context.source.property_id`. Null for a key scoped to several properties; the page then passes `propertyId`. |
49
+ | `environment` | `context.source.environment`. |
50
+ | `consent.purposes` | The purposes the property asks for. Only those are reported; the rest read as unanswered. Strict is the only mode. |
51
+ | `queryParamAllowlist` | Query parameters kept on page URLs (plus campaign parameters). |
52
+ | `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](#device-ids). |
53
+ | `apiHost` | Where events go, when not the host the settings came from. |
54
+ | `plan.track` | Segment's tracking plan: an event with `enabled: false` is dropped at the source. |
55
+
56
+ **The page configures only what the page knows** (the second argument):
57
+
58
+ | Option | |
59
+ |---|---|
60
+ | `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. |
61
+ | `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](#session-ids)). `anonymousId` is read only when the settings name no device cookie: see [Device ids](#device-ids). |
62
+ | `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. |
63
+ | `identity` | The signed-in visitor, `{ userId, emailSha256 }`. `userId` goes on every event; the `identify` is sent once per session and user. |
64
+ | `getCurrency`, `getShipToCountry`, `getLocale` | Read per event; see below. |
65
+ | `preset` | A platform preset, passed in by the host. |
66
+ | `pageType` | `context.page.type` of this page's page view. |
67
+ | `dataLayer` | `{ name?, ignore? }`: connect `window[name ?? 'dataLayer']` through the preset's mapping. Absent: no bridge. |
68
+ | `propertyId` | Only for a key scoped to several properties, whose settings name none. |
69
+ | `consentCookieDomain` | `Domain` for the consent cookie, e.g. `.acme.com` to share it with checkout. |
70
+ | `storageNames` | Rename what the SDK stores; see [Storage](#storage). |
71
+ | `debug` | Log every event and delivery problem. |
72
+ | `settings` | Development and tests only: use this settings response, never fetch or cache one. |
73
+ | `extend` | `createAnalytics` options that are functions, such as `getRecommendations` or `getExtensions`. |
74
+
75
+ What `bootstrap` does:
76
+
77
+ - **Resolves to the client**, or `null` when the key is not enabled, the settings cannot be fetched
78
+ on a first visit, or the options are invalid. Invalid options log one console warning listing
79
+ every problem. It never rejects, and a second call on the same page returns the first client.
80
+ - **Settings.** The last good answer is cached in `localStorage`, only once analytics is granted,
81
+ and forgotten on a refusal. A page with a cache starts from it at once and asks the API again for
82
+ the next page; a first visit waits for the API (which answers `Cache-Control: max-age=60,
83
+ stale-while-revalidate=600`). A key the API switches off clears the cache, so the next page is
84
+ off too.
85
+ - **Consent**, identity, one page view, the dataLayer bridge and a once-per-session `identify`, as
86
+ configured.
87
+ - **Never waited for.** Call `hp` (`@thg-agentic/hyper-personalisation-web/hp`) from anywhere, at
88
+ any time, and never await or flush it: calls made before the client starts wait, each with the
89
+ messageId and time it was made at, and are sent in order once it has. A page that navigates
90
+ first (a sign-in that redirects at once) carries them in sessionStorage, and the next page's
91
+ `bootstrap` sends them once, before its own page view (since 3.9). So does a page whose settings
92
+ request never answered: the API down, or the request cancelled by the navigation (3.9.1). The carry is a device write,
93
+ so it is made only once this device has granted analytics, or unanswered with `holdAcrossPages`;
94
+ the next page's consent decides whether they are sent, and a refusal drops them. Once started,
95
+ the SDK hands its queue to `sendBeacon` at `pagehide` itself.
96
+
97
+ ## Multi-currency and multi-destination tenants
98
+
99
+ One property can trade in several currencies and ship to many countries: a visitor on one site can
100
+ pick EUR and a German delivery address halfway through a visit. So `context.currency`,
101
+ `context.ship_to_country` and `context.locale` are read **per event**, never fixed per tenant.
102
+
103
+ - **Getters.** Pass `getCurrency`, `getShipToCountry` and `getLocale` (to `bootstrap`,
104
+ `createAnalytics` or `cdpPlugins`). They are called for every event. `createAnalytics` and
105
+ `cdpPlugins` also take static `currency` / `shipToCountry` / `locale`, which a getter wins over
106
+ whenever it returns a value.
107
+ - **Validation.** Values are uppercased and validated on read: currency `^[A-Z]{3}$`, ship-to
108
+ country `^[A-Z]{2}$`, locale a BCP-47 tag such as `en-GB` (a platform's `en_GB` is normalised to
109
+ `en-GB`). An invalid value is dropped (sent as null), never emitted, so it cannot quarantine the
110
+ event.
111
+ - **Prices keep their own currency.** A GA4 push carries the currency its prices are in.
112
+ - Cart Viewed and Checkout Started take it as `properties.currency`.
113
+ - Every event mapped from that push also gets it as its `context.currency`, so a price pushed
114
+ in EUR is never stamped with the site's current GBP.
115
+ - A push with no currency falls back to the site's current one.
116
+ - **Country is not here.** `context.country` (where the visitor is) is derived at the edge, so
117
+ there is deliberately no `getCountry`.
118
+
119
+ ## The lower-level API
120
+
121
+ ```js
122
+ import { createAnalytics, getCookie, hashEmail } from '@thg-agentic/hyper-personalisation-web';
123
+ import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
124
+ import { connectDataLayer } from '@thg-agentic/hyper-personalisation-web/datalayer';
125
+
126
+ const analytics = createAnalytics({
127
+ writeKey: 'hp_pk_live_…',
128
+ propertyId: 'acme-gb', // stable id of this site
129
+ currency: 'GBP',
130
+ locale: 'en-GB', // the site's locale, not the browser's
131
+ consent: didomiConsent(), // strict: nothing is sent until analytics is granted
132
+ consentCookieDomain: '.acme.com',
133
+ queryParamAllowlist: ['colour', 'size', 'sort'],
134
+ getAnonymousId: () => getCookie('device_id'),
135
+ getSessionId: () => getCookie('session_id'),
136
+ });
137
+
138
+ analytics.page({ type: 'product' });
139
+ connectDataLayer(analytics); // every GA4 ecommerce push becomes a spec event
140
+ analytics.identify({ userId: customer.id, email_sha256: await hashEmail(customer.email) });
141
+ ```
142
+
143
+ `createAnalytics` takes everything as options and fetches nothing: it is what `bootstrap` calls once
144
+ the settings have answered. Add the named methods (`analytics.productViewed(...)` and so on)
145
+ wherever a page has something the dataLayer does not.
146
+
147
+ Since 3.1 (spec 1.2) there is a named method for every Segment Ecommerce V2 event, including the
148
+ five spec 1.2 restored:
149
+
150
+ | Method | Event | Notes |
151
+ |---|---|---|
152
+ | `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 |
153
+ | `productShared` / `cartShared` | Product Shared / Cart Shared | `share_message` and `recipient` are personal data; the ingest splits them off before anything is stored |
154
+ | `couponEntered` | Coupon Entered | The attempt, before the outcome. The code goes in `coupon_name` (Segment's `coupon_id` is accepted too) |
155
+ | `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 |
156
+
157
+ Since 3.5 (spec 1.4):
158
+
159
+ | Method | Event | Notes |
160
+ |---|---|---|
161
+ | `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 |
162
+ | `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 |
163
+ | `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()` |
164
+ | `newsletterUnsubscribed` | Newsletter Unsubscribed | An opt-out made on the site. The ESP reports its own opt-outs as Email Unsubscribed |
165
+ | `checkoutFailed` | Checkout Failed | `stage` and `reason_kind` are required. `payment_method` is the kind of instrument, never card data |
166
+
167
+ `identityLinked` and `slotEngaged` complete the set: every spec event a browser may send has a
168
+ named method, and `hp.track('<Spec Event>', …)` is typed by the event's schema as the client's
169
+ `track` is.
170
+
171
+ New optional fields on existing methods: `search_request_id` on `productsSearched`, `productClicked`
172
+ and `productAdded` (your search engine's id for the result set, such as Algolia's `queryID`);
173
+ `initiator` (`shopper` or `system`) on `productAdded` and `productRemoved`;
174
+ `products[].subscription` on basket and order lines; `channel` and `phone_sha256` on
175
+ `newsletterSubscribed`. A search page sends `productsSearched` with its results and never a
176
+ `productListViewed` for the same grid.
177
+
178
+ ## Options
179
+
180
+ | Option | Default | |
181
+ |---|---|---|
182
+ | `apiHost` | the HP API | Only for a first-party proxy. Events are POSTed to `${apiHost}/v1/batch`. |
183
+ | `writeKey` | `'web'` | The source's publishable key, `hp_pk_…`. Sent in the batch body. |
184
+ | `propertyId` | required | `context.source.property_id`. |
185
+ | `channel`, `market`, `environment` | `null` | The rest of `context.source`. |
186
+ | `currency`, `getCurrency` | `null` | ISO-4217 currency of this request; the getter is read per event and wins. A per-call override wins over both. |
187
+ | `shipToCountry`, `getShipToCountry` | `null` | ISO-3166 alpha-2 destination, per event. |
188
+ | `locale`, `getLocale` | `null` | The storefront locale served (BCP-47), per event. |
189
+ | `country` | `null` | Visitor country; the ingest derives it at the edge and overwrites this. |
190
+ | `consent` | none | A consent adapter (see below). Makes the SDK strict. |
191
+ | `consentTimeoutMs` | `5000` | How long events wait for the CMP before they are dropped. |
192
+ | `consentCookieDomain` | host only | `Domain` for the consent cookie. |
193
+ | `onConsentStatus` | none | Called with the gate's status: `granted`, `denied` or `unknown` (timed out). |
194
+ | `storageNames` | `thg_hp_*` | Rename what the SDK stores; see [Storage](#storage). |
195
+ | `plan` | none | A Segment tracking plan, `{ track: { [event]: { enabled } } }`; `__default` covers unlisted events. |
196
+ | `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`. Patterns are anchored and case-insensitive. |
197
+ | `queryParamBlocklist` | `[]` | Strip these params. Ignored when an allowlist is given. |
198
+ | `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. |
199
+ | `getAnonymousId`, `getUserId`, `getSessionId`, `getRenderId` | none | Identity accessors. Without `getSessionId` the SDK keeps its own session (see [Session ids](#session-ids)); without `hp_cookie` it never mints a device id. |
200
+ | `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. |
201
+ | `pageType` | `null` | `context.page.type` before the first `page()` call. |
202
+ | `onEvent` | none | Observer for every event that will be sent. |
203
+ | `debug` | `false` | Logs every event and delivery problem to the console. |
204
+ | `validate`, `throwOnInvalid` | none | Inject `validateEvent` from `@thg-agentic/hyper-personalisation-spec` in development. |
205
+ | `transport` | built in | Replace delivery, for example in tests. |
206
+
207
+ The client has one method per browser event in the spec, plus `track`, `page`, `identify`,
208
+ `reset`, `signedOut`, `flush`, `servingHeaders`, and the read-only `consent` and
209
+ `lastPageMessageId`. Every method takes an optional last argument of context overrides, either as
210
+ the overrides or as `{ context }`. An event method returns the event, or null when the tracking
211
+ plan disables it. Declarations are in `types/`, and event properties are typed from the registry.
212
+
213
+ ## Calling the serving API
214
+
215
+ Recommendations, decisions and audience membership personalise only for a visitor who granted
216
+ personalisation. Every request to them carries the visitor's consent in the `hp-consent` header,
217
+ in the consent cookie's serialisation (`a=1&p=0`):
218
+
219
+ ```js
220
+ const res = await fetch(`https://api.example.com/v1/recommendations?placement=pdp`, {
221
+ headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
222
+ });
223
+ ```
224
+
225
+ Without `p=1` the API answers with the non-personalised response. `servingHeaders(state)` from
226
+ `./consent` builds the header from any consent state.
227
+
228
+ ### The visit, for a rail that reacts to it
229
+
230
+ Since 3.11, `hp.recommendationContext()` returns what this visitor did earlier in the visit, as the
231
+ recommendations API's session parameters: `viewed` (products viewed, clicked or added), `basket`,
232
+ `q` (searches) and `impressed` (products shown and not engaged with, as `id:count`), newest first
233
+ and within the API's limits. Append it to the rail request, or send it to the server that makes it:
234
+
235
+ ```js
236
+ const visit = new URLSearchParams(hp.recommendationContext());
237
+ const res = await fetch(`https://api.example.com/v1/recommendations?recipe=similar_items&sku=${sku}&${visit}`, {
238
+ headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
239
+ });
240
+ ```
241
+
242
+ The SDK keeps the visit from the events it sends (Product Viewed, Clicked, Added, Removed, Added to
243
+ Wishlist, Products Searched, and the products of a list viewed or filtered), in this browser's storage for the
244
+ session, only while the visitor allows personalisation; withdrawing it forgets the visit. The call
245
+ is synchronous and never throws. Before the client has started on a page it answers with the visit
246
+ an earlier page kept (for 30 minutes from its last event), so a rail requested at page load, before
247
+ the SDK has downloaded, still carries it. It returns `{}` without personalisation consent, and for a
248
+ client whose host decided consent itself (no consent adapter), which the API would not personalise. `createAnalytics` clients have it as
249
+ `client.recommendationContext()`; `recommendationContextFrom(signals)` from `./session` builds the
250
+ same from session signals you keep yourself.
251
+
252
+ ### Holdout and exposure
253
+
254
+ Since 3.2 (spec 1.3). A property can hold a share of its shoppers out of personalisation, so its
255
+ uplift can be measured. The settings carry each property's holdout, and the edge, the public API
256
+ and the SDK all hold out the same shoppers (by a hash of the anonymous id;
257
+ `spec/assignment/holdout.json` has the vectors). A held-out shopper gets the default everywhere.
258
+ Since spec 1.6 a property may also set a ramp, the share of all shoppers personalised; shoppers
259
+ above holdout + ramp are `excluded` and get the default too, with no exposure. Personalise in the page
260
+ only for the `personalised` group:
261
+
262
+ ```js
263
+ if (hp.personalisationGroup() === 'personalised') applySessionAudiences();
264
+ ```
265
+
266
+ `inHoldout()` means "show the default": true for held-out and excluded shoppers alike, and before
267
+ the client has loaded, so the older `if (!hp.inHoldout())` gate stays safe under a ramp.
268
+ `personalisationGroup()` gives the exact group.
269
+
270
+ A membership, decisions or recommendations response that personalised the page, or would have for
271
+ a held-out shopper, carries an `exposure` block. Record it after rendering, so both sides of the
272
+ holdout are counted on the same trigger. The SDK sends one Personalisation Exposed per token:
273
+
274
+ ```js
275
+ const body = await res.json();
276
+ render(body);
277
+ hp.recordExposure(body);
278
+ ```
279
+
280
+ Since 3.7, recording a decisions response also puts its decisions in force. Every later event in
281
+ the browser session carries them as `context.decisions`, latest per slot, so a basket is credited
282
+ to the slot shown before it. Send the session id your events carry as `session_id` on
283
+ `/v1/decisions` too: the reward joins a basket to a decision within one session. Pass
284
+ `getDecisions` to supply `context.decisions` yourself instead.
285
+
286
+ ### Server-side assignments
287
+
288
+ A page your server rendered from `POST /v1/assignments` and cached per arm has no `decision_id` in
289
+ it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously,
290
+ for that page's `Slot Engaged`:
291
+
292
+ ```js
293
+ const decisionId = hp.assignmentDecisionId({ slot, arm });
294
+ if (decisionId) hp.slotEngaged({ slot, arm, engagement: 'view', decision_id: decisionId });
295
+ ```
296
+
297
+ It reads the session cookie named in `identityCookies.sessionId`, else the visitor cookie
298
+ (`identityCookies.anonymousId`), as your renderer forwards them to HP, so it answers once
299
+ `bootstrap` has been called, before the client has started. It returns `null` before that, or
300
+ with neither cookie. The id is `UUIDv5(ASSIGNMENT_DECISION_NAMESPACE, "<session>:<slot>:<arm>")`;
301
+ `assignmentDecisionId({ sessionId, slot, arm })` from the main entry computes it from values you
302
+ pass, and the spec's `assignment/decision-id.json` has the vectors.
303
+
304
+ Such a page also reports the property holdout's exposure itself, when the slot is on screen, for
305
+ both groups. The answer's `hash` is `holdout` for a held-out visitor (cache that page apart from
306
+ the `""` one); on any other page with a hash the visitor was treated. The exposure token is the
307
+ assignment's derived id for the arm shown, control included. Since 3.13 the shares come from the
308
+ property's settings when the exposure has none:
309
+
310
+ ```js
311
+ const token = hp.assignmentDecisionId({ slot, arm });
312
+ hp.recordExposure({
313
+ exposure: { token, surface: 'decisions', holdout },
314
+ decisions: [holdout ? { slot, holdout: true } : { slot, arm, decision_id: token }],
315
+ });
316
+ ```
317
+
318
+ A surface your server fetched from HP for the page, so the page never sees HP's answer, reports
319
+ its exposure with `surfaceViewed` when it comes into view: a product rail your server asked HP for,
320
+ which HP answers with its rail for a treated visitor and the site's own rail for a held-out one.
321
+ Since 3.14 the SDK groups the visitor itself, with the holdout settings, salt and visitor id serving
322
+ used (`spec/assignment/holdout.json`), so it is the group serving answered with. It records one
323
+ exposure per session per surface, and nothing for a visitor outside the experiment: no holdout in
324
+ the settings, outside the ramp, no visitor or session id, or a rail or audience without
325
+ personalisation consent, which serving needs to personalise them:
326
+
327
+ ```js
328
+ hp.surfaceViewed({ surface: 'recommendations' });
329
+ ```
330
+
331
+ Since 3.15 markup does the same without a call: `bootstrap`'s autocapture calls `surfaceViewed`
332
+ for an element tagged `data-hp-exposure="<surface>"` (`recommendations`, `decisions` or
333
+ `audiences`) once it is viewable, half on screen for a continuous second (MRC), including one
334
+ rendered after the SDK started. Tag what renders for both groups. `trackExposures(client)` from
335
+ `./auto` is the same for `createAnalytics` clients; `autocapture: { exposures: false }` turns it
336
+ off.
337
+
338
+ ```html
339
+ <section data-hp-exposure="recommendations">…</section>
340
+ ```
341
+
342
+ ### Bandit against the default
343
+
344
+ Since 3.16. A readout slot (the placement's **Peak readout** setting in the console) splits the
345
+ shoppers it personalises 50/50, by a hash of the device id salted with the slot
346
+ (`banditSplit(slot, anonymousId)`, vectors in the spec's `assignment/bandit-split.json`): half get
347
+ the bandit's arm, half the slot's default. The settings list each property's readout slots
348
+ (`holdouts.<property>.readoutSlots`), so `recordExposure` adds the shopper's side to the slot's
349
+ entry as `variant: 'bandit' | 'default'`, for a cached server-rendered page as for a serving
350
+ response (whose own `variant` wins). The default side is no decision: its `decision_id` is dropped
351
+ and it is not put in force. Nothing changes in your calls.
352
+
353
+ ### Preview
354
+
355
+ Since 3.15. A trader's preview link from the console opens the site with `?hp_preview=<token>`.
356
+ The SDK keeps the token in the `thg_hp_preview` session cookie for the rest of the browser
357
+ session, for your server to forward to `/v1/assignments` as `preview` (the query parameter on the
358
+ first request, the cookie after), which then answers that link's arm and logs nothing. While a
359
+ preview is on, `recordExposure`, `surfaceViewed`, `data-hp-exposure` and `slotEngaged` send nothing
360
+ and put nothing in force: the trader is not a shopper, so must neither train a slot nor count in its
361
+ readout. Every other event is sent as usual. `?hp_preview=` with no value ends the preview. The
362
+ SDK never checks the token: serving does, and ignores one that has expired.
363
+
364
+ ### Audiences only some integrations may target
365
+
366
+ Since 3.8, only audiences marked Targetable in the console are membership. Pass the membership
367
+ response's `audiences` and `qualified_for`, and the datafile's `session_audiences`, to
368
+ `resolveAudienceMemberships` exactly as before:
369
+
370
+ ```js
371
+ const memberships = resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for);
372
+ ```
373
+
374
+ A targetable visit audience may require an audience nobody may target. The server sends that base
375
+ in `qualified_for` (never in `audiences`), and the datafile ships it with `targetable: false` if the
376
+ browser evaluates it. From 3.8 the resolver uses both to meet the `requires`, and never returns
377
+ them, so `context.audiences` and exposures carry targetable audiences only. Before 3.8 such an
378
+ audience matches nobody, and a shipped untargetable visit audience the visitor meets may be reported
379
+ as membership. Every other audience resolves as it did.
380
+
381
+ Social proof has a client of its own, which batches, caches and never throws:
382
+
383
+ ```js
384
+ const proof = createSocialProof({ writeKey, getConsent: () => hp.consent });
385
+ const badges = await proof.forProducts(['123', '456']); // GET /v1/social-proof, Bearer writeKey
386
+ ```
387
+
388
+ It sends the key and `hp-consent` itself; `propertyId` is needed only for a key scoped to several
389
+ properties.
390
+
391
+ ## Consent
392
+
393
+ With a `consent` adapter the SDK is strict:
394
+
395
+ - **Before the CMP answers**, events are held in memory: not sent, not stored. They wait up to
396
+ `consentTimeoutMs`.
397
+ - **Analytics granted:** held events are sent, each carrying the answer in `context.consent`.
398
+ - **Analytics refused:** held events are dropped, and so is everything after. Nothing goes into
399
+ local or session storage.
400
+ - **No answer by the timeout:** held and later events are dropped until an answer arrives. Strict
401
+ is the only mode: an unanswered CMP is never a grant, however long it takes.
402
+ - **Page closed before an answer:** nothing is sent. Switching to another tab is not closing the
403
+ page: held events wait for the visitor to come back.
404
+ - **A navigation before an answer** (a sign-in that redirects) loses what the page held, unless
405
+ you opt in with `holdAcrossPages: true` (since 3.5). Then held events are also kept in
406
+ sessionStorage under `thg_hp_held:<property>` and sent when the next page's answer is a grant,
407
+ stamped with it. A refusal deletes them, and so does a tab left unanswered for 30 minutes. This
408
+ writes event data to the device before the visitor answers, which strict mode otherwise never
409
+ does, so turn it on only where your consent policy allows that.
410
+ - **The retry buffer:** a batch is kept in localStorage only while analytics is granted, and one
411
+ kept on an earlier page is replayed only once this page's answer is a grant. A refusal clears it.
412
+ - **Consent changes:** each change is recorded as a `Consent Updated` event. A decision that
413
+ matches the last one this device reported (read from `thg_hp_consent`) is not a change. A
414
+ withdrawal of analytics is also sent, once, stamped with the withdrawn state: the raw event
415
+ table is where a refusal is shown to have been received, and the warehouse's consent filter
416
+ keeps that event out of every model. After that, nothing more is sent.
417
+ - **Changes nothing announces:** after the first answer, an adapter whose `subscribe` cannot see
418
+ every change (a cookie adapter on a browser without the Cookie Store API, or your own adapter
419
+ without `observesChanges: true`) is re-read every 2 seconds while the page is visible.
420
+
421
+ - **Purposes the property does not ask for:** with the settings' `consent.purposes`, only the
422
+ purposes listed as `true` are reported (analytics always is). The others read as unanswered in
423
+ `context.consent`, the cookie and `hp-consent`, so nothing claims a grant the property never
424
+ sought.
425
+
426
+ `bootstrap` requires a consent adapter. `createAnalytics` without one sends straight away with
427
+ `context.consent: { basis: 'host' }`: that is for a host that has settled the lawful basis itself,
428
+ and `staticConsent` says so more explicitly. Leaving the adapter out is that statement.
429
+
430
+ - **Who decided (3.7.0, spec 1.5):** every event's `context.consent.basis` says whose decision it
431
+ carries. `visitor` is the visitor's own answer, from any adapter but `staticConsent`. `host` is
432
+ the site's decision: no adapter, or `staticConsent`. Under `visitor` a null purpose is unanswered,
433
+ so the warehouse keeps a browser event only with `analytics: true`. Under `host` a null purpose is
434
+ no statement, so the event is kept unless `analytics` is `false`. An adapter that reports the
435
+ site's decision rather than asking the visitor can say so with `basis: 'host'`.
436
+
437
+ ### Adapters
438
+
439
+ All adapters are imported from `@thg-agentic/hyper-personalisation-web/consent`.
440
+
441
+ | Adapter | Reads | Not answered while |
442
+ |---|---|---|
443
+ | `didomiConsent({ purposes })` | `Didomi.getCurrentUserStatus().purposes`, subscribed via `didomiEventListeners` / `consent.changed` | Didomi is not loaded, `shouldUserStatusBeCollected()` is true, or `isUserStatusPartial()` is true |
444
+ | `oneTrustConsent({ groups, impliedConsent })` | `OnetrustActiveGroups`, else the `OptanonConsent` cookie's `groups=`; subscribed via `OneTrustGroupsUpdated`, `OptanonWrapper` and `OneTrust.OnConsentChanged` | The banner was never closed (`OptanonAlertBoxClosed`) and never interacted with (`interactionCount`) |
445
+ | `cookieConsent({ name, categories, parse, refusedValue, source, changeEvent })` | A first-party cookie your banner writes; subscribed via a `thg_hp_consent_change` window event (`changeEvent`) and Cookie Store `change` | The cookie is absent, or its value cannot be parsed |
446
+ | `staticConsent(state)` | The state you pass, for sites with no CMP | Never |
447
+
448
+ - **Didomi purposes.** The defaults are:
449
+ - analytics: `analytics`, `measure_content_performance`, `audience_measurement`
450
+ - marketing: `marketing`, `targeting`, `advertising`, `select_personalized_ads`,
451
+ `create_ads_profile`
452
+ - personalisation: `personalisation`, `personalization`, `select_personalized_content`,
453
+ `create_content_profile`
454
+
455
+ An id also matches Didomi's suffixed custom ids (`analytics-BNjaanaW`). A spec purpose is
456
+ granted if any of its mapped Didomi purposes is enabled. If the notice defines none of them,
457
+ it is `null`.
458
+ - **OneTrust groups.** Defaults: analytics `C0002`, personalisation `C0003`, marketing `C0004`.
459
+ - `impliedConsent: true` accepts OneTrust's groups as soon as it loads. Only use it where the
460
+ geolocation rule is notice-only or opt-out, never for an opt-in (GDPR) audience.
461
+ - Global Privacy Control (`navigator.globalPrivacyControl`, or `isGpcEnabled=1` in the cookie)
462
+ turns marketing and both ad signals off. It does not change analytics.
463
+ - **Ad signals.** In every adapter, `ad_user_data` and `ad_personalization` follow marketing
464
+ unless you map them separately.
465
+ - **Your own banner (`cookieConsent`).** `name` and `categories.analytics` are required.
466
+ - By default the (URL-decoded) cookie value is a comma-separated list of accepted category ids;
467
+ an empty value is unknown, never a refusal.
468
+ - `refusedValue` is a literal value meaning "rejected everything".
469
+ - `parse(raw)` handles any other format: return the accepted ids as a `Set`, or null when
470
+ unreadable.
471
+ - After writing its cookie, the banner should call
472
+ `window.dispatchEvent(new Event('thg_hp_consent_change'))`.
473
+
474
+ Any object with `{ name, read(), subscribe(onChange) }` is an adapter. `read()` returns null until
475
+ the CMP has answered, and the five purposes otherwise.
476
+
477
+ **`forwardedConsent()`** (bootstrap provider `"forwarded"`) is for pages that run without the CMP:
478
+ a separate checkout app, a subdomain or a microsite on the same registrable domain as a page that
479
+ has it. The SDK on the CMP page records the decision in `thg_hp_consent`; set `consentCookieDomain`
480
+ there so the cookie reaches the other pages, and use `"forwarded"` on them. A site that renames the
481
+ cookie with `storageNames.consent` gets the same name read back by `"forwarded"` when both pages
482
+ pass the same `storageNames`, or passes `forwardedConsent({ name })` directly. No cookie, or no analytics answer in it,
483
+ means not answered, and strict mode then drops the events.
484
+
485
+ ## GA4 dataLayer
486
+
487
+ ```js
488
+ const disconnect = connectDataLayer(analytics, {
489
+ dataLayer: window.dataLayer, // default; created if absent
490
+ ignore: ['view_promotion'], // e.g. when trackPromotions() measures impressions instead
491
+ steps: { shipping: 2, payment: 3 },
492
+ map: {
493
+ size_guide_opened: (entry, { products }) => ({ event: 'Product Clicked', properties: { products: products.slice(0, 1) } }),
494
+ },
495
+ });
496
+ ```
497
+
498
+ What it reads and how it hooks in:
499
+
500
+ - **Existing entries.** Entries already on the array are read when you connect.
501
+ - **New pushes.** It wraps `push` and calls the previous `push` first. GTM does the same, so
502
+ both keep working whichever loads first. It never replaces the array and never throws into the
503
+ page.
504
+ - **Push shapes.** The GA4 standard only:
505
+ - `{ event: 'view_item', ecommerce: { items, currency, value } }`, as GTM documents it;
506
+ - non-ecommerce parameters at the top level (`{ event: 'login', method }`);
507
+ - `gtag('event', name, params)`.
508
+ - **Currency** is `ecommerce.currency`, set as `context.currency`.
509
+ - **Items.** GA4 items map to spec products:
510
+ - `productId(item)` (default `item_group_id ?? item_id`) becomes `product_id`, and `item_id`
511
+ becomes `sku`.
512
+ - `item_category` to `item_category5` are joined with `/`.
513
+ - `item_variant`, `discount`, `index` (as a 1-based position), `coupon` and `image_link` are
514
+ carried over.
515
+ - **Duplicates.** Identical pushes (same event, same item ids, same value) within 1 second
516
+ (`dedupeMs`) are sent once.
517
+ - **Unusable pushes.** A push that cannot make a valid event, such as one with no product ids
518
+ where products are required, is skipped.
519
+ - **Custom mappings.** `map` adds or overrides a mapping, keyed by GA4 event name. It receives the
520
+ push and helpers (`products`, `items`, `currency`, `value`, `promotions`, `toProducts`).
521
+ - **Any client.** It works with this package's client and with an analytics-next instance: it
522
+ only calls `track(event, properties, { context })`, plus `signedOut()` when the client has one.
523
+
524
+ | GA4 event | Spec event | Notes |
525
+ |---|---|---|
526
+ | `view_item` | Product Viewed | First item only (the event holds exactly one product); `value`, `currency` |
527
+ | `view_item_list` | Product List Viewed | `list_id` from `item_list_id`, else `item_list_name` |
528
+ | `view_search_results` | Products Searched, or Product List Viewed | Products Searched if `search_term` is present |
529
+ | `search` | Products Searched | `query` = `search_term` |
530
+ | `select_item` | Product Clicked | |
531
+ | `add_to_cart` / `remove_from_cart` | Product Added / Product Removed | `cart_id` when present |
532
+ | `view_cart` | Cart Viewed | `value`, `currency`, `coupon`, products |
533
+ | `begin_checkout` | Checkout Started | `value`, `currency`, `coupon`, products |
534
+ | `add_shipping_info` | Checkout Step Completed | `step` 2, `step_name` `'shipping'`, `shipping_method` = `shipping_tier` |
535
+ | `add_payment_info` | Payment Info Entered | `step` 3, `step_name` `'payment'`, `payment_method` = `payment_type` |
536
+ | `view_promotion` / `select_promotion` | Promotion Viewed / Promotion Clicked | Fields from `ecommerce` or its items; one event per `promotion_id`. `creative_slot` is sent as Segment's `position` |
537
+ | `add_to_wishlist` | Product Added to Wishlist | |
538
+ | `share` | Product Shared | Only when `item_id` is present and `content_type` is absent or a product (`product`, `item`); `share_via` = `method`, `product_id` = `item_id` |
539
+ | `login` / `sign_up` | Signed In / Signed Up | `method` mapped to the spec enum (`email` becomes `password`, anything unknown becomes `other`) |
540
+ | `purchase`, `refund` | not mapped | Orders are reported by the service that took them, which sees every order exactly once, including app and redirect orders. The ingest refuses them from browser keys. |
541
+
542
+ GA4 has no event for removing from a wishlist, moving a wishlist item to the basket, sharing a
543
+ basket, signing out or filtering a list, and no coupon events at all (`coupon` is a parameter on
544
+ its commerce events, carried through above). Add them with `map` or a preset if your site pushes
545
+ them, or call the client's named methods directly.
546
+
547
+ ## Presets
548
+
549
+ The core knows GA4 and Segment. A preset teaches it one platform's conventions. It is plain
550
+ configuration, spread into the options, and each part can be overridden:
551
+
552
+ - `eventName(entry)`: the GA4 name of a push (default `entry.event`);
553
+ - `transform(entry)`: rewrites a push into the GA4 shape before anything reads it, or returns null
554
+ to skip it;
555
+ - `productId(item)`: the parent product id of an item;
556
+ - `map` and `ignore`: as above;
557
+ - `consent`: optional adapter factories by provider name, used by `bootstrap`.
558
+
559
+ `firstId(...candidates)`, exported from `./datalayer`, is the id helper the core uses: the first
560
+ non-blank candidate as a string, or null.
561
+
562
+ A preset for a hypothetical platform whose pushes look like
563
+ `{ acmeEvent: 'basket_add', money: 'EUR', lines: [{ sku, style }] }`:
564
+
565
+ ```js
566
+ export const acme = {
567
+ eventName: (entry) => ({ basket_add: 'add_to_cart', pdp_view: 'view_item' })[entry.acmeEvent] ?? null,
568
+ transform: (entry) =>
569
+ entry.acmeEvent
570
+ ? {
571
+ ...entry,
572
+ ecommerce: {
573
+ currency: entry.money,
574
+ items: (entry.lines ?? []).map((line) => ({ item_id: line.sku, item_group_id: line.style, ...line })),
575
+ },
576
+ }
577
+ : entry,
578
+ productId: (item) => item.item_group_id ?? item.item_id,
579
+ map: { wishlist_remove: (entry, { products }) => ({ event: 'Product Removed from Wishlist', properties: { products } }) },
580
+ ignore: [],
581
+ };
582
+
583
+ connectDataLayer(analytics, { ...acme, ignore: [...acme.ignore, 'begin_checkout'] });
584
+ ```
585
+
586
+ ## Identity and hashing
587
+
588
+ `emailSha256(email)` returns the SHA-256 of the trimmed, lowercased email as lowercase hex,
589
+ synchronously (since 3.9), so a page hashes an address in the same call that reports it and never
590
+ awaits the SDK. This is the rule `identify.email_sha256` expects. It returns null for anything that
591
+ is not an email address. `hashEmail(email)` is the same as a promise, and `sha256(value)` the
592
+ underlying helper.
593
+
594
+ `hashPhone(phone)` returns the SHA-256 of the E.164 number (`+447700900123`), after removing spaces,
595
+ dots, dashes and brackets. This is the rule `identify.phone_sha256` expects. It returns null for a
596
+ national number such as `07700 900123`: convert to E.164 first, since the country cannot be guessed
597
+ and a number hashed as typed never matches the same number sent in E.164.
598
+
599
+ Already have the digest? Platforms encode SHA-256 as hex or as base64 (some platforms return
600
+ base64). `bootstrap`'s `identity.emailSha256` and `identify`'s `email_sha256` (since 3.9) accept
601
+ either, and `normaliseSha256(value)` converts either to the lowercase hex the spec wants. It re-encodes the same digest and never
602
+ re-hashes, so pass the hash, never the email.
603
+
604
+ ## Handing off to server producers
605
+
606
+ Server-side producers (for example an order service using the Java SDK) read two first-party cookies:
607
+
608
+ - **`thg_hp_consent`** carries the visitor's consent decision.
609
+ - It is written whenever the decision is known or changes, with `Path=/`, `SameSite=Lax`, a
610
+ `Max-Age` of 180 days, `Secure` on https, and `Domain` from `consentCookieDomain`.
611
+ - Its value is `a=1&m=0&p=1&aud=0&adp=0`: analytics, marketing, personalisation,
612
+ ad_user_data and ad_personalization.
613
+ - `1` means granted, `0` refused, and a missing key means unknown.
614
+ - The format is byte-identical to `ConsentCookie` in analytics-java, and a test checks this
615
+ against the Java parser.
616
+ - **The device and session cookies.** The device cookie is the one the source's settings choose:
617
+ your own, or `thg_hp_anonymous_id` under `hp_cookie`. Your server must read that one, and the
618
+ session cookie you name in `identityCookies` (or return from `getSessionId`), so both sides agree
619
+ on who the visitor is. `client.anonymousId` is the id events carry.
620
+
621
+ ## Choosing a delivery path
622
+
623
+ There are two ways to deliver events. Both use the same consent adapters, the same consent
624
+ logic, the same context shaping and URL scrubbing, the same settings and the same cookie. Only the
625
+ delivery differs.
626
+
627
+ **Built-in client** (`bootstrap` / `createAnalytics`)
628
+ - Dependency-free.
629
+ - Batched, with a retry buffer in localStorage: each batch is stored before it is sent and
630
+ removed when the ingest accepts it (or rejects it for good), so an outage costs a delay, not the
631
+ events. The buffer is per tenant and capped at 20 batches and 1 MB, oldest dropped first.
632
+ - On page unload, sends by `sendBeacon` in chunks that fit.
633
+ - Size: the CDN script, which is `bootstrap` with every consent adapter and the dataLayer bridge,
634
+ is 13.4 KB gzip (38.4 KB minified). A test fails it past 15 KB.
635
+
636
+ **analytics-next plugins** (`cdpPlugins` from `./segment`)
637
+ - For sites already using `@segment/analytics-next`: keep it, and add the spec on top of it.
638
+ - Size: about 50 KB gzip as a single bundle; with code splitting, about 39 KB loads up front.
639
+
640
+ Use the built-in client on performance-critical storefronts. Use the plugins if you are already
641
+ on Segment: the wire format and the spec are identical.
642
+
643
+ ```js
644
+ import { AnalyticsBrowser } from '@segment/analytics-next';
645
+ import {
646
+ cdpPlugins,
647
+ DEFAULT_API_HOST,
648
+ hpLoadOptions,
649
+ } from '@thg-agentic/hyper-personalisation-web/segment';
650
+ import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
651
+
652
+ const hp = { writeKey: 'hp_pk_live_…' };
653
+ const analytics = AnalyticsBrowser.load(
654
+ {
655
+ writeKey: hp.writeKey,
656
+ cdnURL: DEFAULT_API_HOST, // or your proxy, passed as hp.apiHost too
657
+ plugins: cdpPlugins({ consent: didomiConsent(), getCurrency: () => getCookie('currency') }),
658
+ },
659
+ hpLoadOptions(hp),
660
+ );
661
+ ```
662
+
663
+ Things to get right when configuring analytics-next:
664
+
665
+ - **`cdnURL` is the HP API** (`DEFAULT_API_HOST`), or your proxy. analytics-next fetches its settings from
666
+ `{cdnURL}/v1/projects/{writeKey}/settings`, the same Segment-compatible document `bootstrap`
667
+ reads. The plugins take the property, environment, reported purposes, allowlist and tracking plan
668
+ from its `hp` block.
669
+ - **`hpLoadOptions` is required with it.** The settings carry no `Segment.io` destination, and
670
+ without one analytics-next sends to api.segment.io with no key. `hpLoadOptions` points delivery
671
+ at the settings' apiHost with the write key, turns analytics-next's own telemetry off (it would
672
+ otherwise POST to `<apiHost>/m` every 30 seconds), and makes a disabled key a no-op.
673
+ - **`plugins` go in the first argument** of `AnalyticsBrowser.load`. In the second, they are
674
+ ignored.
675
+ - **Endpoints.** analytics-next POSTs one event per request to `<apiHost>/v1/t`, `/v1/p` and
676
+ `/v1/i`, or a `{ batch }` body to `/v1/b` with the batching strategy. The write key is in the
677
+ body and in an `Authorization: Basic` header.
678
+ - **Keep strict consent strict.** `hpLoadOptions` sets `disableClientPersistence`, which stops
679
+ analytics-next writing its queue and ids to storage before consent. Keep it if you build the
680
+ options yourself.
681
+ - **Device id.** Pass `getAnonymousId` to `cdpPlugins` to use the site's device id instead of
682
+ analytics-next's.
683
+
684
+ `@segment/analytics-consent-tools` is not used, for two reasons. It writes
685
+ `context.consent.categoryPreferences`, which is not the spec's consent block. And it keeps sending
686
+ to Segment.io after a withdrawal.
687
+
688
+ ## CDN script
689
+
690
+ For a site without a bundler, `dist/hp.min.js` is `bootstrap` as one script (13.4 KB gzip), and
691
+ `dist/snippet.min.js` is the loader to paste inline in `<head>`, Segment-style: it puts a stub on
692
+ `window.hp` that queues every call, loads the script asynchronously, and the script drains the
693
+ queue into the client once the settings have answered.
694
+
695
+ ```html
696
+ <script>
697
+ /* dist/snippet.min.js, with your write key and options in its hp.load(…) call */
698
+ </script>
699
+ <script>
700
+ hp.productViewed({ products: [{ product_id: '10530943' }] }); // queued until the script loads
701
+ </script>
702
+ ```
703
+
704
+ The snippet's `hp.load(config, options, src)` takes the same `{ writeKey }` (and optional `apiHost`) and options as
705
+ `bootstrap`, and the script URL: `https://cdn.jsdelivr.net/npm/@thg-agentic/hyper-personalisation-web@3/dist/hp.min.js`
706
+ once published, or wherever you host `dist/hp.min.js`. Once loaded, `window.hp` stays the same
707
+ object, forwards every call to the client, and holds it as `hp.client`. A key the settings do not
708
+ enable leaves every method a no-op. Without the snippet, call `hp.load(config, options)` after the
709
+ script has loaded.
710
+
711
+ The ESM build is the primary distribution; the script is for pages that cannot use it. It targets
712
+ evergreen browsers.
713
+
714
+ ## Storage
715
+
716
+ Everything the SDK stores carries the `thg_hp_` prefix, and every name can be changed with
717
+ `storageNames` (on `bootstrap`, `createAnalytics` or `cdpPlugins`). The names are page-wide: one
718
+ SDK per page.
719
+
720
+ | Kind | Default name | Where | Scope |
721
+ |---|---|---|---|
722
+ | `consent` | `thg_hp_consent` | cookie | the consent decision, for your servers and `forwarded` pages |
723
+ | `retry` | `thg_hp_retry:<property>` | localStorage | batches waiting for the ingest; only under a grant |
724
+ | `sessionSignals` | `thg_hp_session_signals:<property>:<session>` | localStorage (sessionStorage until a session is bound) | in-session audience counters |
725
+ | `identified` | `thg_hp_identified:<property>` | sessionStorage | the identify already sent this session |
726
+ | `previousPage` | `thg_hp_prev_page:<property>` | sessionStorage | the previous page view's messageId; only under a grant |
727
+ | `decisions` | `thg_hp_decisions:<property>` | sessionStorage | the decisions in force, latest per slot; only under a grant |
728
+ | `settings` | `thg_hp_settings:<writeKey>` | localStorage | the last good settings; only under a grant |
729
+ | `held` | `thg_hp_held:<property>` | sessionStorage | events held for an unanswered CMP; only with `holdAcrossPages` |
730
+ | `carried` | `thg_hp_carried:<writeKey>` | sessionStorage | `hp` calls made before the SDK started, on a page that navigated first (3.9); only under a grant, or with `holdAcrossPages` |
731
+ | `recommendationContext` | `thg_hp_recommendation_context` | localStorage | The visit's wire form, for `hp.recommendationContext()` on the next page before the SDK starts there; 30 minutes from the last event, only while personalisation is granted (3.11) |
732
+ | `anonymousId` | `thg_hp_anonymous_id` | cookie, one per browser | Hyper-Personalisation's device id, for a source set to `hp_cookie`; only under a grant (see [Device ids](#device-ids)) |
733
+ | `session` | `thg_hp_session` | cookie, one per browser | the session id the SDK keeps when you name none, 30 minutes from the last event; only under a grant (3.15, see [Session ids](#session-ids)) |
734
+ | `preview` | `thg_hp_preview` | session cookie | a trader's preview token from `?hp_preview=` (3.15, see [Preview](#preview)) |
735
+ | — | `thg_hp_conformance_page_type` | sessionStorage | the previous page's type, only while the development-only conformance checker runs |
736
+
737
+ Keys are scoped by property because one origin can serve several tenants. The names the SDK used
738
+ before 3.0 (`cdp.retry`, `cdp_session_signals`, `cdp_identified`, `cdp_prev_page_message_id`) are
739
+ moved to the new names once, on the first page that loads 3.x, and deleted. A banner using
740
+ `cookieConsent` announces a change with the `thg_hp_consent_change` window event.
741
+
742
+ ### Device ids
743
+
744
+ Each web source takes its device ids (`anonymousId`) from one place, chosen in the console and
745
+ served in the settings as `hp.anonymousId`. There is never a fallback from one to the other: one
746
+ browser with ids from two places would split into two visitors, and nothing could say why.
747
+
748
+ | `hp.anonymousId` | The device id | |
749
+ |---|---|---|
750
+ | `{ source: 'site_cookie', cookie: 'site_visitor_id' }` | your own first-party cookie, by the name the console gives | read only, never minted; a visitor without it is sent with no `anonymousId` and `identity_trust: 'none'` |
751
+ | `{ source: 'site_cookie', cookie: null }` | the cookie you name in `identityCookies.anonymousId` (or return from `getAnonymousId`) | as above. Every web source that existed before the setting is set to this |
752
+ | `{ source: 'hp_cookie' }` | Hyper-Personalisation's first-party cookie, `thg_hp_anonymous_id` | minted by the SDK; your own device cookie is ignored. The default for a new source |
753
+ | absent (settings from before 3.6) | as `site_cookie` with `cookie: null` | |
754
+
755
+ The `hp_cookie` cookie is one per browser, not per property. It is written with `Path=/`,
756
+ `SameSite=Lax`, `Secure` on https, and the `Domain` of `consentCookieDomain` when you set one. Its
757
+ `Max-Age` is 400 days, and it is written again on every page under a grant, so it lasts 400 days
758
+ from the last visit. With a consent adapter it is never written before analytics is granted: until
759
+ then the id lives in memory, and in the held store with `holdAcrossPages`, so a redirect keeps it.
760
+ A refusal or withdrawal deletes it. Without a consent adapter you have decided the lawful basis
761
+ yourself, so it is written at once.
762
+
763
+ Changing a source's choice gives every returning visitor a new device id. A device id 3.5 minted
764
+ into localStorage (`thg_hp_anonymous_id:<property>`) becomes the cookie's value under a grant, and
765
+ the localStorage key is removed. `mintAnonymousId` is deprecated since 3.6 and ignored; with
766
+ `debug` the SDK says so once. `client.anonymousId` reads the id events carry, so a server can be
767
+ handed the same one.
768
+
769
+ ### Session ids
770
+
771
+ Every event carries `context.session_id`. Name your own session cookie in
772
+ `identityCookies.sessionId`, or pass `getSessionId`, and the SDK only reads it: it is then the
773
+ only source, even for a visitor who lacks the cookie. Without either, since 3.15, the SDK keeps
774
+ its own, as Amplitude and GA4 do: a random UUID in the `thg_hp_session` cookie, ended by 30 minutes
775
+ without an event. Its `Max-Age` is 30 minutes and every event writes it again, so it is shared by
776
+ the site's tabs and ends on its own. It is written with the attributes of the device cookie. With a
777
+ consent adapter it is never written before analytics is granted (until then the session lives in
778
+ memory), and a refusal deletes it. Before 3.15 such a site sent `session_id: null`.
779
+
780
+ ## CSP and proxying
781
+
782
+ The SDK makes requests only to `apiHost` (the settings, and the events unless the settings name
783
+ another host), so allow it in `connect-src`: `resolveApiHost(apiHost)` is that origin, the HP API
784
+ (`DEFAULT_API_HOST`) when you set none. Since 3.17 that is `https://hp.uat.thgingenuity.com`
785
+ (`connect-src https://hp.uat.thgingenuity.com`); 3.16 and earlier used a staging host that is
786
+ being retired. Allow the CDN host in `script-src` too if you use the script. Call the API host directly rather than through a CDN proxy route: behind a proxy the ingest
787
+ would see the CDN's IP and country instead of the shopper's.
788
+
789
+ ### Default host until production is live
790
+
791
+ | Release | `DEFAULT_API_HOST` | For |
792
+ |---|---|---|
793
+ | 3.12 to 3.16 | A staging host | Being retired. Upgrade. |
794
+ | 3.17 | `https://hp.uat.thgingenuity.com` | Interim: HP's nonprod stack. Test keys, UAT and previews only. |
795
+ | A later release | `https://hp.thgingenuity.com` | Production, for test and live keys alike. |
796
+
797
+ `hp.uat.thgingenuity.com` is the interim nonprod host, for test keys only, and is not open to
798
+ shoppers on the internet: where it cannot be reached the SDK starts, its settings request fails
799
+ and it sends nothing, which never breaks the page. Use it for UAT and preview builds with a
800
+ `hp_pk_test_…` key. **Do not ship a live key to production on 3.17**: go
801
+ live on the release that moves the default to `hp.thgingenuity.com`, whose notes will say so.
802
+
803
+ The SDK loads no scripts and sets no cookie except `thg_hp_consent`, once the CMP has answered,
804
+ and `thg_hp_anonymous_id` for a source set to `hp_cookie`, once analytics is granted.
805
+
806
+ ## Debugging
807
+
808
+ - **`debug: true`** logs every event as it is built, plus any delivery problems.
809
+ - **`validate: validateEvent`** checks every event against the spec as it is built and logs
810
+ anything the ingest would quarantine. Import `validateEvent` from `spec/lib/index.mjs`, or from
811
+ `@thg-agentic/hyper-personalisation-spec` where it is published, and use it in development only, since it pulls in
812
+ ajv. Add `throwOnInvalid: true` to fail fast in tests.
813
+ - **`createConformanceChecker`**, from `./conformance`, reports what a page is missing against
814
+ the registry's expectations (`page_expectations`). Since spec 1.2 a `list` page owes Product List
815
+ Viewed, and `checkout` owes Checkout Step Viewed on every page and Checkout Started on entry:
816
+ the first page of a run of checkout pages, known from the previous page's type in
817
+ `sessionStorage` (`thg_hp_conformance_page_type`), so a checkout on its own origin counts its first
818
+ page as the entry. It also looks inside `products[]`, against the product version each event
819
+ holds. An item the ingest would reject is an error. Two things the ingest accepts but the
820
+ warehouse cannot use are warnings: priced products with no currency, on the event or in
821
+ `context.currency` (the amounts drop out of revenue), and a category separated by `>` or `|`
822
+ rather than `/` (it counts as one level).
823
+
824
+ ## Licence
825
+
826
+ MIT. See [LICENSE](LICENSE).