Playbook

The pricing change notifier.

Stripe price update fans out to CSMs, customers, HubSpot deals, and your docs. One source of truth. Five downstream actions. Each independently retried.

8 min read

The pain

Why pricing changes go sideways

You raise prices on the Pro plan from $99 to $129. The Stripe price object updates. CSMs find out two weeks later when a customer escalates a renewal quote. Your docs site still shows $99 for a month. HubSpot deal stages aren't updated, so sales forecasts the wrong amount.

The four common downstream gaps:

  • CSMs don't know. Customer-facing teams find out from the customer.
  • Customers find out at renewal. No proactive notice = trust erosion.
  • HubSpot deals stale. Open opportunities still forecast at the old price.
  • Public docs lag. The pricing page on the marketing site shows yesterday's number.

The architecture

What we're building

A single workflow triggered by Stripe's price.updated webhook. Fan out to five downstream actions. Each action is its own step so partial failure doesn't undo the rest.

workflow({
  id: "pricing-change-notifier",
  trigger: { event: "stripe.price.updated" },

  steps: async ({ event, step }) => {
    const price = event.data;

    // 1. Notify CSMs immediately
    await step.run("notify-csms", () =>
      slack.send("#csms", `Price ${price.id} changed: ${formatChange(price)}`)
    );

    // 2. Find affected open deals in HubSpot
    const deals = await step.run("find-affected-deals", () =>
      hubspot.deals.search({
        filterGroups: [{ filters: [
          { propertyName: "deal_stage", operator: "NEQ", value: "closedwon" },
          { propertyName: "stripe_product_id", operator: "EQ", value: price.product },
        ]}],
      })
    );

    // 3. Update each deal — fan out concurrently with cap
    for (const deal of deals) {
      await step.run(`update-deal-${deal.id}`, () =>
        hubspot.deals.update(deal.id, {
          amount: recomputeDealAmount(deal, price),
          notes: `Price updated to ${price.unit_amount / 100}. Old amount: ${deal.amount}.`,
        })
      );
    }

    // 4. Email affected customers (60-day notice for paid customers)
    const customers = await step.run("find-active-subscribers", () =>
      stripe.subscriptions.list({ price: price.id, status: "active" })
    );

    for (const sub of customers) {
      await step.run(`email-${sub.customer}`, () =>
        sendPriceChangeEmail(sub.customer, price)
      );
    }

    // 5. Re-deploy docs site to pick up new price
    await step.run("redeploy-docs", () =>
      vercel.redeploys.create({ projectId: DOCS_PROJECT_ID })
    );

    // 6. Audit
    await step.run("audit", () =>
      db.priceChanges.insert({
        priceId: price.id,
        oldAmount: price.previous_attributes?.unit_amount,
        newAmount: price.unit_amount,
        affectedDeals: deals.length,
        affectedCustomers: customers.length,
        changedAt: new Date(),
      })
    );
  },
});

The customer email

Get this part right

Price-change emails set tone for the relationship. Three rules: give 60 days notice minimum, explain the why, offer a one-time lock-in.

async function sendPriceChangeEmail(customerId, price) {
  const customer = await stripe.customers.retrieve(customerId);
  const oldAmount = price.previous_attributes?.unit_amount ?? 0;
  const pctChange = ((price.unit_amount - oldAmount) / oldAmount) * 100;

  return resend.emails.send({
    to: customer.email,
    subject: `Pricing update for your ${customer.name} plan`,
    react: PriceChangeEmail({
      customerName: customer.name,
      oldAmount: oldAmount / 100,
      newAmount: price.unit_amount / 100,
      effectiveDate: addDays(new Date(), 60),
      lockInOption: pctChange > 0 ? "/lock-current-price" : null,
    }),
    headers: { "Idempotency-Key": `price-change-${price.id}-${customerId}` },
  });
}

Edge cases

What goes wrong, and how to handle it

Stripe sends the same webhook twice. The workflow is triggered with idempotencyKey: `price-${price.id}-${price.updated}`. Duplicate triggers collapse.

HubSpot rate-limits during the deal update fan-out. Each deal update is its own step. Failed steps retry with backoff. Successful ones don't re-run.

Vercel deploy fails. The next manual deploy or push fixes it. The audit row records the deploy attempt so you know to check.

Wrong price was published. Roll back: update Stripe price to the old amount. The same workflow fires again with the corrected price. The audit table tracks both changes.

The math

What this costs on Rotor

A SaaS with ~50 active subscribers and ~30 open deals on a changed price. One price change = ~90 step-runs (notify + 30 deals + 50 customers + audit + redeploy).

Even with 10 price changes a year, that's under 1,000 step-runs. Fits Hobby ($9/mo) easily. The value is in the reliability, not the volume.

Fork this playbook on Rotor.

$9 to start. 30-day money back. Hard caps protect you from runaway bills.

Start shipping