Why pricing-as-code needed a small YAML file

Every SaaS billing setup eventually grows a second product: the pricing system.

Not the checkout page. Not the invoice email. The hidden one.

The one spread across Stripe products, Paddle price IDs, backend if statements, marketing copy, feature gates, trial logic, coupons, sales exceptions, and a few scripts nobody wants to rerun on Friday.

That is the part I wanted to make boring.

The research started with one question

The question was simple:

Is there already a standard YAML or JSON format for SaaS pricing that can be validated, reviewed, and deployed to payment providers?

I expected the answer to be "yes, obviously". Pricing is not exotic. SaaS teams keep building the same shape of system:

  • plans
  • prices
  • billing intervals
  • entitlements
  • usage limits
  • coupons
  • provider IDs
  • environment-specific sync

And yet the answer was mostly no.

There are tools. Some are good. Some are abandoned. Some solve adjacent problems. But I did not find a boring, provider-neutral file format that a product engineer could put in Git and treat as the source of truth for billing.

That gap is why Raterunner exists.

What already exists

Stripe has CLI fixtures. They are official, JSON-based, and useful for issuing a series of API requests. You can reference earlier responses inside later requests. Great for test data and repeatable setup.

But fixtures describe API calls, not your product model.

Terraform can manage Stripe too. The community lukasaron/stripe provider was a serious option when I first researched this, but it is now archived and points users to Stripe's official provider. Terraform brings state, planning, CI, and review. If your company already manages everything through Terraform, that may be the right answer.

But HCL is still infrastructure syntax. Your product manager is not going to review entitlement rules in Terraform with much confidence.

Tier.run was the closest match philosophically. It let teams define pricing in pricing.json, push it to Stripe, and use SDKs for access checks and metering. It had the shape I wanted: one pricing model, synced outward. But Tier was archived on January 2, 2024. A good reference, not something I want as the dependency at the center of a new billing workflow.

Paddle has a full API reference, SDKs, and a dashboard. I did not find a first-party config-as-code workflow like Stripe fixtures.

So the options looked like this:

OptionGood atWeak at
Stripe fixturesRepeatable Stripe API setupProduct-level pricing model
TerraformState, review, CI workflowsSimple SaaS pricing authoring
Tier.runPricing-as-code shapeArchived project
Custom scriptsFull controlEvery edge case becomes your problem

And custom scripts are where many teams land.

Not because they love writing billing scripts. Because the alternative is often worse.

The real problem is not creating a Stripe price

Creating a price is easy.

Keeping product behavior, billing provider state, and human decisions aligned is the problem.

Imagine a Pro plan change:

  • marketing changes the pricing page from $29 to $39
  • someone creates a new Stripe price
  • backend code changes the monthly API limit
  • sales still has an old promotion code
  • staging has different IDs from production
  • the customer portal still exposes the previous plan

Each single change is reasonable. Together they create drift.

Then someone asks the worst possible question:

What exactly did Pro include when this customer subscribed?

If the answer requires opening five dashboards, reading old Slack threads, checking app constants, and guessing which script ran last, the billing system does not have a source of truth. It has folklore.

That is funny until invoices are wrong.

The file should describe the product, not the provider

The first useful design decision was to stop copying provider objects.

A pricing file should not be "Stripe, but in YAML". Stripe already has an API. Paddle already has an API. Terraform already has provider resources.

The file should describe the SaaS product in terms the team actually uses:

yaml
version: 1plans:  starter:    name: Starter    description: For small teams validating the product    prices:      monthly:        amount: 1900        currency: usd        interval: month    entitlements:      projects: 3      api_calls_per_month: 10000      audit_log_days: 7  pro:    name: Pro    description: For teams running billing in production    prices:      monthly:        amount: 4900        currency: usd        interval: month      annual:        amount: 49000        currency: usd        interval: year    entitlements:      projects: 25      api_calls_per_month: 250000      audit_log_days: 90      sso: truepromotions:  launch_50:    percent_off: 50    duration:      months: 3    applies_to:      plans:        - pro

This is not enough to bill a customer by itself. Good. It should not pretend to be.

It is the contract.

From that contract, tools can do the provider-specific work:

  • create or map Stripe products and prices
  • create Paddle products and prices
  • validate that annual prices match the expected discount rule
  • generate entitlement constants for the app
  • produce a dry-run diff before writing anything
  • fail CI when a plan references an unknown entitlement

The provider is an output. Not the place where the product model is invented.

Validation matters before sync

The boring version of this workflow starts before any API request:

bash
raterunner validate raterunner/billing.yaml

That command should catch mistakes like:

  • missing currency
  • duplicate plan IDs
  • prices without billing intervals
  • a promotion that points to a deleted plan
  • an entitlement value with the wrong type
  • a production sync without provider mapping

Then comes the dry run:

bash
raterunner plan --provider stripe --env sandbox raterunner/billing.yaml

The output should be something a reviewer can understand:

