Feedback form for a website or app6 min read
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.
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.
- The customer presses Cancel plan on the billing page.
- They see one question and can answer it or skip it.
- Either way, the server sets the Stripe subscription to cancel at period end, with the reason when there is one.
- The customer sees the confirmation right away.
- Only after the response is sent does the server post the answer to Ikiform.
- Ikiform stores it with the plan and tenure and sends a webhook, which a relay can post to a churn channel.
Fields
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
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:
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;
}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.
"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");
}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'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.
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;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 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
- On a test subscription, pick a reason and cancel. The confirmation page appears, and the Stripe subscription shows
cancel_at_period_endwith the same feedback value. - Open Submissions. The answer has
account_id,planandmonths_subscribedfilled in by the server. - Cancel another test subscription with Skip and cancel. Stripe shows the cancel; Ikiform has nothing new.
- Set
IKIFORM_SECRET_KEYto 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
detailsas 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.