Pricing Optimization
Workflow
1. Value Metric Selection
The value metric is what you charge for. Get this wrong and everything else fails.
Good value metric criteria:
- Scales with value delivered to customer
- Easy for customer to understand
- Predictable for customer to budget
- Grows as customer succeeds
| Metric type | Examples | Best fit | Watch out for |
|---|---|---|---|
| Per seat | $X/user/month | Collaboration tools where every user gets value | Customers share logins; AI agents replace seats (seat counts can shrink) |
| Per usage / metered | $X/API call, $X/GB, $X/1k tokens | Infra, APIs, AI products where cost tracks consumption | Unpredictable bills hurt buyer trust → add caps, alerts, or prepaid credits |
| Hybrid (platform fee + usage) | $X/mo base + overage | Usage products needing revenue floor & expansion | Two dials to explain; keep the base meaningful, not a tax |
| Per feature / tier | Tier-gated access | Horizontal SaaS with distinct segments | Feature gates feel arbitrary if not value-aligned |
| Per outcome | $X/lead, $X/transaction, % of GMV | Performance tools that can attribute results | Attribution disputes; revenue swings with customer's business |
| Committed spend | Annual $ commitment drawn down by usage | Enterprise usage products, procurement-friendly | Requires forecasting; overage/rollover policy must be explicit |
| Flat rate | $X/month | Simple, single-persona products | Leaves expansion revenue on the table |
Decision framework (guidelines, not laws — validate against your buyer):
- Value scales ~linearly with active users, and seats aren't easily shared → per seat (but stress-test against AI/automation eroding seat counts).
- Cost-to-serve and value both track consumption → usage / metered; pair with a platform fee (hybrid) when you need a predictable revenue floor and land-and-expand.
- Features cleanly separate segments by their jobs-to-be-done → tier-based.
- You can credibly attribute a business outcome → outcome-based.
- Selling to procurement-led enterprises → committed spend with usage drawdown.
- There is rarely one "right" metric: many durable companies run hybrid (e.g. seats + usage, or platform fee + transaction %). Prefer the metric the buyer already uses to measure success internally.
2. Van Westendorp Price Sensitivity
Survey questions (ask all 4):
- At what price would this be so cheap you'd question the quality?
- At what price is this a bargain — great buy for the money?
- At what price is this getting expensive — you'd think twice?
- At what price is this too expensive — you'd never consider it?
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.
Sampling & rigor:
- Minimum ~150–200 responses per segment for stable curves. Report results per segment (SMB vs mid-market vs enterprise behave very differently); a blended curve hides everything.
- Recruit qualified buyers, not a generic panel. Screen for category awareness and purchase intent, or the stated prices are fiction.
- VW measures price perception, not demand or willingness-to-pay. It tells you a plausible range, not a revenue-maximizing point. Treat it as a starting hypothesis to A/B test, with confidence intervals — not a precise number.
- Stated-preference bias: people under-report what they'd pay and over-report price sensitivity. Anchor against actual conversion data once you have it.
B2B caveat — separate buyer from user: the person who feels the price (economic buyer / procurement) is usually not the daily user. Survey both, and weight the economic buyer's range for the headline number.
Stronger alternatives when stakes are high:
- Gabor–Granger — ask purchase likelihood at specific discrete prices; yields a demand curve and a revenue-maximizing price (better for what to charge, where VW only gives a range).
- Conjoint / MaxDiff — trades off features × price to reveal willingness-to-pay per feature and optimal packaging, not just one price. Best when designing tiers.
- Live price tests / paywall experiments — the only ground truth. Randomize price by cohort and measure conversion + retention + expansion, not just first-order conversion.
3. Tier Design
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:
- Professional should be 2-3x Starter price
- Enterprise should be 3-5x Professional (or custom)
- Professional tier should be the obvious "best value" (anchor effect)
- Include one "decoy" feature in Professional that makes it clearly better than Starter
- Enterprise commonly uses "talk to sales" (custom pricing, security review, MSA negotiation). But this is not a law: product-led-growth companies increasingly offer self-serve annual contracts, in-product procurement/SSO upgrades, and usage commitments with a sales-assist motion. Use "talk to sales" when deals genuinely need negotiation; otherwise an "Enterprise, from $X" self-serve path reduces friction and shortens cycles.
4. Discount Strategy
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:
- Never discount more than 40% (devalues product permanently)
- Always trade something: discount for annual commitment, case study, referral
- Track discount rate by rep (flag reps averaging > 20%)
- Sunset discounts: "This rate is locked for 12 months, then standard pricing"
- Document every discount reason in CRM
Implementing discounts in Stripe — coupons vs promotion codes:
- A Coupon defines the discount (percent/amount, duration:
once/repeating/forever). Don't expose raw coupon IDs to customers. - A Promotion code is a customer-facing code that wraps a coupon, with its own limits (max redemptions, expiry, first-time-customer only, minimum amount). Surface these via
allow_promotion_codes: truein Checkout (see §8). - Apply a coupon programmatically with
discounts: [{ coupon }](Checkout/Subscriptions); avoid the legacy top-levelcouponfield.
Discount governance & compliance: "was/now" and percentage-off claims are regulated in the EU (Omnibus Directive: the reference price must be the lowest in the prior 30 days) and the UK/other markets. Keep promo terms, expiry, and renewal price honest and auditable. This is general guidance, not legal advice — verify promotional displays with counsel.
5. Price Localization
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:
- Use IP geolocation for the initial display, then let users self-select country/currency (geolocation is approximate and trips up travelers/VPNs).
- Allow currency switching that adjusts the actual price, not just the symbol.
- Present the local price as the price for that market; you don't need to surface an internal "discount %." But it must not be deceptive — keep terms, renewal price, and tax treatment honest in every locale.
- Localized tiers should still ladder consistently (don't make a Tier-4 plan cheaper in absolute terms than the same plan one tier up).
Legal / compliance guardrails (get a professional review before launch):
- Tax (VAT/GST/sales tax): EU/UK B2C prices must generally be shown VAT-inclusive; US sales tax is typically added at checkout. This affects what number you display per region. Let Stripe Tax (or equivalent) compute and collect — see §8.
- Consumer transparency (EU Omnibus, UK CMA, etc.): the displayed price, recurring amount, renewal date, and cancellation terms must be clear and accurate. "Was/now" discount claims are regulated (reference-price rules).
- Arbitrage & fairness: geo-priced plans invite VPN abuse. Decide your stance (tolerate small leakage vs. verify billing country via payment method / tax ID) and apply it consistently — discriminatory enforcement creates legal and reputational risk.
- Existing-customer fairness: don't silently raise an individual's localized price; honor §7's notice timeline.
- Sanctions / export: screen restricted jurisdictions; don't sell where you're not permitted.
Pricing, tax, and consumer-protection rules vary by jurisdiction and change. The above is general guidance, not legal or tax advice — verify your specific tiers, displays, and renewal notices with qualified counsel/tax advisors.
6. Annual vs Monthly
Best practices:
- Default to annual on pricing page (show monthly price as comparison)
- Annual discount: 15-20% (2 months free is standard messaging)
- Show monthly price per-month even for annual ("$49/mo billed annually")
- Offer monthly-to-annual upgrade path with prorated credit
- Track annual vs monthly mix (target: 60%+ annual for predictable revenue)
7. Price Increase Playbook
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.
8. Stripe Integration Quickstart
Checkout Session Creation
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',
// Omit payment_method_types to let Stripe auto-manage enabled methods
// (cards, wallets, local methods) from the Dashboard.
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`,
automatic_tax: { enabled: true }, // requires Stripe Tax + origin address
tax_id_collection: { enabled: true }, // collect VAT/GST IDs for B2B reverse-charge
customer_update: { name: 'auto', address: 'auto' }, // needed so Tax can use the address
allow_promotion_codes: true, // promotion codes (see §4) — not raw coupon IDs
metadata: { userId },
subscription_data: { metadata: { userId } },
});
}
Tax: enable Stripe Tax (automatic_tax) so the right VAT/GST/sales tax is calculated and collected per the customer's location and your registrations — this is what makes the localized, consumer-law-compliant prices in §5 correct. Display EU/UK B2C prices tax-inclusive where required.
Webhook Handler
// 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 });
}
// Idempotency: persist event.id and no-op if already processed.
// Stripe retries deliveries; the same event can arrive more than once.
if (await alreadyProcessed(event.id)) return new Response('OK', { status: 200 });
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, grant/refresh entitlements, send receipt
break;
}
case 'invoice.payment_failed': {
// Dunning: Stripe Smart Retries will retry automatically.
// Read invoice.next_payment_attempt; email the customer a fix-card link.
// Revoke access only after the retry schedule is exhausted (see subscription status).
break;
}
case 'customer.subscription.trial_will_end': {
// Fires ~3 days before trial end — nudge the user to add/confirm payment.
break;
}
case 'customer.subscription.updated': {
// Plan changes + status transitions. Watch status:
// 'past_due' / 'unpaid' → in dunning; 'active' → recovered; 'canceled' → revoke.
break;
}
case 'customer.subscription.deleted': {
// Mark subscription canceled, revoke access at period end
break;
}
}
await markProcessed(event.id); // commit idempotency record after handling
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.
Production essentials this implies:
- Idempotency store: record every handled
event.id(a unique-indexed table or KV row) and short-circuit duplicates. Stripe guarantees at-least-once, not exactly-once, delivery. - Entitlements: grant access on
invoice.paid/checkout.session.completedand revoke on cancel/unpaid — drive feature access off subscription status, not just plan. Stripe also offers a managed Entitlements API that emits an active-entitlements summary you can cache. - Dunning / recovery: enable Smart Retries and the Customer Portal so customers can update a failed card (
invoice.payment_failed→ email a portal link). Most involuntary churn is recoverable here. - Audit trail: log raw event payloads + your handling outcome for reconciliation and dispute defense.
9. Subscription Patterns
| 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 | A Billing Meter + a price with recurring.usage_type: 'metered' and recurring.meter: <meter_id>; report usage via meter events (see below) | API/AI products, infrastructure |
| Prepaid credits | Sell credits, draw down via meter events / Billing Credits | Bursty AI usage; predictable customer spend |
Freemium Feature Gates
// lib/subscription.ts
type Plan = 'free' | 'pro' | 'enterprise';
const FEATURE_ACCESS: Record<string, Plan[]> = {
'basic-projects': ['free', 'pro', 'enterprise'],
'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) ?? false; // unlisted = denied (fail closed: a typo cannot expose a paid feature)
}
Usage-Based Billing (Billing Meters — current API)
As of 2024, Stripe's usage-based billing is built on Billing Meters + meter events. The old subscriptionItems.createUsageRecord / standalone usage_type: 'metered' price flow is legacy; do not use it for new integrations. Note: since acquiring Metronome (January 2026), Stripe positions Metronome as the recommended path for new usage-based integrations; Billing Meters remains fully supported and is the simpler fit for standard SaaS usage pricing.
One-time setup (per metered dimension):
// 1. Create a meter — defines the event name and how Stripe aggregates usage.
const meter = await stripe.billing.meters.create({
display_name: 'API calls',
event_name: 'api_calls', // events reference this name
default_aggregation: { formula: 'sum' }, // or 'count'
customer_mapping: { // how an event maps to a customer
type: 'by_id',
event_payload_key: 'stripe_customer_id',
},
value_settings: { event_payload_key: 'value' }, // which payload key holds the number
});
// 2. Create a metered price tied to the meter, then sell it via Checkout (§8).
const price = await stripe.prices.create({
currency: 'usd',
unit_amount: 1, // 1 cent per unit, or use tiers
recurring: { interval: 'month', usage_type: 'metered', meter: meter.id },
product_data: { name: 'API usage' },
});
Report usage (real-time or batched) — this replaces usage records:
// Send a meter event whenever usage occurs. Stripe aggregates by customer + period.
await stripe.billing.meterEvents.create({
event_name: 'api_calls',
payload: {
stripe_customer_id: customerId, // matches customer_mapping above
value: String(apiCallCount), // matches value_settings.event_payload_key
},
identifier: `api_${requestId}`, // idempotency: unique within a rolling 24h
// timestamp defaults to now; must be within ~35 days past / 5 min future
});
- Idempotency is built in: a unique
identifiermakes retries safe — Stripe rejects duplicates within a rolling 24-hour window (duplicate_meter_event). Use a stable per-event id (e.g. request/job id). - Aggregation is asynchronous. Reported usage is summarized into the invoice's metered line items; it is not reflected instantly. Read current totals via the meter's event summaries rather than assuming real-time balances.
- High throughput / low latency: use the v2 meter event stream (
POST /v2/billing/meter_event_streamagainstmeter-events.stripe.com) with a short-lived meter-event session token (valid ~15 min) to batch many events per call. The single-event call above is fine for typical volumes. - Related building blocks: Billing Credits (grant prepaid/free credits drawn down by metered usage) and usage alerts/thresholds (notify or act when a customer crosses a usage level). For complex, high-scale metering (token/GPU-second billing à la OpenAI/Anthropic/Databricks), Metronome is the heavyweight option: Stripe completed its acquisition of Metronome in January 2026, and Stripe's docs now recommend Metronome as the primary platform for new usage-based integrations, while classic Billing Meters remains fully supported as a low-level building block. Choose plain Billing Meters for standard usage products; reach for Metronome when you need hierarchical accounts, complex rating/credits, or real-time spend orchestration.
APIs and the recommended approach evolve. Verify current meter syntax and limits at https://docs.stripe.com/billing/subscriptions/usage-based and the API version notes at https://docs.stripe.com/changelog.
10. Pricing Page Implementation
Plan Comparison Component Pattern
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/Downgrade Flows
Upgrade — apply now and charge the prorated difference:
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: 'always_invoice', // raise an invoice for the difference immediately
});
Downgrade — defer to the end of the current period.
A plain subscriptions.update with proration_behavior: 'none' changes the item immediately (just without proration) — it does not schedule the change for renewal. To actually take effect at period end, use a subscription schedule with two phases:
// 1. Promote the subscription to a schedule (no-op billing-wise).
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
// 2. Phase 1 = current plan until period end; Phase 2 = new (lower) plan after.
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const item = sub.items.data[0];
await stripe.subscriptionSchedules.update(schedule.id, {
end_behavior: 'release', // return to a normal subscription once phases complete
phases: [
{
items: [{ price: item.price.id, quantity: item.quantity }],
start_date: item.current_period_start,
end_date: item.current_period_end, // run the current plan to period end
},
{
items: [{ price: newPriceId, quantity: 1 }], // downgraded plan kicks in here
},
],
});
// The customer keeps full access until current_period_end, then drops to the new plan.
Alternatively, if you use the Customer Portal for self-serve plan changes, enable "schedule downgrades at period end" (the schedule_at_period_end portal setting) and Stripe creates the schedule for you. Either way, gate in-app entitlements off the subscription's effective plan/status, not the requested one.
Customer Portal (self-serve management)
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard/billing`,
});
// Redirect user to portalSession.url
11. Testing Payments
| 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 — there are three distinct layers, don't conflate them:
- API write idempotency — pass an
idempotencyKeyso a retried create call doesn't double-charge. Use Checkout/PaymentIntents, not the legacy Charges API:
// Idempotent one-time payment (PaymentIntent — current API; Charges is legacy)
await stripe.paymentIntents.create(
{ amount: 2000, currency: 'usd', automatic_payment_methods: { enabled: true } },
{ idempotencyKey: `pi_${orderId}` },
);
// Or, for a Checkout Session:
await stripe.checkout.sessions.create({ /* ... */ }, { idempotencyKey: `co_${orderId}` });
-
Meter-event idempotency — the meter event
identifier(see §9) dedupes usage within a rolling 24h window; this is separate from the header above. -
Webhook idempotency — store each handled
event.idand skip duplicates (see §8). Inbound delivery is at-least-once.
Testing checklist:
- Successful checkout → subscription created in DB
- Card decline → user sees error, no DB record created
- Webhook replay (
stripe trigger checkout.session.completed) → idempotent - Subscription cancel → access revoked, status updated
- Plan upgrade → prorated charge correct
- Plan downgrade → takes effect at period end