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

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/feedback-form/cancellation-survey

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.

Diagrama: O que acontece enquanto o cliente espera, e o que acontece depois que ele vê a confirmação

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

Formulário: Cancellation survey

- **Qual é o principal motivo do cancelamento?** (`reason`, radio, obrigatório)
- **Algo que você gostaria que soubéssemos?** (`details`, textarea)
- **Para qual ferramenta você está mudando?** (`switched_to`, text)
- **Conta** (`account_id`, text, obrigatório)
- **Plano** (`plan`, text, obrigatório)
- **Meses de assinatura** (`months_subscribed`, number)

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.

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

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

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

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

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.

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

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

| 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

### O que uma pesquisa de cancelamento deve perguntar?

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.

### A pesquisa de cancelamento deve ser obrigatória?

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.

### Posso salvar o motivo do cancelamento no Stripe também?

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.

### E se o Ikiform estiver fora do ar quando alguém cancelar?

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.

## Mais guias

- [Formulário de feedback para um site ou app](https://ikiform.com/pt-br/guides/feedback-form.md): Coloque um formulário de feedback em qualquer página ou dentro do seu app: uma nota, um comentário, a página de origem, filtro de spam e alertas para notas baixas.
- [Pesquisa NPS](https://ikiform.com/pt-br/guides/feedback-form/nps-survey.md): Rode uma pesquisa NPS com a nota de 0 a 10 no e-mail, uma pergunta de acompanhamento na sua própria página que muda com a nota e um script que calcula o NPS.
- [Formulário de pedido de funcionalidade](https://ikiform.com/pt-br/guides/feedback-form/feature-request.md): Um formulário de pedido de funcionalidade que pergunta pelo problema em vez da solução, armazena pedidos por tema, conta pessoas, não linhas, e avisa quando sair.
- [Formulário de relato de bug](https://ikiform.com/pt-br/guides/feedback-form/bug-report.md): Um formulário de relato de bug que pede às pessoas os passos e a gravidade, e deixa a página adicionar sozinha a URL, o navegador, o viewport e os erros recentes.
- [Widget de feedback em React](https://ikiform.com/pt-br/guides/feedback-form/react-feedback-widget.md): Um botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.
- [Feedback de beta testers](https://ikiform.com/pt-br/guides/feedback-form/beta-tester-feedback.md): Um formulário de feedback de beta com um link pessoal por tester e build, duas escalas de nota e um resumo semanal que mostra se cada build melhorou.
