What it is

An experimentation primitive built on /api/ints. Pass clientSeed = experimentId + ":" + userId and get a uniform integer the same way, every time, forever. Combine that with a weighted variant table and you have a reproducible bucket assignment that's also verifiable by a third party.

Internal hash-based bucketing (the standard "hash userId with an experiment salt") gives you reproducibility but not verifiability — change the salt or lose the code path and historical assignments become unprovable. The A/B test bucketing API solves both.

The pain point

Three things usually break in homegrown bucketing:

  1. Audit gaps. A regulator asks "show me that user 8821 was really in the treatment arm of the checkout v2 test on March 14" — and you have to dig through old code, hope the salt hasn't changed, and re-run the hash by hand.
  2. Cross-platform drift. Server and client implement the hash slightly differently and users flip variants depending on where they're served.
  3. Trust on external readouts. Sharing experiment results with a partner or a board member is a "trust me, the numbers say so" exercise.

A verifiable RNG fixes all three by making the assignment a deterministic, signed function of inputs everyone can see.

Try it live

Two users, same experiment, deterministic buckets. Hit Run twice — same input, same output, every time.

curl "https://api.provable.io/api/ints?clientSeed=exp_checkout_v2:user_8821&count=1&min=1&max=100"
curl "https://api.provable.io/api/ints?clientSeed=exp_checkout_v2:user_4410&count=1&min=1&max=100"

Integration snippet

// Assign a user to a variant of a multi-arm experiment.
async function bucketFor(experimentId, userId, variants) {
  // variants = [{ name: "control", weight: 50 }, { name: "treatment", weight: 50 }]
  const total = variants.reduce((s, v) => s + v.weight, 0);
  const clientSeed = `${experimentId}:${userId}`;

  const url = new URL("https://api.provable.io/api/ints");
  url.searchParams.set("clientSeed", clientSeed);
  url.searchParams.set("count", "1");
  url.searchParams.set("min", "1");
  url.searchParams.set("max", String(total));

  const res = await fetch(url, {
    headers: { "x-api-key": process.env.PROVABLE_KEY }
  });
  const { outcome, serverHash } = await res.json();

  let acc = 0;
  for (const v of variants) {
    acc += v.weight;
    if (outcome[0] <= acc) {
      return { variant: v.name, clientSeed, serverHash };
    }
  }
}

const { variant, serverHash } = await bucketFor(
  "exp_checkout_v2",
  "user_8821",
  [{ name: "control", weight: 50 }, { name: "treatment", weight: 50 }],
);
// Persist `variant` + `serverHash` with the assignment event.

Why this is fair

Rollout patterns

The full recipe

This page is the keyword-targeted entry point. For the long-form walkthrough — including stickiness across rollouts, deterministic holdouts, and how to wire bucketing into a real experimentation pipeline — see the recipe: Deterministic A/B Buckets.

Where it fits

Related

FAQ

What clientSeed should I use for A/B bucketing?

Combine experiment ID with user ID: clientSeed = experimentId + ':' + userId. The HMAC stream is deterministic, so the same seed always returns the same bucket, and unrelated experiments stay independent.

Should I cache the bucket assignment?

Yes. The first call defines the assignment — cache it locally to avoid the round trip on every page view. The API stays deterministic, so re-derivation is always possible from the committed serverHash.

How do I grow treatment from 10% to 50% without reshuffling existing users?

Use sticky bands. Treatment = rolls 1..10 at 10%; bump to 1..50 at 50%. Everyone in the first 10% remains in treatment under the expanded band; nobody flips variants.

How can a regulator audit an assignment months later?

Hand them the published serverHash, the clientSeed (experimentId:userId), and the variant weight table. They re-run /api/ints and confirm the bucket — no internal salts or legacy hashing code required.