# 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.

Terbit 2026-10-25 · https://ikiform.com/id/guides/feedback-form/cancellation-survey

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.

Diagram: Apa yang terjadi selama pelanggan menunggu, dan apa yang terjadi setelah mereka melihat konfirmasi

1. Pelanggan menekan Cancel plan di halaman penagihan.
2. Mereka melihat satu pertanyaan dan bisa menjawab atau melewatinya.
3. Apa pun pilihannya, server mengatur subscription Stripe agar berhenti di akhir periode, dengan alasannya jika ada.
4. Pelanggan langsung melihat konfirmasi.
5. Baru setelah respons dikirim, server mengirim jawabannya ke Ikiform.
6. Ikiform menyimpannya bersama paket dan lama berlangganan lalu mengirim webhook, yang bisa diteruskan relay ke channel churn.

Tidak ada di baris bawah yang bisa memperlambat atau memblokir baris atas. Jika Ikiform tidak bisa dijangkau, pelanggan tidak akan pernah tahu.

## Field

Formulir: Cancellation survey

- **Apa alasan utama Anda membatalkan?** (`reason`, radio, wajib)
- **Ada yang ingin Anda sampaikan kepada kami?** (`details`, textarea)
- **Anda pindah ke alat apa?** (`switched_to`, text)
- **Akun** (`account_id`, text, wajib)
- **Paket** (`plan`, text, wajib)
- **Lama berlangganan (bulan)** (`months_subscribed`, number)

Pelanggan hanya melihat tiga field pertama. `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:

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

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.

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

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.

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

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `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](https://ikiform.com/id/guides/feedback-form.md#routing) 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

1. Pada subscription uji, pilih alasan lalu batalkan. Halaman konfirmasi muncul, dan subscription Stripe menampilkan `cancel_at_period_end` dengan nilai feedback yang sama.
2. Buka **Submissions**. Jawabannya berisi `account_id`, `plan`, dan `months_subscribed` yang diisi server.
3. Batalkan subscription uji lain dengan **Skip and cancel**. Stripe menampilkan pembatalan; Ikiform tidak menerima apa-apa.
4. Atur `IKIFORM_SECRET_KEY` ke 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 `details` sebagai 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

### Apa yang sebaiknya ditanyakan survei pembatalan?

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.

### Haruskah survei pembatalan diwajibkan?

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.

### Bisakah saya juga menyimpan alasan pembatalan di Stripe?

Bisa. Subscription Stripe menerima cancellation_details dengan nilai feedback dan komentar. Memakai ID feedback Stripe sebagai nilai opsi Anda menjaga kedua sistem tetap selaras.

### Bagaimana jika Ikiform sedang down saat seseorang membatalkan?

Tidak ada yang berubah bagi pelanggan. Subscription dibatalkan lebih dulu, dan kiriman survei berjalan setelah respons atau dengan timeout singkat serta mengabaikan error.

## Panduan lainnya

- [Formulir feedback untuk website atau aplikasi](https://ikiform.com/id/guides/feedback-form.md): Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
- [Survei NPS](https://ikiform.com/id/guides/feedback-form/nps-survey.md): Jalankan survei NPS dengan skor 0 sampai 10 di email, pertanyaan lanjutan di halaman Anda sendiri yang berubah sesuai skor, dan script yang menghitung NPS.
- [Formulir permintaan fitur](https://ikiform.com/id/guides/feedback-form/feature-request.md): Formulir permintaan fitur yang menanyakan masalahnya alih-alih solusinya, menyimpan permintaan per tema, menghitung orang bukan baris, dan mengirim email saat fiturnya rilis.
- [Formulir laporan bug](https://ikiform.com/id/guides/feedback-form/bug-report.md): Formulir laporan bug yang menanyakan langkah dan tingkat keparahan, dan membiarkan halaman menambahkan URL, browser, viewport, dan error terbaru dengan sendirinya.
- [Widget feedback React](https://ikiform.com/id/guides/feedback-form/react-feedback-widget.md): Tombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.
- [Feedback beta tester](https://ikiform.com/id/guides/feedback-form/beta-tester-feedback.md): Formulir feedback beta dengan tautan pribadi per tester dan build, dua skala rating, dan ringkasan mingguan yang menunjukkan apakah setiap build makin baik.
