Customer-facing surfaces

Route cancellations through Backstop.

Backstop's save flow only works if the customer actually reaches it. That means taking the “cancel” action offStripe's hosted billing portal and routing it through Backstop instead. This is the single most important setup step — skip it and customers cancel straight through Stripe without ever seeing your offer.

Why you can't just “turn it on”

Stripe's Customer Portal (billing.stripe.com) is hosted by Stripe. No third party — not Backstop, not Churnkey, not ProsperStack — can inject a custom flow into it. Stripe's portal has a tiny built-in retention feature (one cancellation survey and one coupon), but it can't run your multi-step survey → offer → confirm flow.

So the integration model is the same across every retention tool: you point your own “Cancel” control at the save flow, and you close the Stripe-portal bypass so there's no back door. Three steps.

Step 1 — Route your cancel button through Backstop

Wherever your app today links to the Stripe portal “to cancel,” swap it for the Backstop embed. The customer clicks your button, we mint a one-shot token, and they land in your hosted cancel flow (redirect or modal). Full snippet + signing instructions:

  • Embed Backstop — open the flow as an embedded modal (load embed.js, call Backstop.cancel(…)) or as a hosted redirect (no SDK — your backend swaps a signed bundle for a Backstop URL). Snippet pre-filled for your workspace under Cancel flows → Embed on your site in the dashboard; the “How should it open?” toggle there switches modal ↔ redirect.

Only when the customer clicks through and confirms does Backstop actually cancel the subscription on Stripe. A “Never mind” at any step keeps them subscribed.

Step 2 — Disable cancellation in Stripe's Customer Portal

This is the step people miss — and it's the one that makes the integration airtight. In the Stripe Dashboard:

  1. Go to Settings → Billing → Customer portal (or open dashboard.stripe.com/settings/billing/portal).
  2. In the Functionality section, turn off“Cancel subscriptions” (Stripe relabels this control occasionally — it's the cancellation toggle, sometimes shown as “Customers can cancel subscriptions”).
  3. Save. The portal can still be used for invoices and card updates — just not cancel.

Now the only path to cancel is your in-app button → Backstop. A customer who opens the Stripe portal directly can't cancel from there; they have to come back to your app, which routes them through the save flow.

Step 3 (recommended if you link to the Stripe portal today) — Replace the Stripe portal link entirely

If you currently send customers to the Stripe portal for allsubscription management, replacing that link closes the same bypass as Step 2 from the other direction: swap it for Backstop's own hosted portal — pause / cancel / update-card in one place, with the save flow already wired into the cancel path. The handshake is the same as the cancel embed — your backend signs customer + "\n" + timestamp(no subscription ID; the portal manages all of a customer's subscriptions), POSTs it, and gets back { url }:

  1. Your backend signs an HMAC over customer + "\n" + timestamp with the workspace embed secret.
  2. POST { workspace, customer, timestamp, signature } to /api/embed/portal.
  3. You get back { url } — a long-lived /portal/<token>link (90-day, reused across visits, so it's bookmarkable). Redirect the customer there, or render it as your “Manage subscription” button.
import crypto from 'node:crypto'

app.post('/api/portal-link', async (req, res) => {
  const { customer } = await resolveBillingFromSession(req)  // your auth → cus_xxx
  const timestamp = Math.floor(Date.now() / 1000)
  const signature = crypto
    .createHmac('sha256', process.env.BACKSTOP_EMBED_SECRET)
    .update(customer + '\n' + timestamp)
    .digest('hex')

  const r = await fetch('https://www.trybackstop.com/api/embed/portal', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ workspace: 'your-slug', customer, timestamp, signature }),
  })
  const { url } = await r.json()
  res.json({ url })  // your frontend redirects the customer to this
})

Verify it's airtight

  • From your app, click your “Cancel” button → you should land in the Backstop flow (survey first), not Stripe's cancel screen.
  • Open the Stripe Customer Portal as a test customer → there should be no “cancel subscription” option, only invoices / payment method. (In test mode you can grab a login link from a customer's page in the Stripe Dashboard, or via billingPortal.sessions.create.)
  • Complete a cancel in the Backstop flow → the subscription shows cancel_at_period_end in Stripe, and a row appears under Cancel flows → Analytics.