Quoin docs

Quickstart

Define a flag in code, read it three ways, and understand exactly when an exposure gets recorded.

This page explains the one idea that all other parts of Quoin use: a flag is defined in code, and its rules live in Quoin. At the end of this page, you know the difference between isOn(), value() and decide(). You also know why that difference is the main point.

Design stage

@quoin/flags (the engine for this page) exists in the repo today. defineFlags, evaluate, and the rollout/experiment hashing are implemented and tested. As of QN-02a, createClient is also implemented and tested. It is the hosted read path over GET /api/flags with a bearer API key. It has a 60-second cache and fallbacks to the stale cache and to the code default. It sends the first-read beacon that tells Quoin that a flag exists (see API). The connect screen is also built. Its snippets are the same as the snippets in SDK. As of QN-02b, the exposure queue of decide() and POST /api/exposures are also real (see Exposures). These items are not built yet: the web app that edits rules and the quoin CLI. The package is also not published to a registry. In this page, "the finished SDK" means the code in the repo.

1. Define your flags once

You declare the key, type, default, owner and expiry of a flag in code with defineFlags. This is the one place where the shape of a flag is decided. All other information is a rule, and rules live in Quoin. This information includes whether the flag is on, at what percentage, and for which experiment.

import { defineFlags, flag } from '@jingsu96/quoin-flags';

export const flags = defineFlags({
	'checkout.one_click': flag.boolean({
		default: false,
		description: 'Skip the review step for returning customers',
		owner: 'mia',
		expires: '2026-12-01',
	}),
	'onboarding.checklist_v2': flag.boolean({
		default: false,
		description: 'New five-step onboarding checklist',
		owner: 'sam',
		kind: 'experiment',
		expires: '2026-11-15',
	}),
});

Every temporary flag must have an expires date. A temporary flag has kind: 'release', the default, or kind: 'experiment'. Quoin has no permanent flag by accident. If a flag must live forever, give it kind: 'ops'. An ops flag has no expiry.

2. Read a flag three ways

const isOn = flags.isOn('checkout.one_click');
const value = flags.value('onboarding.checklist_v2');
const decided = flags.decide('checkout.one_click');

All three calls answer the same question: what does this flag say for this unit, now? But only one of the calls counts as an exposure:

CallRecords an exposure?Use it for
isOn(key) / value(key)NoTo decide what to render, to log, and for all work that you do before the unit has experienced the difference
decide(key)YesThe one call that fires when your app serves the variant to the unit. Your sample size and your sample ratio check use this call

This split exists because many flag libraries record an exposure on every read. This makes the denominator too large and skews it. A page can call isOn() three times to decide what to show. This does not mean that the user saw the feature three times. The Quoin rule is narrow and exact: exposure happens at decide(), never anywhere else. You can call isOn() and value() as many times as necessary while you decide what to render. Call decide() exactly one time, when the unit sees the result.

3. What decide() does under the hood

decide() evaluates the rules of the flag. The first match wins. The order is:

  1. A force rule for this unit. This is an explicit override, usually for QA or for a specific account.
  2. A rollout rule. This is a percentage. Quoin hashes it by unit id, so the same unit always gets the same bucket.
  3. An experiment rule. Quoin gives the unit a variant. Quoin hashes the variant in the same way.
  4. The code default of the flag.

When a rule decides the value, decide() records an exposure. For an experiment rule, the exposure is { experiment, phase, variant, unitId }. For a rollout rule below 100% coverage, the exposure is the equivalent rollout exposure row. An in-memory queue sends these exposures in batches to POST /api/exposures every 1 second or 50 items. The queue never blocks your request. See Exposures for the full delivery contract.

4. See it end to end

An app uses createClient as the entry point. It takes the registry from step 1 and an API key. It fetches the current rules for the environment of that key from Quoin. It returns the three read calls from step 2:

import { createClient } from '@jingsu96/quoin-flags';
import { flags as registry } from './flags';

const quoin = createClient(registry, {
	apiKey: process.env.QUOIN_API_KEY,
	subject: (input) => ({ id: getUnitId(input as Request) }),
});

export async function handler(request: Request) {
	const flags = await quoin.flags(request);

	if (!flags.isOn('checkout.one_click')) {
		return renderStandardCheckout();
	}

	return renderOneClickCheckout(flags.decide('checkout.one_click'));
}

apiKey is a server-scope or client-scope key. Use qn_dev_… for development and qn_live_… for production. The key names its environment, so no other value must name it. subject turns the value that you pass to flags() into the Subject of the engine. The value can be a Request, a session, or a plain object. The id of the Subject is the unit id. Every exposure and event uses the unit id as its key. The SDK records no exposure for a subject without an id. If you do not give a mapper, the subject is {}. Then every rule that needs an id or an email holds, and the code default answers. If a mapper throws, the SDK does the same. It never gives an error in your request.

Inside the SDK, flags() does these steps:

  • It reads GET /api/flags with the bearer key.
  • It keeps the rows for 60 seconds (cacheTtlMs).
  • If a network error or a 5xx occurs, it uses the last good rows. If there is no cache yet, it uses the code defaults.
  • One time for each process, it posts the definitions of the registry to POST /api/flags/first-read. The flag then shows in Quoin as confirmed. At this time, a code-defined flag stops being "unconfirmed".

baseUrl defaults to the hosted service, https://app.usequoin.app. To point at a local Worker, give your own baseUrl.

The isOn() check decides which branch to take. It has no exposure cost. The decide() call inside the branch that serves the feature is the call that counts toward the sample size and the health checks of the experiment.

createClient wraps createFlags(registry, rows, subject, { onExpose }) of the engine. This is the pure primitive that takes rules that you already fetched. Use createFlags directly only if you run the engine against your own rule store. Do not use it to integrate with Quoin.

Next

On this page