Targeting rules
Decide who is eligible for a feature flag. Predicates against user, account, request, or any custom attribute — ANDed, evaluated locally, zero network cost.
A targeting rule narrows the population that can match a feature flag. Without rules, the rollout percentage applies to everyone — every user who hits the SDK is a candidate. With rules, only the users matching every predicate are candidates; the rollout then samples within that filtered set.
Rules let you say things like "100% of plan=pro users in country IN ['US','CA']" without ever
touching code. The predicates evaluate against the ctx object you pass to gate().
The shape
A rule is a list of predicates:
{ attr: "plan", op: "eq", value: "pro" }
{ attr: "country", op: "in", value: ["US", "CA"] }
{ attr: "signup", op: "gte", value: "2025-01-01" }Predicates are ANDed. To get true from the feature flag, a user must match every predicate and
fall inside the rollout percentage.
Operators
| Op | Type | Meaning | Example |
|---|---|---|---|
eq | any | Exact match | plan eq "pro" |
neq | any | Negation of eq | plan neq "free" |
in | array | Value is one of | country in ["US","CA","GB"] |
nin | array | Value is none of | region nin ["EU","CN"] |
gt / gte | number, ISO date | Greater than (or equal) | age gte 18 |
lt / lte | number, ISO date | Less than (or equal) | signup lte "2024-12-31" |
contains | string | Substring match (case-insensitive) | email contains "@bigco.com" |
startsWith | string | Prefix match | path startsWith "/admin" |
regex | string | Regex match (RE2 syntax, anchored) | email regex "^[a-z]+@example\\.com$" |
semverGte | semver string | Semantic-version comparison | appVersion semverGte "2.4.0" |
exists | n/a | Attribute is present + non-null | betaToken exists |
regex is RE2, so no backreferences or lookarounds — but it's safe (no catastrophic backtracking).
The pattern is anchored: use .* explicitly at start/end if you want partial matches.
Passing context to the Client
The ctx object you bind to the Client is what gets matched against:
const flags = new Client({
userId: session.user.id,
plan: session.account.plan,
country: request.geo.country,
appVersion: request.headers.get("x-app-version"),
email: session.user.email,
});
flags.getFlag("checkout-v2");Three things to internalise:
userIdis special — it's the bucketing key. Required for sticky bucketing. The other attributes are matched against rules.- Attributes are matched by name. A rule against
planlooks forctx.plan. If you passctx.userPlan, the rule misses and the user falls out of the eligible set. - Missing attributes never match. A rule
country eq "US"against actxwith nocountrykey returnsfalsefor the predicate, dropping the user from eligibility. Passcountry: nulland it still won't match —eqrequires non-null equality.
Defining rules
In the dashboard, expand a feature flag, add rules under Targeting. Each row is one predicate; rows are ANDed. Equivalent JSON shape if you'd rather edit raw via the Admin API:
"rules": [
{ "attr": "plan", "op": "eq", "value": "pro" },
{ "attr": "country", "op": "in", "value": ["US", "CA"] }
]Or directly via the Admin API (resolve the feature flag's id first, then PATCH it):
GATE_ID=$(curl -sS \
-H "Authorization: Bearer $SHIPEASY_ADMIN_KEY" \
"https://shipeasy.ai/api/admin/gates?name=checkout-v2" | jq -r '.data[0].id')
curl -X PATCH "https://shipeasy.ai/api/admin/gates/$GATE_ID" \
-H "Authorization: Bearer $SHIPEASY_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"rules": [
{ "attr": "plan", "op": "eq", "value": "pro" },
{ "attr": "country", "op": "in", "value": ["US","CA"] }
]
}'Common shapes
Beta allow-list
A small set of accounts get the feature, no one else. Rule on an account attribute, rollout 100% of the matched set:
plan eq "beta-tier" AND rollout 100%Adding an account to the beta becomes a database update on your side, not a config push on ours.
Regional progressive rollout
Ramp safely by geography. Start with countries where you control the integrations end-to-end:
country in ["US","CA"] AND rollout 5% → 25% → 100%Then expand the country set, not the percentage:
country in ["US","CA","GB","DE"] AND rollout 100%Internal-only feature
Test in production with employees first. Use an attribute that's hard to spoof from outside your auth system:
email contains "@yourcompany.com" AND rollout 100%Don't use country eq "internal-test" or anything fake — anyone can spoof their request headers.
Use an attribute your auth system signs.
Power-user soft launch
Roll out to high-engagement users first because they're more forgiving of UI changes:
sessionsPerWeek gte 10 AND rollout 25%This requires you to actually pass sessionsPerWeek in ctx — i.e. compute it server-side or
pass it from your analytics layer.
Rule evaluation order
For gate(name, ctx), the SDK runs:
1. killswitch ON → false
2. enabled OFF → false
3. ctx.userId ∈ overrides[true] → true
4. ctx.userId ∈ overrides[false] → false
5. any rule predicate fails → false ← rules
6. hash(salt + bucketBy) % 10000 < rolloutPct → true ← rollout
7. otherwise → falseRules run before the rollout. "100% of plan=pro" means 100% of pro users — not a global 100%
bucket then filtered. If you want random sampling on top of targeting, set the rollout to the
percentage you want within the targeted set.
The rule evaluates against the ctx you pass at that call. If a user's plan changes from
free to pro, the next feature flag call uses the new value. Sticky bucketing (point 6) is the
only thing held constant across calls — eligibility can change.
Custom attributes
You can pass anything into ctx — the SDK doesn't validate against a schema. Common patterns
beyond user/account:
const flags = new Client({
userId: user.id,
plan: account.plan, // identity
signupDays: daysSince(account.createdAt), // tenure
trialsUsed: account.trialsConsumed, // usage
cohort: account.cohortLabel, // bucketing tag
device: request.device.type, // request shape
feature: { betaTester: account.isBeta }, // nested
});
flags.getFlag("paywall-v2");For nested attributes, refer to them with dot paths in rules: feature.betaTester eq true.
Keep ctx small. The SDK serialises it for sticky bucketing only when bucketBy references a
nested path; everything else stays local. Still, payloads in the megabytes are wasteful — pass
what your rules use, not your whole user record.
Debugging a rule
When a user complains "I should be in this beta but I'm not," check four things in order:
- Is the feature flag enabled?
shipeasy release flags listshowsenabledand the current rollout per feature flag; the feature flag detail page in the dashboard also showskillswitchstate at the top of the header. - Does the user match the rule? Open Feature flags → checkout-v2 → Test evaluator in the dashboard and paste a JSON
ctx— same evaluator the SDK runs, prints the matched rule + bucket. - Is there an override? Overrides win over rules. Check Feature flags → checkout-v2 → Overrides.
- Are you passing the attribute the rule expects? A rule on
planwon't matchctx.userPlan. Log the user you bind to theClientand compare against the rule shape.