# Add a cancellation survey to your SaaS cancel flow

> Ask why customers cancel inside your SaaS cancel flow, keep Skip one click away, save the reason to Stripe and Ikiform, and never delay the cancel.

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/cancellation-survey

Show one optional question on the cancel page, with a Skip button next to Submit, and cancel the subscription first whatever the person chooses. Then post the answer from your server, with the account, plan and tenure taken from the session, after the response or with a short timeout so the survey can never hold up the cancel. Save the same reason on the Stripe subscription so both systems agree.

## What's different in a cancel flow

Everywhere else, a feedback form is a favour you ask. In a cancel flow it stands between a customer and something they've decided to do. That sets the rules:

- **The cancel must never depend on the survey.** Not on an answer, not on Ikiform being reachable, not on a slow request. The subscription is cancelled first, and the survey post can fail without anyone noticing.
- **Skipping is one click.** A **Skip and cancel** button sits next to the submit button. Hiding it, or adding a second and third screen of offers, turns a survey into an obstacle, and people remember that when they decide whether to come back.
- **The context comes from the server.** You know the account, plan and how long they've paid. Take them from the session, not from hidden inputs, so the data is trustworthy.

Diagram: What happens while the customer waits, and what happens after they've seen the confirmation

1. The customer presses Cancel plan on the billing page.
2. They see one question and can answer it or skip it.
3. Either way, the server sets the Stripe subscription to cancel at period end, with the reason when there is one.
4. The customer sees the confirmation right away.
5. Only after the response is sent does the server post the answer to Ikiform.
6. Ikiform stores it with the plan and tenure and sends a webhook, which a relay can post to a churn channel.

Nothing in the bottom row can slow down or block the top row. If Ikiform is unreachable, the customer never knows.

## Fields

Form: Cancellation survey

- **What's the main reason you're cancelling?** (`reason`, radio, required)
- **Anything you'd like us to know?** (`details`, textarea)
- **Which tool are you moving to?** (`switched_to`, text)
- **Account** (`account_id`, text, required)
- **Plan** (`plan`, text, required)
- **Months subscribed** (`months_subscribed`, number)

The customer only sees the first three. `account_id`, `plan` and `months_subscribed` are added by the server from the session.

The reason values are the same as Stripe's `cancellation_details.feedback` options, so one value goes to both places and reports line up. The labels are reworded in plain language. `switched_to` is the most useful free-text answer you'll get: a list of the tools people leave for is your competitive analysis.

| Field | Source | Why |
| --- | --- | --- |
| `reason` | The customer | One choice, the same ids Stripe uses |
| `details` | The customer | Optional. Often the only place you learn the real story |
| `switched_to` | The customer | Only kept when the reason is `switched_service` |
| `account_id`, `plan` | Your session | Look up usage before you reply |
| `months_subscribed` | Your session | Early churn and late churn have different causes |

## The cancel action

Both versions share a small list of reasons, so the form and the server agree on valid values:

`reasons.ts`:

```ts
export const REASONS = [
  ["too_expensive", "It's too expensive"],
  ["missing_features", "It's missing something I need"],
  ["switched_service", "I found an alternative"],
  ["unused", "I don't use it enough"],
  ["too_complex", "It was too hard to use"],
  ["low_quality", "It didn't work well"],
  ["customer_service", "Support didn't help"],
  ["other", "Something else"],
] as const;

export type Reason = (typeof REASONS)[number][0];

export function readReason(value: FormDataEntryValue | null): Reason | null {
  return REASONS.find(([id]) => id === value)?.[0] ?? null;
}
```

**Next.js**

The server action cancels first, then schedules the Ikiform post with `after()`, which runs once the response is finished. The redirect isn't delayed by it, and a failed post is swallowed. **Skip and cancel** uses `formNoValidate`, so the required radio doesn't block it.

`app/billing/cancel/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { after } from "next/server";
import Stripe from "stripe";
import { requireSession } from "@/lib/session";
import { readReason } from "./reasons";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function cancelPlan(formData: FormData) {
  const session = await requireSession();
  const skipped = formData.get("intent") === "skip";
  const reason = skipped ? null : readReason(formData.get("reason"));
  const details = skipped ? "" : String(formData.get("details") ?? "").slice(0, 2000);

  await stripe.subscriptions.update(session.subscriptionId, {
    cancel_at_period_end: true,
    ...(reason ? { cancellation_details: { feedback: reason, comment: details || undefined } } : {}),
  });

  if (reason) {
    const origin = (await headers()).get("origin");
    after(async () => {
      await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          reason,
          details,
          switched_to: reason === "switched_service" ? String(formData.get("switched_to") ?? "") : "",
          account_id: session.accountId,
          plan: session.plan,
          months_subscribed: Math.floor((Date.now() - session.subscribedAt.getTime()) / 2_629_800_000),
        }),
      }).catch(() => undefined);
    });
  }

  redirect("/billing/cancelled");
}
```

`app/billing/cancel/page.tsx`:

```tsx
import { cancelPlan } from "./actions";
import { REASONS } from "./reasons";

export default function CancelPage() {
  return (
    <form action={cancelPlan}>
      <h1>Cancel your plan</h1>
      <p>Your plan stays active until the end of the billing period. One question first, if you have a moment.</p>
      <fieldset>
        <legend>What's the main reason you're cancelling?</legend>
        {REASONS.map(([value, label]) => (
          <label key={value}>
            <input type="radio" name="reason" value={value} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="switched_to">If you're moving to another tool, which one?</label>
      <input id="switched_to" name="switched_to" maxLength={120} />
      <label htmlFor="details">Anything you'd like us to know?</label>
      <textarea id="details" name="details" rows={4} maxLength={2000} />
      <button name="intent" value="submit">Cancel my plan</button>
      <button name="intent" value="skip" formNoValidate>Skip and cancel</button>
    </form>
  );
}
```

**SvelteKit**

SvelteKit's form action has no `after()`, so the post gets a three-second `AbortSignal.timeout` and its errors are ignored. The cancel has already happened by then. The form markup is the same as the Next.js page, with `method="POST"`. Put `reasons.ts` in `src/lib`, and declare both keys in `src/env.ts` with `defineEnvVars` so `$app/env/private` can read them.

`src/routes/billing/cancel/+page.server.ts`:

```ts
import { redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, STRIPE_SECRET_KEY } from "$app/env/private";
import Stripe from "stripe";
import { readReason } from "#lib/reasons.js";
import type { Actions } from "./$types";

const stripe = new Stripe(STRIPE_SECRET_KEY);

export const actions = {
  default: async ({ request, locals, fetch, url }) => {
    const session = locals.session;
    if (!session) redirect(303, "/sign-in");
    const form = await request.formData();
    const reason = form.get("intent") === "skip" ? null : readReason(form.get("reason"));
    const details = reason ? String(form.get("details") ?? "").slice(0, 2000) : "";

    await stripe.subscriptions.update(session.subscriptionId, {
      cancel_at_period_end: true,
      ...(reason ? { cancellation_details: { feedback: reason, comment: details || undefined } } : {}),
    });

    if (reason) {
      await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        signal: AbortSignal.timeout(3000),
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          Origin: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          reason,
          details,
          switched_to: reason === "switched_service" ? String(form.get("switched_to") ?? "") : "",
          account_id: session.accountId,
          plan: session.plan,
          months_subscribed: session.monthsSubscribed,
        }),
      }).catch(() => undefined);
    }

    redirect(303, "/billing/cancelled");
  },
} satisfies Actions;
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

If you use Stripe's hosted customer portal for cancellations instead of your own page, the portal can ask for a reason itself, and you read it from the subscription. This page is for teams whose cancel button lives in their own app.

## Act on the answers

Add a webhook that posts each answer to a churn channel; the pillar's [alert relay](https://ikiform.com/guides/feedback-form.md#routing) needs only its condition changed. Read every answer for the first months. Then look at reasons by plan and tenure in the CSV export: "too expensive" from customers in their first month is a pricing-page problem, and the same answer after two years is often a budget cut.

Reply to people who wrote details, from your own inbox, without a discount in the first message. A short "thanks, this is useful, here's what we're doing about it" gets replies that the survey never would. Ikiform doesn't email the customer itself.

## Check it works

1. On a test subscription, pick a reason and cancel. The confirmation page appears, and the Stripe subscription shows `cancel_at_period_end` with the same feedback value.
2. Open **Submissions**. The answer has `account_id`, `plan` and `months_subscribed` filled in by the server.
3. Cancel another test subscription with **Skip and cancel**. Stripe shows the cancel; Ikiform has nothing new.
4. Set `IKIFORM_SECRET_KEY` to a wrong value and cancel again. The cancel still succeeds and the customer sees no error.

## Gotchas

- Keep it to one screen. Each extra step loses answers and costs goodwill.
- Don't make the reason required to cancel. It's required by the form definition only so an answered survey is complete; skipping sends nothing.
- Some regions regulate how easy cancelling must be. Keep the flow as short as signing up was, and check the rules where you sell.
- Treat `details` as untrusted text. It's what a frustrated customer typed, and it goes into your chat channel.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| Answers missing in Ikiform, but cancels work | The post fails silently by design. Check the server logs for the request, and look for `403 forbidden` from a wrong secret key or `422` from a field mismatch. |
| `422 validation_failed` | The post doesn't match the form's field definition. `error.fields` names each field and the reason. Match the input `name` to the field id. |
| `402 plan_required` | The workspace has no plan and the post came from a live site. It is held, not lost. Pick a plan or buy a pack under **Settings › Plan and usage** and held submissions arrive. |

## Questions

### What should a cancellation survey ask?

One choice of reason, an optional comment, and which tool they're moving to if they found an alternative. Keep the whole survey skippable, and take the plan and tenure from your own data instead of asking.

### Should the cancellation survey be required?

No. Put a Skip and cancel button next to the submit button and cancel either way. A survey people must answer to leave collects worse answers and resentment.

### Can I save the cancellation reason in Stripe too?

Yes. Stripe subscriptions accept cancellation_details with a feedback value and a comment. Using Stripe's feedback ids as your option values keeps both systems in step.

### What if Ikiform is down when someone cancels?

Nothing changes for the customer. The subscription is cancelled first, and the survey post runs after the response or with a short timeout and ignores errors.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [NPS survey](https://ikiform.com/guides/feedback-form/nps-survey.md): Run an NPS survey with a 0 to 10 score in the email, a follow-up question on your own page that changes with the score, and a script that calculates NPS.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): A feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- [React feedback widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- [Beta tester feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): A beta feedback form with a personal link per tester and build, two rating scales and a weekly summary that shows whether each build got better.
