@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/billingAfternoon 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.entitlementsdecorated); otherwise403 entitlement_denied. - Billing unreachable (network failure, or no cached value to fall back
on) →
onUnavailable: 'open'allows the request through;'closed'returns503 billing_unavailable. - A 4xx from the API (bad key, malformed subject) is misconfiguration, not
an outage — always denied (
403 billing_misconfigured) regardless ofonUnavailable, 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:
- 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.
- 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 ashadowErroron the report instead of an exception. - Every report carries
subjectId, both answers (or the legacy/shadow error), anagreeboolean, per-path latency, and the raw shadowstatus/sourcefor diagnosis. sampleRateshort-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 forentitlementsTtlMs(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 withstale: trueinstead of throwing. It only throws when there's no cached value, or on a 4xx (bad key, validation) — staleness never masks misconfiguration.createCheckout,portalUrl, andgetSubscriptionalways call the API and throwBillingApiErroron any failure.- Every failure is a
BillingApiErrorwithstatusandcode. Network failures usestatus: 0, code: 'network_error'so callers can tell "billing is unreachable" apart from "request was denied".
