Plans, features & quotas
Declare plan-gatable features, metered quotas with enforcement policies, and one-off purchasable products — the vocabulary the Control Plane binds prices and limits to.
Your manifest declares what a plan can sell
An app does not decide, in code, what each customer's plan includes. It declares the vocabulary — the features a plan may switch on, the metered quotas it may cap, and the one-off products a customer may buy — in app.manifest.yaml. The Control Plane binds concrete values to that vocabulary per plan and per org: which features are on, what each quota's limit is, how the limit is enforced. Your manifest never carries a per-plan number; it carries the definition the number attaches to.
The shape is the products-and-prices split behind Stripe Billing: you declare the catalogue, the platform binds prices, meters usage and enforces limits.
spec.features is the only required section of the whole spec. Everything else — routes, events, config, products — is optional; a valid manifest must at least declare its plan features (even an empty capability set is a deliberate statement).
Three concepts, three shapes:
| Concept | Section | What it is |
|---|---|---|
| Feature | spec.features[] | A plan-gatable capability or limit: a boolean toggle, a string choice, or an integer number. Per-plan value bound in CP. |
| Quota | feature.quota | Metering + enforcement for an integer feature. The feature's default is the limit; the quota block says how usage is counted against it. |
| Product | spec.products[] | A one-off purchasable SKU (a credit pack, a top-up). CP runs a Stripe one-time checkout and calls your fulfilment endpoint on success. |
Features are definitions, not values
Every feature is a definition CP renders into the plan-builder and the public /pricing table. The manifest fixes the feature's shape and a platform default; the plan that a customer subscribes to overrides that default in CP.
| Field | Required | Purpose |
|---|---|---|
code | yes | Stable identifier (^[a-z][a-z0-9_.]*$), e.g. egav.schemas.count. Referenced by feature gates and quotas. |
name | yes | Human label in the CP plan-builder. |
description | yes | What the feature controls. |
ui_hint | yes | Operator-facing note on how toggling it behaves. |
type | yes | One of boolean, integer, string. Fixes the widget and the kind of value a plan binds. |
unit | no | Unit label for integer features (records, GB, webhooks). Display + quota conversion. |
default | no | Platform default when a plan does not override. For an integer quota feature, this is the limit. |
marketing_label | no | Sales-facing label on the public /pricing comparison table. Falls back to name. |
marketing_description | no | Sales-facing tooltip / detail on /pricing. Falls back to description. |
quota | no | Metering declaration. Valid only when type: integer (see below). |
features:
- code: myservice.example.enabled # boolean toggle
name: Example feature
description: Enable the example module.
ui_hint: Toggle ON to enable the example module.
type: boolean
default: true
marketing_label: Example feature
marketing_description: Enable the example module.
- code: myservice.example.mode # string choice
name: Example mode
description: A string-typed feature offering a fixed set of modes.
ui_hint: Select the operating mode.
type: string
default: standard
- code: myservice.example.count # integer limit + quota
name: Example record limit
description: Maximum number of example records per tenant (metered quota).
ui_hint: Cap on example records.
type: integer
unit: records
default: 100
quota:
source: cumulativeAbooleanorstringfeature is a pure entitlement — it gates behaviour but is never metered. Only anintegerfeature can carry aquota.
Quotas meter an integer feature
Add a quota block to an integer feature and CP starts tracking reported usage against the feature's default limit. The block does not restate the limit — the feature's default is the number — it tells CP how to aggregate usage and, optionally, what to do when usage reaches the limit.
| Field | Required | Purpose |
|---|---|---|
source | yes | How usage aggregates. cumulative sums all deltas into one counter; daily resets every UTC day; per_request checks the limit per call without aggregating. |
metric_code | no | The metric identifier usage producers and snapshot queries use. Defaults to the feature's code. |
multiplier | no | Converts the feature's limit into the unit the producer reports (integer ≥ 1, default 1). Example: unit: GB, default: 5, producer reports bytes, multiplier: 1073741824. |
name | no | Display label on the quota dashboard. Defaults to the feature's name. |
policy | no | Enforcement at the limit (default notify). See the enforcement table. |
grace | no | Soft buffer past the limit before a policy enforces. |
overage | no | Metered-overage pricing. Required when policy: overage. |
self_service | no | Which at-the-limit choices the portal offers the customer. |
notify | no | Threshold / audience / channel policy for quota warnings. |
Most services declare only source — that is a full, valid quota. When the producer reports in a different unit than the customer-facing limit, add metric_code and multiplier:
features:
- code: comments.attachments.storage_gb
name: Comment attachment storage (GB)
description: Cumulative size of all uploaded comment attachments.
ui_hint: Cap on comment attachment storage.
type: integer
unit: GB
default: 0
quota:
# Limit is expressed in GB; the producer reports bytes.
metric_code: comments.attachments.bytes
source: cumulative
multiplier: 1073741824Enforcement, grace, and overage
policy is the platform default for what happens when a tenant reaches the limit. It is a default — CP lets an operator override it per plan and per org/instance. Omitting policy preserves the legacy observe-and-warn behaviour.
policy | At the limit |
|---|---|
allow | Pure metering. Count usage, never block or charge. |
notify (default) | Observe and warn. Fires the notify thresholds; never blocks or bills. |
block | Reject the next consume once the limit (plus any grace) is reached. |
overage | Meter past the included amount and bill the excess. Requires an overage block. |
suspend | Disable the capability after a grace/dunning window elapses. |
grace widens the point at which block / overage bite, and sets the dunning window for suspend:
grace field | Default | Purpose |
|---|---|---|
units | 0 | Extra absolute units allowed past the limit before enforcement. |
pct | 0 | Extra units as a percentage of the limit. |
period | P0D | ISO-8601 duration of the grace / dunning window (e.g. P7D) used by suspend. |
overage is mandatory when policy: overage. The first included units are free; only the excess is metered and billed:
overage field | Default | Purpose |
|---|---|---|
price_ref | null | Names a plan_prices row carrying the metered provider price (Stripe price_…). |
hard_ceiling | null | Absolute maximum even under overage. null = unbounded. |
self_service chooses which recovery actions the portal offers the customer at the limit; notify controls the warnings:
self_service field | Default | Offer to |
|---|---|---|
upgrade | true | Move to a higher plan. |
enable_overage | false | Opt into metered overage billing. |
request_increase | true | Ask the operator for a manual limit bump. |
notify field | Default | Purpose |
|---|---|---|
thresholds_pct | [80, 100] | Percentages of the limit at which a warning fires. |
audiences | [client_admin, operator] | Who is notified. client_admin / client_billing are the tenant; operator is the platform side. |
channels | [email] | Delivery channels for the warning. |
features:
- code: myservice.api.calls_per_day
name: API calls per day
description: Daily API call allowance for this tenant.
ui_hint: Cap on API calls per day.
type: integer
unit: requests
default: 100000
quota:
source: daily
policy: overage
grace:
pct: 10
period: P3D
overage:
price_ref: api_calls_overage
hard_ceiling: 500000
self_service:
upgrade: true
enable_overage: true
request_increase: false
notify:
thresholds_pct: [75, 90, 100]
audiences: [client_admin, client_billing, operator]
channels: [email]Features and quotas are declared here but enforced at request time by therequire_featureandrequire_quotagates. See Auth & the five gates for how a route composes them (feature not in plan →403; over quota →429).
Products are one-off purchases
Where a feature is recurring plan entitlement, a product is a single purchase — a credit pack, a storage top-up, a one-time add-on. You declare the SKU and a fulfilment endpoint; CP owns the money. On a customer purchase, CP runs a one-time Stripe checkout and, on successful payment, POSTs to your fulfillment.handler_endpoint. The declaring app never touches Stripe, never sees a card, and never verifies a payment — it only grants what was bought.
| Field | Required | Purpose |
|---|---|---|
code | yes | Unique SKU code within the app (^[a-z][a-z0-9_]*$, ≤ 100). |
name | yes | Customer-facing product name (≤ 200). |
description | no | Longer blurb shown in the catalog (≤ 1000). |
kind | no | one_time (default) or recurring_add_on. Recurring add-ons use the subscription purchase flow, never the one-time checkout route. |
price_cents | yes | Price in the minor unit of currency (e.g. cents). Active products require an integer > 0; an inactive placeholder may use 0 until its commercial ticket sets the price. |
currency | no | ISO-4217 lowercase code (^[a-z]{3}$, default usd). |
active | no | Whether the SKU is purchasable (default true). |
recommended | no | Badges this SKU as the catalog's "Best value" default (default false). Seeds the CP product on first sync; an operator's later CP-side toggle survives re-publish. |
resource | no | Provision a generic platform component before fulfillment. Its component_type is a safe manifest code such as db or compute; CP records the resulting resource in a tenant binding. |
fulfillment | yes | Where CP delivers the purchase (see below). |
The fulfillment block names the internal endpoint CP calls on settled payment:
| Field | Required | Purpose |
|---|---|---|
handler_endpoint | yes | Where CP POSTs on success: /v1/<app>/internal/…, app://<target>/…, or internal://<target>/…. Must resolve to an internal-tier, x-api-key-gated route. |
params | no | Static body merged into the fulfilment POST (e.g. {credits: 1000}). CP injects org_guid, payment_ref, and reason — do not declare those keys. |
products:
- code: ai_credits_1000
name: 1,000 AI Credits
description: Starter top-up — 1,000 AI credits added to your org pool.
kind: one_time
price_cents: 1000
currency: usd
fulfillment:
handler_endpoint: /v1/egav-ai/internal/credits/grant
params:
credits: 1000
source: purchase
- code: ai_credits_5000
name: 5,000 AI Credits
description: 5,000 AI credits — best value for active teams (10% off).
kind: one_time
price_cents: 4500
currency: usd
recommended: true # badged "Best value" in the catalog
fulfillment:
handler_endpoint: /v1/egav-ai/internal/credits/grant
params:
credits: 5000
source: purchaseMake the fulfilment handler idempotent. CP injects apayment_refon every call; dedupe on it so a retried delivery returns the existing ledger result instead of creating another grant. The reserved keys (org_guid,payment_ref,reason) arrive from CP, not from yourparams.
Products backed by provisioned resources
Set resource.component_type when fulfillment depends on a platform resource, not only an app-local ledger entry:
products:
- code: reporting_database
name: Reporting database
kind: recurring_add_on
price_cents: 2500
currency: usd
resource:
component_type: db
fulfillment:
handler_endpoint: /v1/example/internal/resource-claimsCP provisions and records the tenant binding first. A tenant binding is CP's durable record that links an organization, app instance, component type, and provisioned resource. CP then adds a resource claim — the generation-specific grant or revoke request that the provider app must acknowledge — to the existing fulfillment request. The resource_claim object carries operation (grant or revoke), monotonic generation, stable claim_ref, binding_guid, component_type, resource_code, and an optional opaque handle_ref. It does not include credentials, DSNs, or an app-specific database-row shape.
Use the SDK's ResourceClaimRequest / ResourceClaimResponse models. Persist the app-local result idempotently and acknowledge the exact claim_ref only after that commit. When CP retries the same claim_ref, return the same acknowledgement rather than creating another entitlement. Non-2xx or malformed acknowledgements leave claim_status, claim_reason, and claim_attempts available for inspection and cause the fulfillment worker to retry the durable claim. Test both sides with check_resource_claim_conformance(manifest, implemented_claim_handlers=...); the gate rejects both a declared-but-missing handler and a mounted-but-undeclared resource-claim handler.
A binding represents one resource-ownership lifecycle, not a purchase ledger. CP refuses a second payment or grant while its current grant is pending, failed, or active; retry uses the same generation and payment reference. A later grant is allowed only after the app has acknowledged the binding's revoke. Ordinary repeat-purchase credit packs therefore remain non-resource products.
The one-time checkout path durably reserves the selected binding generation before redirecting the customer. The reservation is the provider idempotency key and must be echoed in signed provider metadata before CP records the invoice or sends the grant. An ambiguous provider error leaves the reservation open so retry reuses the same key; only a definitive rejection or signed failed/expired event cancels it. A signed asynchronous-payment state disables the local opening deadline until the provider reports success or failure. Deletion is blocked while the reservation is genuinely open.
Do not use lifecycle_hooks for this contract. They are instance lifecycle notifications; paid resource ownership belongs to the durable product fulfillment path.
Typed handler integration boundary
The existing Data app shows what accepting a database grant actually does. Source reference: egav/backend/app/api/v1/egav/routes/internal_storages.py at commit 0a6360eb19432a3c1d3ac600794c09b68f3bbfd5, apply_resource_claim. Its app-owned EGAVResourceClaimRequest uses the SDK's ResourceClaim type and forbids unexpected envelope fields.
The following is an abridged source excerpt, not a deployable handler. Comments mark omitted validation and replay/conflict branches; retain those branches when studying the complete implementation.
@router.post("/resource-claims", response_model=ResourceClaimResponse)
async def apply_resource_claim(
body: EGAVResourceClaimRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ResourceClaimResponse:
claim = body.resource_claim
payload_hash = _claim_payload_hash(body)
binding_guid = str(claim.binding_guid)
# Omitted: reject non-db claims, primary storage, and invalid handle GUIDs;
# derive tenant_database_guid only after those checks.
await assert_org_owns_instance(
org_guid=str(body.org_guid),
instance_guid=request.headers.get("x-app-instance-guid"),
app_code="egav",
authoritative=True,
)
async with async_transaction_context(db):
await _acquire_resource_claim_locks(
db,
binding_guid=binding_guid,
resource_code=claim.resource_code,
)
row = await db.scalar(
select(EGAVDataStorage)
.where(EGAVDataStorage.resource_binding_guid == binding_guid)
.with_for_update()
)
# Omitted: stored identity/generation/payload conflict checks,
# stale and identical replay handling, and active-code ownership checks.
if row is None:
row = EGAVDataStorage(
code=claim.resource_code,
name=f"Extra ({claim.resource_code})",
tenant_database_guid=tenant_database_guid,
resource_binding_guid=binding_guid,
)
db.add(row)
# Omitted: reject code changes and revocation of referenced storage.
row.tenant_database_guid = tenant_database_guid
row.resource_binding_guid = binding_guid
row.resource_claim_generation = claim.generation
row.resource_claim_ref = claim.claim_ref
row.resource_claim_payload_hash = payload_hash
if claim.operation == "revoke":
row.soft_delete("system")
else:
row.is_deleted = False
row.deleted_at = None
row.deleted_by = None
await db.flush()
return ResourceClaimResponse(
claim_ref=claim.claim_ref,
status="acknowledged",
)This is commit-before-ack: flush() alone is not a commit; successful exit from async_transaction_context commits before the response is returned. The actual app effect is a tenant-local EGAVDataStorage row. Its persisted binding identity, generation, claim reference and payload hash survive process restarts. Advisory locks and row locks serialize concurrent delivery. The omitted branches acknowledge an identical replay without a second effect, reject changed payloads at the same generation, and prevent an older delivery from undoing newer state. A failed transaction must not return an acknowledgement.
Before mounting your own declared endpoint, require both internal-key authentication and a verified service identity attributed to the exact control-plane caller, including its instance identity. A valid internal key alone does not establish that caller. The existing AI dependency require_control_plane_caller in egav-ai/backend/app/api/deps.py illustrates that additional caller check; it is not automatically applied by the excerpt.
Validate tenant scope authoritatively: the organization must own the selected app instance, and the database session must belong to that instance. Neither the body org_guid nor X-App-Instance-Guid is authority by itself. The source example uses the existing internal API-key gate plus assert_org_owns_instance; do not expose this excerpt as an unauthenticated public route. Known limitation of the source example: its generic internal API-key gate does not itself enforce the exact Control Plane caller. The excerpt demonstrates persistence, not complete caller authorization. Reject a missing instance header, reject a foreign organization/instance pair, and fail closed if the ownership authority is unavailable.
Use your app's existing route/service/CRUD transaction boundaries and durable storage, not an in-memory set of claim references. Keep the receipt and local effect atomic. Validate supported components and opaque handles, reject conflicting identity/payload changes, and return the exact SDK response only after persistence. The shared request model preserves app-owned fulfillment fields; your app must separately validate those fields with a strict schema: an app-owned Pydantic model with ConfigDict(extra="forbid"), the typed envelope/app fields, and resource_claim: ResourceClaim. Merely parsing ResourceClaimRequest does not reject unknown app-owned fields. Data's storage table and GUID handle format are an example, not a requirement for other apps or something to add to Control Plane.
Offline contract check
With the Python SDK already installed in an isolated development environment, save this as check_resource_claim.py and run python check_resource_claim.py. It uses only SDK validation and conformance APIs: no HTTP request, database, provisioning, credentials or payment.
from egav_app_sdk import check_resource_claim_conformance
from egav_app_sdk.resource_claims import (
ResourceClaimRequest,
ResourceClaimResponse,
)
from pydantic import ValidationError
endpoint = "/v1/example/internal/resource-claims"
manifest = {
"spec": {
"products": [{
"code": "reporting_database",
"resource": {"component_type": "db"},
"fulfillment": {"handler_endpoint": endpoint},
}],
},
}
empty_manifest = {"spec": {"products": []}}
# This minimal dictionary tests the conformance helper, not full manifest validity.
assert check_resource_claim_conformance(
manifest, implemented_claim_handlers={endpoint},
) == []
assert check_resource_claim_conformance(
manifest, implemented_claim_handlers=set(),
) == [f"resource-backed product declares unimplemented handler: {endpoint}"]
assert check_resource_claim_conformance(empty_manifest,
implemented_claim_handlers={endpoint},
) == [f"resource-claim handler has no resource-backed product declaration: {endpoint}"]
binding = "00000000-0000-4000-8000-000000000001"
payload = {
"org_guid": "00000000-0000-4000-8000-000000000002",
"payment_ref": "offline-example",
"reason": "Offline contract check",
"resource_claim": {
"operation": "grant",
"generation": 1,
"claim_ref": f"{binding}:1:grant",
"binding_guid": binding,
"component_type": "db",
"resource_code": "reporting_database",
"handle_ref": "00000000-0000-4000-8000-000000000003",
},
}
request = ResourceClaimRequest.model_validate(payload)
assert request.idempotency_key == f"{binding}:1:grant"
# Validate an example response shape only; no resource has been claimed.
response = ResourceClaimResponse(
claim_ref=request.idempotency_key, status="acknowledged",
)
assert response.claim_ref == request.resource_claim.claim_ref
bad_payload = {
**payload,
"resource_claim": {**payload["resource_claim"], "claim_ref": "wrong"},
}
try:
ResourceClaimRequest.model_validate(bad_payload)
except ValidationError:
pass
else:
raise AssertionError("mismatched claim identity was accepted")
print("Offline contract controls passed; no resource was provisioned or claimed.")The positive control supplies a matching declaration and handler path; the two negative controls remove each side in turn. For your own app, validate the full manifest first and pass handler paths discovered from its real mounted routes, not a second copy of the declaration. TypeScript builders have the equivalent checkResourceClaimConformance, parseResourceClaimRequest and parseResourceClaimResponse exports in the TypeScript SDK.
This does not prove authentication, persistence, retry delivery, or a real second app claiming an extra database. Before go-live, test your actual handler with authenticated and rejected callers, cross-tenant rejection, concurrent/identical replay, conflicting payloads, stale generations, and commit failure followed by retry. Verify the app-local effect and durable receipt together, then verify a real provisioned database reaches a second app without Control Plane source changes. That integrated proof is separate from this offline check.
Compatibility, rollout, and rollback
Resource claims require Python egav-app-sdk 0.20.0. TypeScript manifest consumers use egav-app-sdk-ts 0.41.0. The platform's own services already support claims, so a provider app needs only the SDK versions above.
Roll out a provider before the Control Plane can send claims to it:
- Install SDK 0.20.0 in the provider service.
- Apply the provider's receipt migration and load its claim handler.
- Verify the provider route accepts an SDK-validated request and returns an
acknowledgement for the same claim_ref.
- Apply the Control Plane binding migration, load the CP web processes and
worker, then publish the updated manifest.
This order keeps CP from dispatching a claim to a provider that has neither the receipt table nor the handler contract. Rollback is also fail-closed: the Data and AI services' downgrade guards refuse to drop receipt tables while any durable claim provenance remains, including revoked or soft-deleted receipts. The CP downgrade guard likewise refuses destructive binding/product cleanup while durable claim or resource-backed state remains. Retire or migrate that state through the owning service before retrying a downgrade; do not delete receipt or binding rows merely to make the migration proceed.
Defining features and quotas — best practices
The tables above are the reference; this is the judgement. A few rules keep a plan model clean as it grows.
Naming and shape.
- Treat a feature
codeas permanent.{service}.{area}.{capability}(e.g.egav.schemas.count) is the stable key that plan bindings, feature gates and quotas all point at — renaming or deleting a live code orphans every binding and every gate that names it. To retire one, mark it deprecated and add a new code, then migrate. - Pick the
typeby the decision, not the value.booleanfor an on/off entitlement;stringfor a fixed set of tiers or modes (spell the allowed values out indescription/ui_hint);integerfor anything countable or limitable. Never model on/off as aninteger0/1 — a boolean gates more clearly and cannot be metered by accident. - Default to the lowest tier.
defaultis what a plan gets before it overrides — the free or entry value. For an integer quota feature the default is the free-tier limit; do not set it to your top tier and hope every plan lowers it. - One feature, one decision. Several small features beat one overloaded one — each maps to a single gate call and one column on the pricing table. Give anything shown on
/pricingits ownmarketing_label/marketing_descriptionso buyer copy is never your internalcode.
Metering and enforcement.
- Match
sourceto how usage accrues.cumulativefor a stock that only grows (total records, stored GB) — one lifetime counter.dailyfor an allowance that refills (calls per day) — resets each UTC day.per_requestfor a per-call ceiling (max items in one batch) — checked, never aggregated. - Keep the limit in human units. Express
default+unitin what the customer reads (GB, not bytes) and usemultiplierso the producer can report raw units. - Start soft, tighten deliberately.
notify(the default) observes and warns while you learn real usage; graduate toblock,overage, orsuspendonce you know the numbers.allowis pure metering with no enforcement;overageneeds anoverage.price_ref;suspendneeds agrace.perioddunning window. - Leave a buffer before you block. A small
grace(pctorunits) stops a customer being hard-stopped the instant they touch the line. Becausepolicyandgraceare platform defaults, an operator can loosen them for a generous plan without a re-publish. - Let customers self-recover. Keep
self_service.upgradeandrequest_increaseon so a capped tenant resolves it themselves — fewer support tickets; turn onenable_overageonly once you have priced it. - Warn early, warn the right people. The default
[80, 100]thresholds are a floor — add90for an expensive quota — and notifyclient_admin(andclient_billingwhen money is involved) so the tenant acts before the wall. - Report usage idempotently. For a
cumulativequota especially, dedupe usage deltas (on an event id) so an at-least-once producer retry never double-counts.
Feature, quota, product, or config?
- Recurring entitlement or limit → a feature (add a
quotawhen it is a metered integer). - One-off purchase (credit pack, top-up) → a product.
- An operator knob a customer's plan should not change (a TTL, a retry count, a rotation policy) → the config block, not a feature.
What a buyer actually sees
Declaring a feature entitles it. It does not advertise it, and the two are separate decisions on purpose.
A published catalogue is an engineering surface: retention windows, dispatch rates, payload ceilings, instance classes. Most of it is meaningless to somebody deciding whether to buy, and rendering all of it produces a pricing page nobody can read. So the public page draws from an opt-in subset.
Two gates control it, and both default to off:
show_on_pricing— whether a feature appears on the public pricing
comparison. Declare it per feature in your manifest, or let operators set it in the Control Plane under the app's feature catalogue.
plans.is_public— whether a plan appears on the public pricing page at all.
This is deliberately not the same flag as is_active. A plan can be live, provisioning correctly, and still have no business being quoted to a visitor: a plan built for one customer, a plan mid-assembly, or a plan created by an automated test.
show_on_pricing has three states, not two
- code: support.tickets.enabled
name: Ticketing
description: Enable the ticket desk.
ui_hint: Toggle ON to enable ticket workflows.
type: boolean
default: true
show_on_pricing: true # put it in front of buyers| Your manifest says | What happens |
|---|---|
| nothing | The operator's choice stands, and survives every republish. |
true | The feature appears on the public comparison. |
false | It is removed, overriding the operator. |
Leaving it out is not the same as false. Most manifests should leave it out: which features sell is an editorial decision that changes far more often than your app does, and operators own it. Reach for an explicit value when the answer belongs in your source control — true when a feature ships as a selling point, false when you want it withdrawn from the page in the same commit that withdraws it from the product.
Three more plan fields drive how a published plan is presented, so none of it needs a frontend change:
| Field | Effect on the public page |
|---|---|
tier | Ordering, and which call to action the card gets — a trial self-serves, an enterprise plan starts a conversation. |
badge | The highlight ribbon, e.g. "Most popular". Empty means no ribbon. |
trial_days | The free-trial line on the card. Null means the plan has no trial. |
Prices come from the plan's price points. Declare both a monthly and an annual point and the page offers a billing switch, showing the annual figure as its monthly equivalent alongside the amount actually billed. If the annual price is not cheaper than twelve monthly ones, no discount is advertised.
Writing copy a buyer can use
marketing_label and marketing_description are what the public page shows, falling back to name and description when they are not set. Treat them as genuinely different text rather than a copy of the engineer-facing fields — duplicating the technical wording defeats the fallback and puts sentences like "soft-enforced via the event bus" in front of a prospect.
State the outcome, in the buyer's words, in one line:
- code: cp.members.count
name: Organization member seats # engineer-facing
description: Maximum number of user accounts in the org.
marketing_label: Team members # buyer-facing
marketing_description: People who can sign in to your workspace, across every role.
type: integer
default: 10Where prices and values are bound
Nothing in this section carries a per-plan value. The manifest fixes the vocabulary; the Control Plane binds the numbers:
- Feature values — which features are on and each integer limit — are set per plan and per org in CP, not in the manifest.
- Quota enforcement —
policy, grace, overage pricing — ships a platform default here and is overridable per plan and per org/instance in CP. - Product prices are operator-tunable: change
price_centsand re-publish, or let an operator adjust in CP; therecommendedtoggle survives re-publish.
Re-publishing the manifest updates the definitions; it does not reset the bound values an operator has tuned. To expose a new operator-facing knob that is not a plan feature — a TTL, a retry count, a rotation policy — declare it in the config block, not as a feature.
How an operator binds them
An operator does this in the Control Plane console (Catalog & billing → Plans), or through the same admin API with an operator token. A plan is versioned: every change sends base_version, the plan's current catalog_version, and a stale one is refused with 409.
| Step | Call (/v1/control-plane/cp/...) |
|---|---|
| Create the plan | POST /plan-definitions/ with code, name, tier (a tier code such as starter, pro, business) |
| Include your app | PUT /plan-definitions/{code}/applications/{app_code} with app_version |
| Turn a feature on or off | PUT /plan-definitions/{code}/features/{app_code}/{feature_code} with is_enabled (and config for a limit) |
| Price an interval | PUT /plan-definitions/{code}/prices/{interval} with interval_days and price_cents |
| Grant or withdraw a feature for one organization | POST /organization-plan-overrides/ with organization_id, subscription_id, app_code, feature_code and is_enabled; change it later with PUT /organization-plan-overrides/{id} |
An override belongs to the organization's subscription, not to the organization, so it does not follow the organization to a new plan. Your app's feature gate reads the organization's effective features from CP, so a change shows up on the next requests; on a local stack it took under a second.
To put a customer on a plan without self-serve sign-up, the operator onboards it: POST /v1/control-plane/cp/subscriptions/onboard with org_name, org_slug, org_subdomain, admin_email, plan_code and interval. org_subdomain is lowercase letters and digits only, with no hyphens. The slug may have hyphens. The organization is provisioned into every app on the plan, which calls each app's provision endpoint (see Provisioning), and the admin is emailed an invitation.
Operator access on a local platform stack
The steps above need an operator account. On a local platform stack:
- The first operator is seeded. Its email is
SEED_EMAILinegav-control-plane/backend/.env. Set its password with Forgot password on the local console's sign-in page (https://cp.your-domain.example:3202/login). The reset email lands in Mailpit (http://127.0.0.1:8025). The console then asks you to enrol MFA, because operators must use it. - Each person gets their own operator. MFA is enrolled per account, so a second developer can't use the seeded operator once someone has enrolled it: its password reset then stops at the MFA step. Sign in as an existing operator and invite them from Security & access → Operators → Invitations (or
POST /v1/control-plane/cp/operator-invitations/withemailandrole).adminis enough to manage plans, features, prices, overrides and onboarding. The invitee accepts from Mailpit and enrols their own MFA. - Scripts sign in like the console. The operator flow is the headless one on the
/cp/oidc/...tier:/cp/oidc/authorize/headless, then/cp/oidc/headless/credentials, then/cp/oidc/headless/mfawith a current TOTP code (or/cp/oidc/headless/mfa/enrolland/enroll/confirmthe first time), then/cp/oidc/token. The operator access token lasts 15 minutes.