Feedback-Formular für eine Website oder App

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.

Preet SutharGründer, Ikiform

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.
Was passiert, während der Kunde wartet, und was, nachdem er die Bestätigung gesehen hatwährend der Kunde wartetnach der ResponseTarifkündigenauf derAbrechnungsseiteeineFrageantwortenoderüberspringenStripeKündigung zumPeriodenende,GrundgespeichertBestätigungTarif aktivbisPeriodenendeStripeKündigung zumPeriodenende,kein Grundafter()läuftnur bei einerAntwortIkiformspeichertGrund, Tarif,LaufzeitWebhooksubmission.createdChurn-Channeldas Teamliest esheuteabsendenüberspringenPostRelayWas passiert, während der Kunde wartet, und was, nachdem er die Bestätigung gesehen hatwährend der Kunde wartetnach der ResponseTarif kündigenauf derAbrechnungsseiteeine Frageantworten oderüberspringenStripeKündigung zumPeriodenende, GrundgespeichertBestätigungTarif aktiv bisPeriodenendeStripeKündigungzumPeriodenende,kein Grundafter() läuftnur bei einerAntwortIkiformspeichert Grund,Tarif, LaufzeitWebhooksubmission.createdChurn-Channeldas Team liest esheuteabsendenPostRelayüberspringen
  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

Vorschau

Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.

Was ist der Hauptgrund für deine Kündigung?
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.

FeldQuelleWarum
reasonDer KundeEine Auswahl, mit denselben IDs, die Stripe nutzt
detailsDer KundeOptional. Oft der einzige Ort, an dem du die wahre Geschichte erfährst
switched_toDer KundeNur behalten, wenn der Grund switched_service ist
account_id, planDeiner SessionSchlag die Nutzung nach, bevor du antwortest
months_subscribedDeiner SessionFrü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
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.

app/billing/cancel/actions.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
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 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 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 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

ProblemWas zu tun ist
Antworten fehlen in Ikiform, aber Kündigungen funktionierenDer 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_failedDer 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_requiredDer 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.