QUICKSTART

Live in about 10 minutes

Three pieces: a script on your cancel button, two small endpoints on your server, and your secret in an env var. Works with Dodo Payments and Stripe today; Paddle, Lemon Squeezy, Polar and Chargebee are coming soon.

1Create your workspace

Sign up, pick your gateway in onboarding and copy your public key (bb_pub_โ€ฆ). In Dashboard โ†’ Settings, rotate once to reveal your signing secret and save it as BOOLBUL_SIGNING_SECRET on your server.

The signing secret is shown once. It never goes in browser code. Your gateway API key never leaves your server either โ€” BoolBul doesn't need it.

Shortcut: install with an AI coding agent

Paste this prompt into Cursor, Claude Code, or your AI coding agent to inspect your app and wire up the integration automatically:

Prompt for Cursor / Claude Code
BoolBul is a cancellation-flow widget for SaaS that intercepts cancellation attempts to offer pauses or discounts, and you need to integrate it into this codebase.

First, inspect the codebase: find the existing "cancel subscription" button and the current cancellation code, and detect the framework and where server routes live.

Step 1: Client integration
Install @boolbul/sdk (or use the script tag if there is no bundler) and open BoolBul from the cancel button.

React / Next.js example:
import { useBoolBul } from "@boolbul/sdk/react";

export function CancelButton({ user }) {
  const openRetention = useBoolBul({ publicKey: "bb_pub_your_key" });
  return (
    <button
      onClick={() =>
        openRetention({
          customerId: user.customerId,
          subscriptionId: user.subscriptionId,
          mrrCents: user.planPriceCents,
          onOfferAccepted: (offer) => api.post("/api/boolbul/apply", { offerJwt: offer.offerJwt }),
          onCancel: (details) => api.post("/api/boolbul/cancel", details).then(() => router.refresh()),
        })
      }
    >
      Cancel subscription
    </button>
  );
}

Plain HTML / script tag example:
<script src="https://boolbul.com/v1/boolbul.js" data-public-key="bb_pub_your_key" defer></script>
<script>
  document.getElementById("cancel-subscription").addEventListener("click", () => {
    BoolBul.open({
      publicKey: "bb_pub_your_key",
      customerId: currentUser.customerId,         // your billing customer id
      subscriptionId: currentUser.subscriptionId, // sub_... from Dodo or Stripe
      mrrCents: 4900,                             // optional: powers "saved MRR"

      // Customer took an offer -> apply it on YOUR server
      onOfferAccepted: (offer) =>
        fetch("/api/boolbul/apply", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ offerJwt: offer.offerJwt }),
        }).then((res) => { if (!res.ok) throw new Error("apply failed"); }),

      // Customer chose to cancel (or BoolBul was unreachable for 1.5s)
      onCancel: ({ reasonId, comment }) =>
        fetch("/api/boolbul/cancel", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ reasonId, comment }),
        }).then(() => location.reload()),
    });
  });
</script>

Step 2: Server routes
Add two server routes, apply and cancel. Adapt this Next.js example to the detected framework:

// app/api/boolbul/apply/route.ts  (Next.js; same idea in Express, Hono, Remix...)
import { redeemOffer } from "@boolbul/sdk/server";

const gateway = { gateway: "dodo_payments", apiKey: process.env.DODO_PAYMENTS_API_KEY!, environment: "live_mode" } as const;

export async function POST(req: Request) {
  const user = await requireUser(req); // YOUR auth: never trust ids from the body
  const { offerJwt } = await req.json();

  await redeemOffer(offerJwt, {
    signingSecret: process.env.BOOLBUL_SIGNING_SECRET!, // Dashboard -> Settings
    expected: { subscriptionId: user.subscriptionId },
    // Optional replay guard: return true if this nonce was already used
    seenNonce: async (nonce) => !(await redis.set(`boolbul:${nonce}`, 1, { nx: true, ex: 300 })),
    gateway,
  });
  return Response.json({ ok: true });
}

// app/api/boolbul/cancel/route.ts
import { cancelSubscription } from "@boolbul/sdk/server";

export async function POST(req: Request) {
  const user = await requireUser(req);
  const { reasonId, comment } = await req.json();
  // Cancels at period end and records the exit reason in your gateway's churn analytics
  await cancelSubscription(user.subscriptionId, gateway, { reasonId, comment });
  return Response.json({ ok: true });
}

Step 3: Environment variables
Add placeholder names for BOOLBUL_SIGNING_SECRET and DODO_PAYMENTS_API_KEY to .env.example only. Never invent or hard-code values.

Security rules:
- The signing secret and the gateway key are server-only, never in browser code.
- Take the subscription id from the logged-in user's session, never from the request body.
- Keep the existing cancellation path working, because onCancel also runs when BoolBul fails open.

Step 4: Verification
Use a test-mode subscription, click cancel, accept an offer, confirm the subscription changed in Dodo Payments.

List the files you changed.

Documentation:
- https://boolbul.com/docs/quickstart
- https://boolbul.com/llms.txt

2Open BoolBul from your cancel button

