Email Marketing9 min read

Pricing change emails: what Cursor and Copilot taught AI SaaS

How to email customers about a pricing or usage-limit change: per-customer impact numbers, notice timing, California's 7-30 day rule, and usage alerts.

R

React Emails Pro

September 29, 2026

On June 16, 2025, Cursor changed what its $20 Pro plan meant. The announcement moved Pro "from request limits to compute limits," with at least $20 of model inference at API prices each month. For people who had been counting toward 500 requests, the new unit was hard to translate. Some learned what it meant from unexpected charges.

Eighteen days later, Cursor CEO Michael Truell published Clarifying our pricing. The post said the changes were not communicated clearly, offered refunds for unexpected usage between June 16 and July 4, and committed to advance notice and clearer documentation for future changes.

“

We recognize that we didn't handle this pricing rollout well, and we're sorry.

Michael Truell

Cursor, Clarifying our pricing (July 4, 2025)

Cursor is not the only AI product to change pricing in public. In the follow-up posts from both Cursor and GitHub, the companies pointed to how the change was communicated, not to the price itself. For anyone running a usage-heavy SaaS product, that is useful: the notice email is something you control completely, and it is mostly an engineering problem. You need each customer's usage, both price functions, and a template that can say something specific.


What happened, 2025 to 2026

Three companies, three approaches to telling customers. The dates and details below come from each company's own posts.

May 2025

GitHub delays premium request limits

A May 7 changelogannounced Copilot premium request limits. An editor's note added on June 5 delayed enforcement.

Jun 2025

Cursor moves Pro to compute limits; GitHub starts billing

Cursor's change took effect June 16. On June 18, GitHub's allowances took effect with every counter reset to zero for the first cycle and a default spending limit of $0.

Jul 2025

Cursor apologizes; Anthropic gives a month of notice

Cursor's July 4 post offered refunds. On July 28, Anthropic announced weekly limits for Claude Pro and Max starting August 28, with an estimate that they would apply to less than 5% of subscribers.

Apr 2026

GitHub tightens individual plans, then announces usage billing

An April 20 posttightened limits, offered refunds to Pro and Pro+ subscribers who canceled before May 20, and said GitHub needed "to do a better job communicating the guardrails." On April 27 it announced usage-based billing for all plans.

Jun 2026

Copilot moves to usage-based billing

AI Credits replaced premium requests on June 1, after a preview bill gave users a projected cost on their billing page in early May.

The good ideas in that list are easy to pick out. A counter reset and a $0 default spending limit meant nobody paid for extra requests in the first month unless they raised the limit themselves. An estimate of how many people were affected told most readers they could stop worrying. A preview bill let people see their own number before it counted. Each of these answers the question a customer actually has when a pricing email arrives.


The problem is usually the unit, not the price

Customers budget in the unit their dashboard shows them. Before the change, a Cursor Pro user saw requests. After the change, their allowance was measured in dollars of inference at API prices, which depends on the model, the length of each prompt, and the output. The July post also said Cursor had not been clear that "unlimited usage" applied only to its Auto model selection.

No generic email solves that, however well written. "Pro now includes $20 of usage" is accurate and tells a customer nothing about next month. What does help is doing the conversion for them: "Your September usage would have cost $31.50 under the new pricing." That sentence needs data you already have and one function you probably haven't written yet.

The order of questions matters. A customer opening a pricing email first wants to know whether it affects them, then by how much, then what they can do. Most pricing announcements answer in the reverse order, starting with the reasons for the change.


Put the customer's own numbers in the email

Run last period's real usage through both the current and the new price function. The plans below are illustrative: a $20 plan with 500 requests and $0.04 per extra request, against a $20 plan with $20 of included token usage. Swap in your own pricing.

lib/pricing-impact.ts
export interface PeriodUsage {
  customerId: string;
  // Usage in the last full billing period, in the units each plan meters.
  requests: number;
  inputTokens: number;
  outputTokens: number;
}

// Returns the bill for one period in cents.
export type PlanPrice = (usage: PeriodUsage) => number;

// Illustrative plans, not any vendor's real prices.
export const requestPlan: PlanPrice = ({ requests }) =>
  2000 + Math.max(0, requests - 500) * 4;

export const tokenPlan: PlanPrice = ({ inputTokens, outputTokens }) => {
  const usageCents = Math.round((inputTokens * 300 + outputTokens * 1500) / 1_000_000);
  return 2000 + Math.max(0, usageCents - 2000);
};

