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:
| Option | Good at | Weak at |
|---|---|---|
| Stripe fixtures | Repeatable Stripe API setup | Product-level pricing model |
| Terraform | State, review, CI workflows | Simple SaaS pricing authoring |
| Tier.run | Pricing-as-code shape | Archived project |
| Custom scripts | Full control | Every 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
$29to$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:
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: - proThis 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:
raterunner validate raterunner/billing.yamlThat 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:
raterunner plan --provider stripe --env sandbox raterunner/billing.yamlThe output should be something a reviewer can understand:
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_daysOnly after that should the tool write to the provider:
raterunner apply --env sandbox raterunner/billing.yamlThe 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:
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: trueOnce 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:
{ "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.