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:
- Disabled → the default value.
- Targeting fails → the default value. Targeting is a list of property conditions over the contact's properties; an empty list matches everyone.
- Outside the rollout → the default value. Rollout is a percentage; membership is a stable hash of the contact and the flag key.
- Boolean flag →
true. - 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.
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, type | enabled — the live master switch |
variants — the multivariate arms | rollout — the eligible share |
defaultValue, description | targeting — 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.
import { useFlag } from "@hogsend/react";
const showBanner = useFlag("preview-banner"); // boolean | undefined; a typo key won't compileAfter 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:
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:
import { useFlag } from "@hogsend/react";
function Checkout() {
const newFlow = useFlag("new-checkout-flow");
return newFlow ? <NewCheckout /> : <LegacyCheckout />;
}On the server — for one contact:
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.
{ "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 field | Type | Description |
|---|---|---|
key | string | The stable identifier the SDK reads. Unique among live flags; immutable once created. |
type | boolean | multivariate | Boolean serves true; multivariate serves an arm's value. |
enabled | boolean | Off serves the default value to everyone. |
rollout | number | 0–100. The share eligible once targeting passes. |
targeting | condition[] | Property conditions; empty matches everyone. |
variants | arm[] | Multivariate arms: { key, value, weight }. |
defaultValue | any | Served on disabled / targeting-miss / outside-rollout. |
origin | string | native 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.
GTM dataLayer bridge
Two-way interop between @hogsend/js and window.dataLayer — mirror captured events out to GTM, and pipe an allowlist of existing dataLayer events into the capture spine to trigger journeys.
Integrations
PostHog (the headline source, with its own section) plus built-in webhook presets that turn Clerk, Supabase, Stripe, and Segment webhooks into Hogsend events — signature-verified, env-driven, and served at /v1/webhooks/{id}.