text
Plan: pro  price monthly    amount: 2900 -> 4900    action: create new provider pricePromotion: launch_50  action: create coupon and promotion codeNo changes:  plan starter  entitlement audit_log_days

Only after that should the tool write to the provider:

bash
raterunner apply --env sandbox raterunner/billing.yaml

The dry run is the habit that changes the team's behavior. It turns "someone clicked around in Stripe" into "we reviewed a billing diff", which is a different conversation entirely when the change can affect invoices, access, and support tickets.

Entitlements are where pricing bugs become product bugs

Prices get most of the attention because money is visible. Entitlements cause the quieter failures.

A customer upgrades to Pro and still cannot invite the tenth teammate. A beta account keeps enterprise limits after the promotion ends. The pricing page says "90 days of audit logs", but the API still cuts off at 30 because that number lives in a backend constant written six months ago. Support sees the invoice, engineering sees the feature gate, finance sees the plan name, and none of those systems quite agree.

That is why I do not want entitlements treated as notes attached to pricing. They are part of the billing contract.

The file has to say what a plan unlocks in the same place it says what the plan costs:

yaml
entitlements:  projects:    type: integer    description: Active projects per workspace  audit_log_days:    type: integer    description: Audit log retention window  sso:    type: boolean    description: SAML or OIDC single sign-onplans:  starter:    entitlements:      projects: 3      audit_log_days: 7      sso: false  pro:    entitlements:      projects: 25      audit_log_days: 90      sso: true

Once entitlements are typed and versioned, the app can stop guessing. The API can check audit_log_days from generated constants or a synced config endpoint. The pricing page can read from the same source. CI can reject a plan that assigns "yes" to a boolean entitlement or references api_requests when the schema only defines api_calls_per_month.

This is the part dashboards rarely control well. A payment provider can know that a customer pays for Pro. It usually cannot tell your product what Pro means without your code adding another translation layer.

Raterunner should make that translation boring enough to review.

Why not just use Terraform?

Use Terraform if it matches your team.

I am not interested in pretending Terraform is bad. It is good at the thing it was built for: declared resources, state, plans, and apply. If your company already keeps provider configuration in Terraform and everyone involved can review HCL, that is a valid workflow.

The problem I kept running into was product ownership.

Pricing is not only infrastructure. It is product policy.

The people who care about pricing include engineers, founders, product managers, support, finance, and sometimes sales. A YAML file with plans and entitlements is easier to review across that group than provider-shaped HCL.

Terraform can still be part of the implementation. But I do not want Terraform to be the product catalog.

Why not just use Stripe fixtures?

Fixtures are useful, especially for tests and setup flows.

But a fixture is a list of requests:

json
{  "fixtures": [    {      "name": "product_pro",      "path": "/v1/products",      "method": "post",      "params": {        "name": "Pro"      }    }  ]}

That is honest and practical. It is also Stripe-shaped from line one.

If the problem is "seed Stripe with test data", fixtures are fine. If the problem is "make the pricing model reviewable and portable", fixtures are too low-level.

The hard parts do not disappear

A small YAML file is not magic. It moves complexity to a place where you can see it.

There are still annoying questions:

  • Should provider prices be immutable?
  • How do you map sandbox IDs to production IDs?
  • What happens when a provider supports a feature another provider does not?
  • Should the file include tax behavior, customer portal settings, and webhooks?
  • How much state should the tool store locally?
  • How do you represent one-off enterprise contracts?

These are not reasons to keep pricing in dashboards. They are reasons to make the model explicit.

When the model is explicit, the team can decide where the boundary is. For example, maybe the core file owns plans, prices, entitlements, and promotions, while webhooks stay in Terraform. Fine. Write that down. Make it consistent.

The mistake is letting the boundary be whatever the last person clicked.

Where Raterunner fits

The version I wanted was less dramatic than the usual "new platform" story.

Keep Stripe. Keep Paddle if that is your provider. Keep Terraform for the resources that belong there. I just want one boring file that says what the product sells, what each plan unlocks, and which provider objects represent it in each environment.

That file becomes the thing people argue over in a pull request. Not a screenshot from a dashboard. Not a half-remembered Slack decision. A diff.

After that, the tool can do ordinary tool work:

  • pricing page data
  • entitlement checks
  • SDK constants
  • CI validation
  • provider migration reports
  • release notes for pricing changes

I care about the first two more than the rest. If the public pricing page says Pro includes SSO and the backend says it does not, the provider sync is not the bug. The product contract is broken.

Pricing changes are product changes.

They need review, validation, history, and rollback. Dashboards are still useful, provider APIs are still useful, and Terraform is still useful, but none of them answered the question I cared about when the research started:

Where is the product's billing model?

For Raterunner, the answer starts with a file that ties the price, the entitlement, and the provider mapping together before anybody ships the change.

The tool behind this work is Raterunner, an open-source pricing management project for SaaS billing configuration. Star it if the problem feels familiar.

Sources checked while writing: Stripe CLI fixtures, Tier.run repository, community Terraform Stripe provider archive, Stripe Terraform provider, and Paddle API reference.