view .md
Guides · Grow

Feature flags

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.

Time
≈ 15 min
Outcome
A flag you can read from Worker code and a card, flip instantly with a kill switch, ramp by sticky percentage, target by portal/contact/actor — and a clear picture of where the CRM projection stands.
Prerequisites
  • An HS-X app already deployed and linked (see the getting-started guide if not).
  • The hs-x CLI authenticated against your account.
  • For UI-extension evaluation: a card extension and the ability to edit the app’s permittedUrls.

TL;DR — HS-X flags evaluate inside your own Worker from a KV snapshot — a flag check is a local memory read, with no call to HubSpot or the HS-X control plane. Author flags in HS-X, flip a kill switch instantly, ramp by sticky percentage, target by company, contact, or actor. The state is yours; the app stays leaveable.

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 (or archived) short-circuits ahead of every targeting rule and rollout and serves the flag's defaultVariation. The write re-projects the KV snapshot in the same request, and every edge location sees it as KV propagates (seconds, about a minute at worst); 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. Every authoring surface — CLI, dashboard, agent API — reaches your tenant Worker through a short-lived signed flags:write grant; the CLI routes through the control plane when you are logged in, and can write directly to the tenant Worker (--runtime-origin, or logged out with .hs-x/cloudflare.json present) when the control plane is unavailable. 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 control-plane flags API (session or personal access token; POST …/hubspot-apps/<appId>/flags/define|list). You never author flags inside HubSpot. (A one-directional projection of flag state into the CRM is planned; see the last step for where it stands.)

Mental model

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.

const worker = defineWorker('checkout');
 
worker.action('start-checkout', {
  label: 'Start checkout',
  objectType: 'deal',
  async handler(ctx) {
    // (key, defaultValue) — the default is returned on any fail-safe path.
    // ctx.flags is typed optional: it exists only on linked deploys where
    // hs-x deploy provisioned the flags KV namespace.
    const useNewFlow = (await ctx.flags?.getBoolean('new-checkout', false)) ?? false;
    const variant = (await ctx.flags?.getString('checkout-variant', 'control')) ?? 'control';
    const maxItems = (await ctx.flags?.getNumber('cart-max-items', 50)) ?? 50;
    const config = (await ctx.flags?.getJson('checkout-config', { theme: 'light' })) ?? { theme: 'light' };
 
    if (useNewFlow) {
      return runNewCheckout(ctx, { variant, maxItems, config });
    }
    return runLegacyCheckout(ctx);
  },
});
 
export default worker;

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). ctx.flags itself is optional on HandlerContext — an unlinked deploy has no flags KV namespace and the property is undefined — which is why the sample reads through ?. and falls back with ??. 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.

Expect

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 an untrusted HubSpot iframe, so the launch contract is server-only: a flag-using card must declare flags: 'server' and a backend. The backend evaluates through ctx.flags, returns inert values in its ordinary signed dispatch response, and the iframe reads only that snapshot. hs-x check and codegen reject a flag-using card without a backend.

// hsx.config.ts — the card declares its backend and opts into server-evaluated flags
cards: [
  card({
    id: 'beta-card',
    name: 'Beta card',
    location: 'crm.record.sidebar',
    objectTypes: ['contacts'],
    entrypoint: './src/app/cards/BetaCard.tsx',
    backend: 'beta-card-data',
    flags: 'server',
  }),
],

The beta-card-data card backend reads ctx.flags like any handler and returns the resolved values in its response. Nothing injects a flags field for you: the backend builds the UiFlagsSnapshot shape ({ value, reason } per key) itself:

worker.cardBackend('beta-card-data', {
  objectTypes: ['contacts'],
  async handler(ctx) {
    const flags = ctx.flags;
    return {
      flags: {
        'beta-card': { value: (await flags?.getBoolean('beta-card', false)) ?? false, reason: 'server' },
        'card-density': { value: (await flags?.getString('card-density', 'comfortable')) ?? 'comfortable', reason: 'server' },
      },
    };
  },
});

The card fetches that response through hubspot.fetch and hands the flags field to FlagsProvider:

import { Text, hubspot } from '@hubspot/ui-extensions';
import { FlagsProvider, useFlags } from '@hs-x/sdk/ui/react';
import type { UiFlagsSnapshot } from '@hs-x/sdk/ui';
const defaults = {
  'beta-card': false,
  'card-density': 'comfortable',
};
declare const flagsSnapshot: UiFlagsSnapshot; // the `flags` field your card backend returned, fetched with hubspot.fetch
 
function Card() {
  const { flags, isLoading } = useFlags(['beta-card', 'card-density'], defaults);
  const mode = flags['beta-card'] ? 'Beta' : 'Stable';
  return <Text>{isLoading ? `${mode} with defaults` : `${mode} · ${flags['card-density']}`}</Text>;
}
 
hubspot.extend(() => (
  <FlagsProvider initialSnapshot={flagsSnapshot}>
    <Card />
  </FlagsProvider>
));

useFlags reads synchronously from the server-delivered snapshot, caches missing keys as fail-safe results, and overlays the supplied typed defaults. @hs-x/sdk/ui intentionally exports no endpoint, fetch, signer, grant, or targeting-context client. Replacing the snapshot object starts a new cache epoch. The React integration remains isolated to @hs-x/sdk/ui/react, so importing the non-React /ui entry does not pull React into other consumers.

Generated linked Workers bind Cloudflare’s Rate Limiting API to every flag route. Each deployment and exact route gets a stable key, with 120 calls per 60 seconds in each Cloudflare location. A rejected request returns 429 rate_limited with Retry-After: 60 before its JSON body, grant, replay nonce, install owner, or flag snapshots are read. A missing or failing binding returns 503 flags_rate_limiter_not_configured; it never silently bypasses protection.

Expect

The card backend resolves beta-card, and the iframe renders the returned value without a second evaluation request. A missing snapshot value renders the stable default.

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 on the trusted server and initialSnapshot in the UI are the ergonomic path.

Expect

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. By default the CLI resolves your project through the control plane and writes via its flag conduit; if the control plane is down, hs-x flags … --runtime-origin <worker-url> signs a flags:write grant from .hs-x/cloudflare.json and writes straight to your tenant Worker. Non-interactive and --json runs need --yes to confirm a write.

# 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 1234567

A 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. Versions are monotonic: to update an existing flag, bump version in the file (or let enable / disable / archive bump it for you); resubmitting an equal version with different content is rejected with 409 flag_version_conflict. The dashboard Flags tab does the same flips from a UI, and the control-plane flags API lets an agent with a personal access token read and write flags programmatically — all three go through the same signed conduit to your tenant Worker.

Expect

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 (two of which currently match the same identity):

  • installed_portal and company — the portal/install the request is running in. Company-to-portal resolution is deferred until the sync app ships, so company rules take portal ids today.
  • 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. From Worker code the subject is the installed portal (no actor is present in ctx.flags), so a portal stays on one side of the line as you ramp; a card backend or OpenFeature caller that passes actor identity buckets per user instead. Raise the percent to ramp; set state to disabled to kill it instantly regardless of rules.

Expect

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, production environment only) lists your flags with key, type, state, version, and rollout percentage — and gives you enable / disable / archive buttons that flip state through the same control-plane conduit the CLI and API use. Rule and rollout editing is CLI-and-file only today.

When an HS-X CRM destination is connected, it projects the flags currently enabled for each customer portal into the Company's hsx_flags_enabled multi-checkbox property. Values are namespaced as <appslug>:<flagKey>, options are append-only, and the roll-up is computed from source flag evaluation outcomes rather than CRM records.

After HubSpot approves FEATURE_FLAG, the destination can also project one app-object record per flag, with company- and contact-level targeting surfaced as associations. That catalog projection is one-directional — editing it inside HubSpot does not change evaluation — and its writer only runs when the destination reports the approved schema and grant. The pure declaration and record mapping remain available in @hs-x/sdk/experimental (featureFlagAppObject, flagDefinitionToAppObjectRecord) until the approved delivery path ships.

Design preview

The Company roll-up needs only a connected CRM destination. App-object approval gates the richer flag catalog, not segmentation and never evaluation.

Expect

A flag you author shows up on the dashboard Flags tab immediately. Once a CRM destination and project sync are enabled, its per-portal outcome participates in hsx_flags_enabled; the app-object catalog remains approval-gated.