@kelpclick/client
v0.7.0
Published
Client SDK for the Kelp commerce platform - search and browse now; events, identity, forms, and push to follow.
Maintainers
Readme
@kelpclick/client
The client SDK to integrate Kelp search, browse, and events into your JavaScript and React project
Full documentation (API, platforms, getting keys from the tenant dashboard) is at docs.kelp.click. This README is the SDK landing page: install, quick start, config, and changelog.
Docs • npm • Usage • Guides • Example apps • Changelog • Versioning • Report a bug
Features
- Typed search and browse client for Kelp collections, generic over your own document type, with optional per-request personalization (
userId/sessionId/personalize) and optionaldistinctBygrouping on search and browse, and an optional named hybridsemanticWeighton search - React hooks (
useSearch,useBrowse) with debouncing, pagination, facet selection, request cancellation, and stale-response guarding - Batched analytics with a closed named event taxonomy, optional per-event
metadata, auto-filled session / user / timestamp, plus a low-level transport. Product view, click, wishlist, and add-to-cart share optional GA4KelpItemFields(item_id,item_name,item_variant,item_brand,price,quantity,currency,item_category,index) and write the ones you pass ontometadata. Purchase lines keepobjectIdand accept the same item fields. - Campaign attribution -
createAttributioncaptures UTM params, click-id inference (Meta/Google/TikTok/Bing and more), and referrer classification; attachesattribution(current / last-touch) to events when present; emits a once-per-sessionsession_startedevent for CVR/ROAS reporting - Email capture -
createKelpEmaillets you subscribe / unsubscribe contacts and emitemail_subscribed/email_unsubscribedevents through the same buffered pipeline as analytics - Capability modules -
@kelpclick/client/search,@kelpclick/client/analytics,@kelpclick/client/emailsubpath exports let you import only what you need; the root@kelpclick/clientre-exports everything - Reliable delivery - optional retry with exponential backoff + jitter; 429 / 503 Retry-After respected; configurable
maxRetries,retryBaseMs,retryMaxMs - Observability hooks -
debugflag, customlogger, andonRequest/onResponse/onErrorlifecycle hooks for request tracing - Richer error model -
KelpErrorbase class;KelpApiErrorcarries acode('auth' | 'rate_limit' | 'client' | 'server' | 'network' | 'timeout') and optionalrequestId; network / timeout failures surface asKelpApiErrorrather than raw errors - Anonymous identity with a rolling session id, SSR-safe storage
- Framework-agnostic core that runs in both the browser and node / SSR
- Built with TypeScript, ships dual ESM + CJS + type declarations, tree-shakeable subpath exports (
@kelpclick/client,@kelpclick/client/search,@kelpclick/client/analytics,@kelpclick/client/email,@kelpclick/client/react)
Getting Started
Kelp is schema-agnostic, so you supply your own document type. The SDK ships type definitions and works in both browser and node environments. Get your API key from your Kelp tenant dashboard.
With a package manager
npm install @kelpclick/client@latest
# or
yarn add @kelpclick/client@latest
# or
pnpm add @kelpclick/client@latestReact 18+ is a peer dependency, needed only for the @kelpclick/client/react entry.
Without a package manager
Load it directly from a CDN:
<script type="module">
import { createKelpClient } from 'https://cdn.jsdelivr.net/npm/@kelpclick/client@latest/+esm';
</script>Subpath exports
The package ships both ESM and CJS builds, so it works with import (ESM bundlers, native modules) and require (CommonJS environments).
There are five entry points:
| Import path | Contents |
| --- | --- |
| @kelpclick/client | Everything - createKelpClient, createAnalytics, createIdentity, createKelpSearch, createKelpEmail, errors, types, VERSION. The root re-exports every capability. |
| @kelpclick/client/search | createKelpSearch(config) - search / browse / suggest. |
| @kelpclick/client/analytics | createAnalytics(options) + event taxonomy types. |
| @kelpclick/client/email | createKelpEmail(options) - email capture (subscribe / unsubscribe). |
| @kelpclick/client/react | KelpProvider, useSearch, useBrowse, context hooks. |
// root - re-exports everything
import { createKelpClient /* ... */ } from '@kelpclick/client';
// capability subpaths - import only what you need
import { createKelpSearch } from '@kelpclick/client/search';
import { createAnalytics } from '@kelpclick/client/analytics';
import { createKelpEmail } from '@kelpclick/client/email';
import { KelpProvider, useSearch /* ... */ } from '@kelpclick/client/react';// CommonJS
const { createKelpClient } = require('@kelpclick/client');Usage
- Quick start
- Client configuration
- Search
- Browse
- Request options
- Response shape
- Typing your documents
- Analytics
- Email capture
- Identity and sessions
- React
- Error handling
- SSR and custom fetch
- Security and privacy
- API reference
Quick start
import { createKelpClient } from '@kelpclick/client';
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click',
analyticsUrl: 'https://<tenant>-events.kelp.click',
apiKey: 'kelp_...', // scoped API key; sent as X-Api-Key
});
const res = await kelp.search('products', { q: 'shirt', perPage: 20 });
console.log(res.found); // total matches
console.log(res.hits); // KelpHit[] - each has .document and .highlights
console.log(res.queryId); // correlate later click events back to this searchClient configuration
createKelpClient(config) accepts:
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click', // required
analyticsUrl: 'https://<tenant>-events.kelp.click', // required
apiKey: 'kelp_...', // required (throws if missing); sent as X-Api-Key
// optional - tenant + transport
tenantCode: 'acme', // tenant slug; sent on the analytics batch envelope (not per-event), omitted when unset
timeoutMs: 10_000, // per-request timeout, default 10000
fetch: myFetch, // inject a fetch impl (SSR / testing)
serializeFilters: (f) => ({ filters: f }), // override the filter wire shape
// optional - search
semanticMinWords: 4, // min words in q before semanticWeight is sent; default 4, clamped 3-50
// optional - resilience
maxRetries: 2, // retry attempts after the initial try, default 0 (no retries)
retryBaseMs: 250, // base delay for exponential backoff, default 250
retryMaxMs: 5000, // cap on per-attempt delay, default 5000
// optional - observability
debug: true, // log every attempt at debug level, default false
logger: console, // custom log sink; SDK log output is emitted only when `debug: true` (defaults to console)
// optional - lifecycle hooks
onRequest(info) { /* { url, method, attempt } */ },
onResponse(info) { /* { url, method, status, attempt } */ },
onError(err) { /* called after all retries exhausted */ },
});Every request automatically sends an X-Kelp-Client: @kelpclick/client/<version> header so the server can identify the SDK version in logs.
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| searchUrl | yes | - | Kelp search base URL. SDK appends /api/v1/collections/{collection}/search or /browse. Trailing slashes are trimmed. Pass '' to use a same-origin proxy. |
| analyticsUrl | yes | - | Analytics base URL. SDK appends /analytics/events when posting batches. Not inferred from searchUrl - search and analytics are separate hosts (e.g. https://<tenant>-search.kelp.click vs https://<tenant>-events.kelp.click). Pass '' to use a same-origin proxy (e.g. in Hydrogen). |
| apiKey | yes | - | Scoped API key, sent as X-Api-Key. createKelpClient throws if missing. |
| tenantCode | no | - | Tenant slug included on every analytics batch envelope as tenantCode (alongside the batch id). Useful for multi-tenant deployments where events from different storefronts flow to a shared ingester. |
| timeoutMs | no | 10000 | Per-request timeout in ms. |
| fetch | no | globalThis.fetch | Inject for SSR / tests. |
| serializeFilters | no | { filters } | Turn selected filters into request-body fields. |
| semanticMinWords | no | 4 | Min words in q before a set semanticWeight is sent (clamped 3-50). The SDK omits the field below this. See semantic search. |
| maxRetries | no | 0 | Retry attempts after the initial try. Retries on 429, 502, 503, 504 and on network / timeout errors. Default 0 means no retries. |
| retryBaseMs | no | 250 | Base delay (ms) for exponential backoff with full jitter. |
| retryMaxMs | no | 5000 | Cap (ms) on per-attempt delay. 429 / 503 Retry-After headers are respected and also clamped to this value. |
| debug | no | false | Emit a debug log line for every attempt. |
| logger | no | - | Custom logger ({ debug, info, warn, error }). Selects the log sink; output is emitted only when debug: true. The SDK never logs the API key or full response bodies. |
| onRequest | no | - | Called before each request attempt with { url, method, attempt }. |
| onResponse | no | - | Called after a response is received (before error checking) with { url, method, status, attempt }. |
| onError | no | - | Called when a request ultimately fails (after all retries) with the thrown error. |
Search
createKelpSearch (from @kelpclick/client/search) creates a lightweight search-only client that returns { search, browse, suggest }. Use it when you need just the search capability without analytics or the full umbrella client:
import { createKelpSearch } from '@kelpclick/client/search';
const kelpSearch = createKelpSearch({
searchUrl: 'https://<tenant>-search.kelp.click',
apiKey: 'kelp_...', // required, throws if missing
// all other KelpClientConfig fields are accepted
});
const res = await kelpSearch.search('products', { q: 'shirt', perPage: 20 });
const res2 = await kelpSearch.browse('products', { filters: [{ field: 'status', op: '=', values: ['ACTIVE'] }] });The root createKelpClient also exposes { search, browse, suggest, trackEvents, identify } and is the right choice when you need both search and analytics in a single client.
const res = await kelp.search('products', {
q: 'sunglasses',
perPage: 100,
fields: ['id', 'title', 'handle'],
});Wire body sent (no semanticWeight - keyword-only):
{ "q": "sunglasses", "perPage": 100, "fields": ["id", "title", "handle"] }Pass a named hybrid mix when you want semantic ranking. The value is sent as-is; omitting it keeps keyword-only:
const hybrid = await kelp.search('products', {
q: 'sunglasses',
semanticWeight: 'strict',
});Wire body sent:
{ "q": "sunglasses", "semanticWeight": "strict" }search(collection, params, opts?):
collection- the collection name, e.g.'products'.params-SearchParams(see request options);qis required. OptionalsemanticWeightis search-only. OptionaldistinctBy,userId,sessionId, andpersonalizeare shared with browse.opts.signal- anAbortSignalto cancel the request.
Browse
Browse is identical to search without q - use it for collection / category pages driven by filters and sort:
const res = await kelp.browse('products', {
filters: [{ field: 'status', op: '=', values: ['ACTIVE'] }],
sort: [{ field: 'variantsInStockPercentage', direction: 'desc' }],
perPage: 10,
fields: ['status'],
});Wire body sent:
{
"filters": [{ "field": "status", "op": "=", "values": ["ACTIVE"] }],
"sort": [{ "field": "variantsInStockPercentage", "direction": "desc" }],
"perPage": 10,
"fields": ["status"]
}Request options
Both search and browse accept the shared params below, including optional distinctBy and personalization (userId, sessionId, personalize). Search also requires q and accepts optional semanticWeight (browse does not):
type SemanticWeight = 'strict' | 'heavy' | 'default' | 'balanced' | 'light' | 'keyword' | 'off';
interface BrowseParams {
facets?: string[]; // facet fields to count
page?: number; // default 1
perPage?: number; // default 20
filters?: { field: string; op?: FilterOp; values: string[] }[];
sort?: { field: string; direction: 'asc' | 'desc' }[];
fields?: string[]; // restrict returned document fields
distinctBy?: string; // one hit per keyed-tag / field value; search and browse
userId?: string; // per-visitor stable id; search and browse
sessionId?: string; // session-scoped behavioural signal; search and browse
personalize?: boolean; // opt in/out of behavioural re-ranking; search and browse
extraParams?: Record<string, unknown>; // escape hatch, merged into the body
}
interface SearchParams extends BrowseParams {
q: string;
semanticWeight?: SemanticWeight; // search only; omit for keyword-only
}Facets
facets is optional. Omit it to get counts for all configured facet attributes on the collection. Pass an explicit list to limit which fields are counted. Passing a field that is not a configured facet attribute returns a 400.
// Return counts for specific fields:
const res = await kelp.search('products', {
q: 'shirt',
facets: ['gender', 'productCategory', 'productCollection', 'collections', 'vendor', 'status'],
perPage: 1,
});
// Omit facets to get counts for all configured facet attributes:
const res2 = await kelp.search('products', { q: 'shirt', perPage: 1 });
res.facetCounts; // KelpFacetCount[] - [{ field, counts: [{ value, count }] }]Shopify product options - product documents carry a root options array of colon-separated strings (e.g. ["Color:Red","Color:Blue","Size:M"]). Two faceting patterns are available:
- Generic
optionsfacet - facet on the whole array as-is. The backend emits counts per raw"Color:Red"value. - Projected
options.<Key>field (e.g.options.Color) - when the collection config references the field using dot notation, the backend projects a dedicated field of just the values for that option type (["Red","Blue"]). This projected field is filterable, facetable, and rankable, and behaves identically tokeyedTags.<key>andmetafields.<key>projections.
Use dot notation (options.Color) consistently - not underscore notation.
// Facet on the whole options array (no collection config change needed):
const res = await kelp.search('products', {
q: 'shirt',
facets: ['options'],
});
// res.facetCounts includes { field: 'options', counts: [{ value: 'Color:Red', count: 5 }, ...] }
// Facet on a projected options.Color field (collection config must reference 'options.Color'):
const res2 = await kelp.search('products', {
q: 'shirt',
facets: ['options.Color'],
});
// res2.facetCounts includes { field: 'options.Color', counts: [{ value: 'Red', count: 5 }, ...] }
// Filter on a projected options.Color field:
const res3 = await kelp.search('products', {
q: 'shirt',
filters: [{ field: 'options.Color', op: '=', values: ['Red', 'Blue'] }],
facets: ['options.Color'],
});Filters
Filters are an array of clauses, each { field, op?, values }. op defaults to =. They're sent as { "filters": [ ... ] }:
await kelp.search('products', {
q: 'running shoe',
filters: [
{ field: 'brand', op: '=', values: ['globex', 'initech'] }, // brand in (globex, initech)
{ field: 'price', op: '>', values: ['0'] },
{ field: 'price', op: '<=', values: ['150'] },
{ field: 'rating', op: 'range', values: ['3.5', '5'] }, // inclusive
{ field: 'status', op: '!=', values: ['discontinued'] },
],
});| op | Meaning | Values |
| --- | --- | --- |
| = | equals (default) | one or more (IN-list) |
| != | not equals | one or more |
| > >= < <= | numeric comparison | one (values[0]) |
| range | inclusive range | two ([min, max]) |
Combining: clauses are ANDed together (every clause must match, even two clauses on the same field). Values within one = clause are OR/IN (values: ['globex', 'initech'] means brand ∈ {globex, initech}). So OR values of one field in a single clause; use separate clauses for AND. There is no OR across different fields.
Filterable fields are the collection's configured facet attributes - filtering an unconfigured field (e.g. id, price on a collection that doesn't expose it) returns a 400.
Need a different wire shape? Override serializeFilters on the client (receives the KelpFilter[]):
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click',
analyticsUrl: 'https://<tenant>-events.kelp.click',
apiKey: '...',
serializeFilters: (clauses) => ({ filter_by: clauses }),
});Sort
Multi-key, applied in order. direction is 'asc' or 'desc':
await kelp.browse('products', {
sort: [
{ field: 'variantsInStockPercentage', direction: 'desc' },
{ field: 'title', direction: 'asc' },
],
});Two catalog fields are sortable out of the box when the collection config references them:
bestSeller- all-time units sold (sort descending for a bestsellers listing).trending- units sold in the trailing 60 days (sort descending for a trending listing).
// Sort by trending products (trailing 60 days).
await kelp.browse('products', {
sort: [{ field: 'trending', direction: 'desc' }],
});
// Sort by all-time bestsellers.
await kelp.browse('products', {
sort: [{ field: 'bestSeller', direction: 'desc' }],
});Important: the field must be referenced in the collection config on the Kelp backend. Sorting on an unconfigured field returns a 400. The SDK surfaces this as
KelpApiErrorwithstatus === 400andcode === 'client'. Catch it and show a fallback rather than letting it propagate:import { KelpApiError } from '@kelpclick/client'; try { await kelp.browse('products', { sort: [{ field: 'trending', direction: 'desc' }] }); } catch (err) { if (err instanceof KelpApiError && err.status === 400) { // Field not configured in collection config - fall back to default sort. } else { throw err; } }
Fields
Trim the payload to just the fields you render:
await kelp.search('products', { q: 'sunglasses', fields: ['id', 'title', 'handle'] });Personalization
Pass a stable per-visitor userId (for example a cookie value or gate token read
in a server loader) and optionally the current sessionId to let the backend
re-rank results for that visitor. personalize is an explicit opt in/out for
behavioural re-ranking on the request:
await kelp.search('products', {
q: 'running shoe',
userId: 'usr_123', // stable per-visitor id, caller-supplied
sessionId: 'sess_abc', // optional session-scoped signal
personalize: true, // opt in to behavioural re-ranking
});All three fields are optional and passed through on the request body unchanged.
userId and personalize are independent: with personalize omitted the backend
may still personalise using its own session signals; set personalize: false to
explicitly opt out. Each field is only sent when supplied, so a request with none
of them is byte-identical to before. Both search and browse accept them:
await kelp.browse('products', {
userId: 'usr_123',
sessionId: 'sess_abc',
personalize: true,
});semanticWeight
Optional on search only. A named hybrid keyword/semantic mix:
'strict' | 'heavy' | 'default' | 'balanced' | 'light' | 'keyword' | 'off'
Omit the field for keyword-only ranking. The SDK never defaults it on and never sends raw hybrid alpha floats - the enum name is the wire value.
// Keyword-only (field omitted from the body):
await kelp.search('products', { q: 'running shoe' });
// Named hybrid mix:
await kelp.search('products', { q: 'running shoe', semanticWeight: 'strict' });browse does not accept semanticWeight. Responses do not require a
vectorDistance field.
If you also set extraParams.semanticWeight, the typed semanticWeight wins
(same merge order as q). Prefer the typed field; do not put a float in
extraParams for this.
distinctBy
Optional on search and browse. Collapse hits to one per keyed-tag / field value
(e.g. 'styleId' for one hit per keyedTags.styleId). Omitted from the wire
body when unset. Must be non-empty letters, digits, underscore, dot, or hyphen.
Invalid values are omitted. See docs.kelp.click.
await kelp.search('products', { q: 'running shoe', distinctBy: 'styleId' });
await kelp.browse('products', { distinctBy: 'styleId' });extraParams
For anything the typed surface does not cover yet - merged straight into the request body:
await kelp.search('products', { q: 'shirt', extraParams: { experimental: true } });Typed fields q, semanticWeight, and distinctBy are merged after extraParams, so
they override the same key if both are set. Other typed fields (page,
personalize, and so on) are merged before extraParams. distinctBy follows
that override rule on both search and browse. Browse then drops q and
semanticWeight from the wire body. Personalization fields stay on browse.
Response shape
Both search and browse resolve to KelpSearchResponse<TDoc>:
interface KelpSearchResponse<TDoc> {
hits: KelpHit<TDoc>[]; // { document: TDoc; highlights: KelpHighlight[] }
facetCounts: KelpFacetCount[]; // { field; counts: { value; count }[] }
found: number; // total matches
page: number;
processingTimeMs: number;
queryId?: string; // thread into click/conversion events
}A highlight is { field: string; snippet: string }; the snippet may contain <mark> tags around matched terms.
const res = await kelp.search('products', { q: 'sunglasses' });
for (const hit of res.hits) {
console.log(hit.document.title);
console.log(hit.highlights[0]?.snippet); // "...premium <mark>sunglasses</mark> built to last..."
}Typing your documents
Kelp is schema-agnostic. Pass your own document type as the generic argument; extend KelpDocumentBase (which guarantees id and objectId):
import type { KelpDocumentBase } from '@kelpclick/client';
interface Product extends KelpDocumentBase {
title: string;
handle: string;
vendor: string;
prices: number[];
status: string;
// Catalog ranking fields - present when referenced in the collection config.
bestSeller?: number; // all-time units sold; sortable as { field: 'bestSeller', direction: 'desc' }
trending?: number; // units sold in the trailing 60 days; sortable as { field: 'trending', direction: 'desc' }
// Shopify product options raw array, e.g. ["Color:Red","Color:Blue","Size:M"].
// Facet as 'options' (whole array) or 'options.Color' (projected field, dot notation).
options?: string[];
}
const res = await kelp.search<Product>('products', { q: 'shirt' });
res.hits[0].document.title; // typed as stringAnalytics
Events mirror the backend AnalyticsEventDto and are sent batched as { id, events: [...] } to the analytics endpoint, where id is a UUID v4 the SDK generates per batch for idempotent ingestion. The SDK constructs the endpoint by appending /analytics/events to analyticsUrl. Search and analytics are separate hosts (https://<tenant>-search.kelp.click for search, https://<tenant>-events.kelp.click for analytics) - analyticsUrl is never inferred from searchUrl. For traffic-source and campaign attribution - capturing UTM params and click ids from landing URLs and attaching them to events - see Campaign attribution below. There are two layers:
kelp.trackEvents(events)- low-level transport. You supply fully-formed named events; unknownkindvalues are dropped.createAnalytics(...)- ergonomic tracker that buffers, batches, and auto-fillsoccurredAt,sessionId, anduserId.
In React, KelpProvider builds a tracker for you and the hooks emit events automatically - see React.
Recommended: createAnalytics
import { createKelpClient, createIdentity, createAnalytics } from '@kelpclick/client';
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click',
analyticsUrl: 'https://<tenant>-events.kelp.click',
apiKey: '...',
});
const identity = createIdentity();
const analytics = createAnalytics({ client: kelp, identity });
// after a search
const res = await kelp.search('products', { q: 'shoes' });
analytics.trackSearch({
collection: 'products',
q: 'shoes',
found: res.found,
page: res.page,
queryId: res.queryId,
filters: [{ field: 'status', op: '=', values: ['ACTIVE'] }], // serialized into filterBy for you
});
// ecommerce funnel (objectId is the product). pass queryId to attribute to the search.
// Optional KelpItemFields are written on metadata only when passed. quantity is not defaulted.
analytics.trackProductClicked({
collection: 'products',
objectId: '9149995942110',
queryId: res.queryId,
item_id: 'SKU-9149995942110',
item_name: 'Trail Boot',
index: 0,
}); // kind 'product_clicked'
analytics.trackProductViewed({
collection: 'products',
objectId: '9149995942110',
item_id: 'SKU-9149995942110',
item_name: 'Trail Boot',
price: 129.99,
currency: 'USD',
}); // kind 'product_viewed' (PDP view)
analytics.trackProductWishlisted({
collection: 'products',
objectId: '9149995942110',
item_id: 'SKU-9149995942110',
item_name: 'Trail Boot',
}); // kind 'product_wishlisted'
analytics.trackProductAddedToCart({
collection: 'products',
objectId: '9149995942110',
queryId: res.queryId,
item_id: 'SKU-9149995942110', // SKU when that is the stable id; no separate sku field
item_name: 'Trail Boot',
item_variant: '9',
item_brand: 'Kelp',
price: 129.99, // number; storefronts should send this
quantity: 1, // units the shopper added; omit when unknown
currency: 'USD', // ISO 4217; storefronts should send this
item_category: 'Footwear',
index: 0, // list position, when the storefront has one
}); // kind 'product_added_to_cart' - GA4 item keys land on metadata
// a collection page view - objectId is the collection handle/id, not a product.
analytics.trackCollectionViewed({ collection: 'products', objectId: 'summer-sale', queryId: res.queryId }); // kind 'collection_viewed'
// a completed order - carries value/currency/orderId/items.
analytics.trackPurchase({
collection: 'products',
orderId: 'order-1001',
value: 129.99,
currency: 'USD',
items: [{
objectId: '9149995942110', // required; existing callers keep sending this
item_id: 'SKU-9149995942110',
item_name: 'Trail Boot',
quantity: 1,
price: 129.99,
}],
}); // kind 'purchase'
// events flush automatically (batch size, interval, pagehide);
// force a flush any time:
await analytics.flush();Tracker methods (each composes the event with its fixed kind and auto-fills the ambient fields):
| Method | Event kind | Notes |
| --- | --- | --- |
| trackSearch(input) | search | A search or browse query; serializes filters into filterBy. |
| trackProductViewed(input) | product_viewed | PDP view; objectId is the product. Optional KelpItemFields are written on metadata when passed. quantity is omitted when missing. |
| trackProductClicked(input) | product_clicked | Result/suggestion click; pass queryId to attribute it. Same optional KelpItemFields as view. |
| trackProductWishlisted(input) | product_wishlisted | Product added to wishlist/favourites. Same optional KelpItemFields as view. |
| trackProductAddedToCart(input) | product_added_to_cart | Product added to cart; pass queryId to attribute to the originating search. Same optional KelpItemFields. quantity is the units added and is omitted when missing. Pass the SKU as item_id. |
| trackCollectionViewed(input) | collection_viewed | Collection page view; objectId is the collection handle/id. |
| trackPurchase(input) | purchase | Completed order; carries value, currency, orderId, items. Each line keeps required objectId and may include KelpItemFields (item_id, item_name, and the rest) next to price and quantity. |
| startSession(input?) | session_started | Capture landing context and emit session_started at most once per session. Requires an attribution store. No-op otherwise. |
| trackSessionStarted(input?) | session_started | Emit session_started directly; no capture and no dedupe. |
| track(event) | named KelpEventKind | Low-level; enqueue a fully-typed named event. Unknown kinds are dropped and never sent. |
Email event kinds (emitted by createKelpEmail - see Email capture):
| Event kind | SDK method | Notes |
| --- | --- | --- |
| email_subscribed | email.subscribe(...) | Contact opted in; also calls identity.identify({ email }). |
| email_unsubscribed | email.unsubscribe(...) | Contact opted out; does NOT reset identity. |
| email_opened | (reserved) | Types defined; engagement/attribution deferred to a future release. |
| email_clicked | (reserved) | Types defined; engagement/attribution deferred to a future release. |
kind is a closed named union (KelpEventKind / KELP_EVENT_KINDS). Prefer the typed helpers above; track() is for named kinds when a helper does not fit. Unknown kinds are a type error and are dropped at runtime (track() and kelp.trackEvents) - they never hit the wire.
createAnalytics options:
createAnalytics({
client,
identity,
flushAt: 10, // flush once this many events are buffered
flushIntervalMs: 5000, // also flush on this interval; 0 disables
maxBufferedEvents: 1000, // drop oldest past this if the ingester is down
serializeFilterBy: (clauses) => clauses.map((c) => `${c.field}:${c.op ?? '='}[${c.values.join(',')}]`).join(' && '),
// Optional PII sanitize hook - runs after ambient fields are filled,
// before the event is buffered. Must be synchronous.
sanitizeEvent: (event) => ({ ...event, q: event.q ? '[redacted]' : event.q }),
// Attribution store - enables startSession() and stamps attribution on events.
// Omit to disable attribution entirely (default).
attribution: createAttribution(), // pass a KelpAttributionStore from createAttribution()
stampAttribution: 'all', // 'all' (default) stamps every event; 'session' stamps only session_started
});PII sanitization with sanitizeEvent
Supply a sanitizeEvent hook to redact or transform each event before it leaves the browser. The hook runs after occurredAt, sessionId, and userId are auto-filled and before the event is buffered. It must be synchronous and must return the event (modified or as-is):
const analytics = createAnalytics({
client: kelp,
identity,
sanitizeEvent(event) {
// Strip the raw query string from search events before it is sent.
if (event.kind === 'search' && event.q) {
return { ...event, q: '[redacted]' };
}
return event;
},
});Idempotent batch ingestion
Each POST to the events endpoint wraps the batch as { "id": "<uuid v4>", "events": [ ... ] } (or { "id": "<uuid v4>", "tenantCode": "<slug>", "events": [ ... ] } when tenantCode is configured on the client). The SDK generates the batch id automatically - one UUID v4 per call, reused on every retry attempt so the server can dedupe the whole batch. Individual events do not carry an id field.
Flush on page unload
When the user closes or navigates away, the browser fires pagehide. The SDK calls flush() at that point as a best-effort async POST. Events that are in-flight when the page tears down may not complete; this is acceptable for analytics.
trackSearch serializes filters into the backend filterBy string via defaultSerializeFilterBy, e.g. brand:=[globex] && price:>0.
Auto-filled on every event: occurredAt (ISO now), sessionId (identity.getSessionId()), and userId. userId precedence is: the value on the event, then the identified user (identity.getUserId()), then a stable anonymous id prefixed anon_ (e.g. anon_1a3b...) so anonymous visitors are tracked as one consistent, clearly-marked user. Analytics is best-effort - a failed flush re-queues and never throws into your UI path.
Campaign attribution
Pass a KelpAttributionStore (from createAttribution) to createAnalytics and call analytics.startSession() once per page load. The tracker stamps attribution (session-scoped last touch / current) onto events when present, and emits a deduplicated session_started event that is the denominator for visit counts, CVR, and ROAS reporting.
The events ingester requires
collectionon every event,session_startedincluded. Always pass it:analytics.startSession({ collection: 'products' }), or thecollectionprop onKelpProvider. Omitting it returns a 400 and the event is dropped.
Wire shape. Optional attribution on events uses the KelpAttribution shape (source required when present):
| Field | Notes |
| --- | --- |
| source | Normalised network, e.g. 'meta', 'google', or a bare referrer host for the long tail. |
| medium | 'cpc', 'display', 'paid_social', 'organic', 'social', 'referral', 'email', 'affiliate', or 'none'. |
| campaign | From utm_campaign. The ad campaign name. |
| content | From utm_content. |
| term | From utm_term. |
| campaignRef | From utm_id. The ad network's own campaign id. Distinct from the email campaignId field. |
| clickId | Raw click-id value, e.g. the fbclid string. |
| clickIdParam | Param name that carried the click id, e.g. 'fbclid'. |
| referrer | Full referrer URL (referral and organic touches). |
| landingPage | Landing path; query string reduced to campaign params by default (see query redaction). |
Click-id inference. When no utm_* params are present the SDK infers source/medium from ad-platform click ids (param match is case-insensitive):
| Param | source | medium |
| --- | --- | --- |
| gclid, gbraid, wbraid | google | cpc |
| dclid | google | display |
| msclkid | bing | cpc |
| fbclid | meta | paid_social |
| ttclid | tiktok | paid_social |
| twclid | twitter | paid_social |
| li_fat_id | linkedin | paid_social |
| sccid | snapchat | paid_social |
| epik | pinterest | paid_social |
| rdt_cid | reddit | paid_social |
| irclickid | impact | affiliate |
UTM params always take precedence over click-id inference. A click id found alongside UTM params still populates clickId / clickIdParam.
Referrer classification. When no UTM params or click ids are present, an external referrer is classified by host:
google./bing./duckduckgo./yahoo.-medium: 'organic'facebook./instagram./tiktok./pinterest./reddit./x.com/twitter./linkedin./youtube.-medium: 'social'- Anything else external -
source: <host>,medium: 'referral'
Same-host referrers return null (internal navigation) and do not update stored attribution.
Last-non-direct write rules. A direct landing (no UTM, no click id, no external referrer) does not overwrite a stored campaign. The stored last touch is kept, and an internal throttled touch() (at most one storage write per touchIntervalMs, default 60s) keeps the window alive while the shopper is active, so a long browsing session does not silently lose its campaign. touch() never revives an already-expired window. If nothing is stored at all, a direct landing seeds { source: 'direct', medium: 'none' } so a direct session is still reportable. Once the session TTL (default 30 min) expires without activity, getCurrent() returns the direct sentinel rather than a stale campaign.
landingPage query redaction. By default only campaign params (utm_* and click-id params) are kept in landingPage; all other query parameters (emails, reset tokens, session ids) are stripped. Pass redactLandingQuery: false to createAttribution to keep the full query string.
campaignId vs attribution.campaign. The campaignId field on KelpAnalyticsEvent is reserved for email campaigns (used by createKelpEmail). Ad-campaign attribution lives in attribution.campaign and attribution.campaignRef. Do not put an ad campaign id in campaignId.
Copy-paste setup:
import { createKelpClient, createIdentity, createAnalytics, createAttribution } from '@kelpclick/client';
const kelp = createKelpClient({ searchUrl: '...', analyticsUrl: '...', apiKey: '...' });
const identity = createIdentity();
const store = createAttribution(); // uses localStorage; falls back to memory in SSR / private mode
const analytics = createAnalytics({
client: kelp,
identity,
attribution: store, // enables startSession() and event stamping
// stampAttribution: 'session', // only session_started carries attribution; smaller payloads
});
// Call once per page load. Captures window.location + document.referrer in the browser.
// No-op on the server or when neither is available. Safe to call on every SPA navigation.
// `collection` is required by the events ingester - see the note below.
analytics.startSession({ collection: 'products' });SSR recipe. Pass url and referrer explicitly when calling from a server or a non-browser environment:
analytics.startSession({
url: request.url,
referrer: request.headers.get('referer') ?? '',
collection: 'products',
});On the server there is no localStorage, so the store falls back to memory and the
once-per-session flag does not survive the request. Calling startSession() in a
per-request loader would therefore emit a session_started on every request. Prefer
capturing client-side (all three integration guides do), or pass a request-scoped
KelpStorage backed by a cookie.
createAttribution itself reads no globals and is safe to construct on the server. captureBrowserAttribution(store) is a helper that reads window.location and document.referrer and returns null outside a browser.
See docs/contracts/analytics-attribution.md for the complete field table, JSON wire examples, index hints, and dashboard query patterns.
Low-level: trackEvents
kind must be a named KelpEventKind. Unknown kinds are stripped from the batch; an all-unknown batch is a no-op.
// The SDK adds the batch-level id (and tenantCode when configured) automatically - pass only the events.
// Wire body sent: { "id": "<uuid v4>", "events": [ ... ] }
// (or { "id": "<uuid v4>", "tenantCode": "<slug>", "events": [ ... ] } when tenantCode is set on the client)
await kelp.trackEvents([
{
kind: 'search',
occurredAt: '2026-07-18T12:00:00Z',
collection: 'products',
sessionId: 'sess-1',
q: 'shoes',
found: 12,
page: 1,
queryId: '019f7571-998f-7bb5-abdd-0945c5be7a4a',
},
{
kind: 'product_clicked',
occurredAt: '2026-07-18T12:00:01Z',
collection: 'products',
sessionId: 'sess-1',
objectId: '9149995942110',
queryId: '019f7571-998f-7bb5-abdd-0945c5be7a4a',
},
]);Event fields (KelpAnalyticsEvent):
| Field | Required | Notes |
| --- | --- | --- |
| kind | yes | Closed KelpEventKind: 'session_started', 'search', 'product_viewed', 'product_clicked', 'product_wishlisted', 'product_added_to_cart', 'collection_viewed', 'purchase', 'email_subscribed', 'email_unsubscribed', 'email_opened' (reserved), 'email_clicked' (reserved). Unknown kinds are dropped and never sent. |
| occurredAt | yes | ISO 8601 timestamp. Auto-filled by createAnalytics. |
| collection | no | Collection name. Required for search/product/collection events; optional for email events (email events are store-wide). |
| sessionId | yes | Session id. Auto-filled by createAnalytics. |
| q | no | Query string. |
| filterBy | no | Serialized filter expression, e.g. brand:=[globex] && price:>0. |
| found | no | Result count (search events). |
| page | no | Page number. |
| objectId | no | Subject document id: the product for product_viewed/product_clicked/product_wishlisted/product_added_to_cart, or the collection handle/id for collection_viewed. |
| queryId | no | Ties the event to the search response. |
| userId | no | Known user id, or a stable anon_-prefixed anonymous id for un-identified visitors (auto-filled by createAnalytics). |
| email | no | Email address (email events). |
| source | no | Opt-in source identifier, e.g. 'footer-form' (email events). |
| listId | no | Target list id (email events). |
| campaignId | no | Email campaign id; for ad-campaign attribution use attribution.campaign / attribution.campaignRef instead. |
| value | no | Order/basket total value (mainly purchase). |
| currency | no | ISO 4217 currency code for value, e.g. 'USD' (mainly purchase). |
| orderId | no | Order id (mainly purchase). |
| items | no | Purchased line items. objectId is required. Optional GA4 fields (item_id, item_name, item_variant, item_brand, price, quantity, currency, item_category, index) ride on the line. A missing quantity is left unset. |
| attribution | no | Session-scoped last-touch / current traffic source (KelpAttribution). Auto-stamped when an attribution store is provided to createAnalytics. Event-supplied values take precedence. source required when present. |
| metadata | no | Optional nested JSON object. Omitted when unset. Product view, click, wishlist, and add-to-cart write the KelpItemFields keys they were given. Purchase lines keep those fields on items, not here. |
Email capture
createKelpEmail (from @kelpclick/client/email) is a capture-only email capability. It records opt-in / opt-out contacts by emitting email_subscribed and email_unsubscribed events through the same buffered pipeline as analytics. When you pass your analytics instance as tracker, email and analytics events share a single buffer and flush loop - so any event can trigger backend automation flows.
Recommended: shared-tracker pattern
import { createKelpClient, createIdentity, createAnalytics } from '@kelpclick/client';
import { createKelpEmail } from '@kelpclick/client/email';
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click',
analyticsUrl: 'https://<tenant>-events.kelp.click',
apiKey: 'kelp_...',
});
const identity = createIdentity();
const analytics = createAnalytics({ client: kelp, identity });
// Pass analytics as `tracker` so email + analytics events ride ONE buffered pipeline.
const email = createKelpEmail({ identity, tracker: analytics });
// Subscribe: calls identity.identify({ email }) AND emits an email_subscribed event.
// subscribe() is synchronous - the event is buffered and flushed with the next batch.
email.subscribe({
email: '[email protected]',
source: 'footer-form', // optional
listId: 'newsletter', // optional
});
// Unsubscribe: emits email_unsubscribed. Does NOT reset identity.
email.unsubscribe({
email: '[email protected]',
source: 'footer-form',
listId: 'newsletter',
});
// Flush and clean up.
await email.flush();
email.close();createKelpEmail options:
createKelpEmail({
identity, // required - KelpIdentity instance
// Supply exactly ONE of the three transport options:
tracker, // recommended - an existing createAnalytics() instance (shared pipeline)
transport, // a low-level transport (e.g. kelp.trackEvents bound to a transport object)
client, // a KelpClient instance (creates its own internal transport)
// optional
collection: 'contacts', // collection name included in events; defaults to none
flushAt: 10, // flush once this many events are buffered (if not using a shared tracker)
flushIntervalMs: 5000, // also flush on this interval; 0 disables
});createKelpEmail returns:
{
subscribe(input: { email: string; source?: string; listId?: string }): void;
unsubscribe(input: { email?: string; source?: string; listId?: string }): void;
flush(): Promise<void>;
close(): void;
isClosed(): boolean;
}subscribe calls identity.identify({ email }) so all subsequent events (search, product, purchase) carry the contact's email as part of the known user. unsubscribe only emits the event - it does not clear the identity.
Identity and sessions
createIdentity persists an anonymous id and a rolling session id (SSR-safe - falls back to in-memory storage on the server or in privacy mode).
import { createIdentity } from '@kelpclick/client';
const identity = createIdentity({
sessionTtlMs: 30 * 60 * 1000, // default 30 min
keyPrefix: 'kelp', // default; set per-tenant if tenants share an origin
});
identity.getAnonymousId(); // stable per-browser id
identity.getSessionId(); // new id after `sessionTtlMs` of inactivity; refreshes on each call
identity.getUserId(); // string | null
// record a known user (stored locally)
identity.identify({ userId: 'user_123', email: '[email protected]' });
// clear the known user, keep the anonymous id
identity.reset();Custom storage (e.g. cookies) - implement KelpStorage:
import { createIdentity, type KelpStorage } from '@kelpclick/client';
const cookieStorage: KelpStorage = {
get: (k) => readCookie(k),
set: (k, v) => writeCookie(k, v),
remove: (k) => deleteCookie(k),
};
const identity = createIdentity({ storage: cookieStorage });Attribution storage keys. createAttribution stores three additional keys in the same storage, scoped by keyPrefix (default 'kelp'):
| Key | Contents |
| --- | --- |
| kelp.attribution.last | JSON KelpAttribution - session-scoped last touch / current. |
| kelp.attribution.lastSeen | Ms timestamp of the last activity write from store.touch(). |
| kelp.attribution.session | The sessionId for which session_started has already been emitted. |
Keep keyPrefix and sessionTtlMs aligned between createIdentity and createAttribution when tenants share an origin - a mismatch would use different key namespaces and TTL windows for the two stores. The attribution store keeps its own lastSeen clock (written by an internal touch() call on every tracked event) rather than reading identity.getSessionId() for freshness checks, because the identity's session-seen timestamp is refreshed on every getSessionId() call - reading it for a staleness check would silently extend the session it was inspecting.
React
Wrap your app in KelpProvider. It builds an identity and analytics tracker automatically (pass your own to override).
import { createKelpClient } from '@kelpclick/client';
import { KelpProvider } from '@kelpclick/client/react';
const kelp = createKelpClient({
searchUrl: 'https://<tenant>-search.kelp.click',
analyticsUrl: 'https://<tenant>-events.kelp.click',
apiKey: '...',
});
function App() {
return (
<KelpProvider client={kelp}>
<SearchPage />
</KelpProvider>
);
}Override identity / analytics if you manage them yourself:
<KelpProvider client={kelp} identity={identity} analytics={analytics}>
{children}
</KelpProvider>Attribution in React
KelpProvider enables campaign attribution by default. On mount it captures window.location + document.referrer and emits a session_started event (at most once per session, even under React StrictMode). No setup code needed beyond the provider:
// Attribution is on by default - no extra config.
<KelpProvider client={kelp}>
{children}
</KelpProvider>To disable attribution (e.g. pending a consent signal):
<KelpProvider client={kelp} attribution={false}>
{children}
</KelpProvider>To supply your own store (e.g. shared with a manually-created createAnalytics instance, or configured with a custom keyPrefix):
import { createAttribution } from '@kelpclick/client';
const store = createAttribution({ keyPrefix: 'acme', sessionTtlMs: 60 * 60 * 1000 });
<KelpProvider client={kelp} attribution={store}>
{children}
</KelpProvider>Pass collection so the auto-emitted session_started carries one - the events
ingester requires it on every event:
<KelpProvider client={kelp} collection="products">
<SearchPage />
</KelpProvider>KelpProvider reads the landing URL during its first render, before any router
redirect can rewrite it, so campaign params survive a / -> /products bounce.
Read the currently captured attribution anywhere in the tree with useKelpAttribution():
import { useKelpAttribution } from '@kelpclick/client/react';
function CampaignDebug() {
const attribution = useKelpAttribution();
// attribution is KelpAttribution | undefined
// Not reactive - a landing is captured once, before any interaction.
return <pre>{JSON.stringify(attribution, null, 2)}</pre>;
}useSearch
Debounced query, pagination, facet selection, request cancellation, and stale-response guarding. Auto-emits a search analytics event on each result and returns trackProductClicked(objectId, item?) (emits a product_clicked event with the current queryId). The optional item argument is KelpItemFields and is written on metadata when set; quantity is not defaulted. useBrowse returns the same helper. When the query is blank it searches "*" (match-all), so an empty box browses the whole collection (the search endpoint requires a non-empty q). Optional semanticWeight is passed through on the search body; omit it for keyword-only. Optional distinctBy, userId, sessionId, and personalize are passed through on search and on useBrowse.
import { useSearch } from '@kelpclick/client/react';
function SearchPage() {
const {
query, setQuery, debouncedQuery,
hits, facetCounts, found,
page, setPage, totalPages,
perPage, setPerPage,
filters, toggleFilter, clearFilters,
status, isLoading, error,
trackProductClicked,
} = useSearch<Product>({
collection: 'products',
facets: ['vendor', 'productType'],
sort: [{ field: 'title', direction: 'asc' }],
fields: ['id', 'title', 'handle'],
perPage: 24,
debounceMs: 200, // default
initialQuery: '',
// semanticWeight: 'strict', // optional; omit for keyword-only
// distinctBy: 'styleId', // optional; one hit per keyedTags.styleId; also on useBrowse
// userId: 'usr_123', // optional; search and browse
// personalize: true, // optional; search and browse
trackSearch: true, // default; false to opt out of auto search events
});
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{isLoading && <span>Searching...</span>}
{error && <span>Something went wrong.</span>}
{hits.map((h) => (
<a
key={h.document.id}
href={`/p/${h.document.handle}`}
onClick={() => trackProductClicked(h.document.id)} // product_clicked event with the current queryId
>
{h.document.title}
</a>
))}
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>Prev</button>
<span>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>Next</button>
</div>
);
}Facet filtering
function VendorFacet() {
const { facetCounts, isFilterActive, toggleFilter } = useSearch<Product>({
collection: 'products',
facets: ['vendor'],
});
const vendor = facetCounts.find((f) => f.field === 'vendor');
return (
<ul>
{vendor?.counts.map(({ value, count }) => (
<li key={value}>
<label>
<input
type="checkbox"
checked={isFilterActive('vendor', value)}
onChange={() => toggleFilter('vendor', value)} // resets to page 1
/>
{value} ({count})
</label>
</li>
))}
</ul>
);
}toggleFilter / isFilterActive operate on a field's equality (=) clause - the facet case. For operator filters (ranges, comparisons) set the array directly with setFilters, e.g. setFilters([{ field: 'price', op: 'range', values: ['10', '100'] }]).
useBrowse
Same return shape as useSearch minus the query fields - for collection pages:
import { useBrowse } from '@kelpclick/client/react';
function Collection() {
const { hits, filters, toggleFilter, page, setPage, totalPages, trackProductClicked } =
useBrowse<Product>({
collection: 'products',
facets: ['vendor'],
sort: [{ field: 'variantsInStockPercentage', direction: 'desc' }],
// distinctBy: 'styleId', // optional; one hit per keyedTags.styleId
// userId: 'usr_123', // optional; search and browse
// sessionId: 'sess_abc',
// personalize: true,
});
// ...render
}Other React hooks
import { useKelpClient, useKelpIdentity, useKelpAnalytics } from '@kelpclick/client/react';
const client = useKelpClient();
const identity = useKelpIdentity();
const analytics = useKelpAnalytics(); // e.g. analytics.trackProductClicked(...) from anywhere in the treeAll hooks throw if used outside a <KelpProvider>.
Error handling
The SDK uses a two-level error hierarchy:
KelpError- base class for all SDK errors.KelpApiError extends KelpError- thrown on non-2xx responses, network failures, and timeouts. Hasstatus,body,code, and optionalrequestId.KelpNotImplementedError extends KelpError- thrown by not-yet-shipped endpoints (suggest,identify).
KelpApiError.code is a KelpErrorCode union: 'auth' | 'rate_limit' | 'client' | 'server' | 'network' | 'timeout'.
import { KelpApiError, KelpError } from '@kelpclick/client';
try {
await kelp.search('products', { q: 'shirt' });
} catch (err) {
if (err instanceof KelpApiError) {
switch (err.code) {
case 'auth':
console.error('Check your API key');
break;
case 'rate_limit':
console.warn('Rate limited - back off and retry');
break;
case 'network':
case 'timeout':
console.warn('Transient failure - retry later');
break;
default:
console.error('Search failed', err.status, err.body, err.requestId);
}
} else if (err instanceof KelpError) {
// KelpNotImplementedError or another SDK error
throw err;
} else {
throw err;
}
}Network failures (fetch rejected) and per-request timeouts both surface as KelpApiError with code: 'network' or code: 'timeout' respectively. Caller-initiated AbortSignal aborts still throw a DOMException named AbortError unchanged.
err.requestId is set when the server included an x-request-id or x-trace-id response header - useful for support requests.
Not-yet-shipped endpoints throw KelpNotImplementedError:
import { KelpNotImplementedError } from '@kelpclick/client';
// suggest() and identify() throw KelpNotImplementedError until their endpoints existCancellation
Pass an AbortSignal to cancel in flight. Aborted requests reject with a DOMException named AbortError (the React hooks handle this for you):
const controller = new AbortController();
const p = kelp.search('products', { q: 'shirt' }, { signal: controller.signal });
controller.abort();SSR and custom fetch
The core runs on the server. Inject a fetch implementation if the runtime has no global one, and pass a signal from your framework where available:
const kelp = createKelpClient({
searchUrl: process.env.KELP_SEARCH_URL!,
analyticsUrl: process.env.KELP_ANALYTICS_URL!,
apiKey: process.env.KELP_PUBLIC_KEY!,
fetch: myFetch, // optional if globalThis.fetch exists
});
export async function loader({ request }: { request: Request }) {
const res = await kelp.search('products', { q: 'shirt' }, { signal: request.signal });
return res;
}Identity falls back to in-memory storage on the server, so getAnonymousId / getSessionId are stable within a request but not persisted across requests - hydrate real ids on the client.
Security and privacy
- Browser keys are public: a key shipped to the browser is visible to anyone (in the JS bundle and the network tab) - this is expected. Only ever use keys scoped to read-only search/analytics client-side; never use an ingest/admin key in the browser.
- Scope API keys: issue keys with only the permissions they need (search and/or analytics). Rotate keys if they are exposed. Never commit keys to source control - load them from environment variables (
VITE_KELP_API_KEY,PUBLIC_KELP_API_KEY, etc.). - Use HTTPS in production: set
searchUrlandanalyticsUrltohttps://URLs in all production environments. The SDK does not enforce this - it will send keys and events over plain HTTP if you configure it that way. - Avoid PII in events: do not put email addresses, tokens, card numbers, or other personal data in
q, filter values, or event payloads. UsesanitizeEventto redact any sensitive content before it leaves the browser:sanitizeEvent: (event) => ({ ...event, q: event.q ? '[redacted]' : event.q }) - localStorage and XSS: session and anonymous IDs are stored in
localStoragein cleartext. Protect your origin against XSS with a strict Content-Security-Policy. A compromised script can read these IDs, but they are not authentication credentials. - Click ids are joinable identifiers:
fbclid,gclid, and similar values can be linked back to an individual by the ad platform. They appear inattribution.clickId. If your consent framework requires it, strip them with asanitizeEventhook before events leave the browser:sanitizeEvent: (event) => ({ ...event, attribution: event.attribution ? { ...event.attribution, clickId: undefined } : event.attribution, }) - Landing URL query redaction:
attribution.landingPagecontains only campaign params by default - all other query parameters (emails, reset tokens, session ids) are stripped. SetredactLandingQuery: falseoncreateAttributionto opt back into the full query string. - Consent-gated session capture: if your consent framework requires opt-in before tracking, gate
analytics.startSession()on the user's consent signal rather than calling it on every page load. In React, passattribution={false}toKelpProvideruntil consent is granted, then switch to a store and callstartSession()manually. - Resetting attribution: call
attributionStore.reset()to clear all threekelp.attribution.*keys from storage - useful on logout or when handling a consent withdrawal.
API reference
Root (@kelpclick/client) - re-exports every capability:
createKelpClient(config)->{ search, browse, trackEvents, suggest, identify }createKelpSearch(config)->{ search, browse, suggest }(also exported from@kelpclick/client/search)createIdentity(options?)->{ getAnonymousId, getSessionId, getUserId, identify, reset }createAnalytics(options)->{ track, trackSearch, trackProductViewed, trackProductClicked, trackProductWishlisted, trackProductAddedToCart, trackCollectionViewed, trackPurchase, startSession, trackSessionStarted, flush, close, isClosed };defaultSerializeFilterBy(filters)is exported for reuse/override (also exported from@kelpclick/client/analytics)createAttribution(options?)->{ capture, getCurrent, claimSessionStart, touch, reset };parseAttribution(input)andcaptureBrowserAttribution(store)are exported alongside it (also exported from@kelpclick/client/analytics)createKelpEmail(options)->{ subscribe, unsubscribe, flush, close, isClosed }(also exported from@kelpclick/client/email)- Storage:
memoryStorage(),localStorageAdapter(),resolveStorage(explicit?) - Constants:
VERSION- the SDK version string (e.g.'0.1.0');KELP_EVENT_KINDS- the closed analytics event kind list;isKelpEventKind(value)- type guard - Errors:
KelpError,KelpApiError,KelpNotImplementedError - Types:
KelpErrorCode,KelpSearchResponse,KelpHit,KelpHighlight,KelpFacetCount,KelpFacetValue,KelpDocumentBase,KelpFilter,KelpFilterOp,KelpFilters,SortItem,SortDirection,SemanticWeight,BrowseParams,SearchParams,KelpAnalyticsEvent,KelpEventKind,KelpItemFields,KelpPurchaseItem,TrackProductInput,TrackAddedToCartInput,KelpClientConfig,RequestOptions,KelpAttribution,KelpAttributionStore,KelpAttributionOptions,ParseAttributionInput
Search (@kelpclick/client/search):
createKelpSearch(config)->{ search, browse, suggest }
Analytics (@kelpclick/client/analytics):
createAnalytics(options)->{ track, trackSearch, trackProductViewed, trackProductClicked, trackProductWishlisted, trackProductAddedToCart, trackCollectionViewed, trackPurchase, startSession, trackSessionStarted, flush, close, isClosed }defaultSerializeFilterBy(filters)createAttribution(options?),parseAttribution(input),captureBrowserAttribution(store)KELP_EVENT_KINDS,isKelpEventKind(value)- Types:
KelpAnalyticsEvent,KelpEventKind,KelpItemFields,KelpPurchaseItem,CreateAnalyticsOptions,TrackProductInput,TrackAddedToCartInput,KelpAttribution,KelpAttributionStore,KelpAttributionOptions
Email (@kelpclick/client/email):
createKelpEmail(options)->{ subscribe, unsubscribe, flush, close, isClosed }
React (@kelpclick/client/react):
KelpProvider,useSearch,useBrowseuseKelpClient,useKelpIdentity,useKelpAnalytics,useKelpAttributionuseKelpQuery,useDebouncedValue- Types:
KelpProviderProps,UseSearchOptions,UseBrowseOptions,UseKelpQueryOptions,KelpQueryRunnerArgs,QueryStatus
Integration guides
Platform guides live at docs.kelp.click (see Framework guides). Copies also live in this repo under guides/:
- Shopify Hydrogen - also in this repo
- Shopify Liquid - also in this repo
- Vite React / React Router - also in this repo
Example apps
Two runnable demos live under examples/. Both consume the published @kelpclick/client from npm (like a real integration would), so they double as install smoke tests.
examples/vite-react- a Vite + React SPA using the React adapter:KelpProvider,useSearch/useBrowse, facet filtering, pagination, click tracking, an email-captureNewsletterForm(createKelpEmail), andtenantCodewired throughcreateKelpClient.examples/hydrogen-storefront- a Shopify Hydrogen storefront doing SSR search via loaders behind a capability seam, a same-origin/analytics/eventsproxy, and email capture viacreateKelpEmail.
cd examples/vite-react # or examples/hydrogen-storefront
npm install # installs @kelpcl