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)
- You load
embed.jsonce. It puts a globalBackstopobject on the page. - 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.
- 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" + timestampSign 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: truemakes it a soft nudge. - Self-suppressing — no in-flight recovery campaign ⇒ no wall mounts; your
onCurrentcallback 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(HTTP401) — the bytes you signed don't match, or the timestamp is outside the ±5-minute window. Almost always a literal\ninstead 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(HTTP401) — the workspace has required-signatures on but the request arrived unsigned. Make sure your frontend is spreading the full bundle (...auth) into the opener, includingtimestampandsignature.customer_not_found/subscription_not_found(HTTP404) — 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(HTTP403) — the workspace's plan doesn't include the embed. Upgrade, or the button will keep erroring.rate_limited(HTTP429) — 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.