Feature flags: dark-ship, ramp, and roll back — at the edge.
HS-X flags evaluate inside your own Worker, from a KV snapshot, by a pure in-isolate evaluator. A flag check is a local memory read — not a call to HubSpot, not a call to the HS-X control plane. You author flags in HS-X; the state is yours and the app stays leaveable. This guide walks the mental model and every place you read a flag: Worker code, both UI-extension paths, OpenFeature, and the CLI.
Where flags evaluate (read this first)
The thing that makes HS-X flags different from a hosted flag SaaS is where the decision happens. When your Worker calls ctx.flags.getBoolean('new-checkout', false), there is no outbound request. The flag's resolved snapshot already sits in your Worker's KV namespace, at the same edge location serving the request, and a pure, I/O-free evaluator reads it and returns a value. The HS-X control plane is not consulted. HubSpot is not consulted. Latency is a memory read.
Three consequences fall out of that, and they shape everything below:
- Flags fail safe. A missing flag, an unreachable KV read, a dangling variation, or a type mismatch all resolve to the default you passed at the call site. A flag can never throw and break a request — the worst case is "you get your default."
- The kill switch is immediate and total. Setting a flag to
disabled(orarchived) short-circuits ahead of every targeting rule and rollout. The next snapshot sync flips it everywhere; there is no per-rule exception to reason about during an incident. - You own the state, so the app stays leaveable. Flags live in your tenant D1 and KV on your Cloudflare. The CLI writes them directly; the dashboard and agent API write through the control plane. Either way, evaluation keeps working if the control plane is unreachable.
You author flags through HS-X surfaces only — the CLI, the dashboard, or the scoped agent API. You never author flags inside HubSpot. (Flag state is projected into the CRM for visibility, one-directionally; see the last step.)
A flag is authored in HS-X → synced to a per-flag KV snapshot on your Worker → read locally by the evaluator. Control plane and HubSpot are off the evaluation path. Defaults are the floor.
Evaluate a flag in Worker code
Inside any handler — a workflow action, a card backend, an app-event handler — read flags off ctx.flags. The install and portal identity is supplied for you, so targeting "just works" without you threading context.
export default defineWorker('checkout', (w) => {
w.workflowAction('start-checkout', async (ctx) => {
// (key, defaultValue) — the default is returned on any fail-safe path.
const useNewFlow = await ctx.flags.getBoolean('new-checkout', false);
const variant = await ctx.flags.getString('checkout-variant', 'control');
const maxItems = await ctx.flags.getNumber('cart-max-items', 50);
const config = await ctx.flags.getJson('checkout-config', { theme: 'light' });
if (useNewFlow) {
return runNewCheckout(ctx, { variant, maxItems, config });
}
return runLegacyCheckout(ctx);
});
});Each getter is typed: asking getBoolean for a flag whose variation is a string returns your default (a type mismatch is a fail-safe path, not an error). The value reflects the flag's state at the moment of the request — flip a flag and the next invocation sees it, once the snapshot has synced.
ctx.flags.getBoolean('new-checkout', false) resolves locally in the isolate. With no new-checkout flag authored yet, you get false — your default — and nothing errors.
Evaluate a flag in a UI extension
UI extensions run in a HubSpot iframe, so they cannot read your KV directly. There are two paths, and which you use depends on whether your card has a backend.
A card with a backend handler already has ctx.flags in that handler (step 1). Resolve flags there and return them in your card's data — the values travel with the render, no extra round trip.
A pure-UI card (no backend) evaluates against your Worker's dedicated endpoint via createFlagsClient from @hs-x/sdk/ui. It calls POST /_hsx/flags/evaluate over hubspot.fetch — your Worker runs the same evaluator and answers. HubSpot is never the source of truth; it is only the transport.
import { hubspot } from '@hubspot/ui-extensions';
import { createFlagsClient } from '@hs-x/sdk/ui';
const flags = createFlagsClient({
// Your deployed Worker origin — must be in the app's permittedUrls.fetch.
endpoint: 'https://your-app.your-account.workers.dev',
});
function Card() {
const [showBeta, setShowBeta] = useState(false);
useEffect(() => {
// Fails safe to the default if the Worker is unreachable — never to HubSpot.
flags.getBoolean('beta-card', false).then(setShowBeta);
}, []);
return showBeta ? <BetaCard /> : <StableCard />;
}You must allow-list your Worker origin in the app's permittedUrls.fetch — hubspot.fetch rejects any URL that isn't there. If the Worker is unreachable, the client returns your caller-supplied default; it never falls back to a HubSpot value.
A pure-UI card resolves beta-card through your Worker's /_hsx/flags/evaluate. With the origin allow-listed and the flag enabled for the viewer, the beta card renders; otherwise the stable card does.
Use the OpenFeature provider (optional, for portability)
If you already standardize on OpenFeature, HS-X ships a provider that conforms to the spec's resolve*Evaluation contract and returns ResolutionDetails — without taking a dependency on the OpenFeature SDK. You supply how a snapshot is fetched; the provider runs the same evaluator and maps the result onto OpenFeature reasons (TARGETING_MATCH, SPLIT, DISABLED, DEFAULT, ERROR).
import { createHsxOpenFeatureProvider } from '@hs-x/sdk';
const provider = createHsxOpenFeatureProvider({
// Hand it your KV snapshot read (or any source). Sync or async.
resolveSnapshot: (flagKey) => snapshotStore.get(flagKey),
staticContext: { accountId, projectId, environment, hubSpotAppId },
});
const details = await provider.resolveBooleanEvaluation('new-checkout', false, {
targetingKey: actorId,
});
// → { value, variant, reason: 'TARGETING_MATCH' | 'DEFAULT' | ... }This is purely a portability layer. If you're not already invested in OpenFeature, ctx.flags and createFlagsClient are the ergonomic path.
provider.resolveBooleanEvaluation returns an OpenFeature ResolutionDetails with a mapped reason, fail-safe to your default on any miss.
Author and flip flags
Flags are authored through HS-X, never inside HubSpot. The leaveable path is the CLI, which signs and writes straight to your tenant — no control plane required.
# Create or update a flag from a definition file (its identity is stamped for you).
hs-x flags create --file new-checkout.json --project-id checkout --app-id 1234567
# Flip lifecycle state — disable is your kill switch, archive retires the flag.
hs-x flags enable --key new-checkout --project-id checkout --app-id 1234567
hs-x flags disable --key new-checkout --project-id checkout --app-id 1234567
hs-x flags archive --key new-checkout --project-id checkout --app-id 1234567
# See what's live.
hs-x flags list --project-id checkout --app-id 1234567A minimal new-checkout.json — you write the flag shape; the CLI stamps the account/project/environment/app identity:
{
"key": "new-checkout",
"type": "boolean",
"state": "enabled",
"variations": [
{ "name": "on", "value": { "type": "boolean", "value": true } },
{ "name": "off", "value": { "type": "boolean", "value": false } }
],
"defaultVariation": "off",
"rules": [],
"version": 1,
"updatedAt": "2026-06-02T00:00:00.000Z"
}A create writes your tenant D1 and syncs the KV snapshot in one step, so the value is live the moment the command returns. The dashboard Flags tab does the same flips from a UI, and a scoped agent API lets an agent read and flip flags programmatically — both route through the control plane to your tenant, while the CLI stays direct.
After hs-x flags create, hs-x flags list shows the flag and your Worker's ctx.flags.getBoolean('new-checkout', …) returns the resolved value — no redeploy.
Target and roll out
A flag's value is decided by an ordered list of targeting rules (first match wins), then an optional flag-level rollout, then the default variation. Rules match on identity HS-X already holds — at four levels:
installed_portal/company— the portal/install the request is running in.contact— the acting contact (email or HubSpot user id).actor— a developer-supplied targeting key, email, or user id.
{
"key": "new-checkout",
"type": "boolean",
"state": "enabled",
"variations": [
{ "name": "on", "value": { "type": "boolean", "value": true } },
{ "name": "off", "value": { "type": "boolean", "value": false } }
],
"defaultVariation": "off",
"rules": [
{ "id": "beta-portals", "level": "installed_portal", "matchKeys": ["555000", "555001"], "variation": "on" }
],
"rollout": { "buckets": [{ "variation": "on", "percent": 20 }] },
"version": 2,
"updatedAt": "2026-06-02T00:00:00.000Z"
}This turns new-checkout on for two named portals outright, and rolls it out to 20% of everyone else. The rollout is sticky: the same subject hashes into the same bucket on every request, across isolates and edge locations, so a user doesn't flicker between variants as you ramp. Raise the percent to ramp; set state to disabled to kill it instantly regardless of rules.
Beta portals get on every time. Everyone else is consistently bucketed — the same user stays on the same side of the 20% line until you change the percentage.
See flags in the CRM and dashboard
Two surfaces give you visibility without touching the evaluation path.
The dashboard Flags tab (per project) lists your flags with their type, state, version, rule count, and rollout — and gives you enable / disable / archive buttons that flip state through the same conduit the agent API uses.
Each flag is also projected into your connected CRM as a Feature Flag app object: one record per flag, with company- and contact-level targeting surfaced as associations, so flag state shows on records and is usable in lists and workflows. The projection is one-directional — editing the object inside HubSpot does not change evaluation, and the next reconciliation restores it to match HS-X. (The CRM projection requires the HS-X sync app's app-object approval and a connected destination portal; until then, flags evaluate exactly the same — the projection is visibility, not a dependency.)
A flag you author shows up on the dashboard Flags tab immediately, and — once your destination CRM is connected — as a Feature Flag record with company/contact associations.