# Monte um formulário de pedido de demonstração que agenda a reunião

> Um formulário de agendar demonstração que armazena o lead e depois manda o visitante para o Cal.com ou o Calendly com nome e e-mail já preenchidos.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/lead-capture/demo-request

Envie o pedido de demonstração primeiro a um backend de formulários, para que o lead fique armazenado e a sua equipe receba o e-mail mesmo que o visitante nunca escolha um horário. Depois mande o navegador para a sua página de agendamento com `name` e `email` na query string, que o Calendly e o Cal.com usam para preencher o formulário deles. Mande equipes pequenas para outro lugar, como um tour gravado, para que a sua agenda fique com reuniões que podem fechar negócio.

## O que muda num pedido de demonstração

Quem pede uma demonstração quer um horário na agenda, não uma troca de e-mails. Cada rodada de "terça serve?" é uma chance de a pessoa agendar com um concorrente. Então a missão do formulário é curta: captar o suficiente para qualificar e colocar uma agenda na frente da pessoa enquanto a aba ainda está aberta.

Três restrições definem a construção:

- **O lead precisa estar armazenado antes de o agendador abrir.** Muitos visitantes abrem a página de agendamento e a fecham. Se o único registro deles fica no agendador, esses leads se perdem. Envie ao Ikiform primeiro e redirecione só em caso de sucesso.
- **O `_redirect` não consegue levar o que a pessoa digitou.** Ele é uma URL fixa, e o Ikiform só o segue para uma URL `https:` em um dos seus **Allowed domains**. Adicionar `cal.com` ou `calendly.com` ali também deixaria passar envios vindos desses sites. Monte o link de agendamento no seu próprio código.
- **Nem todo pedido merece uma reunião ao vivo.** Um fundador solo pedindo demonstração de um produto para equipes fica mais bem atendido com um tour e um link de cadastro. Uma pergunta de escolha fixa decide para onde o navegador vai em seguida.

Diagrama: Para onde vai um pedido de demonstração depois de armazenado

1. O visitante preenche nome, e-mail de trabalho, empresa e tamanho da equipe, e o formulário envia ao Ikiform.
2. O Ikiform armazena o pedido e manda um e-mail para a sua equipe. Nada mais acontece até ele responder ok.
3. O seu código lê o tamanho de equipe que o visitante escolheu.
4. Equipes de 11 ou mais vão para a página de agendamento, com nome e e-mail na query string.
5. Equipes de 1 a 10 vão para um tour gravado com um link de cadastro.

A ramificação roda no seu código, depois que o Ikiform já tem o lead. A equipe recebe todos os pedidos de qualquer forma, então uma equipe pequena que deveria ter tido uma reunião continua visível.

## Quatro campos, um deles para roteamento

| Campo | Tipo | Por quê |
| --- | --- | --- |
| `name` | text | Preenche o formulário de agendamento, para o visitante digitar uma vez só |
| `email` | e-mail | Preenche o formulário de agendamento e vira o reply-to do e-mail de notificação |
| `company` | text, obrigatório aqui | O seu vendedor pesquisa antes da reunião |
| `team_size` | select | Decide entre página de agendamento ou tour, e chega como um de quatro valores conhecidos |

Deixe o caso de uso, o prazo e o telefone para a própria reunião. O agendador pode fazer uma pergunta própria se você precisar de contexto antes da reunião, e tanto o Calendly quanto o Cal.com também conseguem preencher perguntas personalizadas.

## Envie e depois passe adiante

Cada versão envia o pedido, espera a resposta do Ikiform e só então monta a próxima URL. Os exemplos usam o Cal.com; para o Calendly, troque pelo link do seu evento. O Calendly documenta `name` e `email` como parâmetros de preenchimento, e o Cal.com preenche qualquer campo de agendamento pelo identificador dele, incluindo `name` e `email`.

**HTML**

O formulário e o script juntos. Sem JavaScript o formulário ainda envia e cai na sua página de **Redirect after submit**, então coloque o link de agendamento lá como plano B.

`demo.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="demo-form">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required />

  <label for="team_size">Team size</label>
  <select id="team_size" name="team_size" required>
    <option value="1_10">1 to 10</option>
    <option value="11_50">11 to 50</option>
    <option value="51_200">51 to 200</option>
    <option value="201_plus">201 or more</option>
  </select>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert" id="demo-error" hidden></p>
  <button>Pick a time</button>
</form>

<script>
  const BOOKING = "https://cal.com/acme/demo";
  const SELF_SERVE = "/tour";
  const form = document.getElementById("demo-form");
  const error = document.getElementById("demo-error");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const data = new FormData(form);
    form.querySelector("button").disabled = true;
    error.hidden = true;
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (!result.ok) throw new Error(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
      const small = data.get("team_size") === "1_10";
      const next = new URL(small ? SELF_SERVE : BOOKING, location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      location.assign(next);
    } catch (failure) {
      error.textContent = failure instanceof Error ? failure.message : "Couldn't send. Try again.";
      error.hidden = false;
      form.querySelector("button").disabled = false;
    }
  });
</script>
```

