Skip to main content
darkship docs
Open console

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.

Updated Oct 28, 20265 min read Edit this page on GitHub (opens in a new tab)

Billing is the source of truth, enforcement is server-side

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 boolean for "can use CSV export", an int for "seats included", a json for a small bundle of limits.
  • A rule on a plan context attribute serves the plan's value: plan in [pro, business] serves on. 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#

One flag per entitlement. The type is fixed at creation.
Flag keyTypeVariationsDefault (fallthrough)
export-csvbooleanon = true, off = falseoff
max-seatsintsmall = 3, team = 10, large = 50small
audit-retentionjsonshort = { "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:

JSONexport-csv, rule 1 (as stored in the snapshot)
{
  "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:

  1. plan equals "business" serves large
  2. plan equals "pro" serves team
  3. the fallthrough serves small (free and anything unknown)
Compare exactly

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:

JSONexport-csv, target list
{
  "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_acme is 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.

TypeScriptentitlements.ts
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
});

What each customer gets#

Evaluation of export-csv with the rule and the override list above.
OrganisationPlanDecided byValueReason
org_acmefreetarget list tl_partnerstrueTARGET_MATCH
org_initechprorule r_plantrueRULE_MATCH
org_umbrellafreefallthroughfalseFALLTHROUGH
org_hooli(not passed)fallthrough: a missing attribute never matches a positive operatorfalseFALLTHROUGH

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.

Outside the first release

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.
Was this helpful?