Base 3:16
Guides

Gate a feature

The pattern for putting a paywall on the request path.

The shape is always the same: check, work, record.

import { Base316 } from "@base316/sdk";

const base316 = new Base316();

export async function generateReport(accountId: string, requestId: string) {
	// 1. Ask before doing the work.
	const decision = await base316.access.check({
		externalId: accountId,
		feature: "reports",
		requested: 1,
	});

	if (!decision.allowed) {
		throw new PaymentRequired(decision.reason);
	}

	// 2. Do the work.
	const report = await buildReport(accountId);

	// 3. Record it, only now that it succeeded.
	await base316.events.record({
		externalId: accountId,
		event: "report_generated",
		idempotencyKey: requestId,
	});

	return report;
}

Turn a refusal into a useful error

reason is safe to log and safe to branch on. Map it to something your user can act on:

const MESSAGES: Record<string, string> = {
	USAGE_LIMIT_EXCEEDED: "You have used your monthly reports. Upgrade or add credits.",
	HARD_CAP_REACHED: "You have reached this month's spending limit for reports.",
	ENTITLEMENT_REQUIRED: "Reports are not included in your plan.",
	SUBSCRIPTION_INACTIVE: "Your subscription is not active.",
	NO_SUBSCRIPTION: "This account has no subscription.",
};

throw new PaymentRequired(MESSAGES[decision.reason ?? ""] ?? "Not available on your plan.");

Warn before the wall

decision.usage carries remaining. Showing it is the difference between a customer who upgrades and one who files a ticket:

const { used, limit, remaining } = decision.usage ?? {};

if (limit !== null && remaining !== null && remaining < limit * 0.1) {
	notifyNearLimit(accountId, { used, limit, remaining });
}

You can also subscribe to the usage.limit_reached webhook rather than computing it yourself.

Overage: when the limit does not stop anyone

If the plan prices the feature's usage — a per-unit, tiered or package price on it — the limit is soft. check keeps answering allowed: true past it and the extra usage is billed. Tell the user when that starts, because from then on every call costs them money:

if (decision.allowed && decision.usage?.overage) {
	notifyBilledOverage(accountId, {
		billable: decision.usage.billableOverage,
		cap: decision.usage.hardCap,
	});
}

To stop runaway spend, give the entitlement a hard cap. Past it, check refuses with HARD_CAP_REACHED — a different message from USAGE_LIMIT_EXCEEDED, because the fix is different: raising a spending limit, not upgrading the plan. Credits never raise the cap.

A plan with no usage price on the feature keeps a hard limit, refused with USAGE_LIMIT_EXCEEDED exactly as before.

Gating on a permission as well

For a feature only some users in an account may use:

const decision = await base316.access.check({
	externalId: accountId,
	userId: actingUserId,
	feature: "reports",
	permission: "reports.export",
});

Both must pass. The account must be entitled and the user must hold the permission.

Things to get right

  • Check before, record after. Work that fails should cost nothing.
  • Pass requested when you know how much you are about to use, so the refusal happens before the cost.
  • Do not treat allowed as "within the plan". On a soft limit it can mean "allowed, and billed" — read usage.overage.
  • Never cache an allow across a request boundary. The point of the call is that the answer changes.
  • Fail closed on a network error, or fail open deliberately — but decide. Silently allowing on a timeout is a decision you did not know you made.

On this page