Olympus Docs
CookbookIntegrations & billing

Billing-aware authorization

Gate features by subscription plan

Your app has plans: free, pro, enterprise. Features are tiered. Authorization considers not just role but plan.

Storing the plan

ALTER TABLE identities ADD COLUMN plan TEXT DEFAULT 'free';

Or in metadata:

identity.metadata_public.plan = "pro"

Updates from Stripe webhook.

Authz check

function canUseFeature(user: User, feature: string): boolean {
  const featureRequirements = {
    "advanced_reporting": ["pro", "enterprise"],
    "api_access": ["pro", "enterprise"],
    "sso": ["enterprise"],
    "audit_log_export": ["enterprise"],
  };
  const required = featureRequirements[feature];
  return required.includes(user.plan);
}

In your app

{canUseFeature(user, "advanced_reporting") ? (
  <AdvancedReportingPage />
) : (
  <UpgradePrompt feature="advanced_reporting" />
)}

Upgrade prompts

function UpgradePrompt({ feature }) {
  return (
    <div className="upgrade-banner">
      <h3>Upgrade to access {feature}</h3>
      <p>This feature is available on Pro and Enterprise plans.</p>
      <Button href="/billing/upgrade?plan=pro">Upgrade to Pro</Button>
      <Link href="/billing/plans">See all plans</Link>
    </div>
  );
}

Friendly, leads to upgrade flow.

Trial logic

Free trial of paid features:

function canUseFeatureWithTrial(user: User, feature: string) {
  if (canUseFeature(user, feature)) return true;
  
  // Trial check
  if (user.trial_active && featureRequirements[feature].includes("pro")) {
    return true;
  }
  
  return false;
}

Active trial → access to pro features.

Feature flags vs plan gates

These are different:

  • Feature flag: gradual rollout (10% of users). Eventually 100%.
  • Plan gate: permanent restriction based on subscription.

Don't conflate. Same code can use both:

const showAdvanced = featureFlag("new_reporting", user) && canUseFeature(user, "advanced_reporting");

Backend enforcement

UI gating isn't enough. Enforce server-side:

app.get("/api/advanced-report", async (req, res) => {
  const user = await getUser(req);
  if (!canUseFeature(user, "advanced_reporting")) {
    return res.status(403).json({ error: "plan_required", required: "pro" });
  }
  // ...
});

Otherwise: power user just sends API requests directly.

Stripe webhook integration

app.post("/webhooks/stripe", async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], WEBHOOK_SECRET);
  
  if (event.type === "customer.subscription.updated") {
    const sub = event.data.object;
    const newPlan = mapStripeProductToPlan(sub.items.data[0].price.product);
    const customer = await stripe.customers.retrieve(sub.customer as string);
    const olympusId = (customer as Stripe.Customer).metadata.olympus_identity_id;
    
    await kratos.adminPatch(olympusId, [
      { op: "replace", path: "/metadata_public/plan", value: newPlan }
    ]);
  }
  
  if (event.type === "customer.subscription.deleted") {
    // Downgrade to free
    const customer = await stripe.customers.retrieve(...);
    await kratos.adminPatch(olympusId, [
      { op: "replace", path: "/metadata_public/plan", value: "free" }
    ]);
  }
  
  res.json({ received: true });
});

Plan changes propagate within seconds.

Reading plan in JWT

For backend services that need to check, include in access token:

// Hera consent shaper
session.access_token = {
  "https://your-app.com/plan": identity.metadata_public?.plan ?? "free",
  ...
};

Backend:

const plan = req.user["https://your-app.com/plan"];

Cache during the token's lifetime. Refresh tokens get fresh plan.

Grace period

When user downgrades from pro to free, they might lose access mid-session.

Options:

  • Hard cut: features stop immediately.
  • Grace period: 24h continued access.
  • End of billing period: continue until period ends.

Most common: end of billing period (Stripe handles via cancel_at_period_end).

Lurking features

Some features should NEVER be exposed without proper plan, even temporarily:

// In React
if (!canUseFeature(user, "sso")) {
  return null;  // not even visible
}

Don't show "upgrade to use SSO", for some it's confusing.

But: be careful with hidden features. Sometimes you want to advertise via "locked" UI:

<MenuItem disabled lockedBy="pro">
  <LockIcon /> SSO
</MenuItem>

Tooltip explains. User clicks → upgrade flow.

Enterprise overrides

For specific customers on custom contracts:

CREATE TABLE feature_overrides (
  identity_id UUID,
  feature TEXT,
  granted BOOLEAN,
  reason TEXT,
  PRIMARY KEY (identity_id, feature)
);
async function canUseFeature(user, feature) {
  // Check override first
  const override = await db`SELECT granted FROM feature_overrides WHERE identity_id = ${user.id} AND feature = ${feature}`.first();
  if (override) return override.granted;
  
  // Default by plan
  return featureRequirements[feature].includes(user.plan);
}

Custom deal: grant pro feature to specific free user.

Audit

audit({
  event: "feature_access",
  user: user.id,
  feature,
  allowed: canUseFeature(user, feature),
  reason: ...,
});

Track gated access for analytics:

  • Most-requested locked features → consider including in lower plans.
  • Most-used premium features → highlight in marketing.

On this page