Base 3:16
Guides

Retention offers

Offer a discount to subscribers who set out to cancel, find out why they are leaving, and see how much revenue the offer keeps.

A subscriber clicking Cancel is the last chance to keep them. A retention offer turns that click into a short flow:

  1. Why are you leaving? An optional survey: one of seven reasons, plus a comment.
  2. Before you go. When the environment makes an offer and the subscriber qualifies, they are offered a discount, say 50% off their next three invoices.
  3. Stay or leave. Accepting applies the discount and the subscription carries on. Declining cancels at the end of the period, as before.

Every attempt is recorded, whichever way it went, so the dashboard can show how many people tried to leave, why, how many stayed, and the monthly revenue they represent.

The hosted billing portal and <SubscriptionManager /> run this flow without any code from you. To build your own cancel screen, use the two-step API below.

Set up the offer

The offer is one of your existing coupons (Product → Coupons). It is not a separate kind of discount, so it is rated, counted against the coupon's maxRedemptions and printed on invoices exactly like any other subscription discount.

In the dashboard, open Settings → Retention offers, choose a coupon and turn on Make an offer. Or from your server:

await base316.retention.update({
	enabled: true,
	couponId: "cpn_...",
	// Optional: replace the default wording, which states the offer's terms.
	headline: "Before you go",
	message: "Stay on your plan and get 50% off your next 3 invoices.",
});

Making an offer needs the Pro plan or above. The survey and the report work on every plan, so a workspace that upgrades already has its history.

Who is offered what

A subscriber is offered the discount only when all of these hold:

  • the offer is on, and its coupon is active, unexpired, not used up and, for a fixed amount, in the subscription's currency;
  • the subscription is not free;
  • the subscription has no discount already. The offer never replaces one, because doing that could quietly swap a better deal for a worse one;
  • the customer has not accepted an offer in the last cooldownDays (365 by default). Without this limit, Cancel becomes the way to get a discount.

When no offer is made, the attempt records why (ineligibleReason) for your reporting. Never show that reason to the subscriber; they see no offer, and cancelling works as it always has.

An offer can be accepted for 24 hours after it is shown. A tab left open for a week must not redeem a coupon that has since been withdrawn.

Leaving is never blocked. The offer is shown, never imposed: confirming works whether an offer was made, expired or ignored, and "No thanks, cancel" sits beside "Accept offer and stay" rather than behind it.

Build your own cancel screen

In the browser, for a signed-in user

// 1. Start: records the reason, cancels nothing.
const attempt = await base316.billing.startCancellation(token, {
	reason: "TOO_EXPENSIVE", // optional
	comment: "Over budget this quarter", // optional
});

if (attempt.offer) {
	// 2. Show attempt.offer: its type, percentOff or amountOff, duration and
	//    durationInPeriods, plus your headline and message.
	//    The coupon's code is never included.
	await base316.billing.acceptRetentionOffer(token, attempt.id); // stays
	// …or
	await base316.billing.confirmCancellation(token, attempt.id); // leaves
} else {
	await base316.billing.confirmCancellation(token, attempt.id);
}

From your server

For an integration with its own sign-in, the same three steps take a secret key and a subscription id:

const attempt = await base316.retention.start(subscriptionId, { reason: "NOT_USING" });
await base316.retention.accept(attempt.id); // or retention.confirm(attempt.id)

Every language SDK has the same methods, for example start_cancellation, accept_retention_offer and confirm_cancellation in Python and Ruby.

The reasons are TOO_EXPENSIVE, NOT_USING, MISSING_FEATURES, SWITCHING, TECHNICAL_ISSUES, TEMPORARY and OTHER. GET /v1/retention lists them in display order.

CallErrors
POST /v1/me/subscription/cancellation404 no_subscription; 403 billed_by_parent
POST /v1/me/subscription/cancellation/:id/accept409 offer_expired, offer_unavailable (the coupon or plan changed since), already_resolved, no_offer; 404
POST /v1/me/subscription/cancellation/:id/confirm409 already_resolved (they accepted the offer); confirming twice answers as the first time did

Starting again while an attempt is open returns that same attempt, so a double click or the back button does not count as two people leaving.

The one-step POST /v1/me/subscription/cancel still works and still cancels straight away. It makes no offer, but it is recorded, so the report counts everyone who left.

Accepting after a cancellation was already scheduled

A subscriber whose subscription is already set to end, and who then goes through the flow and accepts, is un-cancelled: the discount goes on and the subscription renews. The webhook says so with resumed: true.

Webhooks

EventchangeCarries
subscription.updatedretention_offer_acceptedcancellationId, cancellationReason, couponId, discountId, resumed
subscription.updatedcancel_scheduledcancellationId and cancellationReason when it came through a cancel flow

The report

Insights → Retention shows, for the last 7, 30, 90 or 365 days:

  • how many subscribers started to cancel, how many were saved, and the save rate, which is saves divided by offers made;
  • the monthly revenue kept: the recurring value of the saved subscriptions, per currency, before the discount;
  • the breakdown by reason;
  • the most recent attempts, with what was offered and what the subscriber chose. An attempt nobody finished, whose offer has lapsed, shows as Abandoned; that subscriber is still subscribed.

The same figures come from GET /v1/retention/report (base316.retention.report()) and the attempts from GET /v1/cancellations.

Privacy

The comment is in the subscriber's own words. When a person exercises their right to erasure, the comment is cleared wherever their billing account's name and address are stripped. The attempt itself stays, as a billing record.

On this page