Skip to main content
The built-in cancel flow (presentCancelFlow) provides a complete retention sheet out of the box. If you need full control over the UI — custom animations, native-feeling screens, or a flow that matches your app’s design system — use the headless cancel flow primitives instead. You get the same backend-driven questionnaire, save offers, and pause options, but you render every pixel yourself.

How It Works

The headless cancel flow is a set of flat, standalone methods. There’s no session object or state machine — you call each primitive when you need it, in whatever order makes sense for your UI.
┌─────────────┐
│  Your UI     │  ← You build this
└──────┬──────┘


┌──────────────────────────────────────────────────┐
│  cancelFlowConfig         → Read questionnaire   │
│  acceptSaveOffer()        → Apply Stripe coupon   │
│  pauseSubscription()      → Pause via Stripe      │
│  cancelSubscription()     → Cancel via Stripe     │
│  openCustomerPortal()     → Stripe billing portal │
│  submitCancelFlowResponse() → Analytics tracking  │
└──────────────────────────────────────────────────┘

Quick Start

// 1. Config is already loaded — read it directly
guard let config = ZeroSettle.shared.cancelFlowConfig, config.enabled else {
    return // Cancel flow not configured
}

// 2. Show your questionnaire UI using config.questions
let questions = config.questions.sorted(by: { $0.order < $1.order })

// 3. Check if the user's answer triggers a save offer
let selectedOption = questions[0].options.first { $0.id == selectedOptionId }
if selectedOption?.triggersOffer == true, let offer = config.offer, offer.enabled {
    // Show your save offer UI using offer.title, offer.body, offer.ctaText
}

// 4. If user accepts the offer, apply it
let result = try await ZeroSettle.shared.acceptSaveOffer(
    productId: "premium_monthly",
    userId: currentUser.id
)
// result.message = "40% off for 3 months"

// 5. Submit analytics
await ZeroSettle.shared.submitCancelFlowResponse(CancelFlow.Response(
    productId: "premium_monthly",
    userId: currentUser.id,
    outcome: .retained,
    answers: [CancelFlow.Answer(questionId: 1, selectedOptionId: 3)],
    offerShown: true,
    offerAccepted: true
))

Configuration

The cancel flow config is fetched automatically during bootstrap() — no extra network call is needed. Access it directly from the SDK.
let config = ZeroSettle.shared.cancelFlowConfig // CancelFlow.Config?
Returns null if the cancel flow hasn’t been configured on the dashboard.

Config Shape

FieldTypeDescription
enabledBoolWhether the cancel flow is active
questions[Question]Ordered questionnaire questions
offerOffer?Save offer config (if configured)
pausePauseConfig?Pause option config (if configured)

Question

FieldTypeDescription
idIntQuestion identifier
orderIntDisplay order
questionTextStringThe question to display
questionTypesingle_select | free_textInput type
isRequiredBoolWhether an answer is required
options[Option]Answer options (for single-select questions)

Option

FieldTypeDescription
idIntOption identifier
orderIntDisplay order
labelStringOption text
triggersOfferBoolIf true, selecting this option should show the save offer
triggersPauseBoolIf true, selecting this option should show the pause option

Offer

FieldTypeDescription
enabledBoolWhether the offer is active
titleStringOffer heading (e.g., “Wait — how about a discount?”)
bodyStringOffer description
ctaTextStringAccept button text (e.g., “Stay & Save 40%“)
typeStringOffer type (discount, free_trial, free_extension)
valueStringOffer value (e.g., "40" for 40% off)

Pause Config

FieldTypeDescription
enabledBoolWhether pause is active
titleStringPause section heading
bodyStringPause description
ctaTextStringPause button text
options[PauseOption]Available pause durations

Pause Option

FieldTypeDescription
idIntOption identifier (pass to pauseSubscription)
orderIntDisplay order
labelStringDisplay text (e.g., “1 month”, “3 months”)
durationTypedays | fixed_dateHow the duration is specified
durationDaysInt?Number of days (when durationType is days)

Save Offer

When a user selects a questionnaire option where triggersOffer is true, show the save offer from config.offer. If the user accepts, call acceptSaveOffer to apply the discount via Stripe.
do {
    let result = try await ZeroSettle.shared.acceptSaveOffer(
        productId: "premium_monthly",
        userId: currentUser.id
    )
    showConfirmation(result.message) // "40% off for 3 months"
} catch {
    showError(error.localizedDescription)
}

