# Node SDK Integration

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

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/sdk-node
<!-- /docs-source -->

`@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.

> [!NOTE] Requires @monetizekit/node >= 0.2.0
>
> Everything on this page ships in SDK v0.2.0. On v0.1.x, `entitlements.check()` works but returns the pre-enrichment shape, and `mk.entitlements.checkMany(customerId, featureKeys)`, the credit-reservation methods, caching, degradation, and both extension points are unavailable. The underlying REST endpoints (including `POST /entitlements/batch` and the reservation routes) work from any HTTP client regardless of SDK version.

## Install and configure

```bash
npm install @monetizekit/node
```

```typescript
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”.

```typescript
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}`);
  }
}
```

```json
{
  "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.

```typescript
// 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]));
```

> [!NOTE] Batch responses are never HTTP-cacheable
>
> Unlike single-feature checks (cacheable by the caller for 60s), the batch endpoint always computes fresh decisions. The SDK still writes each result into its local cache, so a `checkMany` warm-up makes subsequent single `check()` calls for the same keys cache hits. See the [Caching guide](/docs/guides/caching) for the full policy.

## 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. |

> [!WARNING] Alert on degraded decisions
>
> Every fallback decision is flagged `degraded: true` and reported to your `DecisionObserver`s. A sustained stream of degraded decisions means your product is running on guesses — wire an alert on it in your monitoring stack rather than discovering it from a revenue anomaly.

## 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.

```typescript
// 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:

```typescript
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()`.

```typescript
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");
```

> [!TIP] Observers can never break a decision
>
> The SDK swallows anything an observer throws, and observers are invoked synchronously after the decision is made — do async work (network sends, batching) fire-and-forget inside `onDecision`, never `await`ed.

## 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 guide](/docs/guides/entitlement-patterns)

### Entitlements & Caching

Where decisions come from and how to cache them safely.

[Open guide](/docs/guides/caching)

### Audit & Trace

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

[Open guide](/docs/guides/audit-trace)