@monetizekit/node is the reference SDK for server-side entitlement checks, usage metering, and credit operations. This guide covers the integration surface added in v0.2.0: machine-readable decision causes you can branch on, batch checks, a built-in decision cache with explicit offline behavior, atomic credit reservations for uncertain-cost operations, and the IdentityResolver / DecisionObserver extension points that the Clerk, PostHog, and OpenTelemetry guides plug into.
Install and configure
npm install @monetizekit/nodeimport { MonetizeKit } from "@monetizekit/node";
const apiKey = process.env.MONETIZEKIT_API_KEY;
if (!apiKey) throw new Error("MONETIZEKIT_API_KEY is required");
export const mk = new MonetizeKit({
apiKey, // mk_test_... or mk_live_...
// cache: true uses the defaults (30s TTL, 1000 entries);
// pass { ttlMs, maxEntries } to tune.
cache: { ttlMs: 30_000, maxEntries: 1_000 },
// What check()/checkMany() do when the API is unreachable.
// "throw" (default) | "fail_open" | "fail_closed"
degradation: "fail_closed",
});All options besides apiKey are optional. Leave cache and degradation unset and the SDK behaves like v0.1: every check is a network call and API failures throw.
Enriched decisions: branch on reasonCode
Every check returns a stable, machine-readable reasonCode alongside the free-form reason sentence. Denials additionally carry resetsAt (when a metering window resets) and grantedByPlans (published plans that would grant the feature or raise the limit) — enough to render a useful paywall instead of a generic “access denied”.
const decision = await mk.entitlements.check("cust_dev_1001", "api_requests");
if (!decision.allowed) {
switch (decision.reasonCode) {
case "limit_reached":
// decision.resetsAt: ISO timestamp when the metering window resets
// decision.grantedByPlans: published plans that raise this limit
return res.status(402).json({
error: "limit_reached",
retryAfter: decision.resetsAt,
upgradeTo: decision.grantedByPlans,
});
case "not_in_plan":
return res.status(403).json({
error: "not_in_plan",
upgradeTo: decision.grantedByPlans,
});
case "unknown_feature":
// Typo'd feature key or unpublished feature — a bug, not a paywall.
throw new Error(`Unknown feature key: ${decision.featureKey}`);
}
}{
"customerId": "cust_dev_1001",
"featureKey": "analytics_export",
"allowed": true,
"effectiveValue": true,
"type": "boolean",
"sources": [
"Plan"
],
"reason": "Entitlement grants access",
"reasonCode": "granted",
"planName": "Pro",
"planVersion": 3,
"latencyMs": 4,
"cached": false,
"degraded": false
}| reasonCode | Allowed | Meaning |
|---|---|---|
granted | Yes | A boolean/enum/string entitlement grants access. |
granted_unlimited | Yes | A limit-type feature with an unlimited effective value. |
granted_no_meter | Yes | A limit-type feature with no usage meter mapped — the limit cannot be enforced, so access is granted. |
within_limit | Yes | Metered usage is below the limit; usage / limit / remaining / resetsAt are populated. |
limit_reached | No | Metered usage has reached the limit. resetsAt says when the window resets; grantedByPlans lists plans that raise it. |
not_in_plan | No | The feature exists but the customer's plan does not include it. grantedByPlans lists plans that do. |
unknown_feature | No | No published feature matches this key — usually a typo, not a paywall. |
The API produces exactly 7 codes (the table above). Two more — sdk_fail_open and sdk_fail_closed — are produced locally by the SDK's degradation fallback and never appear in an API response.
Batch checks
Pages that render many gates at once (a settings screen, a pricing table, a feature matrix) shouldn't pay one round trip per gate. mk.entitlements.checkMany(customerId, featureKeys) calls POST /entitlements/batch, which resolves the customer once and evaluates up to 50 feature keys against it — duplicate keys are deduplicated, and each result has the same enriched shape as a single check.
// One request, one customer resolution, up to 50 feature keys.
const decisions = await mk.entitlements.checkMany("cust_dev_1001", [
"analytics_export",
"api_requests",
"sso",
]);
// One decision per unique key, same shape as a single check().
const gates = Object.fromEntries(decisions.map((d) => [d.featureKey, d.allowed]));Caching and degradation
With cache enabled, fresh decisions are served locally (marked cached: true) and every API-served decision refreshes the cache. The degradation option — throw | fail_open | fail_closed — decides what happens when the API is unreachable, and it always prefers a stale cached decision over a fabricated one.
| Mode | On API failure | Use when |
|---|---|---|
throw | Rethrows the transport error (the default). | You have your own retry/fallback logic, or a failed check must abort the request. |
fail_open | Serves a stale cached decision if one exists; otherwise allows with reasonCode sdk_fail_open and degraded: true. | Low-stakes gates where blocking paying users during an outage costs more than a leak. |
fail_closed | Serves a stale cached decision if one exists; otherwise denies with reasonCode sdk_fail_closed and degraded: true. | High-stakes gates (credit spend, expensive compute) where an accidental allow is the worse failure. |
Credit reservations for uncertain-cost operations
AI workloads rarely know their cost up front. A check-then-debit sequence can overspend under concurrency: two requests both pass the balance check, then both debit. Reservations close that window with an atomic hold — reserve the worst case, run the operation, capture the actual cost, and the difference returns to the wallet.
// Hold credits before an uncertain-cost operation (e.g. an LLM call),
// capture the actual cost after, and the remainder returns automatically.
const { value } = await mk.credits.withReservation(
{
customerId: "cust_dev_1001",
amount: 100, // worst-case cost
ttlSeconds: 300,
idempotencyKey: `gen:${requestId}`,
},
async () => {
const completion = await llm.generate(prompt);
return { value: completion, cost: completion.usage.totalCredits };
},
);
// On success: captures the returned cost, releases the rest.
// On a thrown error: releases the full hold, then rethrows.mk.credits.withReservation(data, fn) wraps the three lifecycle calls; use them directly when capture happens in a different process than the reserve:
const { reservation } = await mk.credits.reserve({
customerId: "cust_dev_1001",
amount: 100,
ttlSeconds: 600,
});
try {
const completion = await llm.generate(prompt);
await mk.credits.captureReservation(reservation.id, completion.usage.totalCredits);
} catch (error) {
await mk.credits.releaseReservation(reservation.id);
throw error;
}Endpoints:
POST /credits/reserve,GET /credits/reservations/{reservationId},POST /credits/reservations/{reservationId}/capture,POST /credits/reservations/{reservationId}/release.ttlSecondsdefaults to 300s and must be between 10s and 86,400s (24h). A hold that is never captured or released is expired by an hourly background job — size the TTL to your operation's worst-case duration, not to “long enough to never expire”.Reservation states: held → captured → released → expired (
heldresolves to exactly one of the other three, exactly once — capture and release replays are idempotent and concurrent capture/release races have one winner).Audit trail: a hold writes a
reservation_holdledger entry, a release/expiry writesreservation_release, and a capture writes a standarddebitfor the captured cost (plus areservation_releasefor any returned remainder).Pass an
idempotencyKeyonreserveso a retried request re-uses the existing hold instead of double-holding.
Extension points
The SDK exposes exactly two integration interfaces, and every provider integration is an implementation of one of them — so adding Clerk, PostHog, or OpenTelemetry never changes how your product code calls check().
import { MonetizeKit, type DecisionObserver, type IdentityResolver } from "@monetizekit/node";
const logObserver: DecisionObserver = {
onDecision(event) {
logger.debug("mk decision", event);
},
};
const resolver: IdentityResolver = {
async resolveCustomerId(externalId) {
return lookupCustomerIdInYourDb(externalId);
},
};
const mk = new MonetizeKit({
apiKey: process.env.MONETIZEKIT_API_KEY,
observers: [logObserver],
identityResolver: resolver,
});
const customerId = await mk.resolveCustomerId("user_2abc123");FAQ
Should I use the SDK cache or my own Redis cache?
The built-in cache is per-process and in-memory — ideal for collapsing repeated checks inside one server, with zero infrastructure. If you run many short-lived instances (serverless) or need cross-instance coherence, layer the Redis pattern from the Caching guide instead; the two compose, but usually one is enough.
What happens if my process crashes between reserve and capture?
The hold stays until its TTL elapses, then the hourly expiry job releases it back to the wallet with a reservation_release ledger entry. Nothing is silently lost — but the credits are unavailable to that customer until expiry, which is why TTLs should be as short as the operation allows.
Can I capture more than I reserved?
No — capture is bounded by the held amount. If actual cost can exceed your estimate, reserve the worst case (that is the point of the hold), or split the operation and reserve again for the overage.
Related guides
Entitlement Evaluation Patterns
Boolean gates, limit checks, and metered entitlements in depth.
Open guideEntitlements & Caching
Where decisions come from and how to cache them safely.
Open guideAudit & Trace
Propagate request IDs and correlate API activity end-to-end.
Open guide