Recipes · Plan gating
Plan gating with Darkship
Show or hide a feature by the customer's plan, give one customer an exception, and change either without a deploy. You need typed flags, one rule on a plan attribute and one override list.
Darkship decides what a plan unlocks in your product; it does not know what the customer paid for. Your billing system (for example Stripe) stays the source of truth for the plan, and you pass that plan into the evaluation context. Always enforce on the server: a flag evaluated in the browser only changes what the interface shows, and a visitor can change anything their own browser sends.
The model#
Three pieces of the targeting model do all the work:
- Typed flags carry the entitlement: a
booleanfor "can use CSV export", anintfor "seats included", ajsonfor a small bundle of limits. - A rule on a
plancontext attribute serves the plan's value:plan in [pro, business]serveson. Rules are read top to bottom and the first match wins. - An override list is an attribute-keyed target list (
matchBy: orgId). It is checked before rules, so a customer on the list gets the listed variation whatever their plan is.
The evaluation order is: environment switch, target lists, rules top to bottom, then the default (fallthrough).
1. Create typed flags#
| Flag key | Type | Variations | Default (fallthrough) |
|---|---|---|---|
export-csv | boolean | on = true, off = false | off |
max-seats | int | small = 3, team = 10, large = 50 | small |
audit-retention | json | short = { "days": 7 }, long = { "days": 90 } | short |
Make the safest value the default. A new or unknown plan then gets the most restrictive behaviour, and so does any evaluation that fails (your code passes the same safe default).
2. Add rules on the plan attribute#
On export-csv, in each environment, add one rule. A rule is an OR of groups; a group is an AND of conditions. One group with one condition is enough here:
{
"id": "r_plan",
"groups": [[{ "attribute": "plan", "op": "in", "values": ["pro", "business"] }]],
"serve": { "variation": "on" }
}For max-seats use one rule per plan, most specific first:
plan equals "business"serveslargeplan equals "pro"servesteam- the fallthrough serves
small(free and anything unknown)
Attribute values other than email are compared exactly: "Pro" does not equal "pro". Pass plan keys in one canonical form, the same one you use in your billing code.
3. Add a per-customer override list#
Sales promised a design partner CSV export on the Free plan. Do not change the rule; add a target list to the flag, in the environment where it applies:
{
"id": "tl_partners",
"matchBy": "orgId",
"values": ["org_acme", "org_globex"],
"variation": "on"
}matchBy can be key, email, groups or any attribute name, so you can key the list on your customer's organisation id rather than on individual users. Removing the customer from the list ends the exception.
- Prefer opaque ids to emails. Lists hold identifiers of your end users in plaintext and are served to server keys only. An organisation id such as
org_acmeis enough and reveals nothing if it leaks. - Lists have limits per plan. The entry limits are placeholders until the plans are final; saving over the limit is refused with the count and the limit, nothing is truncated.
- One value, one variation. The same value cannot map to two variations within one
matchBy. Across lists, the order decides.
4. Evaluate with the plan in the context#
Pass the plan from your own billing data, together with the organisation id the override list is keyed on. Use the organisation id as key when entitlements are per organisation, so every member of an account gets the same answer.
import { ds } from './darkship';
export function entitlements(org: { id: string; plan: string }) {
const ctx = { key: org.id, orgId: org.id, plan: org.plan }; // plan comes from billing, never from the request
return {
exportCsv: ds.getBooleanValue('export-csv', ctx, false), // safe default: off
maxSeats: ds.getIntValue('max-seats', ctx, 3),
auditDays: ds.getJsonValue<{ days: number }>('audit-retention', ctx, { days: 7 }).days,
};
}
// enforce on the server, in the handler that does the work
app.get('/api/export.csv', (req, res) => {
if (!entitlements(req.org).exportCsv) return res.status(402).json({ error: 'plan_required' });
// ...stream the CSV
});from darkship_client import ds
def entitlements(org):
ctx = {"key": org.id, "orgId": org.id, "plan": org.plan} # plan comes from billing, never from the request
return {
"export_csv": ds.get_boolean_value("export-csv", ctx, False), # safe default: off
"max_seats": ds.get_int_value("max-seats", ctx, 3),
"audit_days": ds.get_json_value("audit-retention", ctx, {"days": 7})["days"],
}
@app.get("/api/export.csv")
def export_csv(org=Depends(current_org)):
if not entitlements(org)["export_csv"]:
raise HTTPException(status_code=402, detail="plan_required")
...What each customer gets#
| Organisation | Plan | Decided by | Value | Reason |
|---|---|---|---|---|
org_acme | free | target list tl_partners | true | TARGET_MATCH |
org_initech | pro | rule r_plan | true | RULE_MATCH |
org_umbrella | free | fallthrough | false | FALLTHROUGH |
org_hooli | (not passed) | fallthrough: a missing attribute never matches a positive operator | false | FALLTHROUGH |
Changing a plan#
When a customer upgrades, your billing webhook updates your own record and the next evaluation uses the new plan; there is nothing to sync into darkship. When the rule itself changes (Pro now includes something), edit the flag in the console: the change reaches servers on their next poll, or within seconds if the SDK streams. Every change is in the audit log.
Gating in the browser#
A client SDK evaluates remotely with the context you give it, so a user can send a different plan. Use client-side flags only to decide what to show. For anything that must be enforced, compute the entitlement on the server (as above) and send the result to the page, or call allFlags(ctx) on the server and pass it as the client bootstrap.
Entitlements as a first-class object (a plan as a bundle of flag values, sync from your Stripe webhook, a "what does this customer have" view and plan-change history) are not part of the MVP. This recipe is the manual version of that idea and keeps working when it ships.
Checklist#
- Every entitlement flag has the most restrictive value as its default, and your code passes the same default.
- The plan in the context comes from your billing data, in one canonical spelling.
- Overrides are keyed on an opaque organisation id, not on emails.
- Enforcement happens in server code; the browser only reflects it.
- Test the table above in a unit test with a bootstrap snapshot so a rule edit that breaks a plan is caught before it ships.
Thanks. This helps us prioritise the docs.