Feedback form for a website or app

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.

Preet SutharFounder, Ikiform

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.
What happens while the customer waits, and what happens after they've seen the confirmationwhile the customer waitsafter the responseCancelplanon thebilling pageonequestionanswer orskipStripecancel atperiod end,reason savedconfirmationplan activeuntil periodendStripecancel atperiod end, noreasonafter()runsonly whenthere's ananswerIkiformstores reason,plan, tenurewebhooksubmission.createdchurnchannelthe teamreads ittodaysubmitskippostrelayWhat happens while the customer waits, and what happens after they've seen the confirmationwhile the customer waitsafter the responseCancel planon the billing pageonequestionanswer or skipStripecancel at periodend, reason savedconfirmationplan active untilperiod endStripecancel atperiod end,no reasonafter() runsonly when there's ananswerIkiformstores reason, plan,tenurewebhooksubmission.createdchurn channelthe team reads ittodaysubmitpostrelayskip
  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

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

What's the main reason you're cancelling?
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.

FieldSourceWhy
reasonThe customerOne choice, the same ids Stripe uses
detailsThe customerOptional. Often the only place you learn the real story
switched_toThe customerOnly kept when the reason is switched_service
account_id, planYour sessionLook up usage before you reply
months_subscribedYour sessionEarly 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
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.

app/billing/cancel/actions.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
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>
  );
}
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 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 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

ProblemWhat to do
Answers missing in Ikiform, but cancels workThe 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_failedThe 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_requiredThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.