**Next.js**

A server action chama `redirect()` com a URL de agendamento assim que o Ikiform responde ok. O Next.js aceita URLs absolutas ali, e o formulário funciona antes da hidratação porque uma server action responde a um envio de formulário simples com um `303`. Mantenha `redirect()` fora de qualquer bloco `try`, já que ele lança uma exceção para fazer o seu trabalho.

`app/demo/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";

const BOOKING = "https://cal.com/acme/demo";

export async function requestDemo(_previous: string, formData: FormData): Promise<string> {
  const lead = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    company: String(formData.get("company") ?? "").trim(),
    team_size: String(formData.get("team_size") ?? ""),
    _gotcha: String(formData.get("_gotcha") ?? ""),
  };
  const origin = (await headers()).get("origin");

  const response = 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(lead),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    return Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
  }

  const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : BOOKING);
  next.searchParams.set("name", lead.name);
  next.searchParams.set("email", lead.email);
  redirect(next.toString());
}
```

`app/demo/demo-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { requestDemo } from "./actions";

export function DemoForm() {
  const [error, action, pending] = useActionState(requestDemo, "");

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
    </form>
  );
}
```

**SvelteKit**

A form action devolve `fail()` com a primeira mensagem nos erros e chama `redirect(303, url, { external: true })` no sucesso; o SvelteKit 3 recusa um redirecionamento para outro site sem essa opção. Declare `IKIFORM_SECRET_KEY` em `src/env.ts` com `defineEnvVars` para que `$app/env/private` consiga lê-la. Combine com qualquer formulário em `+page.svelte` que envie para a action padrão; sem JavaScript o próprio navegador segue o `303`.

`src/routes/demo/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    const lead = Object.fromEntries(
      ["name", "email", "company", "team_size", "_gotcha"].map((key) => [key, String(data.get(key) ?? "").trim()]),
    );

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify(lead),
    });
    const result = await response.json();
    if (!result.ok) {
      const fields: Record<string, string[]> = result.error.fields ?? {};
      return fail(response.status, { lead, message: Object.values(fields)[0]?.[0] ?? result.error.message });
    }

    const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : "https://cal.com/acme/demo");
    next.searchParams.set("name", lead.name);
    next.searchParams.set("email", lead.email);
    redirect(303, next.toString(), { external: true });
  },
} satisfies Actions;
```

**React**

Numa single-page app o navegador envia direto ao Ikiform e segue em frente com `location.assign`. Use **Allowed domains** para proteger o formulário, nunca uma chave secreta.

`src/DemoForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const BOOKING = "https://cal.com/acme/demo";

export function DemoForm() {
  const [error, setError] = useState("");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    setError("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: data });
      const result = await response.json();
      if (!result.ok) {
        const fields: Record<string, string[]> = result.error.fields ?? {};
        setError(Object.values(fields)[0]?.[0] ?? result.error.message);
        setPending(false);
        return;
      }
      const next = new URL(data.get("team_size") === "1_10" ? "/tour" : BOOKING, window.location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      window.location.assign(next);
    } catch {
      setError("Couldn't reach the server. Try again.");
      setPending(false);
    }
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</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](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. As versões no servidor repassam o header `Origin` do visitante, porque um fetch feito no servidor não envia nenhum e o Ikiform detecta por ele os testes gratuitos de `localhost`.

## E-mail de trabalho, sem bloquear o Gmail

O Ikiform não tem uma configuração que recuse provedores de e-mail gratuitos. A opção `allowed_domains` do campo de e-mail é uma allowlist: útil para um formulário interno restrito ao domínio da sua própria empresa, errada para um formulário público de demonstração. Tudo bem, porque bloquear o Gmail também bloqueia fundadores, consultores e compradores que preenchem formulários pelo celular.

Em vez disso, marque esses pedidos onde a sua equipe os vê. Esta função roda no relay que publica cada pedido no chat ou no seu CRM, e dá uma pista ao vendedor antes da reunião:

`lib/demo-tag.ts`:

```ts
const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "proton.me"]);

