Integration Guide
Add Retainly to your app in 15 minutes. Pick your stack below.
Before you start
- ✓ You use Stripe as your payment provider
- ✓ You have access to your frontend code (cancel button)
- ✓ You've connected your Stripe account in Dashboard → Onboarding
- ✓ Your cancel flow is activated in Dashboard → Cancel Flows
▲ Next.js (App Router)
Add environment variable
Add to your .env.local:
NEXT_PUBLIC_RETAINLY_KEY=rk_pub_your_key_hereFind your key: Dashboard → Settings → API Keys
Load the SDK in your root layout
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children?: React.ReactNode }) {
return (
<html>
<body>
{children}
<Script
src="https://getretainly.app/sdk/v1.js"
data-key={process.env.NEXT_PUBLIC_RETAINLY_KEY}
strategy="afterInteractive"
/>
</body>
</html>
);
}Create a Cancel Button client component
// components/cancel-subscription-button.tsx
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function CancelSubscriptionButton({
subscriptionId,
customerId,
email,
}: {
subscriptionId: string;
customerId: string;
email: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleClick() {
if (!window.Retainly) {
// SDK not loaded yet — go straight to cancel
router.push('/account/cancel-confirm');
return;
}
setLoading(true);
await window.Retainly.openCancelFlow({
subscriptionId,
customerId,
email,
onSaved: () => {
setLoading(false);
router.refresh(); // reload server component to show updated plan
},
onCanceled: async () => {
// Call your own cancel endpoint:
await fetch('/api/subscriptions/cancel', { method: 'POST' });
router.push('/account?message=canceled');
},
onAbandoned: () => setLoading(false),
onError: () => {
setLoading(false);
router.push('/account/cancel-confirm'); // always let user cancel
},
});
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Loading...' : 'Cancel subscription'}
</button>
);
}Use it in your subscription page
// app/account/subscription/page.tsx (Server Component)
import { CancelSubscriptionButton } from '@/components/cancel-subscription-button';
import { getCurrentUser } from '@/lib/auth'; // your own auth helper
export default async function SubscriptionPage() {
const user = await getCurrentUser();
// subscriptionId and customerId come from YOUR database, not client-side
return (
<main>
<h1>Your subscription</h1>
<p>Current plan: {user.plan}</p>
<CancelSubscriptionButton
subscriptionId={user.stripeSubscriptionId}
customerId={user.stripeCustomerId}
email={user.email}
/>
</main>
);
}subscriptionId and customerId are passed from a Server Component (where you have database access). They never come from the client. These are non-secret Stripe IDs — safe to include in HTML.Test it
# Open your app in browser, open console:
# You should see: [Retainly] Initialized 1.0.0
# Add ?retainly_debug=1 to your URL for verbose logs:
# http://localhost:3000/account/subscription?retainly_debug=1⚛ React (Create React App / Vite)
Add the script to public/index.html
<!-- public/index.html -->
<head>
<script
src="https://getretainly.app/sdk/v1.js"
data-key="rk_pub_your_key_here"
async
></script>
</head>Add type declarations
Create src/retainly.d.ts:
interface Window {
Retainly?: {
openCancelFlow(opts: {
subscriptionId?: string;
customerId?: string;
email?: string;
language?: 'en' | 'de';
metadata?: Record<string, string>;
onSaved?: (data: { offer: string; offerValue: any }) => void;
onCanceled?: (data: { reason: string }) => void;
onAbandoned?: () => void;
onError?: (err: Error) => void;
}): Promise<void>;
};
}Cancel button component
// src/components/CancelButton.tsx
import { useState } from 'react';
export function CancelButton({
subscriptionId,
customerId,
email,
onCanceled,
}: {
subscriptionId: string;
customerId: string;
email: string;
onCanceled: () => void;
}) {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
await window.Retainly?.openCancelFlow({
subscriptionId,
customerId,
email,
onSaved: () => { setLoading(false); /* show success message */ },
onCanceled: () => { setLoading(false); onCanceled(); },
onAbandoned:() => setLoading(false),
onError: () => { setLoading(false); onCanceled(); }, // always allow cancel
});
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Loading...' : 'Cancel subscription'}
</button>
);
}💚 Vue.js
Script tag in index.html
<head>
<script src="https://getretainly.app/sdk/v1.js"
data-key="rk_pub_your_key_here" async></script>
</head>Cancel button component
<!-- components/CancelButton.vue -->
<template>
<button @click="handleCancel" :disabled="loading">
{{ loading ? 'Loading...' : 'Cancel subscription' }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const props = defineProps<{
subscriptionId: string;
customerId: string;
email: string;
}>();
const router = useRouter();
const loading = ref(false);
async function handleCancel() {
loading.value = true;
const sdk = (window as any).Retainly;
if (!sdk) {
router.push('/account/cancel-confirm');
return;
}
await sdk.openCancelFlow({
subscriptionId: props.subscriptionId,
customerId: props.customerId,
email: props.email,
onSaved: () => { loading.value = false; },
onCanceled: async () => {
await fetch('/api/cancel', { method: 'POST' });
router.push('/account?canceled=true');
},
onAbandoned: () => { loading.value = false; },
onError: () => { loading.value = false; router.push('/account/cancel-confirm'); },
});
}
</script>💎 Ruby on Rails
Add script to application layout
<%# app/views/layouts/application.html.erb %>
<head>
<%= javascript_include_tag "https://getretainly.app/sdk/v1.js",
"data-key": ENV["RETAINLY_PUBLIC_KEY"], async: true %>
</head>Cancel button partial
<%# app/views/subscriptions/_cancel_button.html.erb %>
<button
id="retainly-cancel"
data-subscription-id="<%= current_user.stripe_subscription_id %>"
data-customer-id="<%= current_user.stripe_customer_id %>"
data-email="<%= current_user.email %>"
data-cancel-url="<%= subscription_path %>"
>
Cancel subscription
</button>
<script>
document.getElementById('retainly-cancel')?.addEventListener('click', function(e) {
e.preventDefault();
const btn = e.currentTarget;
Retainly.openCancelFlow({
subscriptionId: btn.dataset.subscriptionId,
customerId: btn.dataset.customerId,
email: btn.dataset.email,
onSaved: () => window.location.reload(),
onCanceled: () => {
// Rails DELETE with CSRF token:
const form = document.createElement('form');
form.method = 'POST';
form.action = btn.dataset.cancelUrl;
form.innerHTML =
'<input type="hidden" name="_method" value="DELETE">' +
'<input type="hidden" name="authenticity_token" value="' +
document.querySelector('meta[name=csrf-token]').content + '">';
document.body.appendChild(form);
form.submit();
},
onError: () => window.location.href = '/subscriptions/cancel_confirm',
});
});
</script>🔴 Laravel
Add to app.blade.php
<head>
<script
src="https://getretainly.app/sdk/v1.js"
data-key="{{ config('services.retainly.public_key') }}"
async
></script>
</head>In config/services.php:
'retainly' => ['public_key' => env('RETAINLY_PUBLIC_KEY')],Blade component
<button
id="retainly-cancel"
data-subscription-id="{{ $subscription->stripe_id }}"
data-customer-id="{{ auth()->user()->stripe_id }}"
data-email="{{ auth()->user()->email }}"
>Cancel subscription</button>
<script>
document.getElementById('retainly-cancel')?.addEventListener('click', function(e) {
e.preventDefault();
const btn = e.currentTarget;
Retainly.openCancelFlow({
subscriptionId: btn.dataset.subscriptionId,
customerId: btn.dataset.customerId,
email: btn.dataset.email,
onSaved: () => window.location.reload(),
onCanceled: () => {
fetch('{{ route("subscription.cancel") }}', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
}).then(() => window.location.href = '/dashboard?canceled=true');
},
onError: () => window.location.href = '/subscription/cancel-confirm',
});
});
</script>🟨 Vanilla JavaScript / HTML
Add script and button
<head>
<script src="https://getretainly.app/sdk/v1.js"
data-key="rk_pub_your_key_here" async></script>
</head>
<body>
<!-- Your cancel button — data attributes set server-side -->
<button
id="cancel-btn"
data-sub="{{ user.stripe_sub_id }}"
data-cus="{{ user.stripe_customer_id }}"
data-email="{{ user.email }}"
>
Cancel subscription
</button>
<script>
document.getElementById('cancel-btn').addEventListener('click', function(e) {
e.preventDefault();
const btn = e.currentTarget;
Retainly.openCancelFlow({
subscriptionId: btn.dataset.sub,
customerId: btn.dataset.cus,
email: btn.dataset.email,
onSaved: function(data) {
// Offer was accepted and applied in Stripe automatically.
// Show a confirmation to the user:
document.getElementById('cancel-btn').textContent = '✓ Saved!';
},
onCanceled: function(data) {
// User confirmed — run YOUR cancellation:
window.location.href = '/cancel-confirmed?reason=' + data.reason;
},
onError: function() {
// Always let the user cancel, even on error:
window.location.href = '/cancel-confirm';
}
});
});
</script>
</body>Frequently asked questions
Do I need to change my backend?
No. The SDK works entirely client-side. Discounts and pauses are applied directly in Stripe via your connected account. The only backend change you might want: an endpoint in onCanceled to log the cancellation in your own database.
What if the SDK doesn't load (network error)?
Always implement onError and call your normal cancel logic inside it. This ensures users can always cancel, even if Retainly is unreachable. The SDK is loaded from a CDN with 99.9% uptime, but defense in depth matters.
Where do subscriptionId and customerId come from?
From your database or from Stripe. You stored these when the user first subscribed (during Stripe Checkout). They're Stripe IDs that start with sub_ and cus_. Never fetch these from the client — always pass them from your server-rendered HTML.
Can I test without real subscriptions?
Yes. Use Stripe test mode. The modal will show with full functionality, and test sessions appear in your Retainly dashboard. No real discounts are applied in test mode.
What about users who don't use Stripe?
The cancel modal shows for all users regardless. However, offer execution (applying discounts, pausing) only works if you've connected Stripe. For other payment providers, implement the offer logic yourself inside onSaved.
Still stuck?
Email us: hello@getretainly.app — we respond within a few hours and will walk you through it.