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.txt2Open 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.