export type Segment = "no-change" | "pays-less" | "pays-more" | "large-increase";

export interface PriceImpact {
  customerId: string;
  currentCents: number;
  projectedCents: number;
  segment: Segment;
}

const LARGE_INCREASE = 0.25;

export function priceImpact(
  usage: PeriodUsage,
  current: PlanPrice,
  next: PlanPrice,
): PriceImpact {
  const currentCents = current(usage);
  const projectedCents = next(usage);
  const delta = projectedCents - currentCents;

  let segment: Segment = "no-change";
  if (delta < 0) segment = "pays-less";
  if (delta > 0) {
    const large = currentCents === 0 || delta / currentCents >= LARGE_INCREASE;
    segment = large ? "large-increase" : "pays-more";
  }

  return { customerId: usage.customerId, currentCents, projectedCents, segment };
}

Prices are in integer cents so the email never shows $31.499999. Running three sample customers through it shows why a flat announcement fails:

Customer usage (one month)Current planNew planSegment
120 requests; 2M input, 0.3M output tokens$20.00$20.00no-change
480 requests; 5M input, 1.1M output tokens$20.00$31.50large-increase
900 requests; 3M input, 0.5M output tokens$36.00$20.00pays-less

Output of priceImpact() for three sample customers under the illustrative plans.

The second customer stayed under 500 requests and never paid an overage. Under the new plan, their long prompts make them the most affected. The third customer, who paid overages every month, saves money. A single announcement would have told both the same thing.

Use the segments to decide what each group receives:

  • no-change:a short notice with the date and a link to the details. Saying "your bill would have been the same" stops a support ticket before it starts.
  • pays-less: say so plainly. It is the one pricing email customers are glad to get.
  • pays-more: the full notice, a spend limit link, and a preview of their bill.
  • large-increase:everything above, plus whatever transition you can afford. Cursor's updated announcement let existing users keep the 500 request option. A fixed period on the old pricing works too. If this segment is large, reconsider the pricing before you write the email.

Count the segments before anything is sent. If the change affects fewer than one customer in twenty, the Anthropic approach of stating that estimate in the announcement is worth copying.


The notice email

One template covers every segment. The impact sentence changes with the segment, the spend limit paragraph only appears for increases, and the transition paragraph only appears when you offer one.

emails/pricing-change-notice.tsx
import * as React from "react";
import {
  Html, Head, Preview, Body, Container, Section, Heading, Text, Button, Link,
} from "react-email";
import type { Segment } from "@/lib/pricing-impact";

export interface PricingChangeNoticeProps {
  firstName: string;
  planName: string;
  effectiveDate: string;
  periodLabel: string;
  currentCents: number;
  projectedCents: number;
  segment: Segment;
  changes: string[];
  previewBillUrl: string;
  spendLimitUrl: string;
  cancelUrl: string;
  // Set when you let affected customers keep the old pricing for a while.
  legacyUntil?: string;
}

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const usd = (cents: number) => money.format(cents / 100);

function impactSentence(p: PricingChangeNoticeProps): string {
  const current = usd(p.currentCents);
  const projected = usd(p.projectedCents);
  switch (p.segment) {
    case "no-change":
      return `Your bill for ${p.periodLabel} would have been the same: ${current}.`;
    case "pays-less":
      return `For ${p.periodLabel}, you would have paid ${projected} instead of ${current}.`;
    case "pays-more":
    case "large-increase":
      return `For ${p.periodLabel}, your usage would have cost ${projected} instead of ${current}.`;
  }
}

