The upgrade nudge that writes itself: convert free users before they hit the wall
By the time a user hits your paywall, they are blocked, annoyed, and halfway to a competitor’s signup page. The best moment to make the pitch was three days earlier - when they were winning. Here is how to catch it, automatically, for every user at once.
The paywall is a hostage negotiation
Think about the emotional state of a user who just hit a hard limit. They were mid-task. Something they needed is now behind a wall, and the wall is asking for money. Some pay; many quietly leave. Every conversion study lands on the same conclusion: pitching at the moment of friction converts worse than pitching at the moment of success, because at the friction moment you are negotiating with someone you just blocked. The user at 80% of quota is a different person. They are getting value right now - the meter proves it - and nothing is interrupted. That is the user you want to talk to. The problem is that talking to them well requires knowing what they are doing, which limit they are approaching, and what users like them care about - per user, at scale. That used to mean a segmentation project. Now it is one compose type.
Find the 80% moment with the meter you already have
If you meter with Vevee, you already have the trigger. usage(userId) returns every counter for the user’s current period - quota, count, remaining - so detecting "this user just crossed 80% of their generations" is arithmetic on data you are already writing. Run the check right after track() on your server (you have the fresh counters in hand), and when a user crosses the threshold for the first time in a period, that is your moment. No new infrastructure, no analytics export, no nightly batch job scoring "PQL likelihood" in a spreadsheet.
// After your normal track() call - the moment detector
const { counters } = await vevee.usage(userId);
const gens = counters.find(c => c.event === 'image_generation');
const crossed80 =
gens && gens.quota > 0 && gens.count / gens.quota >= 0.8;
if (crossed80 && !(await nudgeAlreadyShownThisPeriod(userId))) {
await queueUpgradeNudge(userId); // generate + show on next page load
}One compose type writes every user’s pitch
A compose type is configured once in the Vevee dashboard: an intent prompt, a set of data sources, and a structured output schema. For the upgrade nudge, the intent is something like "This user is approaching their plan limit while actively using the product. Write a short, specific, encouraging upgrade nudge that references what they have actually been doing and frames the paid plan as removing a ceiling on something going well. No false urgency." Then you enable the sources that ground it in reality. Every source resolves against the userId you pass, so the same type produces a different, personally relevant message for each user - with zero persona branching in your code.
- user_usage - which limit they are approaching and the real numbers ("38 of 50 images")
- user_events - what they were doing this week, so the pitch references their actual workflow
- conversion_signals - what tends to precede an upgrade for users of this app
- cohort_compare - where this user sits versus similar users ("top 10% of free-tier creators")
The call: typed output, one fallback, no exceptions on the happy path
On the server, with your secret key, you name the type and pass the user. The generic parameter types the structured output - the schema you defined in the dashboard is the contract, and your UI renders whatever comes back. Two non-success cases and both are cheap to handle: the user may have opted out of AI personalization (a status, not an error - render your static banner), and the workspace AI budget may be exhausted (a VeveeError with code ai_budget_exceeded - same fallback). The static banner you have today becomes the floor, never the ceiling.
import { createClient, VeveeError } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
export async function upgradeNudge(userId: string) {
try {
const res = await vevee.compose<{
headline: string;
body: string;
ctaLabel: string;
}>('upgrade-nudge', userId);
if (res.status === 'opted_out') return STATIC_NUDGE; // respect it, silently
return res.output;
} catch (e) {
if (e instanceof VeveeError) return STATIC_NUDGE; // budget spent, type renamed...
throw e;
}
}What it actually says (and why specificity converts)
Generic nudges get banner blindness because they read like ads. A grounded nudge reads like the product talking: "You’ve generated 38 of your 50 images this month - your best month yet. Pro removes the cap, so next week’s client work doesn’t wait for the 1st." The numbers are real and the user can verify them in their own usage bar, which is exactly what makes the sentence persuasive. A student grinding through exam season, a freelancer with client deadlines, and a hobbyist who just discovered your new model each get a different sentence from the same compose type, because their context blocks differ. You improve all of them at once by editing the prompt in the dashboard - watch the copy change in production without a deploy.
- Freelancer at 80% of images: "38 of 50 used - Pro means client work never waits for a reset."
- Student at 80% of tokens: "You’ve summarized 12 lectures this week. Pro covers the whole semester for the price of a coffee."
- Power user near a premium-model cap: "You clearly prefer the 4k model. Pro makes it your default instead of your treat."
Cache it, cap it, measure it
Three disciplines keep this honest. Cache: generate once per threshold-crossing and store the result on the user record - the nudge does not change between page loads, so neither should the model call. Cap: keep maxOutputTokens low in the type config; this is a headline and two sentences, not an essay, and a tight output schema enforces that structurally. Measure: every compose call returns usage.costMicroUsd, and you should capture an upgrade_nudge_shown analytics event when you render it. Put nudge-shown and checkout_completed in the same funnel and you get the only number that matters: incremental upgrades per dollar of generation cost. At typical costs, a single extra monthly subscription pays for thousands of nudges - but now you can verify that claim in your own dashboard instead of taking it from a blog post.
// Render-side: make the nudge measurable like any other funnel step
await vevee.analytics.capture({
distinctId: userId,
event: 'upgrade_nudge_shown',
properties: { variant: 'composed', thresholdPct: 80 },
});Why this beats rewriting your paywall
The paywall still matters - keep it, personalize it too if you like. But the nudge wins on selection, timing, and tone. Selection: it only fires for users who are demonstrably getting value (you cannot reach 80% of a quota by accident). Timing: it arrives while the user is succeeding, days before frustration. Tone: it is the product noticing your momentum, not the product holding your work hostage. Conversion work usually means rewriting copy and praying; this is the version where the copy writes itself from the user’s own meter, every fallback path is two lines, and the whole loop - trigger, generation, render, funnel - is observable end to end. The wall converts the desperate. The nudge converts the happy. There are more happy users.
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.