Check access¶
Your backend is the client. End users of your product never call e10s for entitlements.
Use a key on a runtime service account (the default for new service accounts). Same resolution, two shapes: POST check (one feature) or GET entitlements (a snapshot).
Pick how you use them:
- Call check when you need a feature
- GET entitlements at session start, or whenever you want a snapshot
- Cache
allowed/limithowever you want - Stay fresh with a TTL, webhooks, or both
Usage stays in your database. GET entitlements and POST check share 100 requests per second per organization — live check on every request is allowed; that bucket is why you might snapshot or cache.
Request shapes: Entitlements API. Credentials: Authentication.
Gate¶
Authenticate the user in your app, take that customer’s id as the subscriber id, then allow or reject (and compare usage for limits). URL-encode the subscriber id.
const KEY = process.env.E10S_MEMBER_API_KEY!
const BASE = "https://api.e10s.io/org"
async function e10s<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"X-API-Key": KEY,
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
})
if (!res.ok) throw new Error(`e10s ${res.status}`)
return res.json() as Promise<T>
}
type Flag = { kind: "flag"; allowed: boolean }
type Limit = { kind: "limit"; limit: number | null }
type Config<T> = { kind: "config"; value: T | null }
const subscriberId = encodeURIComponent(customerId)
const sso = await e10s<Flag>(
`/subscribers/${subscriberId}/entitlements/check`,
{ method: "POST", body: JSON.stringify({ feature: "sso" }) },
)
if (!sso.allowed) return res.status(403).json({ error: "sso_not_entitled" })
const seats = await e10s<Limit>(
`/subscribers/${subscriberId}/entitlements/check`,
{ method: "POST", body: JSON.stringify({ feature: "seats" }) },
)
const used = await countSeats(customerId)
if (seats.limit !== null && used >= seats.limit) {
return res.status(403).json({ error: "seat_limit_reached" })
}
limit: null means unlimited — skip the comparison.
Periodic quotas¶
For a quota that resets, like API calls per month, e10s still only gives you
limit. You keep the count and decide when it resets, usually on your own
billing cycle for that customer.
// Your table: one row per customer per billing period.
// usage(customer_id, period_start, api_calls), primary key (customer_id, period_start)
// Every check is an eval, so cache the limit briefly instead of checking on
// every metered request. Plan changes and overrides show up within the TTL,
// or right away if you clear the cache from an entitlements webhook.
const quotaLimit = await cached(`quota:${customerId}`, 60, async () => {
const r = await e10s<Limit>(
`/subscribers/${encodeURIComponent(customerId)}/entitlements/check`,
{ method: "POST", body: JSON.stringify({ feature: "monthly_api_calls" }) },
)
return r.limit
})
// Count first, atomically, then compare, so concurrent requests can't overshoot.
const periodStart = currentBillingPeriodStart(customerId) // your billing cycle
const { rows: [{ api_calls }] } = await db.query(
`INSERT INTO usage (customer_id, period_start, api_calls) VALUES ($1, $2, 1)
ON CONFLICT (customer_id, period_start)
DO UPDATE SET api_calls = usage.api_calls + 1
RETURNING api_calls`,
[customerId, periodStart],
)
if (quotaLimit !== null && api_calls > quotaLimit) {
return res.status(429).json({ error: "monthly_quota_reached" })
}
A new billing period starts a new row, so the count resets on its own. A plan change or override applies once the cached limit refreshes; the count you already stored stays as it is. Requests refused over the limit still count; decrement on refusal if you don't want that.
Config values¶
A config check returns the value to use. There's no default: value is null
when no override sets it and the subscriber's plan doesn't include it, so pick
your fallback in code.
const tier = await e10s<Config<"basic" | "priority" | "dedicated">>(
`/subscribers/${subscriberId}/entitlements/check`,
{ method: "POST", body: JSON.stringify({ feature: "support_tier" }) },
)
const supportTier = tier.value ?? "basic"
false, 0, and "" are real values: compare against null, not falsiness.
Default GET list is entitled only (a config counts when its value isn't
null). Pass ?all=true if you cache denies.
Failure handling¶
| Situation | Suggested app behavior |
|---|---|
Flag allowed: false |
403 / paywall in your API |
Limit exceeded (used >= limit) |
403 / upgrade prompt — e10s only gave you limit |
| Periodic quota used up | 429 until the period resets, or an upgrade prompt |
| e10s 5xx / timeout | Fail closed (deny) or degrade per your risk tolerance — be explicit |
Unknown feature key |
422 from e10s — treat as configuration error; fix catalog or caller |
| 401 from e10s | Bad/revoked key — fix credentials, don’t treat as “feature off” |
| 403 from e10s | The key’s role doesn’t allow this route. Don’t treat as “feature off”. |
| 429 from e10s | Back off. Do not refetch every subscriber from a webhook handler. |
| 409 from e10s on check / GET entitlements | Monthly eval allotment exhausted after the 7-day grace period. Not “feature off”. See Eval usage. |