Enforce the paywall with one edge function
No backend? The copy-paste Vercel/Netlify edge function that makes your paywall real instead of advisory.
Say it out loud first: everything in @vibemonetize/react is advisory. It renders paywalls; it does not enforce them. If your whole app runs in the browser — the vibe-coding default — a user who clears localStorage, opens a private window, or pokes your code in devtools can walk straight past every gate. That's fine for low-stakes apps, and an honest place to stop. But the moment your app has one genuinely expensive call (almost always its single server-side LLM or API call), you can make the paywall real with one edge function. Copy-paste, deploy-on-save, no backend to run.
The shape
The browser attaches the user's entitlement JWT to the guarded call; an edge function verifies it locally with @vibemonetize/server (no API round trip — see entitlement tokens), runs the expensive call on a pass, returns 402 with a { code, message } body naming the paywall reason on a fail, and meters usage server-side — where a client can't skip it.
1. Client half — attach the token
useEntitlementToken() (from @vibemonetize/react) returns getToken() — the current raw JWT, refreshed automatically when it's close to expiry:
import { useEntitlementToken } from "@vibemonetize/react";
const getToken = useEntitlementToken();
async function ask(prompt: string) {
const token = await getToken();
const res = await fetch("/api/ask", {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { "x-vibemonetize-token": token } : {}),
},
body: JSON.stringify({ prompt }),
});
if (res.status === 402) {
const { code, message } = await res.json();
// "token_expired"/"unauthorized" -> refresh and retry once;
// anything else (payment_required, usage_limit_exceeded, forbidden)
// -> show the paywall with `message`.
return { denied: { code, message } };
}
return res.json();
}2. Vercel Edge Function
Drop this in api/ask.ts at your project root — Vercel serves it at /api/ask alongside your static Vite build, no framework change needed. Set VIBEMONETIZE_APP_ID and VIBEMONETIZE_SECRET_KEY in the project's environment variables (the secret key stays server-side; it never ships to the browser).
// api/ask.ts — the one function that makes your paywall real
import { createClient, VibeMonetizeError } from "@vibemonetize/server";
export const config = { runtime: "edge" };
const monetize = createClient({ appId: process.env.VIBEMONETIZE_APP_ID! });
export default async function handler(req: Request): Promise<Response> {
const token = req.headers.get("x-vibemonetize-token") ?? "";
try {
// Verifies signature/expiry/audience locally (JWKS, cached), then
// asserts the "pro" feature is granted. Fails closed.
const claims = await monetize.requireEntitlement(token, "pro");
const { prompt } = await req.json();
const answer = await callYourModel(prompt); // the call worth protecting
// Metered server-side — the browser can't skip this one.
await monetize.meterUsage({ meterSlug: "answers", endUserId: claims.sub });
return Response.json({ answer });
} catch (err) {
if (err instanceof VibeMonetizeError) {
return Response.json(err.toBody(), { status: 402 }); // { code, message }
}
throw err;
}
}3. Netlify Edge Function
Same handler, Netlify conventions: netlify/edge-functions/ask.ts, Deno runtime (import via npm: specifier), env via Netlify.env:
// netlify/edge-functions/ask.ts
import { createClient, VibeMonetizeError } from "npm:@vibemonetize/server";
const monetize = createClient({
appId: Netlify.env.get("VIBEMONETIZE_APP_ID")!,
secretKey: Netlify.env.get("VIBEMONETIZE_SECRET_KEY")!,
});
export default async (req: Request): Promise<Response> => {
const token = req.headers.get("x-vibemonetize-token") ?? "";
try {
const claims = await monetize.requireEntitlement(token, "pro");
const { prompt } = await req.json();
const answer = await callYourModel(prompt);
await monetize.meterUsage({ meterSlug: "answers", endUserId: claims.sub });
return Response.json({ answer });
} catch (err) {
if (err instanceof VibeMonetizeError) {
return Response.json(err.toBody(), { status: 402 });
}
throw err;
}
};
export const config = { path: "/api/ask" };Metered free tiers
requireEntitlement gates on a feature. If your free tier is a usage cap instead (no feature granted), read the meter snapshot off the verified claims:
const claims = await monetize.verifyToken(token);
if (!claims.features.includes("pro")) {
const meter = claims.meters["answers"];
if (!meter || meter.remaining <= 0) {
throw new VibeMonetizeError("usage_limit_exceeded");
}
}What this stops — and what it doesn't
Stops: using the expensive action without a valid, in-quota entitlement token — from the UI, from devtools, from curl, after a storage wipe. The paywall is no longer a suggestion.
Doesn't stop: two things, on purpose:
- Fresh anonymous identities. Clearing storage mints a new
anonId, and a brand-new anonymous user legitimately gets a fresh free cap. That's an identity question, not an enforcement one — setrequireEmail: trueon your free plan to close it (verified-email sign-in; usage follows the email across devices and wipes). - Meter freshness within the token TTL. The cap check reads the meter snapshot baked into the token, so a still-valid token can overshoot a cap for up to the TTL (≤5 minutes). Treat free-tier caps as product friction, not billing-grade quotas.
This snippet is tested code
The demo app (apps/demo in the repo) runs exactly this recipe in front of its one "expensive" action: apps/demo/api/answer.ts (the Vercel Edge entrypoint) and apps/demo/server/answerHandler.ts (the handler, with the metered-free-tier variant above), unit-tested in vitest and driven end-to-end by a Playwright leg that proves the UI bypass now 402s and a paid token 200s.