npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@develemit/billing

v0.2.1

Published

Typed, fetch-based client for the emit-billing API. Server-side only — the API key is a project-level secret, and entitlement checks must never happen from a browser with that key exposed.

Readme

@develemit/billing

Typed, fetch-based client for the emit-billing API. Server-side only — the API key is a project-level secret, and entitlement checks must never happen from a browser with that key exposed.

Install

pnpm add @develemit/billing

Afternoon integration

import { createClient, BillingApiError } from '@develemit/billing';

const billing = createClient({
  apiKey: process.env.BILLING_API_KEY!,
  baseUrl: 'https://billing.example.com',
});

// Gate a route
app.get('/reports/export', async (req, res) => {
  const entitlements = await billing.getEntitlements(req.subjectId);
  if (!entitlements.features.export) {
    return res.status(403).json({ error: 'upgrade_required' });
  }
  // entitlements.stale === true means this came from cache after an API
  // outage — still safe to trust for gating, just not guaranteed fresh.
  return res.json(await buildExportReport());
});

// Start checkout
app.post('/upgrade', async (req, res) => {
  try {
    const { url } = await billing.createCheckout({
      planKey: 'pro',
      subjectId: req.subjectId,
      successUrl: 'https://app.example.com/billing/success',
      cancelUrl: 'https://app.example.com/billing',
    });
    return res.redirect(url);
  } catch (err) {
    if (err instanceof BillingApiError) {
      return res.status(502).json({ error: err.code });
    }
    throw err;
  }
});

Route gating (Fastify)

billingGuardPlugin wraps getEntitlements with a fail-open/fail-closed policy for route guards, so every gated route states — explicitly, at the type level — what happens when billing itself is unreachable.

It lives at the @develemit/billing/fastify subpath rather than the package root, so importing the core client never pulls in Fastify's types — install fastify yourself (it's a peer dependency) only if you use this part of the SDK.

import { createClient } from '@develemit/billing';
import { billingGuardPlugin } from '@develemit/billing/fastify';

const billing = createClient({
  apiKey: process.env.BILLING_API_KEY!,
  baseUrl: 'https://billing.example.com',
});

await app.register(billingGuardPlugin, {
  client: billing,
  getSubjectId: (req) => req.household.id, // your app's Subject mapping
  onUnavailable: 'closed', // the plugin-wide default; routes may override
});

// Read feature: an outage shouldn't block reads, so fail open.
app.get('/reports/export', {
  preHandler: [app.requireEntitlement('export', { onUnavailable: 'open' })],
  handler: async (req) => buildExportReport(req.entitlements!),
});

// Payments-adjacent write: an outage means we can't confirm entitlement,
// so fail closed rather than risk letting an unpaid write through.
app.post('/invoices/send', {
  preHandler: [
    app.requireEntitlement('send_invoices', { onUnavailable: 'closed' }),
  ],
  handler: async (req) => sendInvoice(req.body),
});

// Limit check: the consumer app owns metering (billing only compares).
app.post('/emails/send', {
  preHandler: [
    app.requireWithinLimit(
      'emails_per_month',
      (req) => req.org.emailsSentThisMonth,
    ),
  ],
  handler: async (req) => sendEmail(req.body),
});

Decision outcomes:

  • Entitlements resolve (fresh or stale) → decided on content: feature truthy, or usage under the limit → allow (request.entitlements decorated); otherwise 403 entitlement_denied.
  • Billing unreachable (network failure, or no cached value to fall back on) → onUnavailable: 'open' allows the request through; 'closed' returns 503 billing_unavailable.
  • A 4xx from the API (bad key, malformed subject) is misconfiguration, not an outage — always denied (403 billing_misconfigured) regardless of onUnavailable, so fail-open can't mask a broken integration.

Choosing a policy: 'closed' for anything payments-adjacent or otherwise risky to let through unchecked; 'open' for ordinary reads where a billing outage shouldn't take down an unrelated feature.