Plain HTML / any framework:

index.html
<script src="https://boolbul.com/v1/boolbul.js" data-public-key="bb_pub_your_key" defer></script>
<script>
  document.getElementById("cancel-subscription").addEventListener("click", () => {
    BoolBul.open({
      publicKey: "bb_pub_your_key",
      customerId: currentUser.customerId,         // your billing customer id
      subscriptionId: currentUser.subscriptionId, // sub_... from Dodo or Stripe
      mrrCents: 4900,                             // optional: powers "saved MRR"

      // Customer took an offer -> apply it on YOUR server
      onOfferAccepted: (offer) =>
        fetch("/api/boolbul/apply", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ offerJwt: offer.offerJwt }),
        }).then((res) => { if (!res.ok) throw new Error("apply failed"); }),

      // Customer chose to cancel (or BoolBul was unreachable for 1.5s)
      onCancel: ({ reasonId, comment }) =>
        fetch("/api/boolbul/cancel", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ reasonId, comment }),
        }).then(() => location.reload()),
    });
  });
</script>

React / Next.js (npm install @boolbul/sdk):

CancelButton.tsx
import { useBoolBul } from "@boolbul/sdk/react";

export function CancelButton({ user }) {
  const openRetention = useBoolBul({ publicKey: "bb_pub_your_key" });
  return (
    <button
      onClick={() =>
        openRetention({
          customerId: user.customerId,
          subscriptionId: user.subscriptionId,
          mrrCents: user.planPriceCents,
          onOfferAccepted: (offer) => api.post("/api/boolbul/apply", { offerJwt: offer.offerJwt }),
          onCancel: (details) => api.post("/api/boolbul/cancel", details).then(() => router.refresh()),
        })
      }
    >
      Cancel subscription
    </button>
  );
}

If BoolBul can't answer within 1.5 seconds, it steps aside and calls onCancel so your normal cancellation still runs.

3Apply offers and cancellations on your server

The widget hands you a short-lived signed offerJwt. Verify it with your secret, then let the adapter call your gateway:

Dodo Payments
// app/api/boolbul/apply/route.ts  (Next.js; same idea in Express, Hono, Remix...)
import { redeemOffer } from "@boolbul/sdk/server";

const gateway = { gateway: "dodo_payments", apiKey: process.env.DODO_PAYMENTS_API_KEY!, environment: "live_mode" } as const;

export async function POST(req: Request) {
  const user = await requireUser(req); // YOUR auth: never trust ids from the body
  const { offerJwt } = await req.json();

  await redeemOffer(offerJwt, {
    signingSecret: process.env.BOOLBUL_SIGNING_SECRET!, // Dashboard -> Settings
    expected: { subscriptionId: user.subscriptionId },
    // Optional replay guard: return true if this nonce was already used
    seenNonce: async (nonce) => !(await redis.set(`boolbul:${nonce}`, 1, { nx: true, ex: 300 })),
    gateway,
  });
  return Response.json({ ok: true });
}

// app/api/boolbul/cancel/route.ts
import { cancelSubscription } from "@boolbul/sdk/server";

export async function POST(req: Request) {
  const user = await requireUser(req);
  const { reasonId, comment } = await req.json();
  // Cancels at period end and records the exit reason in your gateway's churn analytics
  await cancelSubscription(user.subscriptionId, gateway, { reasonId, comment });
  return Response.json({ ok: true });
}
Using Stripe instead?
Stripe
// app/api/boolbul/apply/route.ts  (Next.js; same idea in Express, Hono, Remix...)
import { redeemOffer } from "@boolbul/sdk/server";

const gateway = { gateway: "stripe", secretKey: process.env.STRIPE_SECRET_KEY! } as const;

export async function POST(req: Request) {
  const user = await requireUser(req); // YOUR auth: never trust ids from the body
  const { offerJwt } = await req.json();

  await redeemOffer(offerJwt, {
    signingSecret: process.env.BOOLBUL_SIGNING_SECRET!, // Dashboard -> Settings
    expected: { subscriptionId: user.subscriptionId },
    // Optional replay guard: return true if this nonce was already used
    seenNonce: async (nonce) => !(await redis.set(`boolbul:${nonce}`, 1, { nx: true, ex: 300 })),
    gateway,
  });
  return Response.json({ ok: true });
}

// app/api/boolbul/cancel/route.ts
import { cancelSubscription } from "@boolbul/sdk/server";

export async function POST(req: Request) {
  const user = await requireUser(req);
  const { reasonId, comment } = await req.json();
  // Cancels at period end and records the exit reason in your gateway's churn analytics
  await cancelSubscription(user.subscriptionId, gateway, { reasonId, comment });
  return Response.json({ ok: true });
}

4Check it works

  • Use a test subscription (Dodo test_mode / Stripe test keys) and click your cancel button.
  • Pick a reason, accept an offer โ€” the subscription should show the discount or the new billing date.
  • Open your dashboard: the session appears in the ledger with its reason and saved MRR.
  • Tweak copy and colors in the customizer โ€” changes go live within about a minute.