Webhook icon
Schedule icon
Script icon
Docker icon
Queries icon
If icon
MailSend icon
SlackIncomingWebhook icon
Log icon
Request icon

Subscription Cancellation Save Offer and Churn Guard

Assess subscription cancellation intent, send a tiered save offer, apply the retention win on acceptance, and finalize the churn on decline or timeout.

Categories
BusinessCore

Cancellation is the moment a SaaS company either saves the relationship or loses the revenue, and the decision is a workflow rather than a one off script. This blueprint takes a cancellation intent event from your billing provider, scores the account, sends the right save offer, tracks the response, and either applies the retention win or closes the loop on the churn. Every step lands in PostgreSQL and in the execution log, so the customer success team can always see why a given account received a pause instead of a discount.

How it works

  1. The cancellation_intent trigger (io.kestra.plugin.core.trigger.Webhook, guarded by a secret key) receives the billing provider cancellation event, and the disabled daily timeout_sweep Schedule trigger is the sweep entry point for offers whose response window has closed.
  2. The assess_eligibility task (io.kestra.plugin.scripts.python.Script on a Docker task runner) reads the event body, falling back to the event_payload input on manual runs, and evaluates tenure months, monthly recurring revenue, usage trend, prior save count and segment. It emits eligible, offer_tier (pause, discount, downgrade or none), verdict, final_state, mrr_at_risk, the offer summary and the response state through the ::json:: output protocol.
  3. The record_intervention task (io.kestra.plugin.jdbc.postgresql.Queries with fetchType: NONE) upserts one row per customer into public.churn_interventions, so the latest assessment always replaces the previous one.
  4. The offer_dispatch_gate task (io.kestra.plugin.core.flow.If) sends the save offer only when the account is eligible and the event is a fresh cancellation intent. Eligible accounts get a personalized email (io.kestra.plugin.email.MailSend) whose offer text follows the chosen tier, plus a Slack heads up to the customer success channel through io.kestra.plugin.slack.notifications.SlackIncomingWebhook. Every other run falls through to log_finalize_pref, which records the finalize preference without sending an offer.
  5. The response_phase_gate task branches only when the payload carries an offer response or an offer timeout, so fresh intents end waiting for a reply. Inside it, accepted_gate applies an accepted offer with io.kestra.plugin.core.http.Request against the billing API and writes the retention win back to PostgreSQL, while declined or timed out offers log the churn report and optionally POST the cancellation finalization when auto_finalize_decline is enabled.
  6. Any failure in the database, the SMTP relay or the HTTP calls lands in the errors block, which posts the failing execution to Slack.

What you get

A single eligibility verdict per cancellation event with a tiered offer decision attached to it, so the save motion stays consistent across accounts instead of depending on who picked up the ticket. A personalized offer email that proposes a pause, a time boxed discount or a plan downgrade depending on tenure, revenue and usage trend. A Slack alert that gives the customer success team the account context, the offer tier and the response deadline in one message. A retention win record in PostgreSQL next to the billing API call that applies the credit or pause. A churn report path that records the final state and the monthly recurring revenue at risk when the offer is declined or times out. Typed flow outputs (verdict, offer_tier, eligible, final_state, mrr_at_risk) that a parent revenue flow or dashboard can consume.

Who it's for

Customer success teams that own gross revenue retention and want the first save offer out while the cancellation intent is still fresh. SaaS founders who need a repeatable retention motion without a lifecycle marketer hand writing every offer. Revenue operations teams who care that each intervention, each tier decision and each outcome is queryable in PostgreSQL instead of scattered across an inbox. Platform teams who prefer the billing webhook to trigger a reviewed workflow rather than a script running out of a cron box.

Why orchestrate this with Kestra

The value of a save offer is timing and consistency, and both break the moment the logic lives in an ad hoc script. Kestra gives the run a durable execution record, which event arrived, what the assessment decided, which offer went out and how the response was handled, all tied to one execution id. The eligibility rules sit in a python task with a ::json:: contract, the dispatch is a real branch rather than an if statement buried in a handler, and the declined path is a separate branch that can optionally finalize the cancellation over HTTP. Secrets for SMTP, PostgreSQL, Slack and the billing API stay in the namespace instead of inside the event handler, and the errors handler pages the team when the relay or the database is down. Tightening a threshold or adding a new tier is a YAML diff that gets reviewed before it ever reaches a customer.

Prerequisites

  • A Kestra instance with the core, Python script, PostgreSQL, email and Slack plugins available to the worker, plus network access to your billing API.
  • A PostgreSQL table public.churn_interventions with customer_id as the primary key and columns for segment, tenure_months, mrr_usd, usage_trend, prior_saves, eligible, offer_tier, verdict, event_kind, offer_response and updated_at.
  • A PostgreSQL table public.retention_wins with customer_id as the primary key and columns for offer_tier, retention_action, mrr_credit, mrr_at_risk, execution_id and won_at.
  • A billing provider that can POST a cancellation intent event and a later save offer response event to the webhook, carrying customer id, customer email, segment, tenure months, monthly recurring revenue, usage trend and prior save count.
  • An SMTP relay that accepts submissions on port 465, and a Slack incoming webhook for the customer success channel.

Secrets

  • CANCEL_SAVE_WEBHOOK_KEY: key that guards the cancellation intent webhook trigger.
  • POSTGRES_URL, POSTGRES_USER, POSTGRES_PASSWORD: connection details for the intervention and retention tables.
  • SMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD: SMTP relay credentials used by the email plugin.
  • CS_EMAIL_FROM, CS_TEAM_EMAIL: sender address and internal fallback recipient for the save offer email.
  • SLACK_WEBHOOK_URL: incoming webhook used for the customer success alert and for failure notices.
  • BILLING_API_TOKEN: bearer token the billing API expects on the apply and finalize calls.

Quick start

  1. Add the secrets above to the company.team namespace and create the two PostgreSQL tables described in the prerequisites.
  2. Point variables.billing_api_base at your billing provider API base URL, then set variables.response_deadline_days, variables.discount_percent and variables.pause_days to your offer policy.
  3. Run the flow once with the default event_payload so you can read the assessment and the upserted intervention row before a real event arrives.
  4. Configure your billing provider to POST the cancellation intent event and the later offer response to the webhook using this trigger shape:
triggers:
  - id: cancellation_intent
    type: io.kestra.plugin.core.trigger.Webhook
    key: "{{ secret('CANCEL_SAVE_WEBHOOK_KEY') }}"
  - id: timeout_sweep
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 7 * * *"
    disabled: false
  1. Once offers are going out, flip the timeout_sweep schedule to disabled: false and set the default event_payload to an offer_timeout event so expired response windows are finalized each morning.
  2. Set auto_finalize_decline to true only after the customer success team is ready for declined offers to close the subscription without a manual step.

How to extend

  • Add a io.kestra.plugin.core.execution.Fail task on the churn branch when a declined offer should stop a downstream renewal pipeline instead of only reporting the loss.
  • Replace the offer email with a io.kestra.plugin.core.flow.Pause so a customer success manager reviews and edits the offer before it goes out.
  • Read the intervention table back with io.kestra.plugin.jdbc.postgresql.Query at the start of the run, so accounts that already used two save offers are excluded by stored data rather than by a payload field.
  • Fan the dispatch out by segment with io.kestra.plugin.core.flow.Switch on the segment value, giving enterprise accounts a human follow up while self serve accounts get the automated tier.
  • Forward the churn report to a data warehouse or a BI webhook with io.kestra.plugin.core.http.Request once final_state is known.
  • Accept a second webhook event for downgrades that were completed manually, so final_state also reflects saves that never went through this flow.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.