Paywall copy that rewrites itself for every user
Your paywall says "Upgrade to Pro for unlimited generations." A teacher reads it and shrugs. A student on a budget reads it and closes the tab. The same words, two lost conversions - because the words were written for nobody in particular.
One paywall, many readers
The conversion moment is the least personalized screen in most products. A user hits a limit, sees a generic "Upgrade to Pro," and decides in about three seconds. That decision depends entirely on whether the copy connects what they were just doing to what they would get. A static headline cannot do that, because the same string ships to a teacher building quizzes for thirty students, a student watching every dollar, and a founder evaluating the tool for a team. Each of them needs a different reason to pay, and you are giving all three the same sentence.
The objection is different for each persona
Conversion copy works when it answers the specific objection in the reader’s head. Those objections are not the same. A teacher cares about saving prep time across many students. A student cares about price and whether the free tier is enough. A founder cares about seats, limits, and whether it scales to a team. Write one headline and you answer one of those objections and miss the other two. The information to know which reader you are talking to is already in your analytics: their role, their usage shape, what they just hit a wall on.
- user_usage - what they consumed and which limit they just hit
- user_attributes - role, team size, declared goal
- user_events - the feature they were mid-flow on when blocked
- conversion_signals - what tends to precede an upgrade for users like them
Generate the pitch at the moment of friction
A compose type for the paywall reads the user’s usage and profile and returns structured copy: a headline, two or three value props, and a CTA label. You define the intent once - "Write a short, honest upgrade pitch for this user based on what they were doing and the limit they hit; speak to their role" - and an output schema. When a user hits a cap, your gate calls compose and renders the result instead of a hardcoded banner. The copy is grounded in their real numbers, so it can say "You have generated 47 quizzes this month" instead of "unlimited generations."
// Called from your limit gate, server-side
import { createClient, VeveeError } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
async function paywallCopy(userId: string) {
try {
const { output } = await vevee.compose<{
headline: string;
valueProps: string[];
ctaLabel: string;
}>('paywall-pitch', userId);
return output;
} catch (e) {
if (e instanceof VeveeError) return STATIC_PAYWALL; // never hide the upgrade button
throw e;
}
}The same wall, three pitches
One compose type, resolved per user, produces a pitch aimed at the person reading it. The teacher gets a time-saving frame, the student gets a price-and-value frame, the founder gets a scale-and-seats frame - from the same call, with no persona branching in your code. You tune the angles by editing the prompt in the dashboard and watching conversion move, not by shipping a new paywall component for every segment.
- Teacher: "You’ve built 47 quizzes this month. Pro removes the cap so grading week never stops you."
- Student: "You’re using Vevee like a pro. Pro is the price of one coffee a month and unlocks the rest of the semester."
- Founder: "Your team is hitting the team limit. Pro adds seats and a shared usage dashboard for everyone."
Honesty beats hype, and the data enforces it
Because the copy is generated from real usage, it stays truthful: it references numbers the user can verify in their own dashboard, which is more persuasive than a superlative. Constrain the model with a tight output schema and a low maxOutputTokens so it returns a headline and a couple of props, not a wall of marketing text. Keep the prompt instruction explicit - speak to their role, cite their actual usage, no false scarcity. A personalized pitch that is also accurate converts better and ages better than a generic one that overpromises.
Measure it like any other conversion change
Generate the pitch when the user hits the limit, cache it for the life of that paywall view, and capture a paywall_shown analytics event alongside it so the personalized copy sits in the same funnel as everything else. Then compare upgrade rate for the generated paywall against your old static one. Compose returns usage.costMicroUsd per call, so you can put a real number on cost-per-incremental-conversion and decide, with data, whether personalized paywall copy pays for itself. For most AI products that charge a monthly subscription, one extra upgrade covers thousands of generations.
Keep reading
I rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
ComposeThe trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.