export function demoTag(fields: Record<string, unknown>) {
  const domain = String(fields.email ?? "").split("@")[1]?.toLowerCase() ?? "";
  const size = String(fields.team_size ?? "");
  if (FREE_MAIL.has(domain)) return "personal address, check the company";
  if (size === "51_200" || size === "201_plus") return "priority";
  return "standard";
}
```

Chame-a com `event.data.fields` depois do `verifyWebhook` e então adicione a tag à mensagem do chat ou a uma propriedade do CRM. [Leads no HubSpot ou no Pipedrive](https://ikiform.com/pt-br/guides/lead-capture/leads-to-crm.md) mostra um relay completo.

## Confira se funciona

1. Adicione o domínio do seu site e `localhost` em **Allowed domains**.
2. Envie com o tamanho de equipe **51 to 200**. A página de agendamento abre com seu nome e e-mail preenchidos.
3. Feche a página de agendamento sem escolher um horário. O pedido continua em **Submissions**, e o e-mail de notificação está na sua caixa de entrada.
4. Envie de novo com **1 to 10** e confira se você cai no tour.
5. Envie com um e-mail inválido. O erro aparece no formulário, e a página de agendamento não abre.
6. Agende uma reunião de teste e confira se a confirmação do agendador chega ao endereço que você digitou.

## Pegadinhas

- **Dois registros por demonstração agendada.** O Ikiform guarda o pedido; o agendador guarda a reunião. Associe os dois pelo e-mail no seu CRM, não pelo nome.
- **Nenhuma confirmação do Ikiform.** O Ikiform não envia e-mail para quem preenche o formulário. A confirmação e o convite de calendário do próprio agendador cobrem as reuniões agendadas. Para pedidos que nunca agendam, responda pelo e-mail de notificação no mesmo dia.
- **Query strings ficam visíveis.** Nome e e-mail na URL de agendamento aparecem no histórico do navegador e nas análises do agendador. Não passe mais nada por ali.
- **Embeds.** Se, em vez disso, você incorporar o agendador na sua própria página `/book`, o Cal.com só repassa os parâmetros de query da sua página quando `forwardQueryParams` está ligado. O Calendly documenta o preenchimento em embeds pelo objeto `prefill` do embed em JavaScript, então use isso em vez de um iframe simples.
- **A verificação de spam.** Com **Spam check** definido como **Ikiform**, as versões no navegador precisam chamar `challenge()` de `@ikiform/sdk` antes de enviar, ou recebem `403 challenge_required`. As versões no servidor pulam a verificação.

## Perguntas

### Como passo o nome e o e-mail do formulário para o Calendly ou o Cal.com?

Adicione-os à query string do link de agendamento, como ?name=Ada%20Lovelace&email=ada%40example.com. Monte a URL no seu próprio código depois que o backend de formulários responder ok e mande o navegador para lá.

### Por que não mandar as pessoas direto para a página de agendamento, sem formulário?

Muitos visitantes abrem um agendador e saem sem agendar, e o agendador só registra quem agenda. Um formulário curto antes significa que a sua equipe tem todos os pedidos, com um endereço de reply-to, e pode dar sequência a quem não escolheu um horário.

### Posso usar _redirect para mandar as pessoas para a minha página de agendamento?

Só para uma URL fixa em um dos domínios permitidos do formulário, e ela não pode incluir o que o visitante digitou. Para preencher o formulário de agendamento, monte a URL no script da sua página ou na sua server action.

### O formulário de demonstração pode recusar Gmail e outros endereços gratuitos?

Não há lista de bloqueio nativa. A opção de domínios permitidos do campo de e-mail só aceita os domínios listados, o que serve para formulários internos. Aceite todos os endereços e marque os pedidos de e-mail gratuito no seu relay ou CRM.

## Mais guias

- [Formulário de captação de leads](https://ikiform.com/pt-br/guides/lead-capture.md): Um formulário de leads no seu próprio site que envia e-mail com reply-to, avisa o chat da equipe e alimenta seu CRM, para você responder enquanto o lead ainda está lá.
- [Leads no HubSpot ou no Pipedrive](https://ikiform.com/pt-br/guides/lead-capture/leads-to-crm.md): Mapeie cada webhook assinado do Ikiform para o upsert de contatos do HubSpot ou para as chamadas de pessoa e lead do Pipedrive, sem contatos duplicados quando uma entrega é repetida.
- [Formulário de pedido de consulta](https://ikiform.com/pt-br/guides/lead-capture/consultation-request.md): Um formulário de pedido de consulta para contadores, advogados, coaches e clínicas que pergunta como e quando ligar, e deixa os detalhes sensíveis para a conversa.
- [Formulário de pedido de orçamento](https://ikiform.com/pt-br/guides/lead-capture/quote-request.md): Um formulário de pedido de orçamento que recebe briefings como links compartilhados em vez de uploads, coloca um prazo em cada pedido e mantém leads cheios de links fora do spam.
- [Formulário de consulta de projeto](https://ikiform.com/pt-br/guides/lead-capture/project-inquiry.md): Um formulário de "comece um projeto" para estúdios e freelancers que separa as consultas em quentes, mornas e sem fit por orçamento e prazo antes de alguém ler o briefing.
- [Formulário de leads em Next.js](https://ikiform.com/pt-br/guides/lead-capture/nextjs.md): Um formulário de leads no App Router do Next.js que valida com Zod numa server action, guarda as UTMs do primeiro toque num cookie e manda leads qualificados para a sua agenda.