SaveOfferResult

FieldTypeDescription
messageStringHuman-readable description (e.g., “40% off for 3 months”)
discountPercentInt?Discount percentage, if applicable
durationMonthsInt?Duration of the discount in months, if applicable
acceptSaveOffer creates a Stripe coupon and applies it to the user’s subscription. This is a real billing action — the discount takes effect immediately.

Pause Subscription

When a user selects a questionnaire option where triggersPause is true, show the pause options from config.pause. Let the user pick a duration, then call pauseSubscription with the selected option ID.
do {
    let resumesAt = try await ZeroSettle.shared.pauseSubscription(
        productId: "premium_monthly",
        userId: currentUser.id,
        pauseOptionId: selectedPauseOption.id
    )
    if let resumesAt {
        showConfirmation("Paused until \(resumesAt.formatted())")
    }
} catch {
    showError(error.localizedDescription)
}
Returns an ISO 8601 date string (or Date on Swift) for when the subscription will automatically resume, or null if the pause was applied without a set resume date.

Resume Subscription

To resume a paused subscription early:
try await ZeroSettle.shared.resumeSubscription(
    productId: "premium_monthly",
    userId: currentUser.id
)

Cancel Subscription

If the user completes the questionnaire and still wants to cancel:
try await ZeroSettle.shared.cancelSubscription(
    productId: "premium_monthly",
    userId: currentUser.id,
    immediate: false // Cancel at period end (default)
)
ParameterTypeDefaultDescription
productIdStringThe product to cancel
userIdStringYour app’s user identifier
immediateBoolfalseIf true, cancel immediately. If false, cancel at the end of the current billing period.

Cancel Flow UI

As a fallback or alternative, open the Stripe customer portal where the user can manage billing, update payment methods, and cancel directly:
try await ZeroSettle.shared.openCustomerPortal(userId: currentUser.id)

Analytics

After the user completes your cancel flow — regardless of outcome — submit a response for analytics tracking. This powers your retention funnel metrics on the dashboard.
await ZeroSettle.shared.submitCancelFlowResponse(CancelFlow.Response(
    productId: "premium_monthly",
    userId: currentUser.id,
    outcome: .cancelled,
    answers: [
        CancelFlow.Answer(questionId: 1, selectedOptionId: 5),
        CancelFlow.Answer(questionId: 2, freeText: "Too expensive"),
    ],
    offerShown: true,
    offerAccepted: false
))
This is fire-and-forget — it resolves immediately and never throws. Failed submissions are silently dropped.

Response Fields

FieldTypeDescription
productIdStringThe product the user was cancelling
userIdStringYour app’s user identifier
outcomecancelled | retained | paused | dismissedWhat the user ultimately did
answers[Answer]Questionnaire answers
offerShownBoolWhether you showed the save offer
offerAcceptedBoolWhether the user accepted the save offer
pauseShownBoolWhether you showed the pause option
pauseAcceptedBoolWhether the user accepted the pause
pauseDurationDaysInt?Days paused, if applicable

Typical Flow

Here’s a typical sequence for a cancel flow with a save offer:
User taps "Cancel Subscription"


Read cancelFlowConfig.questions
Show questionnaire UI

    ▼ (user selects an option where triggersOffer = true)

Show save offer (config.offer.title, body, ctaText)

    ├── User accepts → acceptSaveOffer() → Show "Thanks for staying!"

    ├── User declines, triggersPause = true → Show pause options
    │   ├── User pauses → pauseSubscription() → Show "Paused until..."
    │   └── User declines → cancelSubscription() → Show "Cancelled"

    └── User declines → cancelSubscription() → Show "Cancelled"


submitCancelFlowResponse() ← Always call this at the end
The questionnaire, save offer text, and pause options are all configured on the ZeroSettle dashboard — no code changes needed to update them.

Relationship to presentCancelFlow

presentCancelFlow uses the same backend primitives internally. Choose based on your needs:
presentCancelFlowHeadless Primitives
EffortOne method callYou build the entire UI
CustomizationNone — uses the built-in sheetFull control over layout, animations, and copy
PlatformsiOS, Android (built-in UI), FlutterAll platforms
AnalyticsAutomaticYou call submitCancelFlowResponse
State managementHandled internallyYou manage the flow state

Next Steps