# SaaS の解約フローに解約アンケートを追加する

> SaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。

公開日 2026-10-25 · https://ikiform.com/ja/guides/feedback-form/cancellation-survey

解約ページに任意の質問を 1 つだけ表示し、送信ボタンの横にスキップボタンを置き、本人がどちらを選んでも先にサブスクリプションを解約します。そのあと、セッションから取ったアカウント、プラン、契約期間を付けて、レスポンスのあとか短いタイムアウト付きでサーバーから回答を送信し、アンケートが解約を止めることがないようにします。同じ理由を Stripe のサブスクリプションにも保存し、両方のシステムをそろえます。

## 解約フローならではの違い

ほかの場所では、フィードバックフォームはこちらからのお願いです。解約フローでは、顧客と、顧客が決めたことの間に立ちはだかります。そこからルールが決まります。

- **解約は決してアンケートに依存させない。** 回答にも、Ikiform に接続できるかにも、遅いリクエストにも依存させません。サブスクリプションを先に解約し、アンケートの送信は誰にも気づかれずに失敗してもかまいません。
- **スキップはワンクリックで。** 送信ボタンの横に **Skip and cancel** ボタンを置きます。これを隠したり、オファーの 2 画面目、3 画面目を足したりすると、アンケートが障害物になり、人は戻ってくるかを決めるときにそれを覚えています。
- **文脈はサーバーから取る。** アカウント、プラン、支払ってきた期間はすでにわかっています。hidden の入力欄ではなくセッションから取れば、データを信頼できます。

図: 顧客が待っている間に起きることと、確認画面を見たあとに起きること

1. 顧客が請求ページで「プランを解約」を押します。
2. 質問が 1 つ表示され、答えることもスキップすることもできます。
3. どちらの場合も、サーバーは Stripe のサブスクリプションを期間終了時に解約するよう設定し、理由があればそれも保存します。
4. 顧客にはすぐに確認画面が表示されます。
5. レスポンスが送られたあとで初めて、サーバーは回答を Ikiform に送信します。
6. Ikiform はプランと契約期間と一緒に回答を保存し、Webhook を送ります。リレーがそれを解約用のチャンネルに投稿できます。

下の段のどれも、上の段を遅らせたり止めたりできません。Ikiform に接続できなくても、顧客が気づくことはありません。

## フィールド

フォーム: Cancellation survey

- **解約する主な理由は何ですか？** (`reason`, radio, 必須)
- **ほかに伝えたいことはありますか？** (`details`, textarea)
- **どのツールに移りますか？** (`switched_to`, text)
- **アカウント** (`account_id`, text, 必須)
- **プラン** (`plan`, text, 必須)
- **契約月数** (`months_subscribed`, number)

顧客に見えるのは最初の 3 つだけです。`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` | 自分のセッション | 早期の解約と後期の解約では原因が違う |

## 解約のアクション

どちらの版も理由の小さな一覧を共有するので、フォームとサーバーで有効な値が一致します。

`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**

サーバーアクションは先に解約し、そのあとレスポンスが終わってから実行される `after()` で Ikiform への送信を予約します。これでリダイレクトは遅れず、送信が失敗しても握りつぶされます。**Skip and cancel** は `formNoValidate` を使うので、必須のラジオボタンに止められません。

`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 のフォームアクションには `after()` がないので、送信には 3 秒の `AbortSignal.timeout` を付け、エラーは無視します。その時点で解約はもう終わっています。フォームのマークアップは Next.js のページと同じで、`method="POST"` を付けます。`reasons.ts` を `src/lib` に置き、`$app/env/private` から読めるよう、両方のキーを `src/env.ts` で `defineEnvVars` を使って宣言してください。

`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;
```

> サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

解約に自分のページではなく Stripe のホスト型カスタマーポータルを使う場合、ポータル自体が理由を尋ねられ、サブスクリプションから読み取れます。このページは、解約ボタンが自分のアプリの中にあるチーム向けです。

## 回答をもとに動く

各回答を解約用のチャンネルに投稿する Webhook を追加します。ピラーページの[アラートのリレー](https://ikiform.com/ja/guides/feedback-form.md#routing)なら、条件を変えるだけで使えます。最初の数か月はすべての回答を読みましょう。そのあと、CSV エクスポートでプランと契約期間ごとの理由を見ます。契約 1 か月目の顧客の「高すぎる」は料金ページの問題で、2 年後の同じ回答は予算削減であることが多いものです。

詳細を書いてくれた人には、自分の受信箱から、最初のメッセージでは割引を付けずに返信しましょう。「ありがとうございます、とても参考になります。こう対応します」という短い返信には、アンケートでは決して得られない返事が返ってきます。Ikiform 自体が顧客にメールを送ることはありません。

## 動作を確認する

1. テスト用のサブスクリプションで理由を選んで解約します。確認ページが表示され、Stripe のサブスクリプションには同じ feedback の値と一緒に `cancel_at_period_end` が表示されます。
2. **Submissions** を開きます。回答には、サーバーが入れた `account_id`、`plan`、`months_subscribed` があります。
3. 別のテスト用サブスクリプションを **Skip and cancel** で解約します。Stripe には解約が表示され、Ikiform には何も増えません。
4. `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 にも保存できますか？

はい。Stripe のサブスクリプションは、feedback の値とコメントを含む cancellation_details を受け付けます。Stripe の feedback の ID を選択肢の値に使えば、両方のシステムがそろいます。

### 解約のときに Ikiform が落ちていたら？

顧客にとっては何も変わりません。サブスクリプションは先に解約され、アンケートの送信はレスポンスのあとか短いタイムアウト付きで実行され、エラーは無視されます。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [NPS アンケート](https://ikiform.com/ja/guides/feedback-form/nps-survey.md): メールに 0〜10 のスコアを載せ、スコアに応じて変わる追加質問を自分のページで尋ね、スクリプトで NPS を計算する NPS アンケートを実施します。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [React フィードバックウィジェット](https://ikiform.com/ja/guides/feedback-form/react-feedback-widget.md): React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- [ベータテスターのフィードバック](https://ikiform.com/ja/guides/feedback-form/beta-tester-feedback.md): テスターとビルドごとの個別リンク、2 つの評価スケール、各ビルドが良くなったかがわかる週次サマリーを備えたベータ版フィードバックフォームです。
