Formulir feedback untuk website atau aplikasi5 menit baca
Tambahkan survei pembatalan ke alur pembatalan SaaS Anda
Tanyakan mengapa pelanggan membatalkan di dalam alur pembatalan SaaS Anda, jaga Skip tetap satu klik, simpan alasannya ke Stripe dan Ikiform, dan jangan pernah menunda pembatalan.
Tampilkan satu pertanyaan opsional di halaman pembatalan, dengan tombol Skip di samping Submit, dan batalkan langganan lebih dulu apa pun pilihan orang itu. Lalu kirim jawabannya dari server Anda, dengan akun, paket, dan lama berlangganan yang diambil dari sesi, setelah respons atau dengan timeout singkat agar survei tidak pernah menahan pembatalan. Simpan alasan yang sama di subscription Stripe agar kedua sistem sepakat.
Apa yang berbeda di alur pembatalan
Di tempat lain, formulir feedback adalah bantuan yang Anda minta. Di alur pembatalan, formulir itu berdiri di antara pelanggan dan sesuatu yang sudah mereka putuskan. Itu menentukan aturannya:
- Pembatalan tidak boleh bergantung pada survei. Tidak pada jawaban, tidak pada Ikiform bisa dijangkau, tidak pada request yang lambat. Langganan dibatalkan lebih dulu, dan kiriman survei boleh gagal tanpa ada yang menyadarinya.
- Melewati cukup satu klik. Tombol Skip and cancel ada di samping tombol submit. Menyembunyikannya, atau menambahkan layar kedua dan ketiga berisi penawaran, mengubah survei menjadi rintangan, dan orang mengingatnya saat memutuskan apakah akan kembali.
- Konteksnya datang dari server. Anda tahu akun, paket, dan berapa lama mereka sudah membayar. Ambil dari sesi, bukan dari input tersembunyi, agar datanya bisa dipercaya.
- Pelanggan menekan Cancel plan di halaman penagihan.
- Mereka melihat satu pertanyaan dan bisa menjawab atau melewatinya.
- Apa pun pilihannya, server mengatur subscription Stripe agar berhenti di akhir periode, dengan alasannya jika ada.
- Pelanggan langsung melihat konfirmasi.
- Baru setelah respons dikirim, server mengirim jawabannya ke Ikiform.
- Ikiform menyimpannya bersama paket dan lama berlangganan lalu mengirim webhook, yang bisa diteruskan relay ke channel churn.
Field
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
account_id, plan, dan months_subscribed ditambahkan oleh server dari sesi.Nilai alasannya sama dengan opsi cancellation_details.feedback milik Stripe, jadi satu nilai masuk ke kedua tempat dan laporannya selaras. Labelnya ditulis ulang dengan bahasa sederhana. switched_to adalah jawaban teks bebas paling berguna yang akan Anda dapat: daftar alat yang dituju orang saat pergi adalah analisis kompetitor Anda.
| Field | Sumber | Alasan |
|---|---|---|
reason | Pelanggan | Satu pilihan, ID yang sama dengan yang dipakai Stripe |
details | Pelanggan | Opsional. Sering kali satu-satunya tempat Anda mengetahui cerita sebenarnya |
switched_to | Pelanggan | Hanya disimpan jika alasannya switched_service |
account_id, plan | Sesi Anda | Cek pemakaian sebelum Anda membalas |
months_subscribed | Sesi Anda | Churn awal dan churn akhir punya penyebab berbeda |
Action pembatalan
Kedua versi berbagi daftar alasan kecil yang sama, sehingga formulir dan server sepakat tentang nilai yang valid:
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;
}Server action membatalkan lebih dulu, lalu menjadwalkan kiriman ke Ikiform dengan after(), yang berjalan setelah respons selesai. Redirect tidak tertunda olehnya, dan kiriman yang gagal diabaikan. Skip and cancel memakai formNoValidate, sehingga radio yang wajib tidak menghalanginya.
"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>
);
}Form action SvelteKit tidak punya after(), jadi kirimannya mendapat AbortSignal.timeout tiga detik dan error-nya diabaikan. Pembatalan sudah terjadi saat itu. Markup formulirnya sama dengan halaman Next.js, dengan method="POST". Taruh reasons.ts di src/lib, dan deklarasikan kedua key di src/env.ts dengan defineEnvVars agar $app/env/private bisa membacanya.
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 di environment variable khusus server.Jika Anda memakai customer portal Stripe yang di-host untuk pembatalan alih-alih halaman sendiri, portal itu bisa menanyakan alasannya sendiri, dan Anda membacanya dari subscription. Halaman ini untuk tim yang tombol pembatalannya ada di aplikasi mereka sendiri.
Tindak lanjuti jawabannya
Tambahkan webhook yang mengirim setiap jawaban ke channel churn; relay peringatan di pilar hanya perlu diubah kondisinya. Baca setiap jawaban di bulan-bulan pertama. Lalu lihat alasan per paket dan lama berlangganan di ekspor CSV: "terlalu mahal" dari pelanggan di bulan pertama adalah masalah halaman harga, sedangkan jawaban yang sama setelah dua tahun sering kali karena pemotongan anggaran.
Balas orang yang menulis detail, dari inbox Anda sendiri, tanpa diskon di pesan pertama. Pesan singkat "terima kasih, ini berguna, ini yang kami lakukan" mendapat balasan yang tidak akan pernah didapat survei. Ikiform sendiri tidak mengirim email ke pelanggan.
Pastikan berfungsi
- Pada subscription uji, pilih alasan lalu batalkan. Halaman konfirmasi muncul, dan subscription Stripe menampilkan
cancel_at_period_enddengan nilai feedback yang sama. - Buka Submissions. Jawabannya berisi
account_id,plan, danmonths_subscribedyang diisi server. - Batalkan subscription uji lain dengan Skip and cancel. Stripe menampilkan pembatalan; Ikiform tidak menerima apa-apa.
- Atur
IKIFORM_SECRET_KEYke nilai yang salah lalu batalkan lagi. Pembatalan tetap berhasil dan pelanggan tidak melihat error.
Hal yang sering menjebak
- Cukup satu layar. Setiap langkah tambahan menghilangkan jawaban dan mengikis niat baik.
- Jangan jadikan alasan sebagai syarat untuk membatalkan. Alasan diwajibkan di definisi formulir hanya agar survei yang dijawab itu lengkap; melewatinya tidak mengirim apa-apa.
- Beberapa wilayah mengatur seberapa mudah pembatalan harus dilakukan. Jaga alurnya sependek proses mendaftar, dan periksa aturan di tempat Anda berjualan.
- Perlakukan
detailssebagai teks yang tidak tepercaya. Isinya adalah apa yang diketik pelanggan yang frustrasi, dan masuk ke channel chat Anda.
Pemecahan masalah
| Masalah | Yang harus dilakukan |
|---|---|
| Jawaban tidak muncul di Ikiform, tetapi pembatalan berjalan | Kiriman memang dirancang gagal tanpa suara. Periksa log server untuk request-nya, dan cari 403 forbidden karena secret key salah atau 422 karena field tidak cocok. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
402 plan_required | Workspace tidak punya paket dan kiriman berasal dari situs live. Kiriman ditahan, tidak hilang. Pilih paket atau beli paket kiriman di Settings โบ Plan and usage, dan kiriman yang ditahan akan masuk. |
Pertanyaan
Satu pilihan alasan, komentar opsional, dan alat yang mereka tuju jika menemukan alternatif. Jadikan seluruh survei bisa dilewati, dan ambil paket serta lama berlangganan dari data Anda sendiri alih-alih menanyakannya.
Tidak. Taruh tombol Skip and cancel di samping tombol submit dan batalkan apa pun pilihannya. Survei yang wajib dijawab untuk bisa pergi mengumpulkan jawaban yang lebih buruk dan rasa kesal.
Bisa. Subscription Stripe menerima cancellation_details dengan nilai feedback dan komentar. Memakai ID feedback Stripe sebagai nilai opsi Anda menjaga kedua sistem tetap selaras.
Tidak ada yang berubah bagi pelanggan. Subscription dibatalkan lebih dulu, dan kiriman survei berjalan setelah respons atau dengan timeout singkat serta mengabaikan error.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.