Shipping Pricing Changes Without Breaking Your Billing | Deployxa

A pricing change is a data migration wearing a marketing hat. The inventory, rollout order, and reconciliation habits that keep billing whole while your plans change.

← Back to Dispatch Articles
Engineering Log

Shipping Pricing Changes Without Breaking Your Billing

A pricing change is a data migration wearing a marketing hat. The inventory, rollout order, and reconciliation habits that keep billing whole while your plans change.

Friday, 5:04 p.m. The new pricing tier is done: the page is updated, the deploy is green, the announcement is scheduled. By Monday morning, the weekend has quietly rearranged itself into three problems. Billing webhooks are failing for every customer still on the old plan, because the webhook handler has never seen the events your provider now sends. Dunning emails are misfiring — payment reminders going to people who already paid, silence for the ones whose cards actually failed. And two customers have been silently double-charged by a half-finished migration that created their new subscriptions without canceling the old ones. Nothing crashed. The homepage loads, signups work, uptime is green.

Key Facts

  • Why pricing changes are riskier than they look: A price is not a number on a page.

  • First, the inventory: every place a plan lives: Before any code, any copy, and any provider-dashboard clicks: write down every surface where a plan exists in your product.

  • Compatibility rules: add the new, keep the old: Never rename a plan identifier in one deploy.

  • The rollout sequence: how to run a SaaS pricing change migration: The sequence below is ordered, and the order is the safety mechanism.

None of it came from bad marketing. It broke because a pricing change is not a page edit. It is a data migration wearing a marketing hat: underneath the copy sit database rows, payment-provider objects, and webhook handlers that all have to learn the new plan without forgetting the old one — at different speeds.

This guide walks through a SaaS pricing change migration end to end: why changing pricing in a SaaS fails more often than it should, the inventory to write down before touching anything, the compatibility rules that make the change reversible, the rollout sequence in order, how to test the money path on staging, which signals to watch during the transition, and what rollback looks like when you followed the rules — and when you did not. A fictional two-week walkthrough ties it together; the platform part comes late and stays modest.

Why pricing changes are riskier than they look

A price is not a number on a page. It is the last step of a chain: your marketing page tells the customer what they are buying, your plan constants decide what the app grants them, your database row records what they actually have, your provider's subscription object decides what they are charged, and your webhook handlers keep all of those in agreement whenever anything changes. A pricing change asks every link in that chain to understand a new plan — while continuing to serve every customer still on the old one.

Three clocks make that hard. The marketing page flips in seconds. A deploy takes minutes. Moving real customers takes days or weeks, because renewals and billing cycles refuse to hurry. The incident almost always happens in the gaps between the clocks: the page sells a plan the backend does not accept yet, or a handler that only knows the old plan receives an event about the new one.

The failure mode is also asymmetric. A broken feature throws an error a customer can report. A broken pricing change either says nothing — events dropped, rows drifting quietly — or it says something much worse by moving money incorrectly. A wrongly charged customer does not file a bug report; they dispute the charge and tell other people. The money path does not get a crash reporter.

First, the inventory: every place a plan lives

Before any code, any copy, and any provider-dashboard clicks: write down every surface where a plan exists in your product. Most founders can list the marketing page from memory. The point of the exercise is the surfaces that do not announce themselves.

Surface

What lives there

How it breaks if stale

Marketing and pricing page

Tier names, feature bullets, the Upgrade buttons

Sells a plan the backend refuses, or hides one it accepts

Plan constants in code

Tier IDs, limits, and entitlements mapped from each plan

New plan falls back to defaults; old plan loses its grants

Database columns

Each customer's plan, seat counts, renewal dates

Rows say one thing, the provider says another; reporting drifts

Payment provider objects

Products, prices, and per-customer subscriptions

Charges reference a price your code has never heard of

Webhook handlers and feature gates

Switch statements keyed on plan IDs; per-plan checks

Unknown plan IDs dropped or misrouted; gates wrong for the tier

