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.
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.
- Le client appuie sur Résilier l'offre sur la page de facturation.
- Il voit une seule question et peut y répondre ou la passer.
- 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.
- Le client voit la confirmation tout de suite.
- Ce n'est qu'après l'envoi de la réponse HTTP que le serveur envoie la réponse à Ikiform.
- 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.
Champs
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
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.
| Champ | Source | Pourquoi |
|---|---|---|
reason | Le client | Un seul choix, avec les mêmes id que Stripe |
details | Le client | Facultatif. Souvent le seul endroit où vous apprenez la vraie histoire |
switched_to | Le client | Conservé seulement quand la raison est switched_service |
account_id, plan | Votre session | Consulter l'utilisation avant de répondre |
months_subscribed | Votre session | Les 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 :
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.
"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>
);
}L'action de formulaire de SvelteKit n'a pas de after() : l'envoi reçoit donc un AbortSignal.timeout de trois secondes et ses erreurs sont ignorées. La résiliation a déjà eu lieu à ce moment-là. Le markup du formulaire est le même que sur la page Next.js, avec method="POST". Placez reasons.ts dans src/lib, et déclarez les deux clés dans src/env.ts avec defineEnvVars pour que $app/env/private puisse les lire.
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 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
- 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_endavec la même valeur de feedback. - Ouvrez Submissions. La réponse a
account_id,planetmonths_subscribedremplis par le serveur. - Résiliez un autre abonnement de test avec Skip and cancel. Stripe affiche la résiliation ; Ikiform n'a rien de nouveau.
- Donnez une mauvaise valeur à
IKIFORM_SECRET_KEYet 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
detailscomme du texte non fiable. C'est ce qu'a tapé un client frustré, et ça arrive dans votre canal de chat.
Dépannage
| Problème | Que faire |
|---|---|
| Réponses absentes dans Ikiform, mais les résiliations fonctionnent | L'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_failed | L'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_required | L'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
Un choix de raison, un commentaire facultatif et, s'ils ont trouvé une alternative, l'outil vers lequel ils partent. Gardez toute l'enquête facultative, et prenez l'offre et l'ancienneté dans vos propres données au lieu de les demander.
Non. Mettez un bouton Passer et résilier à côté du bouton d'envoi et résiliez dans les deux cas. Une enquête à laquelle il faut répondre pour partir recueille de moins bonnes réponses et du ressentiment.
Oui. Les abonnements Stripe acceptent cancellation_details avec une valeur feedback et un commentaire. Utiliser les id de feedback de Stripe comme valeurs d'option garde les deux systèmes alignés.
Rien ne change pour le client. L'abonnement est résilié d'abord, et l'envoi de l'enquête s'exécute après la réponse ou avec un court timeout, en ignorant les erreurs.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.