# Añade una encuesta de cancelación al flujo de baja de tu SaaS

> Pregunta por qué cancelan tus clientes dentro del flujo de baja de tu SaaS, deja Omitir a un clic, guarda el motivo en Stripe e Ikiform y nunca retrases la baja.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/feedback-form/cancellation-survey

Muestra una pregunta opcional en la página de cancelación, con un botón Omitir junto a Enviar, y cancela primero la suscripción elija lo que elija la persona. Después envía la respuesta desde tu servidor, con la cuenta, el plan y la antigüedad sacados de la sesión, tras la respuesta o con un timeout corto, para que la encuesta nunca pueda frenar la cancelación. Guarda el mismo motivo en la suscripción de Stripe para que los dos sistemas coincidan.

## Qué cambia en un flujo de cancelación

En cualquier otro sitio, un formulario de feedback es un favor que pides. En un flujo de cancelación se interpone entre un cliente y algo que ya ha decidido hacer. Eso marca las reglas:

- **La cancelación nunca debe depender de la encuesta.** Ni de una respuesta, ni de que Ikiform esté disponible, ni de una petición lenta. La suscripción se cancela primero, y el envío de la encuesta puede fallar sin que nadie se dé cuenta.
- **Omitir está a un clic.** Un botón **Skip and cancel** (omitir y cancelar) va junto al botón de enviar. Esconderlo, o añadir una segunda y una tercera pantalla de ofertas, convierte una encuesta en un obstáculo, y la gente lo recuerda cuando decide si volver.
- **El contexto viene del servidor.** Conoces la cuenta, el plan y cuánto tiempo han pagado. Tómalos de la sesión, no de inputs ocultos, para que los datos sean fiables.

Diagrama: Qué pasa mientras el cliente espera y qué pasa después de que ve la confirmación

1. El cliente pulsa Cancelar plan en la página de facturación.
2. Ve una pregunta y puede responderla u omitirla.
3. En ambos casos, el servidor programa la cancelación de la suscripción de Stripe al final del periodo, con el motivo si lo hay.
4. El cliente ve la confirmación al instante.
5. Solo después de enviar la respuesta, el servidor manda la respuesta a Ikiform.
6. Ikiform la guarda con el plan y la antigüedad y envía un webhook, que un relay puede publicar en un canal de bajas.

Nada de la fila de abajo puede ralentizar ni bloquear la fila de arriba. Si Ikiform no está disponible, el cliente nunca lo sabe.

## Campos

Formulario: Cancellation survey

- **¿Cuál es el motivo principal por el que cancelas?** (`reason`, radio, obligatorio)
- **¿Hay algo que quieras contarnos?** (`details`, textarea)
- **¿A qué herramienta te cambias?** (`switched_to`, text)
- **Cuenta** (`account_id`, text, obligatorio)
- **Plan** (`plan`, text, obligatorio)
- **Meses suscrito** (`months_subscribed`, number)

El cliente solo ve los tres primeros. `account_id`, `plan` y `months_subscribed` los añade el servidor a partir de la sesión.

Los valores del motivo son los mismos que las opciones de `cancellation_details.feedback` de Stripe, así que un mismo valor va a los dos sitios y los informes cuadran. Las etiquetas están reescritas en lenguaje llano. `switched_to` es la respuesta de texto libre más útil que vas a recibir: la lista de herramientas por las que te deja la gente es tu análisis de la competencia.

| Campo | Origen | Por qué |
| --- | --- | --- |
| `reason` | El cliente | Una opción, con los mismos ids que usa Stripe |
| `details` | El cliente | Opcional. A menudo el único sitio donde te enteras de la historia real |
| `switched_to` | El cliente | Solo se guarda cuando el motivo es `switched_service` |
| `account_id`, `plan` | Tu sesión | Consulta el uso antes de responder |
| `months_subscribed` | Tu sesión | Las bajas tempranas y las tardías tienen causas distintas |

## La acción de cancelar

Las dos versiones comparten una pequeña lista de motivos, para que el formulario y el servidor coincidan en los valores válidos:

