Formulário de feedback para um site ou app

Formulário de feedback para um site ou app6 min de leitura

Adicione uma pesquisa de cancelamento ao fluxo de cancelamento do seu SaaS

Pergunte por que os clientes cancelam dentro do fluxo de cancelamento do seu SaaS, deixe o Pular a um clique, salve o motivo no Stripe e no Ikiform e nunca atrase o cancelamento.

Preet SutharFundador, Ikiform

Mostre uma pergunta opcional na página de cancelamento, com um botão Pular ao lado do Enviar, e cancele a assinatura primeiro, seja qual for a escolha da pessoa. Depois envie a resposta a partir do seu servidor, com a conta, o plano e o tempo de assinatura tirados da sessão, depois da resposta HTTP ou com um timeout curto, para que a pesquisa nunca segure o cancelamento. Salve o mesmo motivo na assinatura do Stripe para que os dois sistemas concordem.

O que muda num fluxo de cancelamento

Em qualquer outro lugar, um formulário de feedback é um favor que você pede. Num fluxo de cancelamento, ele fica entre o cliente e algo que ele já decidiu fazer. Isso define as regras:

  • O cancelamento nunca pode depender da pesquisa. Nem de uma resposta, nem de o Ikiform estar acessível, nem de uma requisição lenta. A assinatura é cancelada primeiro, e o envio da pesquisa pode falhar sem ninguém perceber.
  • Pular é um clique. Um botão Skip and cancel (pular e cancelar) fica ao lado do botão de envio. Escondê-lo, ou adicionar uma segunda e uma terceira tela de ofertas, transforma a pesquisa num obstáculo, e as pessoas lembram disso quando decidem se vão voltar.
  • O contexto vem do servidor. Você sabe a conta, o plano e há quanto tempo a pessoa paga. Pegue esses dados da sessão, não de inputs ocultos, para que sejam confiáveis.
O que acontece enquanto o cliente espera, e o que acontece depois que ele vê a confirmaçãoenquanto o cliente esperadepois da respostaCancelarplanona página decobrançaumaperguntaresponderou pularStripecancela nofim doperíodo,motivo salvoconfirmaçãoplano ativoaté o fim doperíodoStripecancela nofim doperíodo, semmotivoafter()rodasó quando hárespostaIkiformarmazenamotivo, plano,tempowebhooksubmission.createdcanal dechurna equipe lêhojeenviarpularenviorelayO que acontece enquanto o cliente espera, e o que acontece depois que ele vê a confirmaçãoenquanto o cliente esperadepois da respostaCancelar planona página decobrançaumaperguntaresponder oupularStripecancela no fim doperíodo, motivosalvoconfirmaçãoplano ativo até ofim do períodoStripecancela nofim doperíodo,sem motivoafter() rodasó quando hárespostaIkiformarmazena motivo,plano, tempowebhooksubmission.createdcanal de churna equipe lê hojeenviarenviorelaypular
  1. O cliente clica em Cancelar plano na página de cobrança.
  2. Ele vê uma pergunta e pode respondê-la ou pulá-la.
  3. De qualquer forma, o servidor define a assinatura do Stripe para cancelar no fim do período, com o motivo quando houver um.
  4. O cliente vê a confirmação na hora.
  5. Só depois que a resposta HTTP é enviada o servidor manda a resposta da pesquisa ao Ikiform.
  6. O Ikiform armazena a resposta com o plano e o tempo de assinatura e envia um webhook, que um relay pode postar num canal de churn.
Nada na linha de baixo pode atrasar ou bloquear a linha de cima. Se o Ikiform estiver inacessível, o cliente nunca fica sabendo.

Campos

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

Qual é o principal motivo do cancelamento?
O cliente só vê os três primeiros. account_id, plan e months_subscribed são adicionados pelo servidor a partir da sessão.

Os valores de motivo são os mesmos das opções de cancellation_details.feedback do Stripe, então um valor vai para os dois lugares e os relatórios batem. Os rótulos foram reescritos em linguagem simples. switched_to é a resposta em texto livre mais útil que você vai receber: a lista das ferramentas para as quais as pessoas vão é a sua análise de concorrência.

CampoOrigemPor quê
reasonO clienteUma escolha, com os mesmos ids que o Stripe usa
detailsO clienteOpcional. Muitas vezes o único lugar onde você descobre a história real
switched_toO clienteSó é mantido quando o motivo é switched_service
account_id, planSua sessãoConsulte o uso antes de responder
months_subscribedSua sessãoChurn cedo e churn tardio têm causas diferentes

A ação de cancelamento

As duas versões compartilham uma pequena lista de motivos, para que o formulário e o servidor concordem sobre os 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;
}

A server action cancela primeiro e depois agenda o envio ao Ikiform com after(), que roda quando a resposta termina. O redirecionamento não é atrasado por ele, e um envio com falha é ignorado. Skip and cancel usa formNoValidate, então o radio obrigatório não o bloqueia.

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>
  );
}
As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

Se você usa o portal do cliente hospedado do Stripe para cancelamentos em vez da sua própria página, o próprio portal pode pedir um motivo, e você o lê na assinatura. Esta página é para equipes cujo botão de cancelar fica no próprio app.

Aja com base nas respostas

Adicione um webhook que posta cada resposta num canal de churn; o relay de alerta do guia principal só precisa ter a condição alterada. Leia todas as respostas nos primeiros meses. Depois, olhe os motivos por plano e tempo de assinatura na exportação CSV: "caro demais" de clientes no primeiro mês é um problema da página de preços, e a mesma resposta depois de dois anos muitas vezes é corte de orçamento.

Responda a quem escreveu detalhes, da sua própria caixa de entrada, sem desconto na primeira mensagem. Um curto "obrigado, isso é útil, veja o que estamos fazendo a respeito" rende respostas que a pesquisa nunca renderia. O Ikiform não envia e-mail ao cliente.

Confira se funciona

  1. Numa assinatura de teste, escolha um motivo e cancele. A página de confirmação aparece, e a assinatura no Stripe mostra cancel_at_period_end com o mesmo valor de feedback.
  2. Abra Submissions. A resposta tem account_id, plan e months_subscribed preenchidos pelo servidor.
  3. Cancele outra assinatura de teste com Skip and cancel. O Stripe mostra o cancelamento; o Ikiform não recebe nada novo.
  4. Defina IKIFORM_SECRET_KEY com um valor errado e cancele de novo. O cancelamento ainda funciona e o cliente não vê nenhum erro.

Pegadinhas

  • Mantenha tudo numa tela só. Cada passo a mais perde respostas e custa boa vontade.
  • Não torne o motivo obrigatório para cancelar. Ele é obrigatório na definição do formulário só para que uma pesquisa respondida fique completa; pular não envia nada.
  • Algumas regiões regulam quão fácil o cancelamento deve ser. Mantenha o fluxo tão curto quanto foi o cadastro e confira as regras onde você vende.
  • Trate details como texto não confiável. É o que um cliente frustrado digitou, e isso vai para o seu canal de chat.

Solução de problemas

ProblemaO que fazer
Respostas faltando no Ikiform, mas os cancelamentos funcionamO envio falha em silêncio de propósito. Confira os logs do servidor para a requisição e procure 403 forbidden por uma chave secreta errada ou 422 por um campo que não bate.
422 validation_failedO envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo.
402 plan_requiredO workspace não tem plano e o envio veio de um site publicado. Ele fica retido, não perdido. Escolha um plano ou compre um pacote em Settings › Plan and usage e os envios retidos chegam.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.