The JS SDK.
@dif.sh/sdk is the runtime. Pure TypeScript, zero dependencies. It handles experiment
assignment, exposure delivery, and, when you're connected to dif.sh Cloud, metric tracking
through the same import.
You almost never call the SDK directly for experiments. dif build emits dif/generated/client.ts, which registers every active experiment with the SDK as a
side effect of importing it. Your code calls dif("id", branches) at render sites; the
SDK looks up the spec, evaluates the audience, buckets the user, fires one exposure event, and
returns the branch's value through a thunk.
Two methods sit alongside that callable: dif.init(...) configures the SDK once at boot,
and dif.track(...) fires a metric event to dif.sh Cloud. Tracking only does anything
with a Cloud project + publishable key; without one, calls drop silently.
Install
$npm install @dif.sh/sdk
Import the generated client once, at app boot, so its registrations populate the runtime registry:
// somewhere in your app's entry point import "./dif/generated/client";
Without this import the SDK has no registered experiments: an unknown id safely returns the first branch, but doesn't bucket or emit an exposure. The framework adapters and the server entrypoint ship alongside:
$npm install @dif.sh/react # <DifProvider> + useDif() $npm install @dif.sh/svelte # difLoad + experiment() store # server-side tracking is a subpath of @dif.sh/sdk — no extra install import { DifServer } from "@dif.sh/sdk/server";
@dif.sh/sdk is a peer dependency of both adapters. Install it explicitly. The SDK and
adapters need Node 20.6+.
dif.init()
Configure once, at boot. The SDK needs to know how to look up the current user, what audience
attributes they carry, where to send exposure events, and, for Cloud, which project to talk to.
The attributes() helper comes from the generated audience module; values you pass it
override the resolver-produced ones.
import { dif } from "@dif.sh/sdk"; import { attributes } from "./dif/generated/audiences"; dif.init({ project: "acme-shop", // cloud project slug publishableKey: "dif_pk_live_…", // browser-safe userId: () => currentUser?.id ?? null, attributes: () => attributes({ plan: currentUser?.plan ?? null, }), });
| option | default | notes |
|---|---|---|
project Cloud | — | The Cloud project slug. Carried by configuration; required to attribute Cloud events. |
publishableKey Cloud | — | A dif_pk_… token. Browser-safe; embed it in your bundle. Required to ship events
to Cloud from a browser. |
apiUrl Cloud | https://cloud.dif.sh | Cloud or self-hosted API base URL. |
userId | () => null | Resolves the current user id at assignment time. A null user gets the first branch and no exposure. |
attributes | () => ({}) | Resolves the audience attribute bag. Usually the generated attributes() helper. |
sink | cloud sink, else none | Exposure sink or array of sinks. Defaults to the Cloud sink when a publishable key is set.
Pass [] to disable exposure delivery. |
enabled | true | Global assignment + tracking kill switch. false short-circuits every dif() to control and drops every track(). |
overrides | {} | Initial QA experiment-to-variant forces. |
dif.init configures a module-level singleton; calling it
again replaces the previous state. dif.configure(...) remains a deprecated alias.The dif() call
One call per render site. The experiment id is a string literal; dif build validates
that every id in source maps to an active .md file.
import { dif } from "@dif.sh/sdk"; const checkoutCta = dif("checkout-cta-v2", { control: () => "Place order", variant_a: () => "Get it today", }); // at render time button.textContent = checkoutCta();
dif(...) returns a thunk. Invoking it at the render site is what triggers resolution and
exposure firing, so resolution happens at every render, not at module-load time, which matters when
the user id changes mid-session. Branches can return any value (a string, a style object, a
component). Calling dif() with an empty branches object throws.
Resolution order
One call resolves in order:
- QA force: a valid force wins and emits no exposure.
- Unknown id: the registry has no spec. Return the first declared branch.
(
dif validatecatches these at build time asW001; this is the runtime safety net.) - Disabled or uninitialized:
dif.init()wasn't called orenabled: false. Return the first variant. No exposure. - Null user:
userId()returnednull. First variant. No exposure. - Audience miss: the predicate returned
false. First variant. No exposure. - Resolve: deterministically bucket the user from the experiment salt and user id, pick the variant by cumulative weight, fire one exposure, return that branch's value.
.md file. Order your variants: list with intent: put the safe fallback
first. One exposure fires per (experiment, user) for the lifetime of the loaded
module.Sinks
A sink receives exposure events. Five factories ship; you can write your own. Sinks
are independent of dif.track(). Metric events always go straight to Cloud, never
through sinks.
When publishableKey is set and you don't pass sink, the SDK automatically
sends exposures to POST <apiUrl>/v1/exposure with a bearer publishable key. Pass sink: [] to opt out.
cloudSink({ apiUrl, publishableKey }) // the default when a publishable key is set webhookSink(url) // POST each event as JSON, keepalive fetch segmentSink(analytics) // analytics.track("dif.exposure", event) amplitudeSink(amplitude) mixpanelSink(mixpanel)
Combine the Cloud sink with another to fan an exposure out to both:
import { dif, cloudSink, segmentSink } from "@dif.sh/sdk"; dif.init({ publishableKey: "dif_pk_live_…", sink: [ cloudSink({ apiUrl: "https://cloud.dif.sh", publishableKey: "dif_pk_live_…" }), segmentSink(window.analytics), ], });
Custom sinks
The Sink interface is two fields:
import type { Sink } from "@dif.sh/sdk"; const sink: Sink = { kind: "custom", // identifier for logs/dedupe emit(event) { // must not throw customAnalytics.track(event.event, event); }, };
A sink that throws would crash the render, so swallow errors internally. Pass an array to fan out.
Exposure events
Every exposure event has the same shape, regardless of sink:
interface ExposureEvent { event: "dif.exposure"; experiment: string; variant: string; user_id: string; surface: string; bucket: number; // 0–9999 fired_at: number; // unix ms source: string; // SDK source stamp }
Dedupe
Exposure fires at most once per (experiment, user_id) pair for the lifetime of the
loaded module. The dedupe set clears on page nav. The point: fire on render, not on assignment, and
don't double-count re-renders.
Why render, not assignment
Bucketing is deterministic. The assignment doesn't change between calls. What you're counting is "this user actually saw the variant." Firing at assignment over-counts users who got bucketed but never rendered (audience misses that ran the predicate, route changes that aborted the render). Firing at render under-counts nothing and over-counts nothing.
Server assignment
For request-scoped assignment without a browser, import the generated registry and call assign with an explicit request context. It returns the assignment without reading the
browser singleton, firing an exposure, or touching client dedupe. Safe to call from a long-lived
server process.
import "./dif/generated/client"; import { assign } from "@dif.sh/sdk"; const assignment = assign("checkout-cta-v2", { userId: session.userId, attributes: { locale: requestLocale, plan: session.plan }, overrides: {}, });
interface Assignment { variant: string; bucket: number | null; exposed: boolean; forced?: boolean; }
assign returns null for an unknown id. Use registered() to
assign every experiment at once. If the server renders an eligible assignment, the browser records
the exposure after mount with recordExposure(id, variant, bucket). It uses the browser
SDK's configured user id and sinks and dedupes against later dif() calls. The Svelte
adapter does this server-to-client handoff for you.
React adapter
@dif.sh/react is a provider + hook layer. @dif.sh/sdk is a peer dependency.
Install both.
$npm install @dif.sh/sdk @dif.sh/react
import "./dif/generated/client"; import { attributes } from "./dif/generated/audiences"; import { DifProvider } from "@dif.sh/react"; export function Root({ children }) { return ( <DifProvider config={{ project: "acme-shop", publishableKey: process.env.NEXT_PUBLIC_DIF_PUBLISHABLE_KEY, userId: () => currentUser?.id ?? null, attributes: () => attributes({ plan: currentUser?.plan ?? null }), }}> {children} </DifProvider> ); }
Props: config, children, allowOverrides (default true), preview (default true). DifProvider initializes the SDK once on mount and
doesn't re-init when config changes. Pass a stable config.
Inside the tree, useDif() returns { track, exposure }. exposure has the same signature as the bare dif() function:
import { useDif } from "@dif.sh/react"; export function CheckoutCTA() { const { exposure } = useDif(); const cta = exposure("checkout-cta-v2", { control: () => "Place order", variant_a: () => "Get it today", }); return <button>{cta()}</button>; }
useDif() throws outside <DifProvider>. On mount the provider reads ?_dif= or the _dif cookie and shows a preview badge when overrides are
active; allowOverrides={false} disables overrides and preview={false} hides only the badge. There's no request-scoped SSR helper yet. Use
the pure assign API for custom server rendering.
Svelte adapter
@dif.sh/svelte gives SvelteKit request-scoped assignment, a stable anonymous dif_uid cookie, header-derived attributes, the server-to-client exposure handoff, and a
readable store for experiment values.
$npm install @dif.sh/sdk @dif.sh/svelte
1. Assign in a server layout
// src/routes/+layout.server.ts import "$lib/dif/generated/client"; import { difLoad } from "@dif.sh/svelte/server"; export const load = (event) => ({ dif: difLoad(event) });
difLoad reads or creates the dif_uid cookie, derives attributes from request
headers (Accept-Language → locale, User-Agent → device_type), merges your app attributes over the defaults, reads QA overrides, assigns
every registered experiment without firing exposures, and returns serializable DifData.
export const load = (event) => ({ dif: difLoad(event, { attributes: { plan: event.locals.user?.plan ?? null, returning_visitor: Boolean(event.locals.user), }, }), });
2. Initialize in the root layout
<!-- src/routes/+layout.svelte --> <script lang="ts"> import { setContext } from "svelte"; import { initDif, DIF_CONTEXT_KEY } from "@dif.sh/svelte"; import { PUBLIC_DIF_PUBLISHABLE_KEY, PUBLIC_DIF_CLOUD_URL } from "$env/static/public"; let { data, children } = $props(); setContext(DIF_CONTEXT_KEY, data.dif); initDif({ data: data.dif, publishableKey: PUBLIC_DIF_PUBLISHABLE_KEY, apiUrl: PUBLIC_DIF_CLOUD_URL, }); </script> {@render children()}
initDif takes userId, attributes, and overrides from data. You can't pass them directly. It accepts any publishable key, API URL, sink, or enabled option you add.
3. Render an experiment store
<script lang="ts"> import { experiment, track } from "@dif.sh/svelte"; const cta = experiment("checkout-cta-v2", { control: () => "Place order", variant_a: () => "Get it today", }); </script> <button onclick={() => track("checkout_cta_clicked")}> {$cta.value} </button> <small>Variant: {$cta.variant}</small>
experiment(id, branches) returns a Readable<{ value, variant
}>. Call it during component init (it reads Svelte context). It reuses the server
assignment from DifData when present, otherwise assigns on the client from the stable
cookie, and falls back to the first branch for an unknown experiment. It fires an owed exposure only
after a client subscription. That keeps the server HTML and the first browser render aligned.
dif_uid cookie holds a random UUID (not a secret),
is readable by the client (httpOnly: false) so browser bucketing matches the server,
uses path: /, and has a one-year max age. For local plain-HTTP dev that rejects secure
cookies, pass difLoad(event, { secure: false }). Both difLoad and initDif honor ?_dif=… preview links.dif.track() Cloud
Fire a metric event. Cloud associates it with whatever variant the same user_id was
bucketed into, so you compute lift without writing any join code.
import { dif } from "@dif.sh/sdk"; // the simplest call — a count event dif.track("completed_checkout"); // with a value (revenue, duration, count, …) dif.track("completed_checkout", { value: 49.00, currency: "USD", }); // with extra dimensions dif.track("signup", { props: { plan: "pro", referrer: "hn" } });
| argument | type | notes |
|---|---|---|
metric | string | The metric slug. A new slug lands in the Cloud Metrics catalog the first time it's seen. |
value | number? | Revenue, duration, or count: any numeric quantity to aggregate over. |
currency | string? | ISO-4217 code (e.g. "USD"). Pair with value for revenue. |
unit | string? | Free-form unit (e.g. "ms", "items") for duration / count metrics. |
userId | string? | Override. Defaults to userId() from dif.init(). |
firedAt | number? | Unix ms. Defaults to now. |
idempotencyKey | string? | When present, Cloud deduplicates by (project, key, fired_at). Safe to retry
from a failing route handler. |
props | object? | Arbitrary JSON-serializable extras, stored for later breakdowns. |
Drop conditions & transport
The call is silently dropped (no throw, no retry) when:
dif.init()hasn't run, orenabled: false;userId()returnsnull(no one to attribute to);publishableKeyisn't set: the event is logged withconsole.debugand dropped.
Browser calls post one event at a time to POST <apiUrl>/v1/track with fetch(..., { keepalive: true }) so the request survives page nav. Network
failures are swallowed and warned. Bad analytics must not crash a render.
Server SDK Cloud
Server-side metric tracking lives at the @dif.sh/sdk/server subpath. Use it from route
handlers, server actions, or queue workers: anywhere you have a secret API key instead of a
publishable key.
import { DifServer } from "@dif.sh/sdk/server"; const dif = new DifServer({ apiKey: process.env.DIF_API_KEY, apiUrl: "https://cloud.dif.sh", }); await dif.track({ metric: "completed_checkout", userId: user.id, value: 49.00, currency: "USD", idempotencyKey: order.id, });
| field | type | notes |
|---|---|---|
apiKey | string | Required secret token (dif_live_… / dif_test_…). Never ship in a browser bundle. |
apiUrl | string? | Defaults to https://cloud.dif.sh. |
source | string? | Overrides the default source stamp. |
DifServer.track(...) takes the same options as dif.track(), except userId is required: servers don't have a configured user resolver, so you pass it per
call. It sends one request per call and does not batch or retry; non-success responses and network
errors produce console.warn output without rejecting the call.
Publishable keys Cloud
Cloud has two token types. Pick the right one for where the code runs.
| kind | format | where it runs | what it can do |
|---|---|---|---|
dif_pk_… | publishable | browser bundles, mobile apps | POST to /v1/track and /v1/exposure only. |
dif_live_… / dif_test_… | secret | servers, CI, scripts | All ingest endpoints. Never embed in a browser bundle. |
Ingest endpoints Cloud
If you'd rather not use the SDK, post events to Cloud's HTTP endpoints directly. Same shape, same bearer auth.
POST /v1/track
POST https://cloud.dif.sh/v1/track Authorization: Bearer $DIF_API_KEY Content-Type: application/json { "metric": "completed_checkout", "user_id": "u_8131", "value": 49.00, "currency": "USD" }
POST /v1/exposure
POST https://cloud.dif.sh/v1/exposure Authorization: Bearer $DIF_API_KEY Content-Type: application/json { "event": "dif.exposure", "experiment": "checkout-cta-v2", "variant": "variant_a", "user_id": "u_8131", "surface": "checkout", "bucket": 7142, "fired_at": 1716304931542 }
The exposure shape matches the SDK's ExposureEvent 1:1, useful when forwarding from a
custom sink or backfilling. Add "idempotency_key" to any event and Cloud deduplicates
by (project, key, fired_at), so a retried POST is safe.