Formulaire de feedback pour un site ou une application

Formulaire de feedback pour un site ou une application6 min de lecture

Ajouter une enquête de résiliation au parcours d'annulation de votre SaaS

Demandez pourquoi vos clients résilient dans le parcours d'annulation de votre SaaS, gardez Passer à un clic, enregistrez la raison dans Stripe et Ikiform, sans jamais retarder la résiliation.

Preet SutharFondateur, Ikiform

Affichez une seule question facultative sur la page de résiliation, avec un bouton Passer à côté d'Envoyer, et résiliez d'abord l'abonnement, quel que soit le choix de la personne. Envoyez ensuite la réponse depuis votre serveur, avec le compte, l'offre et l'ancienneté tirés de la session, après la réponse HTTP ou avec un court timeout, pour que l'enquête ne puisse jamais retarder la résiliation. Enregistrez la même raison sur l'abonnement Stripe pour que les deux systèmes concordent.

Ce qui change dans un parcours de résiliation

Partout ailleurs, un formulaire de feedback est un service que vous demandez. Dans un parcours de résiliation, il se trouve entre un client et une décision qu'il a prise. Cela fixe les règles :

  • La résiliation ne doit jamais dépendre de l'enquête. Ni d'une réponse, ni de la disponibilité d'Ikiform, ni d'une requête lente. L'abonnement est résilié d'abord, et l'envoi de l'enquête peut échouer sans que personne ne le remarque.
  • Passer se fait en un clic. Un bouton Skip and cancel se trouve à côté du bouton d'envoi. Le cacher, ou ajouter un deuxième et un troisième écran d'offres, transforme une enquête en obstacle, et les gens s'en souviennent au moment de décider s'ils reviennent.
  • Le contexte vient du serveur. Vous connaissez le compte, l'offre et depuis combien de temps ils paient. Prenez-les dans la session, pas dans des inputs masqués, pour que les données soient fiables.
Ce qui se passe pendant que le client attend, et ce qui se passe une fois qu'il a vu la confirmationpendant que le client attendaprès la réponseRésilierl'offresur la page defacturationunequestionrépondreou passerStriperésiliationen fin depériode,raisonenregistréeconfirmationoffre activejusqu'à la finde périodeStriperésiliationen fin depériode, sansraisonafter() selanceseulement s'ily a uneréponseIkiformstocke raison,offre,anciennetéwebhooksubmission.createdcanalrésiliationsl'équipe lelitaujourd'huienvoipasserenvoirelaisCe qui se passe pendant que le client attend, et ce qui se passe une fois qu'il a vu la confirmationpendant que le client attendaprès la réponseRésilierl'offresur la page defacturationunequestionrépondre oupasserStriperésiliation en finde période, raisonenregistréeconfirmationoffre active jusqu'àla fin de périodeStriperésiliationen fin depériode,sans raisonafter() selanceseulement s'il y aune réponseIkiformstocke raison,offre, anciennetéwebhooksubmission.createdcanalrésiliationsl'équipe le litaujourd'huienvoienvoirelaispasser
  1. Le client appuie sur Résilier l'offre sur la page de facturation.
  2. Il voit une seule question et peut y répondre ou la passer.
  3. Dans les deux cas, le serveur programme la résiliation de l'abonnement Stripe en fin de période, avec la raison s'il y en a une.
  4. Le client voit la confirmation tout de suite.
  5. Ce n'est qu'après l'envoi de la réponse HTTP que le serveur envoie la réponse à Ikiform.
  6. Ikiform la stocke avec l'offre et l'ancienneté et envoie un webhook, qu'un relais peut publier dans un canal dédié aux résiliations.
Rien dans la ligne du bas ne peut ralentir ni bloquer la ligne du haut. Si Ikiform est injoignable, le client n'en sait jamais rien.

Champs

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Quelle est la principale raison de votre résiliation ?
Le client ne voit que les trois premiers. account_id, plan et months_subscribed sont ajoutés par le serveur depuis la session.

