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.
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 cliente clica em Cancelar plano na página de cobrança.
- Ele vê uma pergunta e pode respondê-la ou pulá-la.
- De qualquer forma, o servidor define a assinatura do Stripe para cancelar no fim do período, com o motivo quando houver um.
- O cliente vê a confirmação na hora.
- Só depois que a resposta HTTP é enviada o servidor manda a resposta da pesquisa ao Ikiform.
- 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.
Campos
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
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.
| Campo | Origem | Por quê |
|---|---|---|
reason | O cliente | Uma escolha, com os mesmos ids que o Stripe usa |
details | O cliente | Opcional. Muitas vezes o único lugar onde você descobre a história real |
switched_to | O cliente | Só é mantido quando o motivo é switched_service |
account_id, plan | Sua sessão | Consulte o uso antes de responder |
months_subscribed | Sua sessão | Churn 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:
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.
"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>
);
}A form action do SvelteKit não tem after(), então o envio recebe um AbortSignal.timeout de três segundos e seus erros são ignorados. O cancelamento já aconteceu nesse ponto. O markup do formulário é o mesmo da página do Next.js, com method="POST". Coloque reasons.ts em src/lib e declare as duas chaves em src/env.ts com defineEnvVars para que $app/env/private consiga lê-las.
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 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
- 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_endcom o mesmo valor de feedback. - Abra Submissions. A resposta tem
account_id,planemonths_subscribedpreenchidos pelo servidor. - Cancele outra assinatura de teste com Skip and cancel. O Stripe mostra o cancelamento; o Ikiform não recebe nada novo.
- Defina
IKIFORM_SECRET_KEYcom 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
detailscomo texto não confiável. É o que um cliente frustrado digitou, e isso vai para o seu canal de chat.
Solução de problemas
| Problema | O que fazer |
|---|---|
| Respostas faltando no Ikiform, mas os cancelamentos funcionam | O 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_failed | O 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_required | O 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
Uma escolha de motivo, um comentário opcional e para qual ferramenta a pessoa está indo, se encontrou uma alternativa. Deixe a pesquisa inteira pulável e pegue o plano e o tempo de assinatura dos seus próprios dados em vez de perguntar.
Não. Coloque um botão Pular e cancelar ao lado do botão de envio e cancele de qualquer jeito. Uma pesquisa que as pessoas precisam responder para sair coleta respostas piores e ressentimento.
Sim. Assinaturas do Stripe aceitam cancellation_details com um valor de feedback e um comentário. Usar os ids de feedback do Stripe como valores das opções mantém os dois sistemas em sincronia.
Nada muda para o cliente. A assinatura é cancelada primeiro, e o envio da pesquisa roda depois da resposta ou com um timeout curto e ignora erros.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.