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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/feedback-form/cancellation-survey

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.

Diagramm: Was passiert, während der Kunde wartet, und was, nachdem er die Bestätigung gesehen hat

1. Der Kunde drückt auf der Abrechnungsseite auf Tarif kündigen.
2. Er sieht eine Frage und kann sie beantworten oder überspringen.
3. So oder so setzt der Server das Stripe-Abo auf Kündigung zum Periodenende, mit dem Grund, wenn es einen gibt.
4. Der Kunde sieht sofort die Bestätigung.
5. Erst nachdem die Response gesendet ist, sendet der Server die Antwort an Ikiform.
6. Ikiform speichert sie mit Tarif und Laufzeit und sendet einen Webhook, den ein Relay in einen Churn-Channel posten kann.

Nichts in der unteren Reihe kann die obere Reihe verlangsamen oder blockieren. Ist Ikiform nicht erreichbar, merkt der Kunde nie etwas.

## Felder

Formular: Cancellation survey

- **Was ist der Hauptgrund für deine Kündigung?** (`reason`, radio, Pflichtfeld)
- **Möchtest du uns noch etwas mitteilen?** (`details`, textarea)
- **Zu welchem Tool wechselst du?** (`switched_to`, text)
- **Konto** (`account_id`, text, Pflichtfeld)
- **Tarif** (`plan`, text, Pflichtfeld)
- **Monate im Abo** (`months_subscribed`, number)

Der Kunde sieht nur die ersten drei. `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:

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

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.

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

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.

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

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `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](https://ikiform.com/de/guides/feedback-form.md#routing) 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

1. Wähle bei einem Test-Abo einen Grund und kündige. Die Bestätigungsseite erscheint, und das Stripe-Abo zeigt `cancel_at_period_end` mit demselben Feedback-Wert.
2. Öffne **Submissions**. In der Antwort hat der Server `account_id`, `plan` und `months_subscribed` ausgefüllt.
3. Kündige ein weiteres Test-Abo mit **Skip and cancel**. Stripe zeigt die Kündigung; bei Ikiform kommt nichts Neues an.
4. Setze `IKIFORM_SECRET_KEY` auf 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 `details` als 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

### Was sollte eine Kündigungsumfrage 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.

### Sollte die Kündigungsumfrage Pflicht sein?

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.

### Kann ich den Kündigungsgrund auch in Stripe speichern?

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.

### Was, wenn Ikiform ausfällt, während jemand kündigt?

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.

## Weitere Anleitungen

- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [NPS-Umfrage](https://ikiform.com/de/guides/feedback-form/nps-survey.md): Führe eine NPS-Umfrage mit einem Wert von 0 bis 10 in der E-Mail durch, mit einer Folgefrage auf deiner eigenen Seite, die sich mit dem Wert ändert, und einem Skript, das den NPS berechnet.
- [Formular für Feature-Wünsche](https://ikiform.com/de/guides/feedback-form/feature-request.md): Ein Formular für Feature-Wünsche, das nach dem Problem statt nach der Lösung fragt, Wünsche nach Thema speichert, Personen statt Zeilen zählt und sie beim Release anschreibt.
- [Formular für Bug-Reports](https://ikiform.com/de/guides/feedback-form/bug-report.md): Ein Bug-Report-Formular, das nach Schritten und Schweregrad fragt und URL, Browser, Viewport und letzte Fehler von der Seite selbst ergänzen lässt.
- [Feedback-Widget für React](https://ikiform.com/de/guides/feedback-form/react-feedback-widget.md): Ein schwebender Feedback-Button für eine React-App, der ein kleines Panel öffnet, Ansicht, App-Version und Konto mit jeder Antwort sendet und nie einen Entwurf verliert.
- [Feedback von Beta-Testern](https://ikiform.com/de/guides/feedback-form/beta-tester-feedback.md): Ein Beta-Feedback-Formular mit einem persönlichen Link pro Tester und Build, zwei Bewertungsskalen und einer Wochenübersicht, die zeigt, ob jeder Build besser wurde.
