Designing SaaS pricing plans that don't confuse developers

Developer-facing SaaS products have a specific pricing problem: your buyer is also your evaluator, and they have a very low tolerance for opaque pricing, hidden limits, and surprise bills. The companies that have built the largest developer communities — Stripe, Twilio, GitHub, Vercel — have clear, predictable pricing with a free tier that is genuinely useful. Here is what that looks like in practice and how to implement it technically.

Free tier design

The free tier serves one purpose: letting a developer build something real and decide whether your product is worth paying for. A free tier that is too restrictive forces users into a trial flow before they have decided they want your product. A free tier that is too generous delays the conversion decision indefinitely.

The right design is a permanent free tier with limits that allow a real project to work, but where production-scale usage naturally pushes users into a paid plan. For an auth product, this might look like: 1,000 monthly active users free, unlimited everything else. A solo developer with a side project stays free forever. A startup that launches and grows to 2,000 users hits the limit and is prompted to upgrade.

Avoid time-limited free tiers ("14-day trial") for developer products. Developers need to evaluate products on their own timeline — they might not have time to integrate your SDK this week. A permanent free tier removes the artificial urgency.

Trial mechanics for paid features

When a developer wants to evaluate a paid feature, the trial experience matters. Two models work well:

  • Automatic trial activation: when the developer first uses a paid feature via the API, automatically start a 30-day trial for their account. No credit card required. They can continue using it until the trial expires, at which point they are prompted to upgrade.
  • Explicit trial start: a button in the dashboard that starts a trial for a specific plan tier. The user knows exactly when it started and when it ends.
// Feature gate with automatic trial activation
async function checkFeatureAccess(
  orgId: string,
  feature: string
): Promise<FeatureAccess> {
  const org = await db.orgs.findById(orgId);
  const plan = PLANS[org.plan_id];

  // Feature is included in current plan
  if (plan.features.includes(feature)) {
    return { allowed: true, source: 'plan' };
  }

  // Check for active trial
  const trial = await db.featureTrials.findOne({
    org_id: orgId,
    feature,
    expires_at: { $gt: new Date() }
  });

  if (trial) {
    return {
      allowed: true,
      source: 'trial',
      trial_expires_at: trial.expires_at,
      days_remaining: Math.ceil(
        (trial.expires_at.getTime() - Date.now()) / (1000 * 86400)
      )
    };
  }

  // First use — auto-activate trial
  if (org.eligible_for_trials) {
    const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000);
    await db.featureTrials.insert({
      org_id: orgId,
      feature,
      started_at: new Date(),
      expires_at: expiresAt
    });

    return {
      allowed: true,
      source: 'trial_started',
      trial_expires_at: expiresAt,
      days_remaining: 30
    };
  }

  return { allowed: false, upgrade_url: `/upgrade?feature=${feature}` };
}

Feature gates by plan

The plan definition should be data, not code. Hardcoding plan checks scattered across your codebase makes it extremely difficult to add a new plan or change what features are included. A plan definition object gives you a single source of truth.

// plans.ts — single source of truth for plan capabilities
export const PLANS = {
  free: {
    name: 'Free',
    mau_limit: 1000,
    features: ['basic_auth', 'email_auth', 'social_auth'],
    rate_limits: {
      login_per_minute: 60,
      token_per_minute: 100
    }
  },
  starter: {
    name: 'Starter',
    mau_limit: 10000,
    features: ['basic_auth', 'email_auth', 'social_auth', 'mfa', 'custom_domain'],
    rate_limits: {
      login_per_minute: 300,
      token_per_minute: 500
    }
  },
  pro: {
    name: 'Pro',
    mau_limit: 100000,
    features: [
      'basic_auth', 'email_auth', 'social_auth', 'mfa',
      'custom_domain', 'sso_saml', 'sso_oidc',
      'advanced_audit_log', 'custom_email_templates'
    ],
    rate_limits: {
      login_per_minute: 1000,
      token_per_minute: 2000
    }
  },
  enterprise: {
    name: 'Enterprise',
    mau_limit: Infinity,
    features: 'all',
    rate_limits: 'custom'
  }
} as const;

// Usage check helper
export function planHasFeature(planId: keyof typeof PLANS, feature: string): boolean {
  const plan = PLANS[planId];
  if (plan.features === 'all') return true;
  return (plan.features as readonly string[]).includes(feature);
}

Upgrade prompts that don't annoy

There are two moments to prompt an upgrade: when a user tries to use a feature they do not have, and when they approach a usage limit. Both require different treatment.

For feature gates, the prompt should appear exactly where the user tried to take the action — in the dashboard UI next to the locked feature, or in the API response as a machine-readable error code. Never pop a modal over unrelated content.

// API response for feature gate — machine-readable, developer-friendly
{
  "error": {
    "code": "feature_not_available",
    "message": "SAML SSO requires the Pro plan or higher.",
    "feature": "sso_saml",
    "current_plan": "starter",
    "upgrade_url": "https://app.bastionary.com/settings/billing?upgrade=pro",
    "docs_url": "https://docs.bastionary.com/sso"
  }
}

For usage limits, send warnings at 80% and 95% of the limit via email and in the dashboard. Never just cut the user off at 100% without warning — that breaks production applications and destroys trust.

Grandfathering

When you raise prices or remove features from a plan tier, existing customers on that plan should be grandfathered for a reasonable period — at minimum until their next renewal, ideally for 6–12 months. This is both ethical and practical: sudden changes to working integrations erode trust, and the customers most likely to churn when you break their setup are the ones most likely to leave negative reviews.

// Grandfathering via plan snapshots
interface OrgPlan {
  plan_id: string;
  grandfathered_features?: string[];  // features retained beyond current plan
  grandfathered_until?: Date;         // when grandfathering expires
  plan_locked_at?: Date;              // price locked date for annual contracts
}

async function getEffectiveFeatures(orgId: string): Promise<string[]> {
  const org = await db.orgs.findById(orgId);
  const planFeatures = getPlanFeatures(org.plan_id);

  if (
    org.grandfathered_features &&
    org.grandfathered_until &&
    new Date() < org.grandfathered_until
  ) {
    // Merge current plan features with grandfathered ones
    const combined = new Set([...planFeatures, ...org.grandfathered_features]);
    return Array.from(combined);
  }

  return planFeatures;
}

Keep a record of what plan each customer was on when you made the change and when their grandfathering expires. This data is essential for customer support conversations and for planning future price changes.

← Back to blog Try Bastionary free →