Price testing, value metric selection, packaging strategy, discount frameworks, and willingness-to-pay research.
The value metric is what you charge for. Get this wrong and everything else fails.
Good value metric criteria:
| Metric type | Examples | Best for |
|---|---|---|
| Per seat | $X/user/month | Collaboration tools |
| Per usage | $X/API call, $X/GB | Infrastructure, API products |
| Per feature | Tier-based access | Horizontal SaaS |
| Per outcome | $X/lead, $X/transaction | Performance tools |
| Flat rate | $X/month | Simple products |
Decision framework:
Survey questions (ask all 4):
Analysis: Plot cumulative distributions of all 4 questions. Intersections give:
| Intersection | Meaning |
|---|---|
| "Too cheap" ∩ "Getting expensive" | Point of marginal cheapness |
| "Bargain" ∩ "Too expensive" | Point of marginal expensiveness |
| "Too cheap" ∩ "Too expensive" | Optimal price point |
| "Bargain" ∩ "Getting expensive" | Indifference price point |
Acceptable price range: Between marginal cheapness and marginal expensiveness.
Minimum sample: 200 responses per segment for reliable results.
3-tier standard (recommended starting point):
| Element | Starter | Professional | Enterprise |
|---|---|---|---|
| Price anchor | Low (attract) | Medium (convert) | High (capture) |
| Target | Individual / small team | Growing team | Large organization |
| Value metric limit | Low | Medium | Unlimited or custom |
| Support | Self-serve | Email + chat | Dedicated CSM |
| Features | Core only | Core + advanced | All + custom |
Pricing rules:
Guardrails:
| Discount type | Max | Approval |
|---|---|---|
| Annual prepay | 20% | Self-serve |
| Multi-year deal | 30% | Manager approval |
| Competitive switch | 15% | Manager approval |
| Volume (10+ seats) | 15% | Auto-calculated |
| Strategic / Logo | 40% | VP approval + documented justification |
Rules:
Purchasing Power Parity (PPP) adjustments:
| Tier | Countries | Adjustment |
|---|---|---|
| Full price | US, UK, Canada, Australia, Germany, France | 100% |
| Tier 2 | Spain, Italy, Portugal, Czech Republic, Poland | 70-80% |
| Tier 3 | Brazil, Mexico, Turkey, South Africa | 50-60% |
| Tier 4 | India, Indonesia, Philippines, Nigeria | 30-40% |
Implementation:
Best practices:
Communication timeline:
| When | Action |
|---|---|
| 90 days before | Internal alignment: sales, CS, support briefed |
| 60 days before | Email announcement to all customers (clear, empathetic) |
| 30 days before | Reminder email + lock-in offer (annual at current price) |
| Day of | Price change live + support team ready for questions |
| 30 days after | Review churn impact, adjust if needed |
Email template:
Subject: Changes to your [Product] plan
Hi [Name],
On [date], we're updating our pricing. Your plan will change
from $X/mo to $Y/mo.
Why: [Honest reason — new features, increased costs, market alignment].
What you can do:
- Lock in current pricing by switching to annual before [date]
- Upgrade to [plan] to get [specific new value] at the new rate
- Questions? Reply to this email — we're here to help.
[Name], [Title]
Expected impact: Well-communicated 10-20% increase typically sees < 2% incremental churn. Poorly communicated or >30% increase can see 5-10%+ churn.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createCheckout(priceId: string, userId: string) {
return stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId },
subscription_data: { metadata: { userId } },
});
}
// app/api/stripe/webhook/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// Create subscription record, link to userId from metadata
break;
}
case 'invoice.paid': {
// Extend subscription period, send receipt
break;
}
case 'customer.subscription.updated': {
// Handle plan changes, status transitions
break;
}
case 'customer.subscription.deleted': {
// Mark subscription canceled, revoke access
break;
}
}
return new Response('OK', { status: 200 });
}
Critical: Never parse the body as JSON before passing to constructEvent — it needs the raw string for signature verification.
| Pattern | Implementation | Best for |
|---|---|---|
| Free trial → paid | subscription_data: { trial_period_days: 14 } | Products needing time to show value |
| Freemium | No Stripe until upgrade; gate features in code | Wide-funnel products |
| Metered/usage-based | mode: 'subscription' + usage_type: 'metered' on price | API products, infrastructure |
// lib/subscription.ts
type Plan = 'free' | 'pro' | 'enterprise';
const FEATURE_ACCESS: Record<string, Plan[]> = {
'export-csv': ['pro', 'enterprise'],
'api-access': ['pro', 'enterprise'],
'custom-domain': ['enterprise'],
'team-members': ['pro', 'enterprise'],
};
export function hasAccess(feature: string, plan: Plan): boolean {
return FEATURE_ACCESS[feature]?.includes(plan) ?? true; // unlisted = free
}
// Report usage at end of billing period or in real-time
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity: apiCallCount,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});
const PLANS = [
{ name: 'Free', price: '$0', priceId: null, features: ['5 projects', 'Community support'] },
{ name: 'Pro', price: '$29/mo', priceId: 'price_pro_monthly', features: ['Unlimited projects', 'Priority support', 'API access'], popular: true },
{ name: 'Enterprise', price: 'Custom', priceId: null, cta: 'Contact Sales', features: ['Everything in Pro', 'SSO', 'SLA', 'Dedicated CSM'] },
] as const;
// Upgrade: prorate immediately
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: 'always_invoice', // charge difference now
});
// Downgrade: apply at period end
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: 'none',
billing_cycle_anchor: 'unchanged', // change takes effect at renewal
});
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard/billing`,
});
// Redirect user to portalSession.url
| Item | Details |
|---|---|
| Test card (success) | 4242 4242 4242 4242 any future exp, any CVC |
| Test card (decline) | 4000 0000 0000 0002 |
| Test card (3D Secure) | 4000 0025 0000 3155 |
| Webhook CLI | stripe listen --forward-to localhost:3000/api/stripe/webhook |
Idempotency: Use Idempotency-Key header on Stripe API calls to prevent duplicate charges:
await stripe.charges.create({ amount: 2000, currency: 'usd' }, {
idempotencyKey: `charge_${orderId}`,
});
Testing checklist:
stripe trigger checkout.session.completed) → idempotent