Created: 2026-10-06Last Updated: 2026-10-21Author: Petru Rares Sincraian
Summary
We want to let agents start making changes on the Polar side. Agents will write config files to create products, benefits, and meters. But we want agents to do that safely, for merchants and for customers. To achieve that, we propose to follow some design principles:- Every change goes into a draft catalog. When it’s published, it becomes the live catalog. Every new checkout goes through the live catalog.
- Every change in a product or benefit gets a new row with a new ID. Old rows don’t change, and existing orders and subscriptions keep pointing to them.
- Full freeze. No change reaches existing customers until the merchant explicitly moves them to the new version.
Current state
Today, changes behave differently depending on what changes:
Prices are the only thing that is frozen per customer today. Everything else follows the product.
Goals
- Provide a unified approach to updating billing entities for organizations using declarative config.
- Make billing changes in a safe and predictable way: nothing changes for an existing customer without an explicit action.
- Allow merchants to move existing customers to the latest version.
Out of scope
- Customer notifications related to billing changes.
- Migrations as config (selectors, scheduling, cancel). They get their own design doc, built on the move endpoints described here.
- Meter versioning. Meters work as today: their filter and aggregation are locked once the billing cursor moves. To count usage differently, the merchant creates a new meter with a new
external_id. - Discount versioning. Discounts are not part of the draft and work as today. Only their product restrictions follow the live catalog (see References).
- Multiple drafts or branches, to compare and simulate catalogs. One draft is only to keep things simple for now, there is no technical limit to have several.
Options
Solution
There are only 4 concepts that make all of this possible:- Catalog. It contains the full list of products and benefits at a point in time. It is identified by a hash, not a number, and has a state:
draft,live, orarchived. Every organization has one live catalog and at most one draft.- Like git, we store the full hash, unique per organization, and show the shortest prefix that is unique in the organization (6 characters). The examples below use 4 characters to keep them short.
- The hash is computed from the previous published hash and the catalog content. So every published catalog gets a new hash, even if it goes back to an older definition.
- Hashes don’t have an order, so history is sorted by
published_at.
- Copy-on-write for products and benefits. Every change creates a new row in the draft catalog. Once the catalog is published, those rows are locked.
external_idis the stable name. It is required for every product, benefit, and meter. All rows with the sameexternal_idare versions of one resource. Each row stores the catalog where it was created.is_current. It istruefor rows in the live catalog. It is a fast way to filter current entities and new sales. Only one row perexternal_idcan be current, with a unique index on(organization_id, external_id) WHERE is_current. Catalog membership (catalog_products,catalog_benefits) is the source of truth, andis_currentis only updated when a catalog is published.
What we store
ACME sells Pro at $20 and Starter at $10 in cataloga1f3. Then they change Pro to $25 and publish catalog 9c2e. Starter didn’t change, so its row is reused.
catalogs
products
catalog_products
subscriptions
Things to notice:
prod_Astill exists and still costs $20. Reading `prod_A` always returns the $20 definition. An old id never redirects to the new one.prod_Cwas created ina1f3and reused in9c2e, and it is still current.- A purchase reads its own row directly. We never search “which version does this customer have”.
What creates a new row
A benefit change creates new product rows because the benefit links are part of the product. Today, grants are rebuilt from the product benefits on reactivation, seat claims, product changes, and more. If we edited
prod_A’s links in place, some existing customers would get the new benefit and others not.
The plan and the review screen show why each dependent product changed:
Main flows
Making a change
Products and benefits can be changed from the config or from the dashboard. Both edit the same draft. From the config:applyuploads the draft. A member reviews it in the dashboard, then publishes or discards it. This is the default, and the flow for changes proposed by agents.apply --publishuploads the draft and publishes it in one transaction.
- The dashboard changes the current draft step by step.
applyreplaces the whole draft with the new definition, in the same draft catalog, so the merchant can apply many times while iterating. - If the draft changed in the dashboard since the last
applyorpull,applyfails.apply --forcereplaces it anyway. There is no automatic merge: to keep the dashboard changes, the merchant canpullthe draft into the config first. - Every dashboard save sends the draft revision it was based on. A save based on an old revision fails.
- Publishing is atomic, and checks that the draft was built on the current live catalog. If another publish happened first, it fails, and the merchant applies the changes again on top of the new live catalog.
- Draft rows have
is_current = false, like an archived version, so they cannot be sold. A row is a draft when it is only in the draft catalog. API endpoints don’t return it unless the draft is asked for. - Every draft is complete and valid: it is the full next catalog, including dependent products.
- There is no revert. To go back, the merchant applies the old definition, which publishes a new catalog with a new hash.
New sales
Every new checkout only uses the live catalog, identified byis_current:
Removing an entity
Removing an entity means it is not in the next catalog. When publishing, we set it tois_current = false. Existing purchases keep their rows, as with archiving today.
Moving customers
Publishing never moves existing customers. Moving them is an explicit action, with two endpoints:- Subscriptions. A bulk subscription update. It changes each subscription to the target row with the requested proration behavior (
next_period, orimmediatewithprorateorinvoice), like today’s subscription update. It runs in the background, in batches, and skips subscriptions that can’t move. - One-time orders. A new endpoint that moves an order’s benefits to a target product row. The order itself doesn’t change.
external_id: we keep the grant, point it to the new benefit row, and update it like an in-place benefit edit today. Only benefits that were added or removed are granted or revoked.
Example: the License key benefit allows 3 activations (ben_1). Catalog 9c2e allows 5 (ben_2):
Meter credits are the exception, because they are consumed. Like today’s subscription update, a move settles the old usage, revokes the old credit grant, and grants the new credits. Extra usage is billed at the new price.
What changes for merchants
Adoption
Existing organizations that want to opt in runpolar init. It assigns an external_id to every product, benefit, and meter, and generates a config where the first plan shows no changes. The current state becomes the first live catalog, and existing purchases keep their rows and prices.
References
References made by merchants follow the live catalog. References made by purchases stay pinned to their row.
Discounts already applied to existing subscriptions or checkouts don’t change.
Merchant code that stores raw product IDs goes stale after a publish. The docs tell merchants using declarative config to use
external_id.
API surface
- List endpoints return only current rows by default. With
catalog=draft, they return the rows of the draft catalog instead. GET /products/{id}andGET /benefits/{id}return any published row, so a subscription’sproduct_idor a grant’sbenefit_idcan always be read.- Publishing fires
product.createdandbenefit.createdfor each new row, andproduct.updatedandbenefit.updatedfor rows that leave the live catalog. Payloads includeexternal_id, so consumers can tell a new version from a new product.
Security considerations
- Data in and out: config from merchants and agents, and plan reports with counts and amounts.
- Who can access it: the existing read and write scopes. Publishing a draft and moving customers check permissions again. Moving subscriptions needs
subscriptions:write. Moving one-time orders needs its own write permission. - Data we store: draft and published rows, and catalogs. No new personal data.
- Risks: an agent moves many customers to higher prices. Mitigations: agents propose drafts that a member publishes, publishing never moves customers, and moves are explicit.
Appendix: Milestones
POC goal. Prove the core loop on one internal organization:- Change Pro from $20 to $25 in config.
planshowsprod_A → prod_B.apply --publishpublishes a new catalog.- A new checkout sells
prod_Bat $25. - The old subscription stays on
prod_Aat $20. - A bulk subscription update (
next_periodonly) moves it toprod_Bat its next renewal.