Your inventory will usually find six surfaces for a small SaaS, and the sixth is the one that bites: support macros, onboarding emails, and docs screenshots that still describe the old tiers. None breaks a webhook, but all of them tell customers the wrong thing during the window.

The inventory does two jobs. It converts one intimidating change into a list of small, orderable ones. And it defines your compatibility window: every surface on that list has to understand old and new plans at the same time, for as long as a single old-plan customer exists.

Compatibility rules: add the new, keep the old

Never rename a plan identifier in one deploy. Plan IDs look like cosmetic strings, but they are keys in switch statements, values in database columns, and metadata on provider objects — all referencing each other. Rename an identifier everywhere in a single release and every system still holding the old value breaks at once, with no version of your code that matches your data. The safe pattern is the one database migrations already use: add the new identifier, keep the old, accept both, and retire the old value later in a boring cleanup deploy.

Run an explicit transition window. From the moment the first customer can touch the new plan until the moment the last old-plan customer is gone, every plan-aware component accepts both plan IDs. That is the whole trick. A handler that understands both plans cannot be broken by an event about either one, and that property is what makes everything later in this article — batch migrations, rollback, cleanup — safe.

Grandfathering or migrating: pick a default and say it out loud

Existing customers face exactly two honest policies. Grandfathering: they keep their current plan and price until they choose to move, or until a renewal date you named in advance. Migration: everyone moves on a stated date. Both are legitimate; the incident happens when the announcement says one and the code does the other.

Pick a default per customer segment — monthly and annual customers often deserve different answers — then say it in writing: launch email, changelog, a dated banner on the pricing page. Saying it out loud is not a courtesy; it is the difference between a policy and a surprise, and surprises on invoices cost more than the migration. How much you may change terms for existing customers is also a promise-keeping and legal question for your business — that call is yours, and it is best made before the announcement, not after.

One-way doors: downgrades, refunds, and proration

Upgrades are forgiving: when one misfires, the customer received more than they paid for and tells you happily. Downgrades, refunds, and proration are one-way doors. A refund cannot be un-refunded. A downgrade applied twice quietly strips entitlements a customer is paying for. Proration configured wrong charges the wrong amount — the exact failure this article exists to prevent. During the window, treat these as manual actions the first few times, rehearse them in your provider's test mode, and never let a half-tested migration script perform them in bulk.

The rollout sequence: how to run a SaaS pricing change migration

The sequence below is ordered, and the order is the safety mechanism. Pause between steps; each one assumes the previous one passed its checks.

  1. Create the provider-side prices first. Build the new price (and product, if needed) in your provider's test mode, verify what you can there, then create it live. Nothing else changes yet. The new price exists but nothing references it, so it cannot break anything — that is the point.
  2. Deploy code that understands both plans, before any customer moves. Constants, webhook switch statements, feature gates, and upgrade paths all learn the new plan ID while the old ones keep working untouched. When this deploy lands, the new plan works end to end and nobody is on it — exactly what you want.
  3. Flip the marketing page only when the backend accepts the new plan. Page before backend means selling a plan your Upgrade button cannot deliver; backend before page means a plan nobody can buy. The flip includes the announcement, which states the grandfathering or migration default in writing. Until step 2 passed its checks, this page stays untouched.
  4. Migrate existing customers deliberately: batch, watch, reconcile. Move customers in small batches, or let each customer's renewal do the moving. Pause between batches. After each batch, check webhook failures and run reconciliation (below) before starting the next. Moving everyone at once removes your ability to notice the first failure before it becomes the fortieth.
  5. Contract: remove old-plan support only after the last customer has moved. The cleanup deploy deletes old plan code paths, old constants, and the compatibility shims. This step has no deadline. The window closes when your data says it closes, not when the marketing calendar does.

This is the expand-and-contract idea from zero-downtime database migrations, applied to pricing in plain words: expand by adding the new alongside the old, transition by moving customers at a pace you can watch, contract by removing the old only when nothing references it anymore. Never contract while a single customer still lives on the old plan.

