Dashboard/Integration Guide

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)

1

Add environment variable

Add to your .env.local:

NEXT_PUBLIC_RETAINLY_KEY=rk_pub_your_key_here

Find your key: Dashboard → Settings → API Keys

2

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>
  );
}
3

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>
  );
}
4

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>
  );
}
💡
Key principle: 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.
5

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)

1

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>
2

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>;
  };
}
3

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

1

Script tag in index.html

<head>
  <script src="https://getretainly.app/sdk/v1.js"
          data-key="rk_pub_your_key_here" async></script>
</head>
2

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

1

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>
2

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

1

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')],
2

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

1

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.