Define an offering#
An Offering is the priced thing a Subscription binds to, and its spec is frozen the moment you apply it. This guide authors one that charges a recurring subscription fee: choosing the price and the billing cadence, deciding whether deleted Subscriptions should be held for a while, applying the manifest, and confirming the operator resolved the pricing. It is for a platform engineer with edit rights in the namespace the Offering will live in.
Prerequisites#
- The FinOps Operator is installed and its controller pod is running.
kubectledit permission in the namespace you are creating the Offering in, which isfinops-operator-systemthroughout this guide.- The price and the billing cadence agreed, because neither can be edited afterwards.
- A
SubscriptionChargeCollectionCostJob scheduled, if you want the fee to actually accrue into charge rows rather than only to be declared.
An Offering that charges a fee only needs no PriceBook. If you also want to bill measured consumption, read price resource usage, which covers the resourcePricing half and the active PriceBook it depends on.
Step 1: Settle every field before the first apply#
The Offering's spec carries one CEL rule, self == oldSelf, so every update is refused with offering spec is immutable. That covers omissions as much as changes: an Offering created without a compatibility block can never grow one, and a fee of 40.00 stays 40.00 for as long as the object exists. Offering explains why billing attribution requires that.
Two habits follow from it. Name the object for the terms it carries rather than for the service alone, so a later revision has an obvious name to take:
managed-postgres-v1 # original terms; existing Subscriptions stay on this
managed-postgres-v2 # revised terms; new Subscriptions point here
And check the manifest against the API server before you commit to it, which catches a period format or an out-of-range price without creating anything:
kubectl apply -f offering.yaml --dry-run=server
When terms do need to change later, the move is to create a second Offering and migrate new Subscriptions onto it; existing Subscriptions cannot be re-pointed, because offeringRef is immutable too. Replacing an Offering sets out that sequence.
Step 2: Write the subscription fee#
spec.pricing is required and both blocks inside it are optional. pricing.subscriptionFee is the one that accrues purely from a Subscription being active, with no reference to any workload:
| Field | Required | What it sets |
|---|---|---|
priceMicros |
yes | The price of one tick in micro-currency, minimum 1. 1,000,000 micros is 1.00 of the active PriceBook's currency, so 40000000 is 40.00. |
period |
yes | The tick length. Its format depends on tickAlignment, per step 3. |
tickAlignment |
yes | Where tick boundaries fall: ActivatedAt, HourBoundary, DayBoundary, or MonthBoundary. |
minPeriods |
no | Ticks that must elapse after activation before a deleted Subscription is released, minimum 1. See step 4. |
Leaving subscriptionFee out entirely is valid. An Offering that declares only resourcePricing bills consumption and nothing else, and an Offering with an empty pricing: {} is admitted, becomes ready, and charges nothing at all.
Step 3: Choose the tick alignment#
tickAlignment picks the grid the tick boundaries sit on, and a second CEL rule ties the format of period to it. A mismatch is rejected at admission with period must be a Go duration (e.g. '1h') for ActivatedAt alignment, or a plain integer for boundary alignments.
tickAlignment |
period format |
Examples | Where boundaries fall | Opening tick |
|---|---|---|---|---|
ActivatedAt |
An integer with the suffix h, m, or s |
1h, 30m, 90s |
activatedAt plus multiples of period, a grid private to each Subscription |
A full period, never prorated |
HourBoundary |
An integer count of hours | "1", "2", "6" |
A cluster-wide grid of period hours, 1 giving every clock hour |
Prorated against period hours |
DayBoundary |
An integer count of days | "1", "7", "30" |
The midnight UTC after activation, then every period days from there |
Prorated against period × 24 hours |
MonthBoundary |
An integer count of months | "1", "3", "12" |
The 1st of a month at 00:00 UTC, period months on from the activation month |
Prorated against a fixed 30 day 10 hour 30 minute month |
The accepted form under ActivatedAt is narrower than Go's duration syntax: 1h, 90m, and 3600s all pass, 1h30m does not. period is a string field in the schema, so quote it under the three boundary alignments, where an unquoted 1 is a YAML integer and the API server rejects it on type.
The choice is mostly about whether subscribers should tick together. ActivatedAt gives each Subscription its own cycle and never prorates, which keeps a per-subscriber invoice free of fractional opening charges. The boundary alignments line every Subscription in the cluster up with the same reporting windows, at the cost of a prorated opening tick and a closing tick that is always charged in full. Billing model has the tick arithmetic, the proration formula, and worked examples of both.
Step 4: Decide whether to hold deleted Subscriptions#
minPeriods is a cleanup guard and nothing else. It names a number of full ticks that must have elapsed since the Subscription's activatedAt before the SubscriptionChargeCollection job will take the finalizer off a Subscription that has been deleted. Until then the Kubernetes object stays visible with a deletion timestamp on it.
Warning
minPeriods is not a minimum commitment and adds no charge. Nothing about the fee changes while the guard is waiting: billing carries on tick by tick under the ordinary rules, and a Subscription deleted before the count is reached is never charged for the ticks that did not run. If you need a floor on revenue, that has to come from the price and the period you choose, not from this field.
Two conditions gate the finalizer, and both must hold. The tick count is one. The other is that charges have been finalized through the Subscription's last billable hour, so a deleted Subscription always survives long enough for its closing hours to reach the charge table. Both are evaluated by the collection job, which means a cluster with no SubscriptionChargeCollection CostJob scheduled never releases a deleted Subscription at all.
The count is measured with the same tick counter the fee uses, so it inherits the alignment. minPeriods: 24 against HourBoundary with period: "1" waits for 24 hourly boundaries after activation, which is a wait measured from activation rather than from the delete. A Subscription that had already been running for a month is released as soon as its closing charges settle.
Step 5: Apply the Offering#
A fee that starts when the subscriber does, charging 40.00 an hour with no proration, and holding a deleted Subscription until two hourly ticks have passed:
# offering-managed-postgres.yaml
apiVersion: finops.stakater.com/v1alpha1
kind: Offering
metadata:
name: managed-postgres-v1
namespace: finops-operator-system
spec:
pricing:
subscriptionFee:
period: 1h
tickAlignment: ActivatedAt
priceMicros: 40000000
minPeriods: 2
A fee aligned to the calendar instead, charging 500.00 a month on the 1st, with the opening tick prorated from the activation date:
# offering-platform-base.yaml
apiVersion: finops.stakater.com/v1alpha1
kind: Offering
metadata:
name: platform-base
namespace: finops-operator-system
spec:
pricing:
subscriptionFee:
period: "1"
tickAlignment: MonthBoundary
priceMicros: 500000000
kubectl apply -f offering-managed-postgres.yaml -f offering-platform-base.yaml
Step 6: Verify readiness and resolved pricing#
kubectl get offerings -n finops-operator-system
NAME READY
managed-postgres-v1 True
platform-base True
READY is status.ready, and for a fee-only Offering it turns True as soon as the requirement check passes and pricing resolves. The Ready condition then carries reason ValidationSucceeded, and a PricingResolved condition sits alongside it with reason PricingResolved.
The confirmation worth reading is status.resolvedPricing, which is what the Offering publishes to every Subscription bound to it:
kubectl get offering managed-postgres-v1 -n finops-operator-system -o yaml
status:
ready: "True"
resolvedPricing:
resolvedAt: "2026-08-12T09:15:04Z"
meters: []
subscriptionFee:
period: 1h
tickAlignment: ActivatedAt
priceMicros: 40000000
minPeriods: 2
resolvedPricing.subscriptionFee is the spec's block copied through verbatim, so seeing it there confirms the fee the operator will bill on rather than only the fee you asked for. meters is an empty list because this Offering prices no usage; price resource usage covers reading it when it is not.
Because the spec is immutable, this is computed once and then left alone. A resolvedAt that never moves is the expected state, not a stalled reconcile.
An Offering that stays READY False reports why on its conditions:
kubectl describe offering managed-postgres-v1 -n finops-operator-system
Reasons RequiredOfferingNotFound and RequiredOfferingNotReady point at a compatibility.requiredOfferings entry that does not resolve, and CircularDependencyDetected at a requirement loop. NoActivePricebook and PricebookParseFailed can only appear on an Offering that declares resourcePricing. Status conditions lists the full vocabulary.
Related guides#
- Price resource usage to add metered charges on top of the fee, or to bill usage without one.
- Subscribe to an offering to create the Subscription that accrues against these terms.
- Billing model for tick counting, proration, and the deactivation snap.
- Offering for immutability, the finalizer, and the replacement pattern in full.