export default function PricingChangeNotice(props: PricingChangeNoticeProps) {
  const { firstName, planName, effectiveDate, changes, legacyUntil } = props;
  const increase = props.segment === "pays-more" || props.segment === "large-increase";

  return (
    <Html lang="en">
      <Head />
      <Preview>{`${planName} pricing changes on ${effectiveDate}. ${impactSentence(props)}`}</Preview>
      <Body style={{ margin: 0, backgroundColor: "#f3f4f6", fontFamily: "Arial, sans-serif" }}>
        <Container style={{ maxWidth: "560px", padding: "32px 24px", backgroundColor: "#ffffff" }}>
          <Heading as="h1" style={{ fontSize: "22px", lineHeight: "30px", color: "#111827" }}>
            Your {planName} pricing changes on {effectiveDate}
          </Heading>
          <Text style={{ fontSize: "15px", lineHeight: "24px", color: "#374151" }}>
            Hi {firstName}, here is what the change means for your account,
            calculated from your actual usage.
          </Text>

          <Section style={{ backgroundColor: "#f9fafb", border: "1px solid #e5e7eb", borderRadius: "6px", padding: "4px 16px" }}>
            <Text style={{ fontSize: "16px", lineHeight: "24px", color: "#111827", fontWeight: 600 }}>
              {impactSentence(props)}
            </Text>
          </Section>

          <Heading as="h2" style={{ fontSize: "16px", color: "#111827" }}>What changes</Heading>
          {changes.map((change) => (
            <Text key={change} style={{ margin: "0 0 8px", fontSize: "15px", lineHeight: "22px", color: "#374151" }}>
              • {change}
            </Text>
          ))}

          <Heading as="h2" style={{ fontSize: "16px", color: "#111827" }}>What you can do</Heading>
          <Button
            href={props.previewBillUrl}
            style={{ backgroundColor: "#111827", color: "#ffffff", borderRadius: "6px", padding: "12px 20px", fontSize: "15px" }}
          >
            See your projected bill
          </Button>
          {increase && (
            <Text style={{ fontSize: "15px", lineHeight: "22px", color: "#374151" }}>
              You can <Link href={props.spendLimitUrl}>set a monthly spend limit</Link> before
              the change takes effect. Usage stops at the limit instead of billing past it.
            </Text>
          )}
          {legacyUntil && (
            <Text style={{ fontSize: "15px", lineHeight: "22px", color: "#374151" }}>
              Your account keeps its current pricing until {legacyUntil}. You don&apos;t need to do anything.
            </Text>
          )}
          <Text style={{ fontSize: "15px", lineHeight: "22px", color: "#374151" }}>
            If the new pricing doesn&apos;t work for you, you can{" "}
            <Link href={props.cancelUrl}>cancel from your billing settings</Link> at any time.
          </Text>
          <Text style={{ fontSize: "14px", lineHeight: "22px", color: "#6b7280" }}>
            Questions? Reply to this email. A person on our billing team reads every reply.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

PricingChangeNotice.PreviewProps = {
  firstName: "Sam",
  planName: "Pro",
  effectiveDate: "November 1, 2026",
  periodLabel: "September",
  currentCents: 2000,
  projectedCents: 3150,
  segment: "large-increase",
  changes: [
    "Pro includes $20 of model usage each month instead of 500 requests.",
    "Usage beyond $20 is billed at the per-token prices on our pricing page.",
    "Autocomplete stays unlimited and does not count toward the $20.",
  ],
  previewBillUrl: "https://app.example.com/billing/preview",
  spendLimitUrl: "https://app.example.com/billing/limits",
  cancelUrl: "https://app.example.com/billing",
  legacyUntil: "January 31, 2027",
} satisfies PricingChangeNoticeProps;
  • The subject and preview carry the date and the number. Someone who never opens the email still learns when the change happens and what it would cost them.
  • "What changes" uses the customer's old unit as the reference("$20 of model usage each month instead of 500 requests") and names what is not changing.
  • The first action is the projected bill. It is the link most readers want, and it lets them check your arithmetic.
  • Cancellation is a plain link,not buried in a footer. Some jurisdictions require it, as covered below. A customer who can't find it is more likely to dispute the charge with their bank instead.
  • Replies go to a person. Send from an address someone monitors. A pricing email from noreply@ tells customers you would rather not hear about it.

This is a transactional notice about the customer's account, not a newsletter. Send it through your transactional stream, and send it to every paying customer, including those who unsubscribed from marketing email. The transactional vs marketing guide covers where that line sits.


When to send it

Anthropic announced on July 28 for a change on August 28. GitHub gave users a preview bill weeks before usage billing started. A sequence built from those looks like this:

WhenEmailContents
30+ days beforeAnnouncementPersonal impact, what changes, projected bill, options
7 to 30 days beforeReminderSame numbers, recomputed with newer usage, plus how to cancel
Effective dateNow in effectShort confirmation; spend limit and usage alerts
First invoiceReceipt with explanationLine items in the new unit, compared with the old one

A notice sequence for a pricing change on auto-renewing plans.

Give each email an idempotency key made of the change, the notice type, and the customer ID, such as pricing-2026-11/reminder/cus_123. The job that sends a few thousand notices will be retried at some point, and the key keeps a retry from sending anyone a second copy.

California's automatic renewal law now sets a window. For contracts entered into, amended, or extended on or after July 1, 2025, Business and Professions Code section 17602 requires notice of a fee change on an automatic renewal no less than 7 and no more than 30 days before it takes effect, together with information on how to cancel. An announcement 60 days out does not satisfy that on its own, which is why the reminder above sits inside the window. This is not legal advice. The rule covers consumer subscriptions in California; B2B contracts and other jurisdictions have their own terms, so check yours.

If you use Stripe Billing, its upcoming renewal emails can remind customers before they are charged. They are not a notice of a price change. Send your own.


After the switch: warn before the bill

Cursor's July post also promised better usage visibility and a spend limit. GitHub started with a $0 default. Both are the same idea: under usage pricing, customers should hear about high usage while they can still change it, not when the invoice arrives.

Stripe can do the threshold tracking. A billing alert with alert_type=usage_threshold on a meter fires the billing.alert.triggered webhook when a customer crosses the threshold. The feature is in public preview, and Stripe allows up to 25 alerts per meter and customer. The route below turns that event into an email:

app/api/webhooks/stripe/route.tsx
import Stripe from "stripe";
import { Resend } from "resend";
import UsageAlertEmail from "@/emails/usage-alert";

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not set`);
  return value;
}

const stripe = new Stripe(requireEnv("STRIPE_SECRET_KEY"));
const resend = new Resend(requireEnv("RESEND_API_KEY"));
const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET");

export async function POST(request: Request): Promise<Response> {
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(await request.text(), signature, webhookSecret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type !== "billing.alert.triggered") {
    return new Response(null, { status: 200 });
  }

  const triggered = event.data.object;
  const threshold = triggered.alert.usage_threshold;
  const customer = await stripe.customers.retrieve(triggered.customer);
  if (customer.deleted || !customer.email || !threshold) {
    return new Response(null, { status: 200 });
  }

  const { error } = await resend.emails.send(
    {
      from: "Acme Billing <billing@notifications.acme.com>",
      to: customer.email,
      subject: `You've passed ${threshold.gte.toLocaleString("en-US")} tokens this month`,
      react: (
        <UsageAlertEmail
          used={triggered.value}
          threshold={threshold.gte}
          unit="tokens"
          usageUrl="https://app.acme.com/billing/usage"
        />
      ),
    },
    // Stripe retries deliveries; one event never becomes two emails.
    { idempotencyKey: `stripe-event/${event.id}` },
  );

  // A 500 makes Stripe retry, and the idempotency key keeps the retry safe.
  if (error) return new Response("Email failed", { status: 500 });
  return new Response(null, { status: 200 });
}

