Customer-facing surfaces

Embed Backstop on your site.

One backend endpoint signs a request; Backstop opens the right flow — cancel flow, manage-subscription portal, or failed-payment wall. Open it as an embedded modal on your own site (load embed.js; the customer never leaves your domain) or as a hosted-page redirect (no SDK — your backend swaps a signed bundle for a Backstop URL and sends the customer there and back). The backend signer is byte-for-byte identical for both.

Two ways to open it

  • Embedded modal (recommended) — load the SDK and call Backstop.cancel(…) / Backstop.portal(…). The flow opens in a modal iframe on your own page; the customer never leaves your domain and never sees a Backstop URL.
  • Hosted redirect (no SDK)— for CSP-locked sites, server-rendered links, or anywhere you'd rather not load a third-party <script>. Your backend POSTs the same signed bundle to the embed endpoint, gets back { url }, and redirects the customer to the Backstop-hosted flow (it returns to your site when done).

The backend signer is identical for both — only the front-end glue differs. Wire up the signer once (Step 2), then pick whichever opener fits your stack.

How it works (the 10-second version)

  1. You load embed.js once. It puts a global Backstop object on the page.
  2. Your backend hands the browser a short-lived signed token (an HMAC over the customer ID + a timestamp). The workspace secret never touches the browser.
  3. You call Backstop.cancel(…) / Backstop.portal(…) / Backstop.paymentWall(…). Backstop opens the right flow in a modal iframe, runs it, and tells you the outcome via a callback.

That's the whole integration: a <script>, one backend endpoint that signs, and one function call on the button.

Step 1 — load the SDK

