SDK
Install @jingsu96/quoin-flags, define a flag in code, read it with a key, and understand what "unconfirmed" means. The connect screen's snippets are copied from this page.
This is the reference that the connect screen (S2) copies from. The two snippets that the screen shows are the
two fenced blocks below. A test in apps/web asserts that they are equal, so the app and the docs can never drift
apart. If you already read the Quickstart, the only new item here is the key.
On a private registry
Everything on this page is real, tested code in the repo as of QN-02b (docs/reports/qn-02b.md). This includes
defineFlags, createClient, the read over GET /api/flags, the first-read beacon, and the exposure queue
behind decide() (see Exposures). The SDK is published as @jingsu96/quoin-flags on a
private registry, not on npm. To install it, the .npmrc of your project must route the @jingsu96 scope to
that registry, and your ~/.npmrc must have a read token for it. The quoin CLI is not
published. As of QN-03a-ii, it runs from source inside the repository (see CLI).
1. Install and sign in
pnpm add @jingsu96/quoin-flagsquoin loginquoin login --url <where Quoin runs> signs in the CLI, so that you can change flags from a terminal (see
CLI). Your app authenticates with the key below. The CLI is never required to read a flag.
2. Define your first flag in code
Give the flag a key, a type, a default, an owner and a date by which the flag must be gone. A typo in a key is a
compile error, not a silent false. description is required. defineFlags throws without it, so the snippet
that you paste must have a description.
import { defineFlags, flag } from "@jingsu96/quoin-flags";
export const flags = defineFlags({
"checkout.new_summary": flag.boolean({
default: false,
description: "New order summary on checkout",
owner: "mia",
expires: "2026-12-01",
}),
});Every temporary flag must have an expires date. A temporary flag has kind: 'release', the default, or
kind: 'experiment'. If a flag must live forever, give it kind: 'ops'. An ops flag has no expiry.
3. Create the client with your key
import { createClient } from '@jingsu96/quoin-flags';
import { flags as registry } from './flags';
export const quoin = createClient(registry, {
apiKey: process.env.QUOIN_API_KEY,
subject: (input) => ({ id: getUnitId(input as Request) }),
});QUOIN_API_KEY is the key that the connect screen shows one time. Use qn_dev_… for development and
qn_live_… for production. For other environments, use the own key of the environment. The key names its
environment, so no other value must name it. Store the key as a secret. Quoin keeps only a SHA-256 hash and the
prefix. The prefix is the word plus the first 8 characters, for example qn_dev_a1b2c3d4. A lost key is a new key.
Quoin never recovers a lost key.
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 uses the unit id
as its key. Quoin stores the unit id exactly as sent and never hashes it. Use an opaque identifier, for example a
user id or a device id. Do not use an email address or a name. See Exposures. If you do not give
a mapper, the subject is {}. If a mapper throws, the SDK does the same. It never gives an error in your request.
4. Read it where the difference is
const on = (await quoin.flags(request)).isOn("checkout.new_summary")flags() fetches the rules for the environment of the key. It returns three reads:
| Call | Records an exposure? | Use it for |
|---|---|---|
isOn(key) / value(key) | No | To decide what to render, to log, and for all work before the unit has experienced the difference |
decide(key) | Yes | The one call at the time when your app serves the variant to the unit |
Exposure happens at decide(), never anywhere else. Call isOn() and value() as often as you want. Call
decide() exactly one time, where the unit sees the result. decide() gives the exposure that it recorded to an
in-memory queue. The queue sends the exposures in batches to POST /api/exposures every 1 second or 50 items. The
queue retries on a network failure, a 5xx or a 429. It never retries on a 4xx. The queue never blocks your
request. See Exposures for the full contract of the queue. The contract includes waitUntil for
Workers and other short-lived runtimes. In these runtimes, build the client inside the request handler, for two
reasons. The ctx.waitUntil of a request is valid only for that request. The shared queue gives its flush to the latest
createClient call for a key. This example shows the pattern:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const quoin = createClient(registry, {
apiKey: env.QUOIN_API_KEY,
subject: (input) => ({ id: getUnitId(input as Request) }),
waitUntil: (work) => ctx.waitUntil(work),
});
const flags = await quoin.flags(request);
return renderOneClickCheckout(flags.decide('checkout.one_click'));
},
};What happens on the first read
The first time that a process calls flags(), the client posts the definitions of the registry to
POST /api/flags/first-read. A definition has these fields: key, type, kind, owner, description, expiry, and
whether the flag is client-safe. The client never sends the code default. At this time, a code-defined flag stops
being unconfirmed in Quoin. The workspace gets a definition that it did not have. A server-scope key can
create up to 20 definitions for each call. A client-scope key can only confirm flags that the workspace already
declared. Quoin marks the row for the environment of the key as confirmed, one time. After that beacon arrives, the
connect screen redirects to the page of the flag on the next load. There is no live update. Only reload shows the
change.
The beacon is best effort. The SDK sends it one time for each process for each key. The SDK counts it as sent only
on a 2xx. After a failure, the SDK retries not sooner than the 60 seconds of the row cache. The beacon never
blocks a read and never makes a read fail. If a definition differs from the definition that Quoin already holds,
Quoin logs the difference. Quoin does not rewrite the definition.
On a Starter workspace, the beacon creates new definitions only up to the 5 flags of the plan. It still answers
200. It names the keys that it skipped in skipped. A skipped flag evaluates to its code default. This continues
until the plan changes and the first read of the next process creates the flag. See
Plans and limits.
How reads behave when Quoin is unreachable
createClient never throws. It caches rows in-process for 60 seconds (cacheTtlMs). A kill switch can take that
long to reach your app. It can also take the 30-second edge cache of the server and one fetch. If a network error
or a 5xx occurs, the last good rows answer. If there is no cache yet, the code defaults answer. If the client
does not understand the schemaVersion of a payload, the client discards the payload with one
quoin.sdk_outdated warning. Then the code defaults answer. baseUrl defaults to the hosted service, https://app.usequoin.app. To point at
a local Worker, give your own baseUrl.
The client stops a read that hangs after readTimeoutMs. The default is 2,000 ms, unless you pass another value.
The client answers in the same way as for other failures. Two callbacks let you count the failures. The client
never throws. These are the callbacks:
onReadFailed(problem)runs one time for each failed read. A read fails if Quoin is unreachable, the read times out, the status is an error, the body is malformed, or theschemaVersionis unknown.onInvalid(key, problem)runs when a stored row for a flag does not validate. Then the code default answers.
The row cache, the first-read beacon and the exposure queue of the client are shared for each (baseUrl, apiKey)
for the life of the process. The client never evicts them. The intended shape is one server key for each process.
TBD: whether the client drains and evicts the queue of a rotated key, QN-11b's owner.
The words
- Key scope:
clientkeys are safe to ship in a browser. They see only flags that are declaredclient: true.serverkeys see every flag and can create definitions through the beacon.operatorkeys belong to a person, not to an environment. They cannot read flags. - Unconfirmed: a flag that Quoin knows about, which the app declared or a beacon carried, and for which no SDK has read the row of an environment there. Confirmation is for each environment, never one time for each flag.
- Definition: what the code says (key, type, default, owner, expiry). Rule: what Quoin says (force, rollout, experiment). The first match wins. See Flags and rules.