Meter alerts count usage units, not money. If customers think in dollars, convert with your price function before writing the email, or say "4 million tokens (about $12 at current prices)." Create alerts at a few fixed points, such as 50%, 80%, and 100% of the included amount, instead of a new one for every request.

The AI SaaS email patterns guide covers the usage warning and billing threshold templates in more detail, and Stripe webhook emails in Next.js covers signature checks and event routing for the rest of your billing emails.


What to avoid

Do
  • Show each customer what their last period would have cost
  • Explain the new unit in terms of the old one
  • Tell unaffected customers they are unaffected
  • Put a working cancellation link in the email body
  • Send alerts at usage thresholds after the switch
Avoid
  • One announcement for every customer, whatever their usage
  • Words like "unlimited" without saying what is excluded
  • Leading with the reasons for the change
  • Sending from noreply@ for a change people will have questions about
  • Letting the first invoice be the first warning
Key takeaway
  • Compute every customer's projected bill from real usage before writing a word of copy.
  • Segment by impact: no change, pays less, pays more, large increase. Each gets different content.
  • Lead with "does this affect me," then "by how much," then "what can I do."
  • Announce early, remind inside the 7 to 30 day window, and include how to cancel.
  • After the change, use threshold alerts so the invoice is never the first warning.

Clarifying our pricing

Cursor's July 2025 follow-up: what changed, the refund window, and what they committed to for future pricing changes.

cursor.com

GitHub Copilot is moving to usage-based billing

The April 2026 announcement, including the preview bill and spend controls ahead of the June 1 switch.

github.blog

Monitor usage with billing alerts

Stripe's documentation for usage threshold alerts on meters and the billing.alert.triggered event.

docs.stripe.com

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates