# Crea un formulario de solicitud de demo que reserve la llamada

> Un formulario para reservar una demo que guarda el lead y luego envía al visitante a Cal.com o Calendly con su nombre y email ya rellenados.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/lead-capture/demo-request

Envía primero la solicitud de demo a un backend de formularios, para que el lead quede guardado y tu equipo reciba el email aunque el visitante nunca elija una hora. Después manda el navegador a tu página de reservas con `name` y `email` en la query string, que Calendly y Cal.com usan para rellenar su formulario. Envía a los equipos pequeños a otro sitio, como un tour grabado, para que tu calendario tenga llamadas que puedan cerrarse.

## Qué cambia en una solicitud de demo

Quien pide una demo quiere un hueco en un calendario, no un hilo de emails. Cada ronda de "¿te va bien el martes?" es una oportunidad para que reserve con la competencia. Así que el trabajo del formulario es corto: recoger lo justo para cualificar y poner un calendario delante mientras aún tiene la pestaña abierta.

Tres restricciones dan forma a la implementación:

- **El lead debe guardarse antes de que se abra el calendario de reservas.** Muchos visitantes abren la página de reservas y la cierran. Si el único registro de ellos vive en la herramienta de reservas, esos leads se pierden. Envía primero a Ikiform y redirige solo si todo va bien.
- **`_redirect` no puede llevar lo que escribieron.** Es una URL fija, e Ikiform solo la sigue si es una URL `https:` de uno de tus **Allowed domains**. Añadir ahí `cal.com` o `calendly.com` también dejaría pasar envíos desde esos sitios. Construye el enlace de reserva en tu propio código.
- **No toda solicitud merece una llamada en directo.** A un fundador en solitario que pide una demo de un producto para equipos le sirve mejor un tour y un enlace de registro. Una pregunta de opciones fijas decide adónde va el navegador después.

Diagrama: Adónde va una solicitud de demo después de guardarse

1. El visitante rellena nombre, email de trabajo, empresa y tamaño del equipo, y el formulario se envía a Ikiform.
2. Ikiform guarda la solicitud y avisa a tu equipo por email. No pasa nada más hasta que responde ok.
3. Tu código lee el tamaño de equipo que eligió el visitante.
4. Los equipos de 11 o más van a la página de reservas, con el nombre y el email en la query string.
5. Los equipos de 1 a 10 van a un tour grabado con un enlace de registro.

La bifurcación se ejecuta en tu código, cuando Ikiform ya tiene el lead. El equipo recibe todas las solicitudes en cualquier caso, así que un equipo pequeño que debería haber tenido llamada sigue siendo visible.

## Cuatro campos, uno de ellos para enrutar

| Campo | Tipo | Por qué |
| --- | --- | --- |
| `name` | texto | Rellena el formulario de reserva, así el visitante lo escribe una sola vez |
| `email` | email | Rellena el formulario de reserva y se convierte en el reply-to del email de notificación |
| `company` | texto, obligatorio aquí | Tu comercial lo consulta antes de la llamada |
| `team_size` | select | Decide entre página de reservas o tour, y llega como uno de cuatro valores conocidos |

Deja el caso de uso, los plazos y el teléfono para la propia llamada. La herramienta de reservas puede hacer una pregunta propia si necesitas contexto antes de la reunión, y tanto Calendly como Cal.com también pueden rellenar preguntas personalizadas.

## Envía y luego pasa el relevo

Cada versión envía la solicitud, espera la respuesta de Ikiform y solo entonces construye la siguiente URL. Los ejemplos usan Cal.com; para Calendly, pon tu enlace de evento. Calendly documenta `name` y `email` como parámetros de prerrelleno, y Cal.com rellena cualquier campo de reserva por su identificador, incluidos `name` y `email`.

**HTML**

El formulario y su script juntos. Sin JavaScript, el formulario sigue enviándose y llega a tu página de **Redirect after submit**, así que pon ahí el enlace de reservas como alternativa.

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

La server action llama a `redirect()` con la URL de reservas cuando Ikiform responde ok. Next.js acepta ahí URLs absolutas, y el formulario funciona antes de la hidratación porque una server action responde a un envío de formulario normal con un `303`. Deja `redirect()` fuera de cualquier bloque `try`, porque lanza una excepción para hacer su trabajo.

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

La form action devuelve `fail()` con el primer mensaje si hay errores y llama a `redirect(303, url, { external: true })` si todo va bien; SvelteKit 3 rechaza una redirección a otro sitio sin esa opción. Declara `IKIFORM_SECRET_KEY` en `src/env.ts` con `defineEnvVars` para que `$app/env/private` pueda leerla. Combínala con cualquier formulario `+page.svelte` que envíe a la acción por defecto; sin JavaScript, el navegador sigue el `303` por sí mismo.

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

