Formulario de captación de leads7 min de lectura
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.
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.
_redirectno puede llevar lo que escribieron. Es una URL fija, e Ikiform solo la sigue si es una URLhttps:de uno de tus Allowed domains. Añadir ahícal.comocalendly.comtambié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.
- El visitante rellena nombre, email de trabajo, empresa y tamaño del equipo, y el formulario se envía a Ikiform.
- Ikiform guarda la solicitud y avisa a tu equipo por email. No pasa nada más hasta que responde ok.
- Tu código lee el tamaño de equipo que eligió el visitante.
- Los equipos de 11 o más van a la página de reservas, con el nombre y el email en la query string.
- Los equipos de 1 a 10 van a un tour grabado con un enlace de registro.
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 | 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.
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.
<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>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.
"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());
}"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>
);
}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.
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;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.
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>
);
}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:
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 muestra un relay completo.
Comprueba que funciona
- Añade el dominio de tu sitio y
localhosten Allowed domains. - 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.
- 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.
- Vuelve a enviar con 1 to 10 y comprueba que llegas al tour.
- Envía con un email no válido. El error aparece en el formulario y la página de reservas no se abre.
- 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 cuandoforwardQueryParamsestá activado. Calendly documenta el prerrelleno en embeds mediante el objetoprefillde 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/sdkantes de enviar, o reciben403 challenge_required. Las versiones de servidor se saltan la comprobación.
Preguntas
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.