`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 cancela primero y luego programa el envío a Ikiform con `after()`, que se ejecuta cuando la respuesta ha terminado. La redirección no se retrasa por ello, y un envío fallido se ignora. **Skip and cancel** usa `formNoValidate`, así que el radio obligatorio no lo bloquea.

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

La form action de SvelteKit no tiene `after()`, así que el envío lleva un `AbortSignal.timeout` de tres segundos y se ignoran sus errores. Para entonces la cancelación ya se ha hecho. El markup del formulario es el mismo que el de la página de Next.js, con `method="POST"`. Pon `reasons.ts` en `src/lib` y declara las dos claves en `src/env.ts` con `defineEnvVars` para que `$app/env/private` pueda leerlas.

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

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

Si usas el portal de cliente alojado de Stripe para las cancelaciones en lugar de tu propia página, el portal puede pedir el motivo por sí mismo, y lo lees desde la suscripción. Esta página es para equipos cuyo botón de cancelar está en su propia app.

## Actúa según las respuestas

Añade un webhook que publique cada respuesta en un canal de bajas; al [relay de alertas](https://ikiform.com/es/guides/feedback-form.md#routing) de la guía principal solo hay que cambiarle la condición. Lee todas las respuestas durante los primeros meses. Después mira los motivos por plan y antigüedad en la exportación CSV: "demasiado caro" de clientes en su primer mes es un problema de la página de precios, y la misma respuesta tras dos años suele ser un recorte de presupuesto.

Responde a quien haya escrito detalles, desde tu propia bandeja de entrada, sin descuento en el primer mensaje. Un breve "gracias, esto nos sirve, esto es lo que vamos a hacer" consigue respuestas que la encuesta nunca obtendría. Ikiform no envía emails al cliente por su cuenta.

## Comprueba que funciona

1. En una suscripción de prueba, elige un motivo y cancela. Aparece la página de confirmación, y la suscripción de Stripe muestra `cancel_at_period_end` con el mismo valor de feedback.
2. Abre **Submissions**. La respuesta tiene `account_id`, `plan` y `months_subscribed` rellenados por el servidor.
3. Cancela otra suscripción de prueba con **Skip and cancel**. Stripe muestra la cancelación; Ikiform no tiene nada nuevo.
4. Pon un valor incorrecto en `IKIFORM_SECRET_KEY` y vuelve a cancelar. La cancelación sigue funcionando y el cliente no ve ningún error.

## Errores comunes

- Mantenlo en una sola pantalla. Cada paso extra pierde respuestas y cuesta buena voluntad.
- No hagas obligatorio el motivo para cancelar. Es obligatorio en la definición del formulario solo para que una encuesta respondida esté completa; omitir no envía nada.
- Algunas regiones regulan lo fácil que debe ser cancelar. Mantén el flujo tan corto como fue el registro y revisa las normas donde vendas.
- Trata `details` como texto no fiable. Es lo que escribió un cliente frustrado, y acaba en tu canal de chat.

## Solución de problemas

| Problema | Qué hacer |
| --- | --- |
| Faltan respuestas en Ikiform, pero las cancelaciones funcionan | El envío falla en silencio a propósito. Busca la petición en los logs del servidor y fíjate en un `403 forbidden` por una clave secreta incorrecta o un `422` por campos que no coinciden. |
| `422 validation_failed` | El envío no coincide con la definición de campos del formulario. `error.fields` indica cada campo y el motivo. Haz que el `name` del input coincida con el id del campo. |
| `402 plan_required` | El espacio de trabajo no tiene plan y el envío vino de un sitio en producción. Queda retenido, no se pierde. Elige un plan o compra un pack en **Settings › Plan and usage** y los envíos retenidos llegarán. |

## Preguntas

### ¿Qué debería preguntar una encuesta de cancelación?

Una opción de motivo, un comentario opcional y a qué herramienta se cambian si encontraron una alternativa. Haz que toda la encuesta se pueda omitir, y toma el plan y la antigüedad de tus propios datos en lugar de preguntarlos.

### ¿Debería ser obligatoria la encuesta de cancelación?

No. Pon un botón Omitir y cancelar junto al de enviar y cancela en cualquier caso. Una encuesta que hay que responder para irse recoge peores respuestas y resentimiento.

### ¿Puedo guardar también el motivo de cancelación en Stripe?

Sí. Las suscripciones de Stripe aceptan cancellation_details con un valor de feedback y un comentario. Usar los ids de feedback de Stripe como valores de tus opciones mantiene los dos sistemas sincronizados.

### ¿Y si Ikiform está caído cuando alguien cancela?

Nada cambia para el cliente. La suscripción se cancela primero, y el envío de la encuesta se ejecuta tras la respuesta o con un timeout corto e ignora los errores.

## Más guías

- [Formulario de feedback para un sitio web o una app](https://ikiform.com/es/guides/feedback-form.md): Pon un formulario de feedback en cualquier página o dentro de tu app: una valoración, un comentario, la página de origen, filtro antispam y avisos cuando la nota es baja.
- [Encuesta NPS](https://ikiform.com/es/guides/feedback-form/nps-survey.md): Haz una encuesta NPS con una puntuación de 0 a 10 en el email, una pregunta de seguimiento en tu propia página que cambia según la puntuación y un script que calcula el NPS.
- [Formulario de peticiones de funcionalidades](https://ikiform.com/es/guides/feedback-form/feature-request.md): Un formulario de peticiones de funcionalidades que pregunta por el problema en lugar de la solución, guarda las peticiones por tema, cuenta personas y no filas, y les avisa por email cuando sale.
- [Formulario de reporte de bugs](https://ikiform.com/es/guides/feedback-form/bug-report.md): Un formulario de reporte de bugs que pide a la gente los pasos y la gravedad, y deja que la página añada por su cuenta la URL, el navegador, el viewport y los errores recientes.
- [Widget de feedback en React](https://ikiform.com/es/guides/feedback-form/react-feedback-widget.md): Un botón flotante de feedback para una app React que abre un panel pequeño, envía la pantalla, la versión de la app y la cuenta con cada respuesta, y nunca pierde un borrador.
- [Feedback de beta testers](https://ikiform.com/es/guides/feedback-form/beta-tester-feedback.md): Un formulario de feedback para betas con un enlace personal por tester y build, dos escalas de valoración y un resumen semanal que muestra si cada build mejoró.
