Subotiz A/B testing front-end SDK. Fetches experiment assignment data in the browser and supports on-demand conversion event reporting.
- Browser-first, framework-agnostic (works with React / Vue / plain HTML pages)
- First-class TypeScript support with complete typings
Loading via CDN
The SDK can be included via CDN.
CDN call syntax: abSubotiz(methodName, ...args)
<!-- 1. Synchronously define the global proxy and method queue -->
<script>
window.abSubotizQueue = window.abSubotizQueue || [];
window.abSubotiz = window.abSubotiz || function () {
window.abSubotizQueue.push([].slice.call(arguments));
};
// 2. Start queueing calls immediately
abSubotiz('init', {
storeId: 'your_store_id',
});
abSubotiz('onReady', function (experiments) {
var hero = experiments['homepage_hero_test'];
if (hero && hero.variantKey === 'variant_b') {
document.documentElement.dataset.heroVariant = 'b';
}
});
</script>
<!-- 3. Asynchronously load the real SDK; the queue is replayed automatically once loaded -->
<script async src="https://cdn.subotiz.com/ab-sdk-subotiz/v1/ab-sdk-subotiz.min.js"></script>Reporting events
Business events are reported via track(eventName, properties?), commonly used for experiment conversion analysis (signup completion, button clicks, your own revenue / LTV, and other metrics the platform cannot collect automatically):
abSubotiz.track('signup_complete'); // count / unique-user metrics
abSubotiz.track('ltv', { value: 99.9 }); // numeric metrics (sum / average)Characteristics:
- Network failure / 5xx → queued to the
localStorageoffline queue and automatically resent when back online - 4xx → silently dropped (the event itself is invalid)
- Called before
initcompletes → silently skipped
Do not pass user-private data (phone number, email, ID number, address, etc.) in event properties. The SDK does not filter anything; the caller is responsible for this.
Numeric metrics: properties.value
properties.valueFor metrics that require sum / average aggregation (e.g. revenue, LTV, time on page), put the number in properties.value:
abSubotiz.track('ltv', { value: 99.9 });valuemust be a finite number (typeof === 'number'and notNaN/Infinity) to be included in numeric aggregation by the backend.- An invalid
value(string'99.9',NaN,null, etc.) will not pollute numeric statistics; it is passed through as an ordinary tag and treated by the backend with "non-numeric" fallback semantics. - Count / unique-user metrics (e.g.
signup_complete) do not need avalue; just calltrack('signup_complete').
Debugging: metric whitelist hints
The metric keys declared for experiments in the admin console are delivered together with the assignment result. When debug: true, the SDK uses them for spell-check hints: if the eventName passed to track is not declared in any running experiment's metrics, the console emits a warn (suggesting a possible typo / case mismatch, and printing the current whitelist).
This is only a debugging hint — the event is still reported normally. In production mode (debug: false) there is zero overhead and no impact on reporting behavior.
Exposure events
When an experiment is first hit, the SDK automatically reports an exposure event — no manual call is required. Experiments already present in the sticky cache are not reported again.
Automatic pricing-link injection
When an experiment object type is price_list, the SDK appends the following query parameters to the returned pricing-table link. The link the business receives is already fully injected:
| Parameter | Value |
|---|---|
ab_sbt_sid | Current store id |
ab_sbt_uid | Current user id |
ab_sbt_utype | 'user' or 'anonymous' |
ab_sbt_sch | Source channel (appended only when a valid sourceChannel is passed to init; see below) |
// → https://checkout.subotiz.com/pricing-v2?ab_sbt_sid=store_123&ab_sbt_uid=user_456&ab_sbt_utype=user
const exp = experiments['pricing_redirect_test'];
if (exp && exp.objectType === 'price_list') {
window.location.href = exp.config!.link as string;
}Source-channel targeting (sourceChannel)
You can pass sourceChannel to init to let the backend perform experiment targeting by traffic source (e.g. "enable an experiment only for traffic coming from facebook").
abSubotiz.init({
storeId: 'your_store_id',
sourceChannel: 'facebook',
});Value and normalization rules
The SDK performs no enum validation and no case conversion. The set of allowed values (e.g. facebook / google / tiktok) is agreed upon between you and your product team. Before sending, only the following processing is applied:
| Input value | Result |
|---|---|
| Normal string | Carried after trim (original casing preserved) |
undefined / null / non-string | Treated as not provided; the source_channel field is omitted (not an empty placeholder) |
| Empty string / whitespace only | Treated as not provided; omitted |
Length ≥ 50 after trim | Treated as not provided; omitted (debug: true emits a console warn for over-length) |
Stability:
sourceChannelis treated as a stable value within a single session and is determined only once atinit. The SDK does not watch for changes afterward, nor does it re-fetch assignments when the channel changes.Omitting beats truncating: over-length / invalid values are always omitted, to avoid the backend receiving a silently-rewritten value that diverges from your analytics configuration.
API reference
init(config)
init(config)Initializes the SDK. Can only be called once per SDK instance; repeated calls are ignored. Returns immediately and does not block the main thread.
- Parameter
config: SubotizSDKConfig - Returns
void
Configuration options
init(config) accepts a SubotizSDKConfig object:
| Field | Type | Default | Description |
|---|---|---|---|
storeId | string | — | Required. Store ID (the Subotiz admin store access no) |
sourceChannel | string | — | Optional. Source channel, used for backend traffic targeting. |
timeout | number | 30000 | Assignment request timeout in milliseconds |
debug | boolean | false | Enables debug logging and allows forceVariant to take effect. Keep disabled in production. |
onReady(onSuccess, onError?)
onReady(onSuccess, onError?)Registers a ready callback. Triggered synchronously if the SDK is already ready, asynchronously otherwise. Multiple callbacks can be registered.
abSubotiz.onReady(
(experiments) => { /* experiment data is ready */ },
(error) => { /* network error and no cache */ },
);- Parameters
onSuccess: (experiments: SubotizExperimentMap) => voidonError?: (error: ABTestError) => void
- Returns
void
waitReady()
waitReady()The Promise version of onReady.
const experiments = await abSubotiz.waitReady();- Returns
Promise<SubotizExperimentMap> - On failure: throws
ABTestError(equivalent toonReady'sonError)
isReady()
isReady()Synchronously checks whether the SDK is ready.
- Returns
boolean
getExperiments()
getExperiments()Synchronously gets all currently-hit experiments.
- Returns
SubotizExperimentMap | nullnull— not initialized, not ready, or there are currently no hit experimentsRecord<string, SubotizExperiment>— experiment map keyed byexperimentId
track(eventName, properties?)
track(eventName, properties?)Reports a custom business event. Any event is reported; whether it counts toward an experiment is attributed by the backend.
abSubotiz.track('signup_complete'); // count / unique users
abSubotiz.track('ltv', { value: 99.9 }); // sum / average; value must be a finite number- Parameters
eventName: string— event nameproperties?: Record<string, unknown>— additional event properties. Among them,value(a finite number) is used for sum / average metrics; all other fields are passed through as additional tags
- Returns
void(never throws) - Behavior
- Always reports; no client-side whitelist dropping
debug: trueandeventNamenot in the metric whitelist declared in the admin console → consolewarnspell-check hint (hint only; does not affect reporting)- Called before
initcompletes → silently skipped
forceVariant(experimentId, variantKey)
forceVariant(experimentId, variantKey)Only effective when debug: true. Locally forces a given experiment's variant, convenient for verifying different variant UIs during development.
abSubotiz.forceVariant('homepage_hero_test', 'variant_b');- Parameters
experimentId: string,variantKey: string - Returns
void
Error handling
onReady(_, onError) and waitReady() return an ABTestError on failure:
interface ABTestError {
code: ABTestErrorCode;
message: string;
cause?: unknown;
}Error codes:
| Code | Meaning |
|---|---|
NETWORK_TIMEOUT | Request timed out (weak network) |
NETWORK_ERROR | Network request failed (offline, blocked by CORS) |
HTTP_ERROR | Backend returned a non-2xx status |
INVALID_RESPONSE | Malformed response format |
NOT_INITIALIZED | init validation failed (e.g. missing storeId) |
abSubotiz.onReady(
(experiments) => render(experiments),
(error) => {
if (error.code === "NOT_INITIALIZED") {
console.error('invalid init config', error.message);
}
// do something
},
);Fallback strategy
The SDK has built-in fallback behavior; the business only needs to care about three things:
- Render the control group first —
await waitReady()can wait up totimeoutseconds on a network error, so do not use it to block the first paint - Switch variants inside
onReady/waitReady— minimizes visual disruption for users - Keep the control group on
onError— never show blank or broken UI
| Scenario | SDK behavior |
|---|---|
| Repeat visit + network error | Automatically becomes ready using the localStorage cache |
| First visit + network error | Triggers onError |
track with 5xx / offline | Queued offline and resent automatically on window.online |
track with 4xx | Silently dropped |
Debugging
With debug: true enabled, the SDK logs requests and queue status to the console:
abSubotiz.init({ storeId: 'store_123', debug: true });| Log | Meaning |
|---|---|
[ab-sdk] reportExposure: sending N event(s) | Reporting exposures |
[ab-sdk] track: sending N event(s) | Reporting custom events |
[ab-sdk] track event queued (offline) | Event added to the offline queue |
[ab-sdk] 4xx error, track event dropped | 4xx silently dropped |
[ab-sdk] MetricKeyRegistry rebuilt: N keys | Metric whitelist rebuilt after a successful assignment (with key preview) |
[ab-sdk] track('xxx') did not match the metric_keys ... | track event name did not match the whitelist (still reported) |
[ab-sdk-subotiz] sourceChannel length N >= 50, omitted ... | sourceChannel over-length, omitted |
[ab-sdk] Forced variant: xxx → yyy | forceVariant called |
Resetting SDK state
Object.keys(localStorage)
.filter((k) => k.startsWith('_ab_sdk_') || k.startsWith('ab_event_queue_'))
.forEach((k) => localStorage.removeItem(k));Local storage keys
The SDK uses the following keys in localStorage:
| Key | Content |
|---|---|
_ab_sdk_uuid | Anonymous user UUID (shared across tenants) |
_ab_sdk_exp_{storeId}_USERID | Experiment assignment snapshot (60-day TTL) |
ab_event_queue_{storeId}_USERID_track | Offline event-reporting queue |
ab_event_queue_{storeId}_USERID_exposure | Offline exposure-event queue |
FAQ
The business code already rendered before the SDK loaded — won't it miss the experiment data?
Yes. Let the business code render the control group first, then switch to the variant inside onReady / waitReady. The sticky cache guarantees that users see their variant consistently after refresh (the first visit has a momentary "control → variant" switch).
I called track but the backend did not receive it — how do I troubleshoot?
track but the backend did not receive it — how do I troubleshoot?Check in order:
- Is
abSubotiz.isReady()true?trackis silently skipped when not ready. - Enable
debug: trueand check the console for atrack: sendinglog. - Check the browser network panel for a
POST /sdk/v1/ab/eventsrequest. - Is the response 4xx or 5xx? 4xx is dropped; 5xx is queued for resend.
- Check whether
localStorage.ab_event_queue_*_trackhas events piling up.
I passed sourceChannel, but experiment targeting didn't take effect
sourceChannel, but experiment targeting didn't take effectCheck in order:
- Enable
debug: trueand verify the assignment request body'sattributes.source_channelcarries your value. - Confirm the value is valid: non-empty after
trimand length < 50. Over-length values are omitted (with a consolewarn). - Confirm the value matches the admin targeting rule exactly — the SDK does not convert case, so
Facebook≠facebook. - Targeting-rule matching happens on the backend; use
getExperiments()to check whether the experiment is returned.
My track value didn't make it into the sum / average statistics
track value didn't make it into the sum / average statisticsvalue must be a finite number (typeof === 'number' and not NaN / Infinity) to be included in numeric aggregation. A common pitfall is passing a string: track('ltv', { value: '99.9' }) (a string) is treated as an ordinary tag and not included in sum / average. Use { value: 99.9 } instead.
The same experiment shows different variants before and after refresh
The sticky cache guarantees variant stability for 60 days. Inconsistency is usually caused by:
localStoragebeing cleared (private mode / the user clearing it manually)- Forced user assignment being enabled in the admin console, which overrides the sticky cache
An experiment isn't working in production — how do I troubleshoot?
Check in order:
- Is
storeIdcorrect? (A wrongstoreIddoes not error; you simply get no experiments.) - Is the experiment in the "Running" state in the admin console? (Paused / draft states are not delivered.)
- Does the user match the experiment's targeting rules? (Device / country are parsed by the backend from the HTTP request headers.)
- Use
getExperiments()to inspect the result and confirm whether the variant decision itself is the control group.
I want a specific user to be completely excluded from experiments
The SDK does not provide an "opt-out" API. Workaround: have your business code decide (e.g. by login state / role) and, when the condition is met, skip reading experiments and render the default directly.
Why does getExperiments() return null instead of an empty object?
getExperiments() return null instead of an empty object?null means "not ready" or "no hit experiments", making existence checks easy:
const all = ab.getExperiments();
if (!all) return renderDefault();This avoids writing Object.keys(all).length === 0.
Can it be used in React Server Components / Edge Runtime?
No. The SDK depends on browser APIs such as localStorage / document.cookie / window.
Browser compatibility
Relies on native fetch / Promise / localStorage. Supports:
- Chrome / Edge ≥ latest 2 major versions
- Safari ≥ latest 2 major versions
- Firefox ≥ latest 2 major versions
IE is not supported. The SDK does not bundle any compatibility shims.