Feature flags and A/B tests
Read feature flags in your app or on your server, and measure A/B tests on paywalls.
A feature flag turns a feature on or off, or picks a variant, for each person without a release. An A/B test splits the people who reach a paywall location between several paywalls and measures which converts better. Your code reads flags and decides what to do; A/B tests on paywalls need no code at all.
Feature flags and A/B tests are in early access. Ask us on Discord, or email support@voidhash.com on a paid plan, to turn them on for your team.
| Use | When you want to |
|---|---|
| A feature flag | Roll out a feature gradually, switch it off remotely, or try variants of your own screens. |
| An A/B test | Compare paywalls at a location and read conversion and revenue per arm in Studio. |
Don't gate paid access on a flag. Check perks for that.
Evaluate flags in your app
Each SDK evaluates flags for the current person, caches the results on the device and keeps answering from the cache while Voidhash is unreachable. A flag that is off, unknown or not evaluated yet reads as off, or as the default you set, so write your code so that off is the safe behavior. Changes made in Studio reach the app at its next refresh.
See the SDK pages for Swift, Kotlin, React Native and Web. Flags work without analytics consent.
Evaluate flags on your server
Call POST /projects/{projectId}/feature-flags/evaluate
with a secret key. Name the person by any of personId,
distinctId, email or externalIds, and list the flag keys you need, or omit them for every
flag:
curl -X POST "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/feature-flags/evaluate" \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "distinctId": "user_123", "keys": ["new-checkout"] }'Each entry in flags has key, isEnabled, variantKey, the variant's value, and the reason
for the answer, from the rules below. The call changes nothing and is safe to repeat. It answers as
the person's app would.
How a flag decides
For each person, Voidhash checks these rules in order and stops at the first match:
| Order | Rule | Result | reason |
|---|---|---|---|
| 1 | The flag is disabled. | Off, overrides included. | disabled |
| 2 | An override exists for the person. | The overridden value. | override |
| 3 | The person is on the deny list. | Off. | denied |
| 4 | The flag has an allow list without the person. | Off. | not_allowed |
| 5 | No identity is known for the person. | Off. | no_identity |
| 6 | The person's bucket is outside the rollout. | Off. | rollout |
| 7 | Otherwise: on, with the person's variant for a flag with variants. | On. | rollout |
People on the allow list still go through the rollout, so a 0% rollout gives them nothing; use an override to guarantee someone the flag. Archived flags aren't evaluated at all, and apps fall back to their defaults. Traffic splits evenly between a flag's variants.
Set what Studio can't
Studio sets the rollout only for Boolean flags, and its overrides only force a flag on or off. Use the API, CLI or MCP server for the rest:
- Rollout of a string, number or JSON flag: send
rolloutPercentage(0–100, in 0.01 steps) toPATCH …/feature-flags/{featureFlagId}, or runvoidhash flag rollout button-color 100%. - Pin a person to a variant: send
isEnabled,variantKeyand an optionalnotetoPUT …/feature-flags/{featureFlagId}/overrides/{identity}, whereidentityisdistinct_id:user_123or another identity type. A pinned variant turns the flag on. The CLI equivalent isvoidhash flag override button-color user_123 blue --note "QA".
curl -X PUT "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/feature-flags/$FLAG_ID/overrides/distinct_id:user_123" \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "isEnabled": true, "variantKey": "blue", "note": "QA" }'How assignment stays stable
Voidhash assigns flags and A/B test arms by the person, not at random on each request, and stores nothing per person to do it:
- The same person gets the same answer on every visit and, once identified, on every device where your app identifies them with the same user ID.
- Raising a rollout only adds people. Nobody who has a flag loses it when you go from 10% to 20%.
- Identifying can change the answer. A person who starts anonymous and then signs in is assigned again as the identified person, so their flag variant or test arm may change once.
- Editing an A/B test's split can move people. Changing weights while a test is paused may put some people in another arm when it resumes.
Flags have no environments: the same flag answers in production, sandbox and test.
A/B tests on paywalls
An A/B test runs at one or more locations. Your app keeps calling presentFlow with the same slug,
and Voidhash picks the person's arm when the location resolves. There is no SDK method for A/B tests
and nothing to release. Each arm shows its paywall's current deployed build, so an app whose SDK
can't draw an arm's paywall gets FLOW_NOT_ASSIGNED and isn't counted.
Exposures
When a location resolves to an arm, Voidhash records a $experiment.exposed event for the person,
with the test in experimentId and the arm in variantKey. Repeated exposures to the same arm count
once. No exposure is recorded while the test is paused, or when the person has withheld analytics
consent; they still see a paywall. Turning off the experiment exposure event under the project's
event settings breaks results.
Conversions
A conversion is the test's goal event, sent any time after the person's first exposure. Set it explicitly when you create the test:
$subscription.createdfor a paywall that sells subscriptions. It fires when a subscription starts, including a free trial.$purchase.completedfor one-time products, or a mix. It fires on every new purchase, but not on renewals.- A custom event your app captures, for any other goal.
Without a goal, a test counts purchase_completed, an event Voidhash never sends, so every arm
shows 0 conversions. The goal locks once the test starts.
From the API, set primaryMetricEventName when you create the experiment, or update it while it is
a draft. Revenue per arm counts what exposed people paid after their first exposure; it needs no
extra events.
Results
GET …/experiments/{experimentId}/results
returns each arm's exposures, conversions, conversionRate and revenue (USD cents) since the
test last started or resumed. A person counts in the arm of their first exposure. Results are per
environment: exposures and purchases made through a test key appear only when you read results with
a test secret key, never in Studio or with a production key. Studio, the CLI and the MCP server read
these counts the same way: each arm is compared with the control by a two-sided, pooled
two-proportion z-test at 5% significance, split between the arms when there are several (a
Bonferroni correction). An arm is too early to tell while it or the control has fewer than 100
exposed people, or both together have fewer than 10 conversions. Secondary events are recorded but
not analyzed.
Experiments on your website
The web SDK doesn't present paywalls. To test something on your website, use a flag with variants and track your own event with the variant when the visitor sees it; the web SDK records no exposure when a flag is read.
Next steps
- Manage A/B tests and manage feature flags in the Help center.
- Paywalls in your app: locations, results and fallbacks.
- Events: built-in revenue events and custom events.
- Manage flags and tests from the CLI or an AI agent.
Something wrong or missing on this page? Tell us by email or on Discord.