Anywhere on the page (it's tiny, dependency-free, and safe to load async):

<script src="https://www.trybackstop.com/embed.js" async></script>

Step 2 — sign a token on your backend

The browser must never hold your workspace embed secret, so signing happens server-side. Add one authenticated endpoint that resolves the logged-in customer and returns a fresh signed bundle. The signing string depends on the surface — fields joined by a real newline (0x0A, not the two characters \n):

Portal / payment wall   →   customer + "\n" + timestamp
Cancel flow             →   customer + "\n" + subscription + "\n" + timestamp

Sign it with HMAC-SHA256 (key = your workspace embed secret), output lowercase hex, and use an epoch-seconds timestamp. We accept a ±5-minute window, so the signed bundle is good for about five minutes — sign one fresh per click.

import crypto from 'node:crypto'

const SECRET = process.env.BACKSTOP_EMBED_SECRET // server-only — never shipped to the browser

// Portal / payment wall: sign customer + timestamp.
app.post('/api/portal-config', async (req, res) => {
  const { customer } = await resolveBillingFromSession(req) // your auth → cus_…
  const timestamp = Math.floor(Date.now() / 1000)
  const signature = crypto.createHmac('sha256', SECRET)
    .update(customer + '\n' + timestamp).digest('hex')
  res.json({ customer, timestamp, signature })
})

// Cancel: sign customer + subscription + timestamp.
app.post('/api/cancel-config', async (req, res) => {
  const { customer, subscription } = await resolveBillingFromSession(req)
  const timestamp = Math.floor(Date.now() / 1000)
  const signature = crypto.createHmac('sha256', SECRET)
    .update(customer + '\n' + subscription + '\n' + timestamp).digest('hex')
  res.json({ customer, subscription, timestamp, signature })
})

BACKSTOP_EMBED_SECRET is one secret per workspace that covers all three surfaces. Reveal it from either Cancel flows → Embed on your site or Settings → Customer portal → Manage-subscription link — same value. Python / other runtimes: same HMAC, see the dashboard snippet tabs.

Step 3 — open a flow

Manage-subscription portal

Replace your “Manage subscription” button (the one that used to open Stripe's billing portal). Pause, cancel, update card, switch plan — all in one modal. Cancels from here run your full save flow automatically.

document.getElementById('manage-sub').addEventListener('click', async () => {
  const auth = await fetch('/api/portal-config', { method: 'POST' }).then((r) => r.json())
  Backstop.portal({ workspace: 'your-slug', ...auth })
})

Cancel flow

Wire your “Cancel subscription” button to the survey → offer → confirm flow. The callbacks tell you what happened so you can update your UI.

document.getElementById('cancel-btn').addEventListener('click', async () => {
  const auth = await fetch('/api/cancel-config', { method: 'POST' }).then((r) => r.json())
  Backstop.cancel({
    workspace: 'your-slug',
    ...auth,
    onSaved:     () => location.reload(), // customer accepted an offer / stayed
    onCancelled: () => location.reload(), // customer canceled anyway
  })
})

Failed Payment Wall

The dunning twin: an access-gating block for past-due customers (“update your card to continue”). Call it on any authenticated page — it self-suppresses when the customer has no failed payment, so it's safe to call unconditionally. Reuses the same portal signature (customer + timestamp).

const auth = await fetch('/api/portal-config', { method: 'POST' }).then((r) => r.json())
Backstop.paymentWall({
  workspace: 'your-slug',
  ...auth,
  onUpdated: () => location.reload(), // card fixed → Stripe retries automatically
  // dismissible: true,  // soft nudge instead of a hard wall
})
  • Hard wall by default — no backdrop / ESC dismiss; the customer leaves by fixing their card. dismissible: true makes it a soft nudge.
  • Self-suppressing — no in-flight recovery campaign ⇒ no wall mounts; your onCurrent callback fires instead (if provided).

Callbacks

Every opener accepts the same handler set (all optional):

  • onSaved — cancel flow ended with the customer staying (offer accepted).
  • onCancelled — the subscription was canceled.
  • onUpdated — payment method was updated (payment wall / portal).
  • onClose — modal dismissed for any reason; receives the outcome.
  • onError — the signed request was rejected (bad signature, expired timestamp, unknown customer).
  • onCurrent — payment wall only: the customer had no failed payment, so nothing was shown.

If you provide no callbacks, Backstop reloads the page on close so your UI reflects the new subscription state.

Under the hood

You don't need this to integrate — it's here so you know what the SDK does. Each opener POSTs your signed bundle to /api/embed/cancel, /api/embed/portal, or /api/embed/payment-wall. We verify the HMAC, mint a hosted token, and return its URL. The SDK mounts a backdrop + iframe pointing at that URL with ?embed=modal, and listens for the backstop:close postMessage the hosted page emits at a terminal step — routing its outcome to your callbacks and tearing down the overlay.

Token lifetimes differ by surface: a cancel link is one-shot (single use, then it's spent), while a portal link is long-lived and reused across visits(it outlives a billing cycle, so it's safe to bookmark or render as a persistent “Manage subscription” button). The exact portal lifetime is documented in Route cancellations through Backstop.

Prefer not to load embed.js? You have two no-SDK options, both ready-to-paste in the dashboard: the Hand-rolled iframevariant (under Frontend → “Hand-rolled iframe · no SDK”) gives the same modal UX with an inline-styled overlay you own, and the Hosted webpage toggle drops the iframe entirely — the raw endpoints return { url } so you can redirect the customer to the Backstop-hosted flow and back.

Require-signature flag

Workspaces start with signatures recommended but not required so you can wire the integration up gradually — unsigned requests are accepted during rollout. The embed snippet panel (Cancel flows → Embed on your site and Settings → Customer portal) shows the current state. Once your backend signs every request, flip the required-signature toggle; from then on unsigned requests are rejected with HTTP 401. The flag governs all three surfaces.

Custom domain + custom CSS

For the embedded modal you don't need a custom domain at all — the address bar is already yours and the iframe origin is invisible. Custom domains only matter for standalone hosted-page links (e.g. inside a dunning email). When a workspace has a verified domain, those links and the modal iframe are served from it, and any custom CSS on the cancel page applies. See Custom cancel domain.