Test the money path on staging before the window opens

The rehearsal that matters is specific, not generic. Seed one test customer on the old plan in a staging environment, connected to your provider's test mode, then walk that customer through everything the window will touch:

Scenario

Setup

What must happen

Upgrade from the old plan

Old-plan test customer picks the new tier

One charge, one subscription object, database row matches the provider

Downgrade between tiers

Higher-tier test customer steps down

Entitlements shrink exactly once; no stray charge

Failed payment, then retry

Force a decline, then retry the payment

Dunning fires once, access policy applies, the retry succeeds cleanly

Old-plan renewal

Let an old-plan subscription renew during the window

Renews at the old price without errors — the old plan is still first-class

Then test the handlers directly. Send or replay webhook events that reference the old plan ID and the new one, and confirm both process cleanly. The unknown-plan case deserves its own test: an event naming a plan the code has never heard of should log loudly and page you, never silently drop — silent dropping is how the Friday-night scene begins.

Run this in a real, separate staging deployment — staging and production should be separate projects with separate data, and the Deployxa docs cover that separation — because the one thing a pricing rehearsal must never do is charge a real card.

Watching the transition: four signals, checked daily

During the window, four signals tell you whether the migration is healthy. Three you may already have; the fourth is the habit worth adding for as long as customers are moving.

Signal

What it catches

Where you look

Webhook delivery failure rate

A deploy or migration step broke event processing

Provider delivery logs and your application logs

Renewal and dunning success rate

Old-plan customers whose renewals broke mid-window

Provider dashboard and your billing event records

Unknown-plan log lines

Code that still assumes only one plan exists

Your application logs

Daily reconciliation report

Any drift between provider state and your database

Your scheduled query's output

Reconciliation means comparing, customer by customer, what your provider believes against what your database believes: plan, status, next charge. In calm weeks a weekly cadence is fine. During the window, run it once a day. The shape of the query is simple enough to write in an afternoon:

-- FICTIONAL EXAMPLE — the shape of a daily reconciliation checkSELECT c.id, c.plan AS db_says, s.plan_label AS provider_saysFROM customers cJOIN provider_subscriptions s ON s.customer_id = c.idWHERE c.plan <> s.plan_label; -- every row here is drift with a story

Every row the query returns is a customer your database and your provider disagree about. During the window, each row gets explained the same day — either a benign rename you can map, or the kind of mismatch that becomes a double charge if you wait.

Rollback: easy if you built the window, painful if you skipped it

Follow the compatibility rules and a rollback is a non-event. The previous release still accepts every old plan ID, because you never removed them. The provider-side prices you created ahead of time are inert until something references them. Roll back, fix the new-plan code, redeploy — existing customers never notice.

Skip the window and rollback becomes the incident. Rename plan identifiers in one deploy, and the previous release expects identifiers your database rows no longer contain. Delete the old prices in the provider dashboard, and no version of your code can renew the customers attached to them. Migrate every customer in one sweep, and rolling the code back leaves you holding new data with old logic. In each case there is no deploy — forward or backward — that matches the state of your data. The only way out is a forward repair you could have avoided.

That is the quiet payoff of expand-and-contract: at every moment of the window, at least one version of your code matches the state of your data. Keep that invariant and every incident stays small. Break it and even the rollback button cannot save the weekend.

A two-week pricing change, walkthrough (fictional)

The following is a fictional composite, labeled illustrative — not a case study.

A solo founder with a small SaaS — a few dozen paying customers, illustrative — has two tiers, Starter and Team. Customers keep asking for a middle option, so she decides to add a Pro tier between them, with a higher seat limit than Starter and fewer Team features.

Weekend zero — the inventory. Before touching anything, she lists six surfaces: the pricing page, the plan constants, the customers table's plan column, two provider products with their prices, the webhook switch statement, and the feature gates. Plus the boring seventh: support macros that quote plan features.

Day 1 — provider first. She creates the Pro price in the provider's test mode, runs one upgrade by hand, then creates it live. Nothing else changes.

