Feature Flags
Named booleans you toggle on the portal's Feature Flags page and read in your apps through the SDK — ship code dark, turn it on without a deploy, and turn it back off if it misbehaves. Flags live in a flags schema inside the project's own Postgres database. That schema is not exposed through /rest/v1, so API keys can read the snapshot but never change it — safe to ship in browsers and mobile apps.
A feature flag separates deploying code from releasing it. Normally those are the same event, which is what makes shipping stressful: the moment the build goes out, every user gets the change, and the only way back is another deploy. Behind a flag, the code ships dark, and turning it on is a switch — one you can flip back in seconds, without a rollback, a rebuild, or an app-store review.
That decoupling is what makes short-lived branches practical. Instead of a feature branch living for weeks and merging in one terrifying lump, half-finished work merges to main continuously behind a flag that's off. Everyone integrates against the same code, and the risky moment is a toggle rather than a merge.
Flags are cheap to add and easy to forget, so treat each one as temporary: once a feature is fully on and stable, delete the flag and the dead branch of code. A codebase with fifty stale flags has 2⁵⁰ nominal configurations and no one who knows which are real.
- Kill switches
- Wrap anything expensive or externally dependent — an LLM call, a recommendation feed — so you can shed it under load or when a vendor goes down, without shipping anything.
- Releasing on your own schedule
- Especially for mobile, where a release takes days to review and users update whenever they feel like it. Ship the code in one version, switch it on later for everyone at once — including the people still on that old build.
- Trunk-based development
- Merge incomplete work to main behind an off flag rather than maintaining a long-lived branch that grows harder to merge every day.
- Demo and staging behavior
- Turn a work-in-progress screen on in a demo project while it stays off in production — same binary, different flag state per project.
| Surface | Availability |
|---|---|
| Portal UI | Create/toggle/delete flags and set rollout percentages on the Feature Flags page |
| CLI | shovelbase flags list | create | enable | disable | configure | delete, with --rollout <0-100> to ramp |
| SDK / HTTP | shovelbase.flags.* — the primary way apps read a flag's value |
Percentage rollouts
A flag has two controls: enabled and a rollout percentage. Enabled is the master switch — off means off for everyone, whatever the percentage says, so you can kill a half-finished ramp without losing the number it reached. When it's on, the rollout decides how many people see it.
| Setting | Who sees the flag |
|---|---|
| Off, any rollout | Nobody. The kill switch always wins |
On, 100% | Everyone — how every flag behaves by default |
On, 25% | A quarter of users, and the same quarter every time |
On, 0% | Nobody yet — stage the flag, then ramp it |
The same user always gets the same answer. Whether someone is in a rollout is computed from their id, not from chance, so it survives reloads, app restarts, snapshot refreshes, and SDK upgrades. Ramping up is safe by construction: going from 25% to 50% only ever adds people, and never takes the feature away from someone who already had it.
Bucketing uses the same distinct_id that Signals attributes events to — anonymous before sign-in, your user id after identify(). That sharing is deliberate: an experiment and the numbers measuring it describe the same people. It has one consequence worth planning around — because the id changes at sign-in, a user's bucket is recomputed then, and someone can cross into or out of a rollout at that moment. Keep an experiment either side of the sign-in line rather than straddling it. Signed-in users bucket on their account id, so they get the same treatment on every device.
The server sends the rule, not a yes/no answer, and the SDK buckets locally. One cached snapshot therefore answers for any number of users — a server can decide for thousands of people per second without a request each — and flags keep answering consistently while offline. The bucket is FNV-1a over the UTF-8 bytes of "<flag>:<distinct_id>" finished with MurmurHash3's avalanche step, taken modulo 100; every shovelbase SDK computes it identically. The flag name is part of the hash so that landing in the first 10% of one flag doesn't put you in the first 10% of all of them.
JavaScript / TypeScript
Every shovelbase-js client has a flags handle. The snapshot is fetched once and cached for 60 seconds (tune with createClient(url, key, { flags: { cacheTtlMs } })); lookups never throw — offline they serve the last snapshot, and before the first fetch they return your fallback value.
const shovelbase = createClient(SHOVELBASE_URL, SHOVELBASE_ANON_KEY); if (await shovelbase.flags.isEnabled('new-checkout')) { /* … */ }await shovelbase.flags.isEnabled('kill-switch', true); // fallback when unsetconst all = await shovelbase.flags.getAll(); // { 'new-checkout': true, … }shovelbase.flags.peek('new-checkout'); // sync, last-known valueawait shovelbase.flags.refresh(); // bypass the cache // On a server, decide for any user off the one cached snapshot — no// extra requests, and each user's answer matches what their browser sees.await shovelbase.flags.isEnabledFor('new-checkout', user.id);await shovelbase.flags.getAllFor(user.id); // Standalone (no database/auth client needed):import { ShovelbaseFlags } from 'shovelbase-js';const flags = new ShovelbaseFlags(SHOVELBASE_URL, SHOVELBASE_ANON_KEY);iOS / Swift
The ShovelbaseFlags Swift package (iOS, macOS, tvOS, watchOS) has the same API and caching behavior. It ships in the same shovelbase-swift package (add ShovelbaseFlags instead of, or alongside, Shovelbase).
ShovelbaseFlags.configure(url: "https://<ref>.shovelbase.com", apiKey: ANON_KEY) if await ShovelbaseFlags.shared.isEnabled("new-checkout") { /* … */ }let all = await ShovelbaseFlags.shared.getAll() // ["new-checkout": true, …]ShovelbaseFlags.shared.peek("new-checkout") // sync, last-known valueawait ShovelbaseFlags.shared.refresh() // bypass the cache // Decide for a specific user rather than the stored identity:await ShovelbaseFlags.shared.isEnabled("new-checkout", for: userId)HTTP API (/flags/v1/flags)
The SDKs are thin wrappers over one endpoint, so any HTTP client can read flags:
curl "https://<ref>.shovelbase.com/flags/v1/flags" -H "apikey: $ANON_KEY"# → {"rules": {"new-checkout": {"enabled": true, "rollout": 25},# "kill-switch": {"enabled": false, "rollout": 100}},# "flags": {"new-checkout": false, "kill-switch": false}}Read rules and bucket yourself if you're calling the endpoint directly. The flags object is the pre-rollout response shape, kept so that SDKs released before percentage rollouts keep working; a flag mid-ramp reads false there, because degrading to off is the safe direction for a client that has no way to bucket.
The key can come as an apikey header, Authorization: Bearer, or an ?apikey= query parameter. Flag names must start with a lowercase letter or digit and may then contain lowercase letters, digits, and . _ -, up to 120 characters. Deleting a flag makes clients fall back to the default your code passes.
A toggle is not instant. Clients serve a cached snapshot, so flipping a flag in the portal reaches them within the cache TTL — 60 seconds by default, and only on the next lookup. That's fine for a release switch; if you need a kill switch to bite faster, construct the client with a shorter flags: { cacheTtlMs }, and remember every lookup still returns your fallback value before the first fetch completes.