Help improve docs quality by sharing anonymous interaction telemetry (no PII or token data).

Node SDK Integration

Integrate the Node SDK for enriched decisions, batch checks, caching, degradation, and credit reservations.

@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/node
import { 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
}
reasonCodeAllowedMeaning
grantedYesA boolean/enum/string entitlement grants access.
granted_unlimitedYesA limit-type feature with an unlimited effective value.
granted_no_meterYesA limit-type feature with no usage meter mapped — the limit cannot be enforced, so access is granted.
within_limitYesMetered usage is below the limit; usage / limit / remaining / resetsAt are populated.
limit_reachedNoMetered usage has reached the limit. resetsAt says when the window resets; grantedByPlans lists plans that raise it.
not_in_planNoThe feature exists but the customer's plan does not include it. grantedByPlans lists plans that do.
unknown_featureNoNo 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.

ModeOn API failureUse when
throwRethrows the transport error (the default).You have your own retry/fallback logic, or a failed check must abort the request.
fail_openServes 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_closedServes 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.

  • ttlSeconds defaults 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 (held resolves 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_hold ledger entry, a release/expiry writes reservation_release, and a capture writes a standard debit for the captured cost (plus a reservation_release for any returned remainder).

  • Pass an idempotencyKey on reserve so 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.

Entitlement Evaluation Patterns

Boolean gates, limit checks, and metered entitlements in depth.

Open guide

Entitlements & Caching

Where decisions come from and how to cache them safely.

Open guide

Audit & Trace

Propagate request IDs and correlate API activity end-to-end.

Open guide

Was this page helpful?