Formulario de feedback para un sitio web o una app

Formulario de feedback para un sitio web o una app6 min de lectura

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.

Preet SutharFundador, Ikiform

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.
Qué pasa mientras el cliente espera y qué pasa después de que ve la confirmaciónmientras el cliente esperatras la respuestaCancelarplanen la páginadefacturaciónunapreguntaresponderu omitirStripecancela alfinal delperiodo,guarda elmotivoconfirmaciónplan activohasta fin deperiodoStripecancela alfinal delperiodo, sinmotivose ejecutaafter()solo si hayrespuestaIkiformguarda motivo,plan,antigüedadwebhooksubmission.createdcanal debajasel equipo lolee hoyenvíoomitirenvíorelayQué pasa mientras el cliente espera y qué pasa después de que ve la confirmaciónmientras el cliente esperatras la respuestaCancelar planen la página defacturaciónunapreguntaresponder uomitirStripecancela al final delperiodo, guarda elmotivoconfirmaciónplan activo hastafin de periodoStripecancela alfinal delperiodo,sin motivose ejecutaafter()solo si hayrespuestaIkiformguarda motivo, plan,antigüedadwebhooksubmission.createdcanal de bajasel equipo lo lee hoyenvíoenvíorelayomitir
  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

Vista previa

Ejecuta el validador de Ikiform en tu navegador. No se envía nada.

¿Cuál es el motivo principal por el que cancelas?
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.

CampoOrigenPor qué
reasonEl clienteUna opción, con los mismos ids que usa Stripe
detailsEl clienteOpcional. A menudo el único sitio donde te enteras de la historia real
switched_toEl clienteSolo se guarda cuando el motivo es switched_service
account_id, planTu sesiónConsulta el uso antes de responder
months_subscribedTu sesiónLas 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
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 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
"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>
  );
}
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 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 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

ProblemaQué hacer
Faltan respuestas en Ikiform, pero las cancelaciones funcionanEl 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_failedEl 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_requiredEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.