웹사이트나 앱용 피드백 폼4분 분량
SaaS 해지 흐름에 해지 설문 추가하기
SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.
해지 페이지에 선택 입력인 질문 하나를 보여 주고 제출 버튼 옆에 건너뛰기 버튼을 두세요. 사용자가 무엇을 고르든 구독부터 해지하세요. 그런 다음 세션에서 가져온 계정, 플랜, 사용 기간과 함께 서버에서 답변을 POST하되, 응답을 보낸 뒤에 하거나 짧은 타임아웃을 걸어 설문이 해지를 절대 붙잡지 않게 하세요. 같은 이유를 Stripe 구독에도 저장해 두 시스템이 일치하게 하세요.
해지 흐름에서 다른 점
다른 곳에서는 피드백 폼이 부탁입니다. 해지 흐름에서는 고객과 고객이 이미 결정한 일 사이를 가로막고 있습니다. 그래서 규칙이 정해집니다.
- 해지는 절대 설문에 의존하면 안 됩니다. 답변에도, Ikiform 연결 여부에도, 느린 요청에도 의존하지 않습니다. 구독을 먼저 해지하고, 설문 POST는 아무도 모르게 실패해도 됩니다.
- 건너뛰기는 클릭 한 번입니다. 제출 버튼 옆에 Skip and cancel(건너뛰고 해지) 버튼이 있습니다. 이를 숨기거나 혜택 제안 화면을 두세 개 더 넣으면 설문이 장애물이 되고, 사람들은 다시 돌아올지 정할 때 그걸 기억합니다.
- 맥락은 서버에서 가져옵니다. 계정, 플랜, 결제 기간은 이미 알고 있습니다. 데이터를 신뢰할 수 있도록 hidden 입력이 아니라 세션에서 가져오세요.
- 고객이 결제 페이지에서 플랜 해지를 누릅니다.
- 질문 하나가 보이고, 답하거나 건너뛸 수 있습니다.
- 어느 쪽이든 서버가 Stripe 구독을 기간 종료 시 해지로 설정하고, 이유가 있으면 함께 저장합니다.
- 고객은 바로 확인 화면을 봅니다.
- 응답을 보낸 뒤에야 서버가 답변을 Ikiform으로 POST합니다.
- Ikiform이 플랜, 사용 기간과 함께 저장하고 웹훅을 보내면, 릴레이가 이탈 채널에 올릴 수 있습니다.
필드
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
account_id, plan, months_subscribed는 서버가 세션에서 추가합니다.이유 값은 Stripe의 cancellation_details.feedback 옵션과 같으므로 하나의 값이 양쪽으로 가고 리포트가 일치합니다. 레이블은 쉬운 말로 바꿨습니다. switched_to는 받을 수 있는 가장 유용한 자유 입력 답변입니다. 사람들이 옮겨 가는 도구 목록이 곧 경쟁 분석입니다.
| 필드 | 유입 경로 | 이유 |
|---|---|---|
reason | 고객 | 선택지 하나, 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 POST를 예약합니다. 그 때문에 리디렉션이 늦어지지 않고, 실패한 POST는 무시됩니다. 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()가 없으므로 POST에 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의 호스팅 고객 포털로 해지를 처리한다면 포털이 직접 이유를 물을 수 있고, 구독에서 그 값을 읽으면 됩니다. 이 페이지는 해지 버튼이 자체 앱에 있는 팀을 위한 것입니다.
답변에 따라 행동하기
각 답변을 이탈 채널에 올리는 웹훅을 추가하세요. 필러의 알림 릴레이에서 조건만 바꾸면 됩니다. 처음 몇 달은 모든 답변을 읽으세요. 그다음 CSV 내보내기에서 플랜과 사용 기간별로 이유를 보세요. 첫 달 고객의 "너무 비쌈"은 요금 페이지 문제이고, 2년 뒤의 같은 답은 대개 예산 삭감입니다.
자세히 써 준 사람에게는 첫 메시지에 할인을 넣지 말고 내 받은편지함에서 답장하세요. "감사합니다, 도움이 됩니다, 이렇게 개선하고 있습니다" 같은 짧은 답장은 설문으로는 절대 못 받을 답을 이끌어 냅니다. Ikiform이 고객에게 직접 이메일을 보내지는 않습니다.
작동 확인하기
- 테스트 구독에서 이유를 고르고 해지하세요. 확인 페이지가 나타나고, Stripe 구독에 같은 feedback 값과 함께
cancel_at_period_end가 표시됩니다. - Submissions를 여세요. 답변에 서버가 채운
account_id,plan,months_subscribed가 있습니다. - 다른 테스트 구독을 Skip and cancel로 해지하세요. Stripe에는 해지가 표시되고 Ikiform에는 새 항목이 없습니다.
IKIFORM_SECRET_KEY를 틀린 값으로 바꾸고 다시 해지하세요. 해지는 여전히 성공하고 고객은 오류를 보지 않습니다.
주의할 점
- 한 화면으로 끝내세요. 단계가 늘어날 때마다 답변을 잃고 호감도 잃습니다.
- 이유를 해지의 필수 조건으로 만들지 마세요. 폼 정의에서 필수인 것은 답한 설문이 완전하도록 하기 위해서일 뿐이며, 건너뛰면 아무것도 보내지 않습니다.
- 해지가 얼마나 쉬워야 하는지 규제하는 지역도 있습니다. 흐름은 가입만큼 짧게 유지하고, 판매하는 지역의 규정을 확인하세요.
details는 신뢰할 수 없는 텍스트로 다루세요. 화가 난 고객이 입력한 내용이며 채팅 채널로 들어갑니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
| 해지는 되는데 Ikiform에 답변이 없음 | POST는 설계상 조용히 실패합니다. 서버 로그에서 요청을 확인하고, 시크릿 키가 틀렸을 때의 403 forbidden이나 필드가 맞지 않을 때의 422를 찾아보세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
402 plan_required | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. Settings › Plan and usage에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
이유 하나 고르기, 선택 입력인 코멘트, 그리고 대안을 찾았다면 어떤 도구로 옮기는지입니다. 설문 전체를 건너뛸 수 있게 하고, 플랜과 사용 기간은 묻지 말고 내 데이터에서 가져오세요.
아니요. 제출 버튼 옆에 건너뛰고 해지 버튼을 두고, 어느 쪽이든 해지하세요. 떠나려면 반드시 답해야 하는 설문은 더 나쁜 답변과 반감을 모읍니다.
네. Stripe 구독은 feedback 값과 comment가 담긴 cancellation_details를 받습니다. Stripe의 feedback ID를 옵션 값으로 쓰면 두 시스템이 맞춰집니다.
고객에게는 아무것도 달라지지 않습니다. 구독이 먼저 해지되고, 설문 POST는 응답 후에 또는 짧은 타임아웃과 함께 실행되며 오류를 무시합니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.