Entitlement
- Sells as: the
entitlementproduct (a product-scoped API key). This capability has no separate product-face contract — its public product route reuses the capability contract below; see the Edge route table's Product face row.
Shared consumer entitlement/paywall capability. A (user_id, content-item, mode/tier)-scoped consumer access-DECISION engine (modes hard/soft/metered/time_based/entitlement) + a per-consumer metered free-quota with period reset + a per-category gate-rule engine + the canonical tier ladder + a unified consumer subscription state machine (create/supersede/cancel/pause/resume/prorate) + windowed entitlement grants. On a gate requiring payment the charge is DELEGATED to billing-metering (/v1/charge) and its 402 is relayed — this capability never re-implements charging. Distinct from billing-metering's (organization_id, metric) B2B tenant-metering.
- Group: Commerce & revenue
- Contract:
contracts/entitlement/v1/openapi.yaml - Console: Manage the
entitlementproduct in the console → - Runbook: operational checklist for
entitlement— internal (Vagary Labs ops; not part of this public site):docs/runbooks/capability-operations.md#entitlement - Public base:
https://api.vagarylabs.com(the consolidated API gateway — one host, per-brand siblingapi.<zone>) - Auth: a product API key (
vgk_…) issued from the console —Authorization: Bearer vgk_… - Product face (customer-keyed):
https://api.vagarylabs.com/product/v1/entitlement/check
Edge route table
The routes this capability actually serves on the public edge — live-synced from GET https://api.vagarylabs.com/product/v1/_meta/catalog (snapshot v1). The metric is the request-granularity billing counter; the scope is the key permission required.
Product face (customer-keyed)
| Method | Path | Metric | Scope |
|---|---|---|---|
POST | /product/v1/entitlement/check | entitlement_checks | read |
Endpoints
| Method | Path | Summary |
|---|---|---|
POST | /v1/access/check | The consumer access decision for a content item. Resolves the effective gate mode (a category rule overrides the org default; a free item with no active rule short-circuits to grant) then dispatches on mode. Item facts (access_tier/category/published_at) and the consumer user_id are CALLER-SUPPLIED inputs (I4 — the capability never fetches platform content). Returns {has_access, reason, preview, required_tier, mode, remaining?}. |
POST | /v1/view/{item_id} | Record a metered content view for a consumer (advances the metered free-quota); returns remaining |
GET | /v1/remaining | The consumer's remaining free-quota this period |
POST | /v1/reset-usage/{user_id} | Reset (clear) a consumer's metered usage |
GET | /v1/config | The org's gate config (enabled, free_article_limit, reset_period, default_mode, time_window_hours) |
PUT | /v1/config | Update the org's gate config (persistent per-org; the platform kept this in-memory) |
GET | /v1/tiers | The canonical tier ladder (mechanism — order + labels; PRICES/FEATURES are domain packaging) |
GET | /v1/rules | List the org's per-category gate rules |
PUT | /v1/rules/{category} | Set (upsert) a per-category gate rule {mode, required_tier, enabled} — the domain override point |
DELETE | /v1/rules/{category} | Remove a per-category gate rule |
POST | /v1/memberships | Create (supersede-on-create) a consumer subscription — at most one active per (org, user). If a payment block is present the charge is DELEGATED to billing-metering FIRST (fail-closed — no active subscription is written unless the charge is actually COLLECTED); a billing 402/422 is relayed verbatim, and a charge that only created a hosted checkout (requires_action) is relayed as 402 with its hosted_url rather than granting access. |
GET | /v1/memberships/active | The consumer's active subscription (by user_id) |
DELETE | /v1/memberships/active | Cancel the consumer's active subscription |
GET | /v1/memberships/{subscription_id} | A subscription by id |
POST | /v1/memberships/{subscription_id}/pause | Pause an active subscription |
POST | /v1/memberships/{subscription_id}/resume | Resume a paused subscription (shifts next_billing_date by the pause duration) |
POST | /v1/memberships/{subscription_id}/prorate | Change a subscription's plan with proration. tier_prices is DOMAIN pricing the caller passes (the capability hardcodes no price); it computes the prorated credit/charge over the remaining billing cycle. |
POST | /v1/entitlements | Grant a windowed / one-time entitlement to a consumer for an item (the entitlement-window mode). If a payment block is present the charge is DELEGATED to billing-metering FIRST (fail-closed; a billing 402 is relayed). ttl_seconds (or expires_at epoch) bounds the window; omit both for a permanent grant. |
GET | /v1/entitlements/check | Whether a consumer holds an active (non-expired) entitlement for an item |
POST | /internal/settlement/{rail}/{provider_charge_id} | A charge settled — grant what it bought. Called service-to-service by billing-metering when a verified provider webhook (or a capture) moves a settlement to succeeded. This is the other half of the requires_action relay on the paid paths: those refuse to grant because the customer has not paid yet, PARK what was being bought, and promise the grant happens once the rail settles. This route is that promise. Billing carries ONLY the charge identity; what a payment buys is this service's domain, and a tier or item id on a settlement row would make the money service the authority on access. Exactly-once by construction: the pending intent is claimed with a single conditional UPDATE, so a redelivered webhook grants nothing. An unknown charge and an already-granted one answer identically with granted: false and 200 — not 404, because the caller is a webhook relay that must not retry forever over a charge that was never ours. |
GET | /health | Liveness + store/tenancy/billing-consume posture (never the DSN itself) |
GET | /metrics | Prometheus exposition |
Schemas
Error
| Field | Type | Description |
|---|---|---|
error | string | stable machine code (e.g. validation_error, not_found, unauthorized) |
reason | string | human-readable one-line explanation (no secrets/PII) |
detail | object | |
request_id | string |
Payment
Optional payment block — when present, the charge is DELEGATED to billing-metering /v1/charge (this capability re-implements no charging). A billing failure (402/422) is relayed and the gated action is NOT applied (fail-closed).
SEGMENT ROUTING (ADR-110 D3 + its E14 addendum): supply EITHER rail OR customer_country. With a country, BILLING-METERING selects the rail — India to razorpay (Vagary Labs LLP is the seller of record), rest of world to the Dodo Merchant of Record (the MoR becomes the seller, issues its own invoice, and our tax is suppressed). Entitlement does NOT contain that map: rail choice is a tax decision owned by the money layer, and a copy here would be a second authority for one fact (payment-rail-build-vs-buy.md §3 rules 4-5). NOTE for subscriptions: neither rail supports unattended renewal from a stored card, so each period needs a fresh customer-completed checkout — call billing-metering /v1/rails/select to read unattended_renewal_supported before designing a renewal.
| Field | Type | Description |
|---|---|---|
rail | string | billing-metering rail (paypal |
customer_country | string | ISO 3166-1 alpha-2 of the CUSTOMER — forwarded to billing-metering, which selects the rail. Unrecognised/absent resolves to the Merchant-of-Record rail (the fail-safe direction). |
select_rail | boolean | apply ADR-110 D3 routing with no country known (resolves to the MoR rail). Absent = false. |
amount_cents | integer | |
currency | string | |
recurring | boolean | |
idempotency_key | string |
AccessCheckRequest
| Field | Type | Description |
|---|---|---|
item_id | string | |
user_id | string | null/absent = anonymous consumer |
access_tier | string | the item's required tier (caller-supplied, I4) |
category | string | selects a per-category gate rule if one exists |
published_at | number | epoch seconds (time_based mode input) |
mode | string | optional per-request mode override |
AccessDecision
| Field | Type | Description |
|---|---|---|
has_access | boolean | |
reason | string | machine reason (free_content |
preview | boolean | may a preview be shown on denial |
required_tier | string | |
mode | string | |
remaining | integer | metered mode only — free views left after this one |
organization_id | string | |
item_id | string |
ViewRequest
| Field | Type | Description |
|---|---|---|
user_id | string |
Remaining
| Field | Type | Description |
|---|---|---|
user_id | string | |
limit | integer | |
used | integer | |
remaining | integer | |
reset_period | string |
GateConfig
| Field | Type | Description |
|---|---|---|
organization_id | string | |
enabled | boolean | |
free_article_limit | integer | |
reset_period | string | |
default_mode | string | |
time_window_hours | integer |
RuleCreate
| Field | Type | Description |
|---|---|---|
mode | string | |
required_tier | string | |
enabled | boolean |
Generated by scripts/gen-capability-docs.py from contracts/entitlement/v1/openapi.yaml — the contract IS the source of truth; edit the contract, not this page. The Edge route table section is synced from the live edge catalog snapshot (v1) — re-sync with python3 scripts/sync-edge-catalog.py.