npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@kelpclick/client

v0.7.0

Published

Client SDK for the Kelp commerce platform - search and browse now; events, identity, forms, and push to follow.

Readme

@kelpclick/client

The client SDK to integrate Kelp search, browse, and events into your JavaScript and React project

npm version npm downloads types license

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 optional distinctBy grouping on search and browse, and an optional named hybrid semanticWeight on 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 GA4 KelpItemFields (item_id, item_name, item_variant, item_brand, price, quantity, currency, item_category, index) and write the ones you pass onto metadata. Purchase lines keep objectId and accept the same item fields.
  • Campaign attribution - createAttribution captures UTM params, click-id inference (Meta/Google/TikTok/Bing and more), and referrer classification; attaches attribution (current / last-touch) to events when present; emits a once-per-session session_started event for CVR/ROAS reporting
  • Email capture - createKelpEmail lets you subscribe / unsubscribe contacts and emit email_subscribed / email_unsubscribed events through the same buffered pipeline as analytics
  • Capability modules - @kelpclick/client/search, @kelpclick/client/analytics, @kelpclick/client/email subpath exports let you import only what you need; the root @kelpclick/client re-exports everything
  • Reliable delivery - optional retry with exponential backoff + jitter; 429 / 503 Retry-After respected; configurable maxRetries, retryBaseMs, retryMaxMs
  • Observability hooks - debug flag, custom logger, and onRequest / onResponse / onError lifecycle hooks for request tracing
  • Richer error model - KelpError base class; KelpApiError carries a code ('auth' | 'rate_limit' | 'client' | 'server' | 'network' | 'timeout') and optional requestId; network / timeout failures surface as KelpApiError rather 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@latest

React 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

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 search

Client 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); q is required. Optional semanticWeight is search-only. Optional distinctBy, userId, sessionId, and personalize are shared with browse.
  • opts.signal - an AbortSignal to 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 options facet - 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 to keyedTags.<key> and metafields.<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 KelpApiError with status === 400 and code === '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 string

Analytics

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; unknown kind values are dropped.
  • createAnalytics(...) - ergonomic tracker that buffers, batches, and auto-fills occurredAt, sessionId, and userId.

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 collection on every event, session_started included. Always pass it: analytics.startSession({ collection: 'products' }), or the collection prop on KelpProvider. 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 tree

All 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. Has status, body, code, and optional requestId.
  • 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 exist

Cancellation

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 searchUrl and analyticsUrl to https:// 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. Use sanitizeEvent to 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 localStorage in 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 in attribution.clickId. If your consent framework requires it, strip them with a sanitizeEvent hook before events leave the browser:
    sanitizeEvent: (event) => ({
      ...event,
      attribution: event.attribution ? { ...event.attribution, clickId: undefined } : event.attribution,
    })
  • Landing URL query redaction: attribution.landingPage contains only campaign params by default - all other query parameters (emails, reset tokens, session ids) are stripped. Set redactLandingQuery: false on createAttribution to 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, pass attribution={false} to KelpProvider until consent is granted, then switch to a store and call startSession() manually.
  • Resetting attribution: call attributionStore.reset() to clear all three kelp.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) and captureBrowserAttribution(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, useBrowse
  • useKelpClient, useKelpIdentity, useKelpAnalytics, useKelpAttribution
  • useKelpQuery, 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/:

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-capture NewsletterForm (createKelpEmail), and tenantCode wired through createKelpClient.
  • examples/hydrogen-storefront - a Shopify Hydrogen storefront doing SSR search via loaders behind a capability seam, a same-origin /analytics/events proxy, and email capture via createKelpEmail.
cd examples/vite-react            # or examples/hydrogen-storefront
npm install                       # installs @kelpcl