En una single-page app, el navegador envía directamente a Ikiform y continúa con `location.assign`. Usa **Allowed domains** para proteger el formulario, nunca una clave 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>
  );
}
```

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Entonces todos los envíos vienen 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](https://ikiform.com/es/docs/rate-limits.md) 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. Las versiones de servidor reenvían la cabecera `Origin` del visitante, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desde `localhost`.

## Email de trabajo, sin bloquear Gmail

Ikiform no tiene ningún ajuste que rechace los proveedores de email gratuitos. La opción `allowed_domains` del campo de email es una lista de permitidos: útil para un formulario interno limitado al dominio de tu empresa, inadecuada para un formulario de demo público. Y está bien así, porque bloquear Gmail también bloquea a fundadores, consultores y compradores que rellenan formularios desde el móvil.

En lugar de eso, etiqueta esas solicitudes donde tu equipo las vea. Esta función se ejecuta en el relay que envía cada solicitud al chat o a tu CRM, y le da al comercial una pista antes de la llamada:

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

Llámala con `event.data.fields` después de `verifyWebhook` y añade la etiqueta al mensaje del chat o a una propiedad del CRM. [Leads a HubSpot o Pipedrive](https://ikiform.com/es/guides/lead-capture/leads-to-crm.md) muestra un relay completo.

## Comprueba que funciona

1. Añade el dominio de tu sitio y `localhost` en **Allowed domains**.
2. Envía con un tamaño de equipo de **51 to 200**. La página de reservas se abre con tu nombre y tu email ya rellenos.
3. Cierra la página de reservas sin elegir hora. La solicitud sigue en **Submissions** y el email de notificación está en tu bandeja de entrada.
4. Vuelve a enviar con **1 to 10** y comprueba que llegas al tour.
5. Envía con un email no válido. El error aparece en el formulario y la página de reservas no se abre.
6. Reserva una reunión de prueba y comprueba que la confirmación de la herramienta de reservas llega a la dirección que introdujiste.

## Errores comunes

- **Dos registros por demo reservada.** Ikiform guarda la solicitud; la herramienta de reservas guarda la reunión. Relaciónalos por email en tu CRM, no por nombre.
- **Ikiform no envía confirmación.** Ikiform no envía email a la persona que rellena el formulario. La confirmación y la invitación de calendario de la propia herramienta de reservas cubren las llamadas reservadas. Para las solicitudes que nunca reservan, responde desde el email de notificación el mismo día.
- **Las query strings son visibles.** El nombre y el email en la URL de reservas aparecen en el historial del navegador y en la analítica de la herramienta de reservas. No pases nada más ahí.
- **Embeds.** Si en lugar de eso incrustas la herramienta de reservas en tu propia página `/book`, Cal.com reenvía los parámetros de query de tu página solo cuando `forwardQueryParams` está activado. Calendly documenta el prerrelleno en embeds mediante el objeto `prefill` de su embed de JavaScript, así que usa eso en lugar de un iframe normal.
- **La comprobación antispam.** Con **Spam check** en **Ikiform**, las versiones de navegador deben llamar a `challenge()` de `@ikiform/sdk` antes de enviar, o reciben `403 challenge_required`. Las versiones de servidor se saltan la comprobación.

## Preguntas

### ¿Cómo paso el nombre y el email del formulario a Calendly o Cal.com?

Añádelos a la query string del enlace de reservas, como ?name=Ada%20Lovelace&email=ada%40example.com. Construye la URL en tu propio código después de que el backend de formularios responda ok, y manda ahí al navegador.

### ¿Por qué no mandar a la gente directamente a la página de reservas sin formulario?

Muchos visitantes abren la herramienta de reservas y se van sin reservar, y la herramienta solo registra a quienes reservan. Un formulario corto antes hace que tu equipo tenga todas las solicitudes, con una dirección de respuesta, y pueda hacer seguimiento de quienes no eligieron hora.

### ¿Puedo usar _redirect para mandar a la gente a mi página de reservas?

Solo a una URL fija en uno de los dominios permitidos del formulario, y no puede incluir lo que escribió el visitante. Para rellenar el formulario de reserva, construye la URL en el script de tu página o en tu server action.

### ¿Puede el formulario de demo rechazar Gmail y otras direcciones gratuitas?

No hay una lista de bloqueo integrada. La opción de dominios permitidos del campo de email solo admite los dominios listados, lo que encaja con formularios internos. Acepta todas las direcciones y etiqueta las solicitudes con email gratuito en tu relay o tu CRM.

## Más guías

- [Formulario de captación de leads](https://ikiform.com/es/guides/lead-capture.md): Un formulario de captación de leads en tu propio sitio que te avisa por email con reply-to, avisa al chat del equipo y alimenta tu CRM, para responder mientras el lead sigue ahí.
- [Leads a HubSpot o Pipedrive](https://ikiform.com/es/guides/lead-capture/leads-to-crm.md): Convierte cada webhook firmado de Ikiform en el upsert de contactos de HubSpot o en las llamadas de personas y leads de Pipedrive, sin contactos duplicados cuando se reintenta una entrega.
- [Formulario de solicitud de consulta](https://ikiform.com/es/guides/lead-capture/consultation-request.md): Un formulario de solicitud de consulta para gestorías, abogados, coaches y clínicas que pregunta cómo y cuándo llamar, y deja los detalles sensibles para la llamada.
- [Formulario de solicitud de presupuesto](https://ikiform.com/es/guides/lead-capture/quote-request.md): Un formulario de solicitud de presupuesto que recoge los briefs como enlaces compartidos en lugar de archivos, pone fecha límite a cada solicitud y evita que los leads con muchos enlaces acaben en spam.
- [Formulario de consulta de proyecto](https://ikiform.com/es/guides/lead-capture/project-inquiry.md): Un formulario de inicio de proyecto para estudios y freelances que clasifica las consultas en calientes, templadas y sin encaje por presupuesto y plazos antes de que nadie lea el briefing.
- [Formulario de leads en Next.js](https://ikiform.com/es/guides/lead-capture/nextjs.md): Un formulario de leads en el App Router de Next.js que valida con Zod en una server action, guarda los UTMs del primer contacto en una cookie y envía los leads cualificados a tu calendario.
