Hogsend is brand new.Try it
Hogsend
Feature Flags

Feature flags

Native, DB-backed feature flags — a targeting rule over contact properties plus a sticky rollout, evaluated the same way in the browser, on the server, and inside journeys. Toggle live, no redeploy.

A feature flag is a rule about a person that resolves to a value your app reads. Hogsend already has all three parts — the contact store, the condition engine that powers journey targeting, and a resolved identity for every visitor — so flags are native here, not a bolt-on. You define a flag once; the same evaluation runs in the browser SDK, the server SDK, and journey code.

Flags are operator-editable in Studio: flip enabled, change a rollout, or edit targeting and it takes effect on the next evaluation. No deploy, no build.

What a flag is

Every flag resolves in one deterministic order:

  1. Disabled → the default value.
  2. Targeting fails → the default value. Targeting is a list of property conditions over the contact's properties; an empty list matches everyone.
  3. Outside the rollout → the default value. Rollout is a percentage; membership is a stable hash of the contact and the flag key.
  4. Boolean flagtrue.
  5. Multivariate flag → one arm, picked by weight.

Because membership is a hash of (contact, flag), a contact's answer is sticky with no stored assignment — the same person always lands in the same rollout bucket and the same arm, and raising a rollout from 10% to 20% never reshuffles the first 10%.

flag, not featureFlag. The browser hook is useFlag, not useFeatureFlag, so it sits next to PostHog's hook in the same file without a name clash. If you already run PostHog flags, keep them — these are Hogsend's own, evaluated against your Hogsend contacts.

Define flags in code

The front door for a durable product flag is defineFlag() — the flag sibling of defineJourney / defineCampaign. You commit the flag's contract to the repo; the boot reconciler upserts it into a flags row. Git history is your flag history.

src/flags/index.ts
import { defineFlag } from "@hogsend/engine";

export const previewBanner = defineFlag({
  key: "preview-banner",
  name: "Preview banner",
  type: "boolean",
  description: "Shows the in-progress preview banner.",
});

export const flags = [previewBanner];

Add the flags array to createHogsendClient({ flags }) in both src/index.ts and src/worker.ts (the scaffold already wires this), and deploy.

A definition is only half the flag

A defineFlag definition owns the flag's contract; the DB owns its state. The two never overlap.

Owned by code (defineFlag)Owned by the operator (Studio / admin API)
key, name, typeenabled — the live master switch
variants — the multivariate armsrollout — the eligible share
defaultValue, descriptiontargeting — property conditions

Every code-defined flag is born disabled with rollout 0. Shipping a flag file never flips live traffic — an operator turns it on and dials targeting/rollout in Studio, and the reconciler syncs only contract drift afterward, never touching that state. enabled in a definition is a one-time create seed; flipping it in code later does nothing. There is no targeting or rollout field on a definition — those are live levers, not committed ones.

Typed reads

Run pnpm flags:generate after adding or removing a flag. It infers each flag's served value type — a boolean flag → boolean, a multivariate flag → the union of its variants[].value literals — and writes a FlagRegistryMap augmentation to src/flags/flags.d.ts. Commit it.

browser
import { useFlag } from "@hogsend/react";

const showBanner = useFlag("preview-banner"); // boolean | undefined; a typo key won't compile

After codegen, useFlag / useFlags (@hogsend/react), hogsend.getFlag (@hogsend/js), and client.flags.evaluate (@hogsend/client) all type-check the key and narrow the value against this app's flags. Skip the codegen and every surface degrades to string keys / unknown values — nothing breaks, you just lose the narrowing.

defineFlag for durable flags, client.flags.create() / Studio for dynamic ones. Both create real flags on the same evaluation engine. Reach for the code front door when the flag is content you want reviewed in a PR and read by a typed key; reach for the data plane (below) for a quick kill-switch, an A/B you'll retire, or a flag an operator needs without a deploy. The two coexist — a dynamic flag evaluates unchanged alongside your code-defined ones.

Create a flag dynamically

For an experimental or short-lived flag, skip the repo. In Studio, open Flags → New flag: give it a key, a type, a default, and a rollout. Targeting and multivariate arms are edited as JSON (property conditions and { key, value, weight } arms). Or create it from the server SDK:

server
import { Hogsend } from "@hogsend/client";

const hogsend = new Hogsend({ apiKey: process.env.HOGSEND_SECRET_KEY });

await hogsend.flags.create({
  key: "new-checkout-flow",
  name: "New checkout flow",
  type: "boolean",
  defaultValue: false,
  rollout: 25,
  targeting: [{ type: "property", property: "plan", operator: "eq", value: "pro" }],
});

That flag is served to a Pro contact if they fall in the 25% rollout bucket, and false to everyone else.

Read a flag

In React — reactive, re-renders when the identity changes:

browser
import { useFlag } from "@hogsend/react";

function Checkout() {
  const newFlow = useFlag("new-checkout-flow");
  return newFlow ? <NewCheckout /> : <LegacyCheckout />;
}

On the server — for one contact:

server
const { flags } = await hogsend.flags.evaluate({ userId: "user_123" });
if (flags["new-checkout-flow"]) {
  /* … */
}

In a journey — read the contact's properties and branch as you already do; a flag is the same property model the condition engine evaluates.

The read is recipient-scoped

The browser read (GET /v1/flags) resolves identity server-side from a userToken or the caller's own anonymous id — never from a contact key in the request. A publishable key can read its own flags and no one else's; an anonymousId that collides with an identified contact is rejected. This is the same leak boundary as the in-app feed. The browser SDK also clears its flag cache the moment the identity changes, so a logged-out or switched user never reads the previous user's flags.

Reference

The browser and server reads are the data plane; the admin routes manage definitions.

GET /v1/flags — browser read

Publishable or secret key. Identity from userToken, anonymousId, or (secret only) userId / email.

200 response
{ "flags": { "new-checkout-flow": true, "pricing-copy": "urgent" } }

POST /v1/flags/evaluate — server read

Secret key with the ingest scope. Body { userId?, email? } → the same { flags } map for one contact.

/v1/admin/flags — manage definitions

Admin session. GET / lists (add ?includeArchived=true to include soft-deleted), POST / creates, PATCH /{id} updates (enabled, rollout, targeting, variants, defaultValue, name, description), DELETE /{id} archives (a soft-delete that frees the key).

Flag fieldTypeDescription
keystringThe stable identifier the SDK reads. Unique among live flags; immutable once created.
typeboolean | multivariateBoolean serves true; multivariate serves an arm's value.
enabledbooleanOff serves the default value to everyone.
rolloutnumber0–100. The share eligible once targeting passes.
targetingcondition[]Property conditions; empty matches everyone.
variantsarm[]Multivariate arms: { key, value, weight }.
defaultValueanyServed on disabled / targeting-miss / outside-rollout.
originstringnative today — the seam for syncing flags in from another provider later.

What's deferred. Targeting is contact properties + rollout today; event- and journey-history targeting, per-contact overrides, and syncing flags in from PostHog or LaunchDarkly are planned, not shipped. origin is the marker that makes the sync path additive when it lands.