← All guides

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 — set requireEmail: true on 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.