A cancellations exit-survey is a tiny in-app flow that pops up when a user cancels their subscription. It politely asks why they’re leaving and saves that reason so your Unsatisfied % and churn insights are real—not guesses.

Why it’s useful

Turns “a cancel happened” into actionable data (price too high, confusing UX, missing feature, temporary pause, etc.).

Powers the Unsatisfied % tile on your Owner dashboard.

Lets you trigger smart follow-ups (e.g., offer a pause instead of cancel).

What it looks like (UX)

User clicks Cancel subscription.

Modal appears (short + respectful):

Reason (required): dropdown

“Too expensive”, “Didn’t get value”, “Missing feature”, “Technical issues”, “Temporary pause”, “Other”

Optional comments (textarea)

Buttons: Confirm cancel / Keep plan

On confirm, you:

Save { uid, email, createdAt, reason, note } to cancellations (Firestore).

Proceed with Stripe cancellation.

Minimal implementation (concept)

Frontend (React modal skeleton)

function CancelSurvey({ open, onClose, onSubmit }) {
  const [reason, setReason] = useState("Too expensive");
  const [note, setNote] = useState("");
  return open ? (
    <div className="fixed inset-0 grid place-items-center bg-black/40">
      <div className="w-full max-w-md rounded-xl bg-white p-4">
        <h3 className="text-lg font-semibold">Before you go</h3>
        <p className="text-sm text-slate-600 mt-1">What’s the main reason you're cancelling?</p>
        <select className="mt-3 w-full border rounded p-2"
                value={reason} onChange={e=>setReason(e.target.value)}>
          {["Too expensive", "Didn't get value", "Missing feature", "Technical issues", "Temporary", "Other"]
            .map(r => <option key={r}>{r}</option>)}
        </select>
        <textarea className="mt-3 w-full border rounded p-2" rows={3}
                  placeholder="Anything else we should know?"
                  value={note} onChange={e=>setNote(e.target.value)} />
        <div className="mt-4 flex justify-end gap-2">
          <button onClick={onClose} className="px-3 py-2 rounded border">Keep plan</button>
          <button onClick={()=>onSubmit({reason, note})}
                  className="px-3 py-2 rounded bg-slate-900 text-white">Confirm cancel</button>
        </div>
      </div>
    </div>
  ) : null;
}


Backend (save + cancel)

// POST /billing/cancel
// body: { uid, reason, note }
app.post("/billing/cancel", requireAuth, async (req, res) => {
  const { uid, reason, note } = req.body;
  await db.collection("cancellations").add({
    uid, reason, note: note?.slice(0,500) ?? "",
    createdAt: new Date()
  });
  // then call Stripe to cancel the sub:
  // await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
  return res.json({ ok: true });
});


That “save” is what powers your Unsatisfied %:
unsatisfiedPct = cancellations with reason matching "Didn't get value" / total cancellations this month.

Where it lives

If you use Stripe Customer Portal, add your survey before redirecting to the portal (or send a post-cancel email survey).

If you cancel inside your app, just show this modal and then call your /billing/cancel endpoint.

Respectful UX

Keep it optional but encouraged (only “reason” required).

No dark patterns; “Keep plan” is clearly available.

Tell them it helps improve the product.