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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/feedback-form/cancellation-survey

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.

Schéma : Ce qui se passe pendant que le client attend, et ce qui se passe une fois qu'il a vu la confirmation

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

Formulaire : Cancellation survey

- **Quelle est la principale raison de votre résiliation ?** (`reason`, radio, obligatoire)
- **Quelque chose que vous aimeriez nous dire ?** (`details`, textarea)
- **Vers quel outil partez-vous ?** (`switched_to`, text)
- **Compte** (`account_id`, text, obligatoire)
- **Offre** (`plan`, text, obligatoire)
- **Mois d'abonnement** (`months_subscribed`, number)

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.

| 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 :

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

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

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

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.

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

> 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](https://ikiform.com/fr/docs/rate-limits.md) 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](https://ikiform.com/fr/guides/feedback-form.md#routing) 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è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

### Que doit demander une enquête de résiliation ?

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.

### L'enquête de résiliation doit-elle être obligatoire ?

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.

### Puis-je aussi enregistrer la raison de résiliation dans Stripe ?

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.

### Que se passe-t-il si Ikiform est indisponible quand quelqu'un résilie ?

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.

## Autres guides

- [Formulaire de feedback pour un site ou une application](https://ikiform.com/fr/guides/feedback-form.md): Placez un formulaire de feedback sur n'importe quelle page ou dans votre application : une note, un commentaire, la page d'origine, le filtrage du spam et des alertes quand une note est basse.
- [Enquête NPS](https://ikiform.com/fr/guides/feedback-form/nps-survey.md): Menez une enquête NPS avec un score de 0 à 10 dans l'e-mail, une question de suivi sur votre propre page qui change selon le score, et un script qui calcule le NPS.
- [Formulaire de demande de fonctionnalité](https://ikiform.com/fr/guides/feedback-form/feature-request.md): Un formulaire de demande de fonctionnalité qui demande le problème plutôt que la solution, range les demandes par thème, compte les personnes et non les lignes, et les prévient quand c'est livré.
- [Formulaire de signalement de bug](https://ikiform.com/fr/guides/feedback-form/bug-report.md): Un formulaire de signalement de bug qui demande les étapes et la gravité, et laisse la page ajouter elle-même l'URL, le navigateur, le viewport et les erreurs récentes.
- [Widget de feedback React](https://ikiform.com/fr/guides/feedback-form/react-feedback-widget.md): Un bouton de feedback flottant pour une application React qui ouvre un petit panneau, envoie l'écran, la version et le compte avec chaque réponse, et ne perd jamais un brouillon.
- [Feedback des bêta-testeurs](https://ikiform.com/fr/guides/feedback-form/beta-tester-feedback.md): Un formulaire de feedback bêta avec un lien personnel par testeur et par build, deux échelles de notation et un résumé hebdomadaire qui montre si chaque build s'est amélioré.
