ウェブサイトやアプリのフィードバックフォーム1 分で読めます
SaaS の解約フローに解約アンケートを追加する
SaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。
解約ページに任意の質問を 1 つだけ表示し、送信ボタンの横にスキップボタンを置き、本人がどちらを選んでも先にサブスクリプションを解約します。そのあと、セッションから取ったアカウント、プラン、契約期間を付けて、レスポンスのあとか短いタイムアウト付きでサーバーから回答を送信し、アンケートが解約を止めることがないようにします。同じ理由を Stripe のサブスクリプションにも保存し、両方のシステムをそろえます。
解約フローならではの違い
ほかの場所では、フィードバックフォームはこちらからのお願いです。解約フローでは、顧客と、顧客が決めたことの間に立ちはだかります。そこからルールが決まります。
- 解約は決してアンケートに依存させない。 回答にも、Ikiform に接続できるかにも、遅いリクエストにも依存させません。サブスクリプションを先に解約し、アンケートの送信は誰にも気づかれずに失敗してもかまいません。
- スキップはワンクリックで。 送信ボタンの横に Skip and cancel ボタンを置きます。これを隠したり、オファーの 2 画面目、3 画面目を足したりすると、アンケートが障害物になり、人は戻ってくるかを決めるときにそれを覚えています。
- 文脈はサーバーから取る。 アカウント、プラン、支払ってきた期間はすでにわかっています。hidden の入力欄ではなくセッションから取れば、データを信頼できます。
- 顧客が請求ページで「プランを解約」を押します。
- 質問が 1 つ表示され、答えることもスキップすることもできます。
- どちらの場合も、サーバーは Stripe のサブスクリプションを期間終了時に解約するよう設定し、理由があればそれも保存します。
- 顧客にはすぐに確認画面が表示されます。
- レスポンスが送られたあとで初めて、サーバーは回答を Ikiform に送信します。
- Ikiform はプランと契約期間と一緒に回答を保存し、Webhook を送ります。リレーがそれを解約用のチャンネルに投稿できます。
フィールド
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
account_id、plan、months_subscribed は、サーバーがセッションから追加します。理由の値は Stripe の cancellation_details.feedback の選択肢と同じなので、1 つの値を両方に送れ、レポートもそろいます。ラベルはわかりやすい言葉に言い換えています。switched_to は得られる自由記述の回答の中でいちばん役に立ちます。乗り換え先のツールの一覧は、そのまま競合分析になります。
| フィールド | 流入元 | 理由 |
|---|---|---|
reason | 顧客 | 1 つを選択、Stripe と同じ ID |
details | 顧客 | 任意。本当の事情がわかる唯一の場所であることも多い |
switched_to | 顧客 | 理由が switched_service のときだけ保存 |
account_id, plan | 自分のセッション | 返信する前に利用状況を確認 |
months_subscribed | 自分のセッション | 早期の解約と後期の解約では原因が違う |
解約のアクション
どちらの版も理由の小さな一覧を共有するので、フォームとサーバーで有効な値が一致します。
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;
}サーバーアクションは先に解約し、そのあとレスポンスが終わってから実行される after() で Ikiform への送信を予約します。これでリダイレクトは遅れず、送信が失敗しても握りつぶされます。Skip and cancel は formNoValidate を使うので、必須のラジオボタンに止められません。
"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 のフォームアクションには after() がないので、送信には 3 秒の AbortSignal.timeout を付け、エラーは無視します。その時点で解約はもう終わっています。フォームのマークアップは Next.js のページと同じで、method="POST" を付けます。reasons.ts を src/lib に置き、$app/env/private から読めるよう、両方のキーを src/env.ts で defineEnvVars を使って宣言してください。
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 はサーバー専用の環境変数に置いてください。解約に自分のページではなく Stripe のホスト型カスタマーポータルを使う場合、ポータル自体が理由を尋ねられ、サブスクリプションから読み取れます。このページは、解約ボタンが自分のアプリの中にあるチーム向けです。
回答をもとに動く
各回答を解約用のチャンネルに投稿する Webhook を追加します。ピラーページのアラートのリレーなら、条件を変えるだけで使えます。最初の数か月はすべての回答を読みましょう。そのあと、CSV エクスポートでプランと契約期間ごとの理由を見ます。契約 1 か月目の顧客の「高すぎる」は料金ページの問題で、2 年後の同じ回答は予算削減であることが多いものです。
詳細を書いてくれた人には、自分の受信箱から、最初のメッセージでは割引を付けずに返信しましょう。「ありがとうございます、とても参考になります。こう対応します」という短い返信には、アンケートでは決して得られない返事が返ってきます。Ikiform 自体が顧客にメールを送ることはありません。
動作を確認する
- テスト用のサブスクリプションで理由を選んで解約します。確認ページが表示され、Stripe のサブスクリプションには同じ feedback の値と一緒に
cancel_at_period_endが表示されます。 - Submissions を開きます。回答には、サーバーが入れた
account_id、plan、months_subscribedがあります。 - 別のテスト用サブスクリプションを Skip and cancel で解約します。Stripe には解約が表示され、Ikiform には何も増えません。
IKIFORM_SECRET_KEYを間違った値にして、もう一度解約します。それでも解約は成功し、顧客にはエラーが表示されません。
ハマりどころ
- 1 画面に収めてください。手順が 1 つ増えるたびに回答が減り、好感も失われます。
- 解約に理由を必須にしないでください。フォームの定義で必須にしているのは、回答されたアンケートを完全にするためだけです。スキップすれば何も送られません。
- 地域によっては、解約をどれだけ簡単にすべきかが規制されています。フローは登録したときと同じくらい短く保ち、販売先の地域のルールを確認してください。
detailsは信頼できないテキストとして扱ってください。不満を抱えた顧客が入力したもので、それがチャットのチャンネルに流れます。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
| 解約はできるのに、Ikiform に回答がない | 送信は設計上、黙って失敗します。サーバーのログでリクエストを確認し、間違ったシークレットキーによる 403 forbidden や、フィールドの不一致による 422 を探してください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
402 plan_required | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。Settings › Plan and usage でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
理由を 1 つ選んでもらい、任意のコメントと、代わりを見つけたならどのツールに移るかを尋ねます。アンケート全体をスキップできるようにし、プランと契約期間は尋ねずに自分のデータから取ります。
いいえ。送信ボタンの横に「スキップして解約」ボタンを置き、どちらでも解約します。答えないと退会できないアンケートで集まるのは、質の悪い回答と反感です。
はい。Stripe のサブスクリプションは、feedback の値とコメントを含む cancellation_details を受け付けます。Stripe の feedback の ID を選択肢の値に使えば、両方のシステムがそろいます。
顧客にとっては何も変わりません。サブスクリプションは先に解約され、アンケートの送信はレスポンスのあとか短いタイムアウト付きで実行され、エラーは無視されます。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com