Skip to main content
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:
  1. Every change goes into a draft catalog. When it’s published, it becomes the live catalog. Every new checkout goes through the live catalog.
  2. 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.
  3. Full freeze. No change reaches existing customers until the merchant explicitly moves them to the new version.
Versioning is only enabled for organizations that use declarative config (config as code). For them, changes from the config and from the dashboard go to a draft first. Organizations without declarative config keep today’s behavior: dashboard changes are live right away, and there is no versioning. This doc covers what we ship first. Migrations as config (selectors, scheduling, cancel) get their own design doc, but we need them before the public launch.

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:
  1. 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, or archived. 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.
  2. 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.
  3. external_id is the stable name. It is required for every product, benefit, and meter. All rows with the same external_id are versions of one resource. Each row stores the catalog where it was created.
  4. is_current. It is true for rows in the live catalog. It is a fast way to filter current entities and new sales. Only one row per external_id can 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, and is_current is only updated when a catalog is published.

What we store

ACME sells Pro at $20 and Starter at $10 in catalog a1f3. 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_A still exists and still costs $20. Reading `prod_A` always returns the $20 definition. An old id never redirects to the new one.
  • prod_C was created in a1f3 and reused in 9c2e, 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:
There are two ways to apply:
  • apply uploads 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 --publish uploads the draft and publishes it in one transaction.
From the dashboard, the flow is: edit → save draft → review → publish. On the dashboard we show that the change is only in the draft. There are some rules:
  • The dashboard changes the current draft step by step. apply replaces 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 apply or pull, apply fails. apply --force replaces it anyway. There is no automatic merge: to keep the dashboard changes, the merchant can pull the 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 by is_current:
Checkout sessions created before a publish keep the row they were created with.

Removing an entity

Removing an entity means it is not in the next catalog. When publishing, we set it to is_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, or immediate with prorate or invoice), 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.
Today, a product change revokes the benefits that are not on the new product and grants the new ones. With versioning, every benefit edit is a new row, so this would reissue license keys and remove Discord roles. Instead, grants are re-pointed by benefit 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 run polar 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} and GET /benefits/{id} return any published row, so a subscription’s product_id or a grant’s benefit_id can always be read.
  • Publishing fires product.created and benefit.created for each new row, and product.updated and benefit.updated for rows that leave the live catalog. Payloads include external_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:
  1. Change Pro from $20 to $25 in config.
  2. plan shows prod_A → prod_B. apply --publish publishes a new catalog.
  3. A new checkout sells prod_B at $25.
  4. The old subscription stays on prod_A at $20.
  5. A bulk subscription update (next_period only) moves it to prod_B at its next renewal.
Milestones

Appendix: How others do it