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.

Before you start

  • Stripe connected and synced. The embed resolves the customer (and, for cancel, the subscription) by their real Stripe IDs against your mirrored data — so the customer and subscription have to exist in your workspace already. If you just connected, let the initial sync finish first.
  • A paid plan. The embed surfaces are a paid feature. On a plan without them the endpoints reject the request at runtime with HTTP 403 (embed_not_available), so wire this up on a plan that includes the embed.
  • Your real Stripe IDs to hand. You pass the Stripe customer ID (cus_…) for every surface, plus the subscription ID (sub_…) for the cancel flow. Resolve these from your own logged-in session server-side — never let the browser choose which customer to act on.

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. Pass reloadOnClose: false to opt out of that default reload.

Two more options govern the unhappy paths. workspace and customer are required — call an opener without them and the SDK invokes onError(or throws if you gave it none). And if the signed request can't be minted (network error, rejected signature) and you provided no onError, the SDK navigates to a fallbackUrl when you supply one — a useful escape hatch that sends the customer to, say, your existing Stripe-portal link rather than leaving them on a dead button.

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.

Common pitfalls

Every opener routes a rejected mint to your onErrorhandler with the endpoint's error code. The ones you'll see most:

  • signature_mismatch / signature_stale (HTTP 401) — the bytes you signed don't match, or the timestamp is outside the ±5-minute window. Almost always a literal \n instead of a real newline, a millisecond timestamp instead of epoch seconds, or a clock that's drifted. Sign one fresh bundle per click.
  • signature_required (HTTP 401) — the workspace has required-signatures on but the request arrived unsigned. Make sure your frontend is spreading the full bundle (...auth) into the opener, including timestamp and signature.
  • customer_not_found / subscription_not_found (HTTP 404) — the Stripe ID you passed isn't mirrored in this workspace yet. Confirm Stripe is connected and the initial sync has run, and that you're passing the live-mode ID that matches your connected account.
  • embed_not_available (HTTP 403) — the workspace's plan doesn't include the embed. Upgrade, or the button will keep erroring.
  • rate_limited (HTTP 429) — more than 60 requests/minute from one IP. Fine for real customers; if you hit it in testing, slow your loop down.

The fastest way to isolate a signing bug is the curl round-trip in Step 2 — it takes the browser out of the loop, so a 200 there proves your HMAC is right and any remaining failure is front-end wiring.

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 portal domain.