@vibemonetize/server
Server-side SDK for VibeMonetize. Verifies entitlement JWTs locally via JWKS, meters
usage fire-and-forget, and reads the current user off a verified token. Runs on Node
and edge runtimes — web-standard fetch/crypto.subtle (via jose) only, no node:
imports.
All checks in this package fail closed: any invalid, expired, wrong-audience, or
under-entitled token throws a typed VibeMonetizeError rather than silently allowing
access. (The companion @vibemonetize/react package intentionally fails open in the
UI when the API is unreachable — the enforcement of record always happens here.)
Install
pnpm add @vibemonetize/server
Quick start
import { createClient } from "@vibemonetize/server";
const monetize = createClient({ appId: process.env.VIBEMONETIZE_APP_ID! });
// Next.js Route Handler / any Node or edge server framework:
export async function POST(req: Request) {
const token = req.headers.get("x-vibemonetize-token") ?? "";
// Throws payment_required/forbidden/token_expired/unauthorized — let it bubble
// to your error handler, or catch VibeMonetizeError and map `.code`/`.status`.
const claims = await monetize.requireEntitlement(token, "export");
// ... handle the request using `claims` ...
void monetize.meterUsage({ meterSlug: "exports", endUserId: claims.sub });
return new Response("ok");
}
No backend? Use the edge-function recipe
If your app is a pure client-side Vite build (the vibe-coding default), there is no
server for this package to run on — and without it, every client-side gate is advisory.
The fix is one copy-paste edge function (Vercel/Netlify) in front of your one expensive
call: it reads x-vibemonetize-token, calls requireEntitlement/verifyToken here,
returns 402 { code, message } on fail, and fires meterUsage server-side. Full
recipe: docs.vibemonetize.dev/guides/edge-enforcement
(raw-markdown agent version at /guides/edge-enforcement.md). The tested reference
implementation is the demo app's guarded action: apps/demo/api/answer.ts →
apps/demo/server/answerHandler.ts.
Public API
createClient(options?)
createClient({
apiUrl?: string; // default: VIBEMONETIZE_API_URL env var, else "https://api.vibemonetize.dev"
appId?: string; // default: VIBEMONETIZE_APP_ID env var (required one way or another)
secretKey?: string; // default: VIBEMONETIZE_SECRET_KEY, falling back to VIBEMONETIZE_TEST_SECRET_KEY; sent as `Authorization: Bearer`
issuer?: string; // JWT `iss` to require; defaults to `apiUrl`
verificationKey?: EntitlementSigningKey | JWTVerifyGetKey; // override JWKS fetch — mainly for tests
expectMode?: Mode | "any"; // RFC 022 — enforce the token's mode claim; default derived from secretKey's prefix
meterUsageMaxRetries?: number; // default 3
meterUsageRetryDelayMs?: number; // default 200 (multiplied by attempt number)
}): VibeMonetizeServerClient
Returns a client with:
verifyToken(token): Promise<EntitlementTokenClaims>— verifies the JWT's signature (via the app's JWKS, cached), issuer, and audience (appId). ThrowsVibeMonetizeError("token_expired" | "unauthorized")on any failure. An empty/missingtokenthrowsunauthorizedwith a message calling out the likely cause — forgetting to forward thex-vibemonetize-tokenheader your frontend'suseEntitlementToken()/getToken()produced — rather than a generic "invalid JWT". An issuer mismatch (the api'sENTITLEMENT_JWT_ISSUERdoesn't match this client'sissuer/apiUrl) or an audience mismatch (this client'sappIddoesn't match the token's — the token was issued for a different app) both name both sides of the mismatch and what to do about it, rather than a bare "Invalid entitlement token."requireEntitlement(token, featureSlug): Promise<EntitlementTokenClaims>— verifies the token, then assertsfeatureSlugis granted. Throws:token_expired/unauthorized— the token itself is invalid,payment_required— the user has no active plan/meter grant at all (claims.plan === nullANDfeaturesis empty ANDmetersis empty). This is keyed on the token's actual entitlements, never just onfeatureshappening to be empty — a credit-pack-only or meter-only customer hasfeatures: []but a very real, paidplanclaim, and is never told "no active plan" when their own token says otherwise,forbidden— they have some active entitlement, just not this feature. The message lists the token's actual granted features (Missing feature "x". This token grants: a, b.) so a server-side feature-slug typo — which otherwise locks out every user with a message indistinguishable from a real denial — is obvious.
meterUsage(input): Promise<void>— fire-and-forgetPOST /v1/usage-events. Retries with backoff on network errors or a5xxresponse; any4xx(bad input, auth, unknown meter, or a usage cap already hit) gives up immediately without retrying. Either way the failure is then swallowed — the returned promise always resolves, so a metering hiccup never breaks your request handler. A definitive4xx(e.g. a meter-slug typo) additionally logs oneconsole.warnnaming the meter slug and the API's error — it's almost always a coding mistake, not a transient blip, so it stays visible instead of vanishing along with the usage that never got recorded; a network error or5xxnever warns (only the exhausted-retries case for those is silent, by design — usemeterUsageStrictif you need to react to those in code).input: { meterSlug, delta?, endUserId?, anonId?, idempotencyKey? }.meterUsageStrict(input): Promise<{ accepted: boolean; remaining: number | null }>— samePOST /v1/usage-eventscall, for when you need to enforce a usage cap instead of just recording it. UnlikemeterUsage, it never swallows failure:- on success, resolves the parsed
{ accepted, remaining }body (remainingisnullfor uncapped meters), - on a
403 usage_limit_exceededresponse, resolves{ accepted: false, remaining: 0 }— treat this as your signal to show the paywall instead of serving the request, - on any other
4xx, throws the corresponding typedVibeMonetizeError(no retry), - on network errors or a
5xxresponse, retries with the same backoff asmeterUsage, then throws.
const usage = await monetize.meterUsageStrict({ meterSlug: "exports", endUserId: claims.sub }); if (!usage.accepted) { return new Response("Usage limit reached", { status: 402 }); }- on success, resolves the parsed
getUser(token): Promise<{ id: string; plan: string | null; mode: Mode }>— convenience wrapper oververifyToken.getMode(token): Promise<Mode>— convenience wrapper oververifyTokenthat returns just the verifiedmodeclaim.mergeEndUser(input): Promise<{ endUserId: string }>(RFC 014) — for apps that bring their OWN verified auth (Neon Auth, Clerk, Supabase, ...) and want to attach a signed-in user's anonymous usage/memberships without a second email-code round trip. POSTs{ appId, anonId, email }to thesk_-onlyPOST /v1/end-users/merge(secretKeyrequired) — you are attestingemailis already verified by your own auth, so only call this with an email your own system has confirmed, never a raw user-supplied one. Idempotent: re-merging the sameanonIdis a no-op. UnlikemeterUsage, it never swallows failure — throws the typedVibeMonetizeErrorfrom the response body on any non-2xx (e.g.not_foundfor an unknown app,unauthorizedfor apk_key), and network errors propagate.input: { anonId, email }(appIdcomes from the client's own config).// after your own auth confirms the user's (now-verified) email: const { endUserId } = await monetize.mergeEndUser({ anonId: previousAnonId, email }); // switch the client/session to endUserId — same contract as the identify/verify flow.grantCredits(input): Promise<{ creditGrantId: string; applied: boolean; remaining: number | null }>(#212) — idempotent server-side comp/goodwill credit grant onto aperiod:"credit"meter. POSTs to thesk_-onlyPOST /v1/credits/grant(secretKeyrequired — apk_key getsunauthorized); independent of bothmeterUsage/meterUsageStrict(consumption tracking) and Stripe checkout (a purchased credit pack) — use this for grants that never touch Stripe: support credits, refund goodwill, promos. UnlikemeterUsage, it never swallows failure: retries network errors/5xxwith the same backoff asmeterUsage/meterUsageStrict(safe — the call is idempotent onidempotencyKey), then throws; any4xxthrows immediately, includingVibeMonetizeError("idempotency_conflict")(409) ifidempotencyKeywas already used with a differentmeterSlug/delta/identity, orvalidation_failedif the meter isn'tperiod:"credit".applied: falseon the response means this call was an idempotent replay of an already-recorded grant (the credits were NOT granted again).input: { meterSlug, delta, endUserId?, anonId?, reason, idempotencyKey, metadata? }(appIdcomes from the client's own config;idempotencyKeyis REQUIRED, unlikemeterUsage's optional one).const { remaining } = await monetize.grantCredits({ meterSlug: "credits", delta: 100, endUserId: claims.sub, reason: "refund goodwill — ticket #4821", idempotencyKey: "refund-4821", });reserveUsage/settleUsage/releaseUsage(RFC 030) — secret-key-only, independently idempotent credit holds for long-running work.settleUsageaccepts an optional actual amount and releases the unused portion;releaseUsageconsumes nothing.const hold = await monetize.reserveUsage({ meterSlug: "credits", amount: 1_000, endUserId: claims.sub, idempotencyKey: job.id, reason: "image batch", }); if (!hold.accepted) throw new Error("Insufficient credits"); await monetize.settleUsage({ reservationId: hold.reservationId, amount: 640, idempotencyKey: `${job.id}:settle` });
Also re-exports VibeMonetizeError and the EntitlementTokenClaims/ErrorCode/Mode/
UsageEventResponse/GrantCreditsResponse types from @vibemonetize/core so you don't
need a direct dependency on it just to catch errors or type a claims object.
Developer test/live split (RFC 012)
Every secret key is either sk_test_... or sk_live_... (Stripe-style), and every
entitlement JWT carries a mode: "test" | "live" claim stamped from the key used to
mint it. createClient never parses or branches on the key's prefix — just pass
whichever key you have (secretKey option, VIBEMONETIZE_SECRET_KEY env var, or its
VIBEMONETIZE_TEST_SECRET_KEY fallback — see below) and use getUser(token).mode/
getMode(token) to tell which mode a given request ran in:
const user = await monetize.getUser(token);
if (user.mode === "test") {
// e.g. skip an external side effect you only want to fire for real (live) usage
}
Tokens issued before this claim existed (or minted without an explicit mode) verify
the same as always and report mode: "live" — this is purely additive.
createClient's env-var resolution for secretKey mirrors @vibemonetize/react's
publishable-key fallback: it prefers VIBEMONETIZE_SECRET_KEY (live) and falls back to
VIBEMONETIZE_TEST_SECRET_KEY whenever the live var is unset/blank. vibemonetize signup only ever mints a TEST pair, so this fallback is what makes meterUsage/
mergeEndUser/requireEntitlement work with zero edits right after signup — the
same "zero edits" guarantee the client SDK already gave you.
expectMode — mode enforcement is on by default (RFC 022)
Every entitlement token carries a mode claim, and createClient now enforces it by
default instead of merely surfacing it — this closes a real gap: test-mode checkout is a
4242 card away from free, so without enforcement any end user could mint themselves a
test-mode "pro" entitlement and replay it against a live backend. The default is derived
from your configured secretKey's own prefix:
sk_live_...(and grandfathered baresk_...) -> requiresmode: "live"tokens.sk_test_...-> requiresmode: "test"tokens.
A mismatch throws VibeMonetizeError("mode_mismatch") (403). Pass expectMode: "any" as
the explicit escape hatch (e.g. a preview deployment intentionally serving both modes), or
an explicit expectMode: "test" | "live" to override the derived default. A
verification-only client (no secretKey at all) enforces nothing by default and logs a
one-time console warning recommending you pass expectMode explicitly.
// A live-only backend: sk_live_... already implies expectMode: "live" — no code change.
const monetize = createClient({ appId, secretKey: process.env.STRIPE_LIVE_SECRET_KEY });
// A preview/staging deployment that intentionally serves both modes:
const monetize = createClient({ appId, secretKey, expectMode: "any" });
This is a breaking behavior change for existing integrators: a live client that
previously silently honored test-mode tokens will now throw mode_mismatch on one. That
is the vulnerability closing, not a regression — see this package's changelog.
Error codes you may see
token_expired (401), unauthorized (401), payment_required (402), forbidden
(403), mode_mismatch (403, RFC 022) — see @vibemonetize/core's ERROR_CATALOG for
the full list and default HTTP status codes.
Tests
pnpm test — vitest. Generates a local ES256 keypair, issues tokens with core's
issueEntitlementToken, and verifies them via an injected verificationKey (bypassing
the network JWKS fetch), plus meterUsage/meterUsageStrict retry/give-up/throw
behavior against a mocked fetch.
SDK compatibility telemetry
API requests made by createClient include
x-vibemonetize-sdk: server/<version>. The header contains no user data and is
used to identify deployed SDK versions before changing the /v1 wire protocol.