Feedback-Formular für eine Website oder App6 Min. Lesezeit
Kündigungsumfrage zum Kündigungsablauf deines SaaS hinzufügen
Frag im Kündigungsablauf deines SaaS, warum Kunden kündigen, halte Überspringen einen Klick entfernt, speichere den Grund in Stripe und Ikiform und verzögere die Kündigung nie.
Zeig auf der Kündigungsseite eine optionale Frage mit einem Überspringen-Button neben Absenden und kündige das Abo zuerst, egal was die Person wählt. Sende die Antwort dann von deinem Server, mit Konto, Tarif und Laufzeit aus der Session, nach der Response oder mit einem kurzen Timeout, damit die Umfrage die Kündigung nie aufhalten kann. Speichere denselben Grund am Stripe-Abo, damit beide Systeme übereinstimmen.
Was in einem Kündigungsablauf anders ist
Überall sonst ist ein Feedback-Formular ein Gefallen, um den du bittest. In einem Kündigungsablauf steht es zwischen einem Kunden und etwas, das er beschlossen hat. Das bestimmt die Regeln:
- Die Kündigung darf nie von der Umfrage abhängen. Nicht von einer Antwort, nicht von der Erreichbarkeit von Ikiform, nicht von einem langsamen Request. Das Abo wird zuerst gekündigt, und der Post der Umfrage kann fehlschlagen, ohne dass es jemand merkt.
- Überspringen ist ein Klick. Ein Button Skip and cancel sitzt neben dem Absenden-Button. Ihn zu verstecken oder einen zweiten und dritten Bildschirm mit Angeboten einzufügen, macht aus einer Umfrage ein Hindernis, und daran erinnern sich Leute, wenn sie entscheiden, ob sie zurückkommen.
- Der Kontext kommt vom Server. Du kennst Konto, Tarif und wie lange jemand bezahlt hat. Nimm sie aus der Session, nicht aus Hidden Inputs, damit die Daten vertrauenswürdig sind.
- Der Kunde drückt auf der Abrechnungsseite auf Tarif kündigen.
- Er sieht eine Frage und kann sie beantworten oder überspringen.
- So oder so setzt der Server das Stripe-Abo auf Kündigung zum Periodenende, mit dem Grund, wenn es einen gibt.
- Der Kunde sieht sofort die Bestätigung.
- Erst nachdem die Response gesendet ist, sendet der Server die Antwort an Ikiform.
- Ikiform speichert sie mit Tarif und Laufzeit und sendet einen Webhook, den ein Relay in einen Churn-Channel posten kann.
Felder
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
account_id, plan und months_subscribed ergänzt der Server aus der Session.Die Werte für den Grund entsprechen den Optionen von Stripes cancellation_details.feedback, sodass ein Wert an beide Orte geht und die Berichte übereinstimmen. Die Labels sind in einfache Sprache umformuliert. switched_to ist die nützlichste Freitextantwort, die du bekommst: Eine Liste der Tools, zu denen Leute wechseln, ist deine Wettbewerbsanalyse.
| Feld | Quelle | Warum |
|---|---|---|
reason | Der Kunde | Eine Auswahl, mit denselben IDs, die Stripe nutzt |
details | Der Kunde | Optional. Oft der einzige Ort, an dem du die wahre Geschichte erfährst |
switched_to | Der Kunde | Nur behalten, wenn der Grund switched_service ist |
account_id, plan | Deiner Session | Schlag die Nutzung nach, bevor du antwortest |
months_subscribed | Deiner Session | Frühe und späte Kündigungen haben unterschiedliche Ursachen |
Die Kündigungs-Action
Beide Versionen teilen sich eine kleine Liste von Gründen, damit Formular und Server sich über gültige Werte einig sind:
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;
}Die Server Action kündigt zuerst und plant dann den Post an Ikiform mit after(), das läuft, sobald die Response fertig ist. Die Weiterleitung wird dadurch nicht verzögert, und ein fehlgeschlagener Post wird verschluckt. Skip and cancel nutzt formNoValidate, damit das Pflicht-Radio es nicht blockiert.
"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>
);
}Die Form Action von SvelteKit hat kein after(), also bekommt der Post ein AbortSignal.timeout von drei Sekunden, und seine Fehler werden ignoriert. Die Kündigung ist dann schon passiert. Das Markup des Formulars ist dasselbe wie auf der Next.js-Seite, mit method="POST". Leg reasons.ts in src/lib ab und deklariere beide Keys in src/env.ts mit defineEnvVars, damit $app/env/private sie lesen kann.
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 in reinen Server-Umgebungsvariablen.Nutzt du für Kündigungen statt deiner eigenen Seite das gehostete Kundenportal von Stripe, kann das Portal selbst nach einem Grund fragen, und du liest ihn aus dem Abo. Diese Seite ist für Teams, deren Kündigungs-Button in der eigenen App sitzt.
Auf die Antworten reagieren
Füge einen Webhook hinzu, der jede Antwort in einen Churn-Channel postet; beim Alert-Relay der Hauptanleitung muss nur die Bedingung geändert werden. Lies in den ersten Monaten jede Antwort. Sieh dir dann im CSV-Export die Gründe nach Tarif und Laufzeit an: „zu teuer“ von Kunden im ersten Monat ist ein Problem der Preisseite, und dieselbe Antwort nach zwei Jahren ist oft eine Budgetkürzung.
Antworte Leuten, die Details geschrieben haben, aus deinem eigenen Posteingang, ohne Rabatt in der ersten Nachricht. Ein kurzes „Danke, das ist hilfreich, das tun wir dagegen“ bringt Antworten, die die Umfrage nie bekommen hätte. Ikiform schreibt dem Kunden nicht selbst.
Prüfen, ob es funktioniert
- Wähle bei einem Test-Abo einen Grund und kündige. Die Bestätigungsseite erscheint, und das Stripe-Abo zeigt
cancel_at_period_endmit demselben Feedback-Wert. - Öffne Submissions. In der Antwort hat der Server
account_id,planundmonths_subscribedausgefüllt. - Kündige ein weiteres Test-Abo mit Skip and cancel. Stripe zeigt die Kündigung; bei Ikiform kommt nichts Neues an.
- Setze
IKIFORM_SECRET_KEYauf einen falschen Wert und kündige erneut. Die Kündigung klappt trotzdem, und der Kunde sieht keinen Fehler.
Stolperfallen
- Bleib bei einem Bildschirm. Jeder zusätzliche Schritt kostet Antworten und Wohlwollen.
- Mach den Grund nicht zur Bedingung für die Kündigung. Er ist nur in der Formulardefinition Pflicht, damit eine beantwortete Umfrage vollständig ist; Überspringen sendet nichts.
- Manche Regionen regeln, wie einfach Kündigen sein muss. Halte den Ablauf so kurz wie die Anmeldung und prüfe die Regeln dort, wo du verkaufst.
- Behandle
detailsals nicht vertrauenswürdigen Text. Es ist, was ein frustrierter Kunde eingetippt hat, und es landet in deinem Chat-Channel.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
| Antworten fehlen in Ikiform, aber Kündigungen funktionieren | Der Post schlägt absichtlich still fehl. Prüf die Server-Logs auf den Request und such nach 403 forbidden wegen eines falschen Secret Keys oder 422 wegen nicht passender Felder. |
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
402 plan_required | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter Settings › Plan and usage, und zurückgehaltene Einsendungen kommen an. |
Fragen
Eine Auswahl des Grunds, einen optionalen Kommentar und zu welchem Tool gewechselt wird, falls eine Alternative gefunden wurde. Halte die ganze Umfrage überspringbar und nimm Tarif und Laufzeit aus deinen eigenen Daten, statt danach zu fragen.
Nein. Setze einen Button „Überspringen und kündigen“ neben den Absenden-Button und kündige in jedem Fall. Eine Umfrage, die man beantworten muss, um zu gehen, sammelt schlechtere Antworten und Groll.
Ja. Stripe-Abos akzeptieren cancellation_details mit einem feedback-Wert und einem Kommentar. Nutzt du Stripes Feedback-IDs als Optionswerte, bleiben beide Systeme im Gleichklang.
Für den Kunden ändert sich nichts. Das Abo wird zuerst gekündigt, und der Post der Umfrage läuft nach der Response oder mit einem kurzen Timeout und ignoriert Fehler.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.