ウェブサイトやアプリのフィードバックフォーム

ウェブサイトやアプリのフィードバックフォーム1 分で読めます

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

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

Preet SutharIkiform 創業者

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

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

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

  • 解約は決してアンケートに依存させない。 回答にも、Ikiform に接続できるかにも、遅いリクエストにも依存させません。サブスクリプションを先に解約し、アンケートの送信は誰にも気づかれずに失敗してもかまいません。
  • スキップはワンクリックで。 送信ボタンの横に Skip and cancel ボタンを置きます。これを隠したり、オファーの 2 画面目、3 画面目を足したりすると、アンケートが障害物になり、人は戻ってくるかを決めるときにそれを覚えています。
  • 文脈はサーバーから取る。 アカウント、プラン、支払ってきた期間はすでにわかっています。hidden の入力欄ではなくセッションから取れば、データを信頼できます。
顧客が待っている間に起きることと、確認画面を見たあとに起きること顧客が待っている間レスポンスのあとプランを解約請求ページで質問 1 つ回答かスキップStripe期間終了時に解約、理由を保存確認画面期間終了までプランは有効Stripe期間終了時に解約、理由なしafter()が実行回答があるときだけIkiform理由、プラン、契約期間を保存Webhooksubmission.created解約チャンネルチームがその日に読む送信スキップ送信リレー顧客が待っている間に起きることと、確認画面を見たあとに起きること顧客が待っている間レスポンスのあとプランを解約請求ページで質問 1 つ回答かスキップStripe期間終了時に解約、理由を保存確認画面期間終了までプランは有効Stripe期間終了時に解約、理由なしafter() が実行回答があるときだけIkiform理由、プラン、契約期間を保存Webhooksubmission.created解約チャンネルチームがその日に読む送信送信リレースキップ
  1. 顧客が請求ページで「プランを解約」を押します。
  2. 質問が 1 つ表示され、答えることもスキップすることもできます。
  3. どちらの場合も、サーバーは Stripe のサブスクリプションを期間終了時に解約するよう設定し、理由があればそれも保存します。
  4. 顧客にはすぐに確認画面が表示されます。
  5. レスポンスが送られたあとで初めて、サーバーは回答を Ikiform に送信します。
  6. Ikiform はプランと契約期間と一緒に回答を保存し、Webhook を送ります。リレーがそれを解約用のチャンネルに投稿できます。
下の段のどれも、上の段を遅らせたり止めたりできません。Ikiform に接続できなくても、顧客が気づくことはありません。

フィールド

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

解約する主な理由は何ですか?
顧客に見えるのは最初の 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
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 を使うので、必須のラジオボタンに止められません。

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>
  );
}
サーバー側の版はシークレットキー付きで送信するので、Ikiform は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

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

回答をもとに動く

各回答を解約用のチャンネルに投稿する Webhook を追加します。ピラーページのアラートのリレーなら、条件を変えるだけで使えます。最初の数か月はすべての回答を読みましょう。そのあと、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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com