The framework-agnostic decision function (checkEntitlement from ./guard.js) is exported separately for building adapters to other frameworks.

Shadow mode

createShadowCheck wraps an existing (legacy) entitlement check with emit-billing's answer computed side by side, for validating a migration before cutting over. The returned function's answer is always the legacy answer — shadow-mode never changes behavior, it only reports.

import { createShadowCheck } from '@develemit/billing';

// Shaped after tastease's subscription-guard seam:
// checkSubscribed: (userId: string) => Promise<boolean>
const shadowedCheckSubscribed = createShadowCheck({
  legacy: isHouseholdSubscribed, // (id) => Promise<boolean>, tastease's real check
  subjectIdFor: (userId: string) => userId, // map consumer id -> emit-billing externalId
  client: billing,
  entitled: (e) =>
    e.status === 'active' || e.status === 'trialing' || e.status === 'grace',
  report: (r) => log.info(r), // injectable sink; ShadowReport
  onReportError: (e) => log.warn(e), // optional; see note below
  sampleRate: 1, // 0..1, default 1
  timeoutMs: 800, // shadow fetch budget, default 800ms
});

// Drop-in replacement for the legacy check — same call shape, same answer.
const isSubscribed = await shadowedCheckSubscribed(userId);

Invariants:

  1. The returned function's answer is always the legacy answer, bit for bit — including thrown errors, which propagate exactly as they would without shadow mode.
  2. The shadow path can never delay the caller beyond timeoutMs (it runs concurrently with the legacy check) and never throws into the caller — timeout, network, 5xx, and parse failures all become a shadowError on the report instead of an exception.
  3. Every report carries subjectId, both answers (or the legacy/shadow error), an agree boolean, per-path latency, and the raw shadow status/source for diagnosis.
  4. sampleRate short-circuits before any shadow work — no fetch happens on a skipped call.

Invariant 1 holds even when the consumer-supplied callbacks themselves throw. A report sink that throws (uninitialized logger, circular value hitting JSON.stringify) is swallowed; so is a subjectIdFor mapper that throws, which simply means the call can't be shadowed — the legacy check still runs and still returns its answer. Both escalate through the optional onReportError. The alternative — letting an observability-only code path break real billing checks — is exactly what shadow mode exists to avoid. Without onReportError set, both failures are silent, so pass it.

Reading disagreement reports: a report.agree === false is only worth investigating once you've ruled out two expected, transient causes — shadowFromCache: true combined with a recent plan change (the cached answer just hasn't hit its TTL yet), or a webhook that hasn't landed yet after a provider-side change.

A shadowError report is a health signal, not a disagreement — no shadow answer was obtainable, so there was nothing to compare. Its reason separates whose problem it is:

| reason | Points at | | ----------------- | ------------------------------------------------------ | | timeout | emit-billing slow, or timeoutMs set too tight | | network | transport — DNS, connection refused, TLS | | http_error | emit-billing returned a non-2xx | | parse_error | response didn't match the expected schema | | predicate_error | your entitled() callback threw, not emit-billing |

Latency is measured with performance.now(), so shadowLatencyMs keeps sub-millisecond resolution — a cache-served shadow read reports e.g. 0.1 rather than a flat 0 that's indistinguishable from "never measured."

Behavior

  • getEntitlements(subjectId) caches per-subject for entitlementsTtlMs (default 5000ms). Within the TTL it never hits the network. Past the TTL it refetches; if the API is unreachable or returns a 5xx and a cached value exists, it returns that value with stale: true instead of throwing. It only throws when there's no cached value, or on a 4xx (bad key, validation) — staleness never masks misconfiguration.
  • createCheckout, portalUrl, and getSubscription always call the API and throw BillingApiError on any failure.
  • Every failure is a BillingApiError with status and code. Network failures use status: 0, code: 'network_error' so callers can tell "billing is unreachable" apart from "request was denied".