Les valeurs de raison sont les mêmes que les options cancellation_details.feedback de Stripe : une même valeur va aux deux endroits et les rapports concordent. Les libellés sont reformulés en langage courant. switched_to est la réponse libre la plus utile que vous obtiendrez : la liste des outils vers lesquels les gens partent, c'est votre analyse concurrentielle.

ChampSourcePourquoi
reasonLe clientUn seul choix, avec les mêmes id que Stripe
detailsLe clientFacultatif. Souvent le seul endroit où vous apprenez la vraie histoire
switched_toLe clientConservé seulement quand la raison est switched_service
account_id, planVotre sessionConsulter l'utilisation avant de répondre
months_subscribedVotre sessionLes résiliations précoces et tardives n'ont pas les mêmes causes

L'action de résiliation

Les deux versions partagent une petite liste de raisons, pour que le formulaire et le serveur s'accordent sur les valeurs valides :

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

La server action résilie d'abord, puis programme l'envoi à Ikiform avec after(), qui s'exécute une fois la réponse terminée. La redirection n'est pas retardée, et un envoi en échec est ignoré. Skip and cancel utilise formNoValidate, pour que le bouton radio obligatoire ne le bloque pas.

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>
  );
}
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Si vous utilisez le portail client hébergé de Stripe pour les résiliations plutôt que votre propre page, le portail peut demander lui-même une raison, que vous lisez ensuite sur l'abonnement. Cette page s'adresse aux équipes dont le bouton de résiliation se trouve dans leur propre application.

Exploiter les réponses

Ajoutez un webhook qui publie chaque réponse dans un canal dédié aux résiliations ; il suffit de changer la condition du relais d'alerte du guide principal. Lisez toutes les réponses les premiers mois. Regardez ensuite les raisons par offre et par ancienneté dans l'export CSV : « trop cher » de la part de clients dans leur premier mois, c'est un problème de page de tarifs ; la même réponse après deux ans, c'est souvent une coupe budgétaire.

Répondez aux personnes qui ont donné des détails, depuis votre propre boîte, sans réduction dans le premier message. Un court « merci, c'est utile, voici ce que nous faisons » obtient des réponses que l'enquête n'aurait jamais eues. Ikiform n'écrit pas lui-même au client.

Vérifiez que tout fonctionne

  1. Sur un abonnement de test, choisissez une raison et résiliez. La page de confirmation apparaît, et l'abonnement Stripe affiche cancel_at_period_end avec la même valeur de feedback.
  2. Ouvrez Submissions. La réponse a account_id, plan et months_subscribed remplis par le serveur.
  3. Résiliez un autre abonnement de test avec Skip and cancel. Stripe affiche la résiliation ; Ikiform n'a rien de nouveau.
  4. Donnez une mauvaise valeur à IKIFORM_SECRET_KEY et résiliez à nouveau. La résiliation réussit quand même et le client ne voit aucune erreur.

Pièges

  • Tenez-vous-en à un seul écran. Chaque étape supplémentaire fait perdre des réponses et de la bienveillance.
  • Ne rendez pas la raison obligatoire pour résilier. Elle n'est obligatoire dans la définition du formulaire que pour qu'une enquête remplie soit complète ; passer n'envoie rien.
  • Certaines régions réglementent la facilité de résiliation. Gardez un parcours aussi court que l'inscription, et vérifiez les règles là où vous vendez.
  • Traitez details comme du texte non fiable. C'est ce qu'a tapé un client frustré, et ça arrive dans votre canal de chat.

Dépannage

ProblèmeQue faire
Réponses absentes dans Ikiform, mais les résiliations fonctionnentL'envoi échoue en silence, c'est voulu. Cherchez la requête dans les logs du serveur, et repérez un 403 forbidden dû à une mauvaise clé secrète ou un 422 dû à un champ qui ne correspond pas.
422 validation_failedL'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ.
402 plan_requiredL'espace de travail n'a pas d'offre et l'envoi venait d'un site en ligne. Il est mis en attente, pas perdu. Choisissez une offre ou achetez un pack sous Settings › Plan and usage, et les soumissions en attente arrivent.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.