Boomerang Save
A second save for subscribers who cancel after an offer.

What it does
Catches subscribers who cancel right after taking a save offer, with a win-back that fits their reason.
Why it exists
For any merchant whose saved subscribers still churn soon after. The message matches the reason they gave the first time, and one tap reactivates the subscription with the offer applied.
The code
Paste it as-is, or download boomerang-save.html<!--
BOOMERANG SAVE — Skio customer portal custom block
----------------------------------------------------
Targets subscribers who accepted a cancel-flow offer (status starts with
SAVED_BY_*) and then cancelled again within N days anyway. Matches the
copy to their original cancel reason and sends them into a single
Quick Action that reactivates the subscription and applies the offer.
-->
<!--
Leave these six data-* attributes EMPTY until you've confirmed how your
portal actually passes per-shopper data into a custom block (see DEBUG_MODE
below to test the block's look before you wire real data in at all).
If your portal supports Liquid in this block, replace the empty strings
with the real Liquid tags for your store; if it doesn't, you'll need to
set these via a small script your portal builder runs before this one,
or fetch them client-side — tell me which and I'll adapt this file.
-->
<div
id="boomerang-save-block"
class="boomerang-save"
data-subscription-id=""
data-cancelled-at=""
data-last-session-status=""
data-last-session-reason=""
data-last-session-updated-at=""
data-qa-token=""
></div>
<style>
.boomerang-save {
/* Original Boomerang Save palette — matches the first Block Party demo */
--bs-accent: #3d5a3b; /* moss */
--bs-accent-ink: #f2f6ef; /* moss-ink */
--bs-chip-bg: #f1ddc2; /* cap-soft */
--bs-chip-ink: #c07a3e; /* cap */
--bs-border: color-mix(in srgb, var(--bs-accent) 35%, #ddd3ba);
--bs-card-bg: color-mix(in srgb, var(--bs-accent) 8%, #fffdf8);
--bs-ink: #2a251d;
--bs-ink-soft: #635a49;
font-family: inherit;
}
.boomerang-save__card {
border-radius: 14px;
padding: 16px 18px;
border: 1px solid var(--bs-border);
background: var(--bs-card-bg);
box-shadow: 0 1px 2px rgba(20, 20, 10, 0.06), 0 8px 20px -14px rgba(20, 20, 10, 0.25);
}
.boomerang-save__kicker {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-accent);
margin: 0 0 8px;
}
.boomerang-save__kicker svg { width: 13px; height: 13px; flex-shrink: 0; }
.boomerang-save__title {
font-size: 16px;
font-weight: 600;
line-height: 1.35;
margin: 0 0 6px;
color: var(--bs-ink);
}
.boomerang-save__body {
font-size: 13.5px;
line-height: 1.55;
color: var(--bs-ink-soft);
margin: 0 0 12px;
}
.boomerang-save__chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
background: var(--bs-chip-bg);
color: var(--bs-chip-ink);
padding: 4px 10px;
border-radius: 8px;
margin: 0 0 14px;
}
.boomerang-save__trust {
list-style: none;
margin: 0 0 16px;
padding: 12px 0 0;
border-top: 1px solid var(--bs-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.boomerang-save__trust li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12.5px;
line-height: 1.4;
color: var(--bs-ink-soft);
}
.boomerang-save__trust svg {
width: 14px;
height: 14px;
flex-shrink: 0;
margin-top: 1px;
color: var(--bs-accent);
}
/* !important throughout: this block is dropped into a host page whose own
stylesheet also targets `button`, and on equal specificity whichever
loads last wins — which was silently stripping background/padding/width
and leaving only the portal's default text-link button styling. */
.boomerang-save__cta {
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
width: 100% !important;
box-sizing: border-box !important;
padding: 11px 16px !important;
margin: 0 !important;
border-radius: 10px !important;
border: none !important;
background: var(--bs-accent) !important;
color: var(--bs-accent-ink) !important;
font: inherit !important;
font-weight: 600 !important;
font-size: 13.5px !important;
line-height: normal !important;
text-decoration: none !important;
cursor: pointer !important;
appearance: none !important;
-webkit-appearance: none !important;
transition: filter 0.15s ease, transform 0.15s ease;
}
.boomerang-save__cta:hover { filter: brightness(1.08); }
.boomerang-save__cta:active { transform: scale(0.98); }
.boomerang-save__cta:focus-visible { outline: 2px solid var(--bs-chip-ink); outline-offset: 2px; }
</style>
<script>
(function () {
"use strict";
/* ---------------------------------------------------------------------
* CONFIG — edit these for the merchant you're installing this on.
* ------------------------------------------------------------------- */
// Set true while you're testing placement/styling in the dev store.
// Forces the block to render with sample data so it's never just blank —
// you'll see a console note confirming it's in debug mode. Flip to false
// once the six data-* attributes above are wired to real values.
var DEBUG_MODE = true;
var DEBUG_SAMPLE_DATA = {
subscriptionId: "sub_debug_0001",
cancelledAt: new Date().toISOString(),
lastSessionStatus: "SAVED_BY_DISCOUNT",
lastSessionReason: "too_expensive",
lastSessionUpdatedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
qaToken: "debug-token"
};
// How many days after a cancel-flow save still counts as a "boomerang".
var WINDOW_DAYS = 7;
// Where the Quick Action page lives on this store.
var QUICK_ACTION_URL = "/a/account/quick-action-v3";
// The Quick Actions V3 stack configured in Skio admin for this block.
// Create it once with actions: [reactivateSubscription, applyDiscount]
// (or addFreeGift, etc. per reason) and paste its id here.
var QUICK_ACTION_STACK_ID = "REPLACE_WITH_REAL_STACK_ID";
// Cancel reason -> copy + offer. Keys must match the `reason` value your
// cancel flow stores on the session (see your CancelFlowV2Rebuttals /
// CancelFlowV3 config for the exact strings you use).
var REASON_COPY = {
too_expensive: {
kicker: "Because you mentioned cost",
title: "We saw you cancelled again.",
body:
"You took our offer last time — we get it, budget's tight right now. " +
"It shouldn't be a stretch to stay. Here's 15% off your next 2 orders, on us.",
chip: "15% off · next 2 orders"
},
no_effects: {
kicker: "Because you mentioned results",
title: "We saw you cancelled again.",
body:
"Not feeling it yet? Most subscribers say the effects really ramp up " +
"around month 3 — every month unlocks a bit more. Don't stop right before it clicks.",
chip: "Free bottle · month 3 milestone"
},
// Fallback for any reason not listed above.
default: {
kicker: "One more chance",
title: "We saw you cancelled again.",
body: "You gave us another shot last time and we don't want to lose you. Here's 10% off your next order if you come back.",
chip: "10% off · next order"
}
};
// Brand differentiators shown under the offer so this doesn't read as a
// bare discount grab — it re-sells the shopper on why the brand is worth
// staying for, not just the % off. Placeholder copy for Chimbles; swap in
// whatever's actually true and provable for the merchant you're installing
// this on (third-party test results, sourcing docs, a real founder story).
var TRUST_BUILDERS = [
{ icon: "flask", text: "Every batch 3rd-party lab tested for potency" },
{ icon: "leaf", text: "Organic lion's mane & reishi, sourced direct from growers" },
{ icon: "heart", text: "Formulated with a clinical mycologist, not a flavor house" }
];
var TRUST_ICONS = {
flask: '<path d="M9 3h6M10 3v6l-5.2 9.1A1.5 1.5 0 0 0 6.1 20.5h11.8a1.5 1.5 0 0 0 1.3-2.4L14 9V3"/>',
leaf: '<path d="M11 20A7 7 0 0 1 4 13c0-6 8-11 16-11 0 8-5 16-11 16-1.5 0-2.5-.3-3.5-1Z"/><path d="M4 20 15 9"/>',
heart: '<path d="M12 20.5s-7.5-4.6-9.8-9.3C.7 7.6 2.4 4 6 4c2 0 3.5 1.1 4.3 2.3.4.6 1.4.6 1.8 0C13 5.1 14.5 4 16.5 4c3.6 0 5.3 3.6 3.8 7.2C17.9 15.9 12 20.5 12 20.5Z"/>'
};
/* ---------------------------------------------------------------------
* DETECTION LOGIC
* ------------------------------------------------------------------- */
function parseDate(value) {
if (!value) return null;
var d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
function daysBetween(a, b) {
if (!a || !b) return Infinity;
return Math.abs(a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24);
}
function readContext(el) {
if (DEBUG_MODE) {
return {
subscriptionId: DEBUG_SAMPLE_DATA.subscriptionId,
cancelledAt: DEBUG_SAMPLE_DATA.cancelledAt,
lastSessionStatus: DEBUG_SAMPLE_DATA.lastSessionStatus,
lastSessionReason: DEBUG_SAMPLE_DATA.lastSessionReason,
lastSessionUpdatedAt: DEBUG_SAMPLE_DATA.lastSessionUpdatedAt,
qaToken: DEBUG_SAMPLE_DATA.qaToken
};
}
return {
subscriptionId: el.getAttribute("data-subscription-id") || "",
cancelledAt: el.getAttribute("data-cancelled-at") || "",
lastSessionStatus: el.getAttribute("data-last-session-status") || "",
lastSessionReason: el.getAttribute("data-last-session-reason") || "default",
lastSessionUpdatedAt: el.getAttribute("data-last-session-updated-at") || "",
qaToken: el.getAttribute("data-qa-token") || ""
};
}
// Mirrors the check the block runs: most recent cancel-flow session has
// to be a SAVED_BY_* status, and the gap between that save and this new
// cancellation has to be inside WINDOW_DAYS.
function getBoomerangOffer(ctx) {
var savedAt = parseDate(ctx.lastSessionUpdatedAt);
var cancelledAt = parseDate(ctx.cancelledAt);
var wasSaved = ctx.lastSessionStatus.indexOf("SAVED_BY_") === 0;
var withinWindow = daysBetween(cancelledAt, savedAt) <= WINDOW_DAYS;
if (!wasSaved || !withinWindow) return null;
return REASON_COPY[ctx.lastSessionReason] || REASON_COPY.default;
}
function buildQuickActionUrl(ctx) {
var params = new URLSearchParams({
stackId: QUICK_ACTION_STACK_ID,
subscriptionId: ctx.subscriptionId,
qaToken: ctx.qaToken
});
return QUICK_ACTION_URL + "?" + params.toString();
}
/* ---------------------------------------------------------------------
* RENDER
* ------------------------------------------------------------------- */
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, function (c) {
return { "&": "&", "<": "<", ">": ">" }[c];
});
}
function renderTrustBuilders() {
return (
'<ul class="boomerang-save__trust">' +
TRUST_BUILDERS.map(function (item) {
var path = TRUST_ICONS[item.icon] || TRUST_ICONS.heart;
return (
'<li>' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + path + '</svg>' +
'<span>' + escapeHtml(item.text) + '</span>' +
'</li>'
);
}).join("") +
'</ul>'
);
}
function render(el) {
var ctx = readContext(el);
var offer = getBoomerangOffer(ctx);
if (DEBUG_MODE) {
console.log("[boomerang-save] DEBUG_MODE is on — rendering with sample data, not the page's real attributes.", ctx);
}
if (!offer) {
el.hidden = true; // not eligible — leave nothing behind in the portal
console.log("[boomerang-save] not eligible, block hidden. Context read:", ctx, "— if this is unexpected, check that the data-* attributes on #boomerang-save-block are actually populated (view page source).");
return;
}
el.hidden = false;
el.innerHTML =
'<div class="boomerang-save__card">' +
'<p class="boomerang-save__kicker">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 12h16M14 6l6 6-6 6"/></svg>' +
escapeHtml(offer.kicker) +
"</p>" +
'<h3 class="boomerang-save__title">' + escapeHtml(offer.title) + "</h3>" +
'<p class="boomerang-save__body">' + escapeHtml(offer.body) + "</p>" +
'<span class="boomerang-save__chip">' + escapeHtml(offer.chip) + "</span>" +
renderTrustBuilders() +
'<button type="button" class="boomerang-save__cta">Get Back on Board</button>' +
"</div>";
el.querySelector(".boomerang-save__cta").addEventListener("click", function () {
window.location.href = buildQuickActionUrl(ctx);
});
}
function init() {
try {
var el = document.getElementById("boomerang-save-block");
if (!el) {
console.error("[boomerang-save] #boomerang-save-block not found on the page — the HTML block may not have saved, or this script ran before it was added to the DOM.");
return;
}
render(el);
} catch (err) {
console.error("[boomerang-save] failed to render:", err);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
How to install
- Copy the code above, or download the .html file.
- Go to
Storefront→Customer Portal→Branding→Layoutand clickAdd custom extension. - Pick
HTML with embedded CSS and JavaScriptand paste the whole file into the code editor. - Swap anything the file asks you to, like
[BRAND], product names or Quick Action IDs. The AI prompt below can walk you through it. - Save, click
+to add it to the layout, thenPublish. Check it on a test subscription’s magic link with?forcePreview=true, since some scripts don’t run in Preview.
Fill the six data-* attributes with the subscriber's subscription ID, cancel date and last cancel-flow session, plus a Quick Action token. Turn on DEBUG_MODE to check the design before you wire in real data.
Customize it with AI
Paste this prompt into Claude Code or Codex. It asks for the code and your brand and product details, then writes a version for your store.
I want to add a custom extension from customizeyourportal.xyz to my subscription customer portal. Walk me through customizing it for my brand, then write the finished code. EXTENSION Name: Boomerang Save (No. 05) Page: /blocks/boomerang-save What it does: Catches subscribers who cancel right after taking a save offer, with a win-back that fits their reason. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Fill the six data-* attributes with the subscriber's subscription ID, cancel date and last cancel-flow session, plus a Quick Action token. Turn on DEBUG_MODE to check the design before you wire in real data. HOW TO WORK 1. Ask me to paste the full code (the "Copy code" button on the page) or to give you the path to the downloaded boomerang-save.html. Wait until you have it. 2. Read the whole file. List every value that is specific to the original brand or needs my input: brand name and [PLACEHOLDERS], product and variant names or IDs, prices, discount codes and reward amounts, Quick Action IDs or tokens, store domains and API endpoints, colors, fonts, logos and images, and any copy that names the original brand, products or offers. Include everything in the builder's notes above. 3. Ask me for those values in one numbered list, with the current value next to each, so I can answer or say "keep". If you can open web pages, offer to pull my brand colors, fonts, logo and product names from my store's URL. 4. Don't invent stats, reviews, IDs, prices or claims. If I don't have a value, leave a clearly marked TODO in the code and tell me. 5. Write the customized version as boomerang-save-<my-brand>.html. Change only what my brand needs. Keep the logic, behavior and structure the same, and keep it one self-contained file (HTML, <style> and <script>). 6. Finish with a short list of what you changed, any TODOs left, and these install steps for Skio: 1. Go to `Storefront` → `Customer Portal` → `Branding` → `Layout` and click `Add custom extension`. 2. Pick `HTML with embedded CSS and JavaScript` and paste the whole file into the code editor. 3. Save, click `+` to add it to the layout, then `Publish`. Check it on a test subscription’s magic link with `?forcePreview=true`, since some scripts don’t run in Preview.
Blocks are shared as-is by the people who built them. Read the code before you add it to your portal, and test it with a test subscriber first.