# sdk v0.1

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,
  }),
});
optiondefaultnotes
project CloudThe Cloud project slug. Carried by configuration; required to attribute Cloud events.
publishableKey CloudA dif_pk_… token. Browser-safe; embed it in your bundle. Required to ship events to Cloud from a browser.
apiUrl Cloudhttps://cloud.dif.shCloud or self-hosted API base URL.
userId() => nullResolves 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.
sinkcloud sink, else noneExposure sink or array of sinks. Defaults to the Cloud sink when a publishable key is set. Pass [] to disable exposure delivery.
enabledtrueGlobal assignment + tracking kill switch. false short-circuits every dif() to control and drops every track().
overrides{}Initial QA experiment-to-variant forces.
// notedif.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:

  1. QA force: a valid force wins and emits no exposure.
  2. Unknown id: the registry has no spec. Return the first declared branch. (dif validate catches these at build time as W001; this is the runtime safety net.)
  3. Disabled or uninitialized: dif.init() wasn't called or enabled: false. Return the first variant. No exposure.
  4. Null user: userId() returned null. First variant. No exposure.
  5. Audience miss: the predicate returned false. First variant. No exposure.
  6. 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.
// noteThe control branch is always the first variant declared in the .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-Languagelocale, User-Agentdevice_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.

// noteThe 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" }
});
argumenttypenotes
metricstringThe metric slug. A new slug lands in the Cloud Metrics catalog the first time it's seen.
valuenumber?Revenue, duration, or count: any numeric quantity to aggregate over.
currencystring?ISO-4217 code (e.g. "USD"). Pair with value for revenue.
unitstring?Free-form unit (e.g. "ms", "items") for duration / count metrics.
userIdstring?Override. Defaults to userId() from dif.init().
firedAtnumber?Unix ms. Defaults to now.
idempotencyKeystring?When present, Cloud deduplicates by (project, key, fired_at). Safe to retry from a failing route handler.
propsobject?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, or enabled: false;
  • userId() returns null (no one to attribute to);
  • publishableKey isn't set: the event is logged with console.debug and 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,
});
fieldtypenotes
apiKeystringRequired secret token (dif_live_… / dif_test_…). Never ship in a browser bundle.
apiUrlstring?Defaults to https://cloud.dif.sh.
sourcestring?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.

kindformatwhere it runswhat it can do
dif_pk_…publishablebrowser bundles, mobile appsPOST to /v1/track and /v1/exposure only.
dif_live_… / dif_test_…secretservers, CI, scriptsAll ingest endpoints. Never embed in a browser bundle.
// cloudPublishable keys are intentionally low-privilege but not zero-privilege: anyone who reads your JS bundle can fire events for your project. For server traffic always use a secret key, and never log it.

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.