Days 2–3 — dual-mode code. One deploy teaches the constants, webhook handlers, and feature gates the Pro plan ID. Starter and Team are untouched. Staging passes the four-scenario gauntlet — upgrade, downgrade, failed payment with retry, and an old-plan renewal — plus a replayed event and a deliberately unknown-plan event that logs loudly instead of dropping.

Day 4, a Tuesday morning — page and announcement. The pricing page gains a middle column. The email states the default out loud: existing customers keep their current plan and price for as long as they like; Pro is there when they want it; anyone can switch at a renewal with two clicks.

Week two — deliberate migration. She moves two small batches — the customers who had asked for a middle option — pausing between batches to check webhook failures and run the reconciliation query. Everyone else moves at their next renewal, which requires no action from her at all.

The catch. Day four of week two, reconciliation returns one row: a customer whose database row says Starter and whose provider object says Pro. His upgrade event arrived during a deploy window and its delivery failed; the provider's retries gave up before her deploy was fixed. She replays the event from her own event log, the idempotent handler applies it exactly once, and database and provider agree again. A two-line email confirms the upgrade he already paid for. Total drift: under a day, caught before his next charge — instead of after it.

Day 14 — nothing contracts. The Starter and Team code paths stay until the last customer has moved. The cleanup deploy is scheduled for a boring Tuesday months out; that patience is not indecision, it is the contract step waiting for the data to say it is safe.

Where Deployxa fits — and where it stops

Nothing in this sequence is exotic, but it leans on three deployment properties: two versions coexisting briefly, a cheap way back if a step misfires, and a place to rehearse that cannot charge a real card.

Blue/green releases give you the first two. Deployxa deploys Git repositories or local projects as containerized applications, and releases run blue/green: the new version lands in a standby slot, gets health-verified, and only then receives traffic, while the prior healthy release stays warm for a short rollback window — so two versions briefly coexisting is the default shape of a release, not something you build by hand. If a deploy that touched billing code misbehaves, rollback inside that window is sub-second.

The health gate is the second protection: include the routes and handlers your money path depends on in your health checks, and a release that breaks them fails the gate before customers ever reach the new version. After each rollout step, the Deployxa dashboard gives you per-deployment logs to watch webhook traffic settle. And because staging and production are separate projects, the upgrade gauntlet from the testing section runs where no real card is charged.

The honest limits: your billing logic lives in your application code, and your plans, prices, and migration scripts live in your payment provider's dashboard. Deployxa does not manage any of them — the platform's own plans are a separate thing entirely (its pricing page describes hosting plans, not yours). Deployxa makes each step of the sequence safer to run; the sequence itself, and every plan ID in it, is yours.

The pricing-change checklist

Before the window opens

  • [ ] Inventory written down: every surface where a plan exists, including support macros and docs
  • [ ] New prices created provider-side — test mode first, then live — before any code ships
  • [ ] Code that understands old AND new plan IDs deployed, and verified in staging
  • [ ] Webhook handlers tested against events referencing both plan IDs, plus one unknown plan that logs loudly

During the window

  • [ ] Marketing page and announcement flipped only after staging passes the gauntlet
  • [ ] Grandfathering or migration decided per segment — and announced in writing
  • [ ] Existing customers migrated in small batches, with a signal check and reconciliation between batches
  • [ ] Reconciliation run daily; every drift row explained the same day

Closing it out

  • [ ] Old-plan support removed only after the last customer has moved
  • [ ] One-way doors — downgrades, refunds, proration — rehearsed before any bulk run

If you do one thing this week, make it the rehearsal: create the new plan in your provider's test mode, seed a staging customer on the old plan, and walk one upgrade end to end on a non-production Deployxa project — signature verified, both plan IDs recognized, one charge, database row matching the provider. When that rehearsal is boring, your pricing change is ready to ship. When it is not, you have found the bug on a day no customer was watching.

Ready to deploy with Deployxa?

Deploy your apps globally with automatic SSL and AI diagnostics.

Start Free Now