Guías17 min de lectura
Crea un formulario de captación de leads que te llegue rápido
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í.
Pon un formulario corto en tu propia página que envíe el nombre, el email de trabajo y una pregunta de cualificación a un backend de formularios. Activa los emails de notificación para que cada lead llegue a tu bandeja con Reply apuntando al lead, y envía un webhook firmado al chat de tu equipo y a tu CRM. Después mide los minutos que pasan desde el envío hasta la primera respuesta, porque ese número decide más ventas que el diseño del formulario.
Un formulario de leads es un formulario de contacto con una misión: iniciar una conversación de ventas. Quien lo rellena te está comparando con otra empresa, a menudo en otra pestaña. El equipo que responde primero, con una respuesta útil de una persona real, suele llevarse la llamada. Esta guía crea el formulario en tu propio sitio, te hace llegar cada lead en segundos y deja el CRM como opcional.
Cómo viaja un lead
El formulario envía a Ikiform. Ikiform revisa el envío en busca de spam, lo guarda y luego hace tres cosas a la vez: avisa por email a tu equipo con el lead como dirección de respuesta, envía un webhook firmado y muestra el lead en el panel. El webhook es la forma en que el lead llega al chat del equipo y a tu CRM.
- Un visitante rellena el formulario de leads de tu sitio, que se envía a tu endpoint de Ikiform.
- Ikiform comprueba el honeypot, la puntuación de spam y el límite de frecuencia, y luego guarda el lead.
- Segundos después, cada destinatario de las notificaciones recibe un email, y al pulsar Responder se escribe al lead.
- El lead aparece en el panel, donde puedes buscarlo, filtrarlo y exportarlo.
- Ikiform envía un webhook submission.created firmado.
- Un relay o una herramienta de automatización publica el lead en el chat del equipo y crea el contacto en tu CRM.
Cuatro formas de captar leads
Cada configuración habitual destaca en algo. Elige según dónde quieras que vivan los leads y quién mantiene el formulario.
| Enfoque | Punto fuerte | Inconvenientes |
|---|---|---|
| El formulario propio de tu CRM, como los formularios de HubSpot o los formularios web de Pipedrive | Los leads llegan al CRM como contactos sin integraciones intermedias. El seguimiento y el lead scoring están en un solo producto. | El formulario se incrusta con el script y los estilos del CRM. Cambiar de CRM implica rehacer cada formulario. Algunas funciones dependen del plan. |
| Un formulario incrustado de un constructor, como Typeform o Tally | Formularios de varios pasos pulidos, con lógica y sin código. | Un embed que se parece al creador salvo que pagues por cambiarle el estilo. Los webhooks y los límites de respuestas dependen del plan. |
| Tu propia ruta de API y base de datos | Cualquier lógica que quieras, como puntuar los leads antes de guardar nada. | Tú construyes el filtrado de spam, los límites de frecuencia, las alertas por email, los reintentos y la exportación, y mantienes un servidor funcionando para un solo formulario. |
| Un backend de formularios, como Ikiform | Tu propio HTML en cualquier sitio. Filtrado de spam, alertas por email con reply-to, webhooks firmados y exportación a CSV, sin servidor. | Sin lead scoring, sin registros en el CRM y sin email al lead. Eso viene de tu CRM o de tu herramienta de email, por webhook. |
Si todo tu proceso de ventas vive dentro de un CRM y te gusta su formulario, úsalo. Un backend de formularios encaja cuando el formulario tiene que encajar con tu sitio, cuando podrías cambiar de CRM o cuando quieres que el mismo formulario llegue al email, al chat y a un CRM sin tener que elegir.
Pide menos, cualifica una vez
Cada campo que añades te cuesta envíos. La mayoría de los formularios de leads necesitan cuatro cosas: a quién responder, dónde trabaja, qué quiere y espacio para explicarse. Una pregunta de opciones fijas hace la cualificación, porque llega como uno de unos pocos valores conocidos en los que tu enrutamiento puede confiar. Pruébalo abajo; ejecuta el validador de Ikiform en tu navegador y muestra lo que se guardaría, o el 422 que recibirías.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
| Campo | Por qué | Ten en cuenta |
|---|---|---|
name | Una respuesta que empieza con su nombre | Mantén el id name. Un enlace en un campo de nombre es una señal fuerte de spam |
email | A quién escribe Reply | Mantén el id email. Etiquetarlo como Work email anima a usar uno de trabajo, pero no lo obliga |
company | Infórmate antes de responder | Opcional. Hacerlo obligatorio te cuesta envíos de freelances |
interest | Enrutamiento y prioridad | Un select guarda uno de los valores de la lista o rechaza el envío, así que las automatizaciones pueden ramificarse según él |
message | Contexto para la primera respuesta | Opcional. Una caja de texto obligatoria es el campo en el que la gente abandona |
El teléfono, el tamaño del equipo y el presupuesto van en formularios donde cambian lo que haces después. La plantilla de solicitud de presupuesto es la versión completa, con servicio, presupuesto y plazos como opciones fijas. Si un comercial llama a cada lead, pide un teléfono. Si enrutas según el tamaño del equipo, pídelo como select. Si no, déjalos para la primera respuesta.
El formulario
Elige tu stack. Todas las versiones muestran el mensaje del servidor bajo el campo al que corresponde y conservan lo que escribió el visitante, porque perder un mensaje a medio escribir por una errata es como los leads se rinden. El estado de éxito dice quién responde y cuándo, para que nadie se pregunte si ha funcionado.
El formulario se envía de la forma clásica y redirige a tu página de agradecimiento, así que funciona antes de que cargue ningún script. Cada campo tiene un hueco vacío para su error.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="lead-form" novalidate>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p class="error" data-error-for="name" hidden></p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="error" data-error-for="email" hidden></p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" required>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<p class="error" data-error-for="message" hidden></p>
<input type="hidden" name="page" value="pricing" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Talk to sales</button>
</form>El script mantiene al visitante en la página. Muestra el error de cada campo que devuelve el servidor bajo ese campo, y sustituye el formulario por una línea que dice quién responde y cuándo.
const form = document.getElementById("lead-form");
function showErrors(fields) {
form.querySelectorAll("[data-error-for]").forEach((slot) => {
const message = fields[slot.dataset.errorFor]?.[0];
slot.textContent = message ?? "";
slot.hidden = !message;
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
button.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
const thanks = document.createElement("p");
thanks.setAttribute("role", "status");
thanks.textContent = `Thanks, ${form.elements.name.value.trim().split(" ")[0]}. A person on our team will reply to ${form.elements.email.value} within one working day.`;
form.replaceWith(thanks);
return;
}
showErrors(result.error.fields ?? {});
if (!result.error.fields) alert(result.error.message);
} catch {
alert("Something went wrong. Email sales@example.com and we'll pick it up.");
}
button.disabled = false;
button.textContent = "Talk to sales";
});La server action reenvía el lead con tu clave secreta y devuelve el primer error de cada campo, más los valores que escribió el visitante, para que no se pierda nada con un 422.
"use server";
import { headers } from "next/headers";
export interface LeadState {
sent: boolean;
firstName?: string;
errors: Record<string, string>;
values: Record<string, string>;
}
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"] as const;
export async function sendLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
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(values),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, firstName: values.name.split(" ")[0], errors: {}, values: {} };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type LeadState, sendLead } from "./actions";
const initial: LeadState = { sent: false, errors: {}, values: {} };
export function LeadForm() {
const [state, action, pending] = useActionState(sendLead, initial);
const { errors, values } = state;
if (state.sent) {
return <p role="status">Thanks, {state.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" defaultValue={values.company} />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest" defaultValue={values.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}¿Quieres profundizar con Next.js? Lee Crea un formulario de leads que cualifique en Next.js.
Zod comprueba los campos antes de que nada salga de tu servidor, e isInputError te da sus mensajes por campo. La página debe renderizarse bajo demanda para que el formulario pueda enviar a la action.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to."),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().optional(),
interest: z.enum(["pricing", "demo", "custom", "other"]),
message: z.string().trim().max(5000).optional(),
page: z.string().default("pricing"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { firstName: input.name.split(" ")[0] };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendLead);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Thanks, {result.data.firstName}. A person on our team will reply within one working day.</p>
) : (
<form method="POST" action={actions.sendLead}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Talk to sales</button>
</form>
)}¿Quieres profundizar con Astro? Lee Crea un formulario de leads que cualifique en Astro.
La server function lee el FormData en .validator(), lo reenvía y devuelve el nombre o un mapa de errores por campo que la ruta muestra bajo cada input.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Result = { ok: true; firstName: string } | { ok: false; errors: Record<string, string> };
const sendLead = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (key: string) => String(data.get(key) ?? "").trim();
return {
name: read("name"),
email: read("email"),
company: read("company"),
interest: read("interest"),
message: read("message"),
page: read("page"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Result> => {
const origin = getRequestHeader("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(data),
});
const result = await response.json();
if (result.ok) return { ok: true, firstName: data.name.split(" ")[0] ?? "" };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return { ok: false, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""])) };
});
export const Route = createFileRoute("/contact-sales")({ component: ContactSales });
function ContactSales() {
const send = useServerFn(sendLead);
const [result, setResult] = useState<Result | null>(null);
const [pending, setPending] = useState(false);
const errors = result && !result.ok ? result.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setResult(await send({ data: new FormData(event.currentTarget) }));
} catch {
setResult({ ok: false, errors: { form: "Something went wrong. Email sales@example.com and we'll pick it up." } });
} finally {
setPending(false);
}
}
if (result?.ok) {
return <p role="status">Thanks, {result.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}¿Quieres profundizar con TanStack Start? Lee Crea un formulario de leads con cualificación en TanStack Start.
La route action reenvía el envío y devuelve los errores por campo con el código de estado de Ikiform mediante data(). El componente los lee de actionData.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact-sales";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
const origin = request.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(values),
});
const result = await response.json();
if (result.ok) return { sent: true as const, firstName: values.name.split(" ")[0] };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return data(
{ sent: false as const, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]])) },
{ status: response.status },
);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) {
return <p role="status">Thanks, {actionData.firstName}. A person on our team will reply within one working day.</p>;
}
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}¿Quieres profundizar con React Router? Lee Crea un formulario de leads con cualificación en React Router.
La form action devuelve fail() con los valores y los errores, y update({ reset: false }) mantiene en pantalla lo que escribió el visitante mientras corrige un campo. Declara IKIFORM_SECRET_KEY en src/env.ts con defineEnvVars para que $app/env/private pueda leerla.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = Object.fromEntries(KEYS.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(values),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]]));
return fail(response.status, { values, errors });
}
return { firstName: values.name.split(" ")[0] };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
</script>
{#if form?.firstName}
<p role="status">Thanks, {form.firstName}. A person on our team will reply within one working day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required value={form?.values?.name ?? ""} />
{#if errors.name}<p role="alert">{errors.name}</p>{/if}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
{#if errors.email}<p role="alert">{errors.email}</p>{/if}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" value={form?.values?.company ?? ""} />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" value={form?.values?.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4">{form?.values?.message ?? ""}</textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if errors.form}<p role="alert">{errors.form}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
{/if}¿Quieres profundizar con SvelteKit? Lee Crea un formulario de leads con cualificación en SvelteKit.
Una server route mantiene la clave fuera del cliente y devuelve los errores por campo de Ikiform en el data del error. Define NUXT_IKIFORM_SECRET_KEY en el entorno.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const keys = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(body[key] ?? "").trim()]));
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(values),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: result.error.message,
data: { fields: result.error.fields ?? {} },
});
}
return { firstName: values.name?.split(" ")[0] };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const lead = reactive({ name: "", email: "", company: "", interest: "pricing", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const failure = ref("");
const sending = ref(false);
const firstName = ref("");
async function send() {
sending.value = true;
errors.value = {};
failure.value = "";
try {
const result = await $fetch("/api/lead", { method: "POST", body: { ...lead, page: "pricing" } });
firstName.value = result.firstName ?? "there";
} catch (error) {
const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
if (!Object.keys(errors.value).length) failure.value = data?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="firstName" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="lead.name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" v-model="lead.company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" v-model="lead.interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" v-model="lead.message" name="message" rows="4" />
<input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="failure" role="alert">{{ failure }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
</form>
</template>¿Quieres profundizar con Nuxt? Lee Crea un formulario de leads con cualificación en Nuxt.
Sin un servidor propio, el formulario envía directamente a Ikiform desde el navegador. Deja las claves secretas fuera de este código; en su lugar, Allowed domains protege el formulario.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function LeadForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [firstName, setFirstName] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setErrors({});
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setFirstName(String(body.get("name")).trim().split(" ")[0] ?? "");
setStatus("sent");
return;
}
setErrors(result.error.fields ?? { form: [result.error.message] });
} catch {
setErrors({ form: ["Something went wrong. Email sales@example.com and we'll pick it up."] });
}
setStatus("idle");
}
if (status === "sent") {
return <p role="status">Thanks, {firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name[0]}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email[0]}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form[0]}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Talk to sales"}</button>
</form>
);
}El mismo envío desde el navegador como componente de Vue. El formulario conserva su action, así que un visitante cuyo script no cargue aún puede contactarte.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "sent">("idle");
const errors = ref<Record<string, string[]>>({});
const firstName = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
errors.value = {};
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
firstName.value = String(body.get("name")).trim().split(" ")[0] ?? "";
status.value = "sent";
return;
}
errors.value = result.error.fields ?? { form: [result.error.message] };
} catch {
errors.value = { form: ["Something went wrong. Email sales@example.com and we'll pick it up."] };
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4" />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="errors.form" role="alert">{{ errors.form[0] }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Talk to sales" }}</button>
</form>
</template>- Sustituye
frm_xxxxxxxxxxxxxxxxpor el id de tu formulario, que está en el panel. - El input oculto
_gotchaes el honeypot. Las personas nunca lo ven; los bots que rellenan todos los inputs se marcan solos como spam. - El input oculto
pageregistra de qué página vino el lead: precios, una página de producto, una landing de campaña. Si defines campos, añadepagecomo campo de texto opcional, o el envío se rechazará conUnknown field. - Las versiones de servidor reenvían la cabecera
Origindel visitante, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desdelocalhost. - El formulario HTML puro necesita Redirect after submit, o un input
_redirecten uno de tus Allowed domains, para llegar a tu propia página de agradecimiento.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.La documentación cubre a fondo la configuración de cada framework: Next.js, Astro, SvelteKit, Nuxt, React, Vue, envíos desde un servidor y HTML puro.
Contacta con el lead en minutos
El formulario es la parte fácil. Lo que separa un formulario de leads que vende de uno que solo recopila es el tiempo entre el envío y la respuesta de una persona. Así es ese camino cuando funciona:
- El lead envía el formulario.
- Ikiform lo guarda y responde al navegador en mucho menos de un segundo.
- El email de notificación y el webhook salen de una cola segundos después.
- Alguien del equipo pulsa Responder en su bandeja, o el enlace mailto en el chat, y la respuesta va al lead.
Responde desde tu bandeja de entrada
Cada email de notificación pone al lead como dirección de respuesta. Ikiform la toma primero de un campo _replyto y, si no, de un campo llamado email, email_address, your_email, reply_to, contact_email. Mantén el id de campo email y Reply irá al lead, no a Ikiform. El asunto es New submission: más el nombre del formulario, así que ponle al formulario un nombre que diga lo que es, como Talk to sales, y filtra por ese asunto para etiquetar o destacar los leads.
Añade hasta 10 destinatarios por formulario en los ajustes de notificaciones del formulario: miembros del espacio de trabajo o direcciones externas que primero confirman por email. Envíalo a las personas que responden, no a un alias compartido del que nadie se hace cargo: un lead en cinco bandejas que nadie reclama espera más que un lead en una sola.
Avisa al equipo donde ya está
El email funciona cuando alguien se hace cargo de la bandeja. Un equipo que vive en el chat responde antes cuando el lead aparece ahí. Añade un webhook a Slack, Teams o Discord siguiendo Slack, Microsoft Teams o Discord. Pon un enlace mailto: en el mensaje para que quien lo reclame pueda responder con un clic.
Enruta según lo que pidieron
Como interest solo acepta valores de la lista, un pequeño relay puede ramificarse según él con confianza. Esta ruta envía las peticiones de demo y las preguntas de contrato a sus propios canales, y todo lo demás al canal principal de ventas:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const CHANNELS: Record<string, string | undefined> = {
demo: process.env.SLACK_DEMO_WEBHOOK,
custom: process.env.SLACK_ENTERPRISE_WEBHOOK,
};
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const lead = event.data.fields;
const interest = String(lead.interest ?? "other");
const target = CHANNELS[interest] ?? process.env.SLACK_SALES_WEBHOOK!;
const text = [
`*${lead.name}* from ${lead.company || "an unknown company"} wants: ${interest}`,
`Reply: mailto:${lead.email}`,
lead.message ? `> ${String(lead.message).slice(0, 300)}` : "",
]
.filter(Boolean)
.join("\n");
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookcomprueba la firma y la marca de tiempo, así que nadie más puede publicar leads falsos en tus canales.- Un
502cuando Slack está caído hace que Ikiform reintente la misma entrega, con el mismowebhook-id, durante unos 3 días. - Cambia libremente la etiqueta de una opción. Mantén estable su valor en cuanto un relay o una automatización dependa de él.
Mantén la basura fuera del pipeline
Los formularios de leads atraen un tipo concreto de spam: gente que te quiere vender SEO, link building y desarrollo subcontratado. Desperdicia tu respuesta más rápida, así que fíltralo antes de que llegue a nadie.
| Defensa | Qué frena | Configuración |
|---|---|---|
| Honeypot | Bots que rellenan todos los inputs | El input oculto _gotcha |
| Puntuación de spam | Frases de venta conocidas como seo services (+2), enlaces en el nombre (+3), tres o más enlaces (+2), repeticiones (+3). Con 5 es spam | Ninguna |
| Límite de frecuencia | Scripts que envían en bucle | Pon el formulario en Strict, 2 cada 10 minutos por visitante. Un lead real rara vez envía dos veces en diez minutos |
| Dominios permitidos | Envíos desde otros sitios | Añade tu dominio en Allowed domains |
| Verificación humana | Bots en un navegador real | Pon Spam check en Ikiform. Las versiones con fetch necesitan entonces un token de challenge() |
El spam recibe la misma respuesta de éxito que un lead real, no envía email ni webhook y no cuenta para el uso. Eso también significa que un lead mal juzgado es silencioso. Revisa el filtro Spam una vez por semana; al marcar uno como no spam, se envían en ese momento su email y sus webhooks. La documentación de spam enumera cada señal.
Sabe de dónde vienen los leads
Cada envío ya registra el origen y el referrer de la página, que aparecen en el webhook como meta.origin y meta.referrer. Las etiquetas de campaña necesitan algo de ayuda, porque el visitante a menudo llega a una página y envía el formulario en otra. Este fragmento copia los parámetros utm_ en inputs ocultos y los recuerda durante la sesión:
<input type="hidden" name="utm_source" />
<input type="hidden" name="utm_medium" />
<input type="hidden" name="utm_campaign" />
<script>
const params = new URLSearchParams(location.search);
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
let value = params.get(key);
try {
if (value) sessionStorage.setItem(key, value);
else value = sessionStorage.getItem(key);
} catch {}
const input = document.querySelector(`#lead-form [name="${key}"]`);
if (input && value) input.value = value.slice(0, 200);
}
</script>En un framework, lee los mismos parámetros de la URL en el servidor y renderízalos como inputs ocultos. Si tienes campos definidos, añade cada id utm_ como campo de texto opcional. Los nombres que empiezan por _ nunca se guardan, así que no los uses para datos que quieras conservar.
Lo que ve el lead después
Ikiform no envía emails a la persona que rellena el formulario. Los emails de notificación van solo a ti y a tu equipo, así que no hay respuesta automática, email de confirmación ni doble opt-in. Envíalos desde tu herramienta de email, disparados por un webhook. En un formulario de leads eso suele estar bien: una respuesta personal rápida gana a una automática. Si aun así quieres un acuse de recibo instantáneo, envíalo desde tu CRM o tu herramienta de email cuando el webhook cree el contacto, para que las respuestas y las bajas se registren en un solo sitio.
Envíes lo que envíes, el mensaje de éxito en la página es la promesa. Di quién responde y cuándo, y cúmplelo. Un formulario que dice un día laborable y responde en diez minutos se gana la confianza; uno que dice diez minutos y responde mañana la pierde.
Leads en tu CRM
No hay integraciones de un clic. Cada herramienta se conecta mediante un webhook firmado, directamente o a través de una herramienta de automatización como Zapier o n8n. Para un CRM, eso significa un relay que traduce el cuerpo fijo del webhook de Ikiform a la llamada de crear contacto del CRM. Leads a HubSpot o Pipedrive explica ambos, incluidos los duplicados y los reintentos. Hasta que necesites un CRM, el panel y una exportación a CSV semanal son un buen pipeline.
Elige tu caso
La configuración de arriba sirve para cualquier lead. Estas páginas cubren lo que cambia para un tipo concreto de lead y para llevar los leads a un CRM.
- Formulario de solicitud de demoUn 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.
- Formulario de solicitud de presupuestoUn 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 solicitud de consultaUn 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 consulta de proyectoUn 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.
- Leads a HubSpot o PipedriveConvierte 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 leads en Next.jsUn 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.
- Formulario de leads en AstroUn formulario de leads en Astro basado en Actions: validación con Zod, UTMs del primer contacto en un sitio estático, una página de contacto bajo demanda y una redirección a tu agenda.
- Formulario de leads en TanStack StartUn formulario de leads en TanStack Start: middleware global de peticiones para las UTM del primer contacto, una server function con Zod y un redirect lanzado hacia tu agenda.
- Formulario de leads en React RouterUn formulario de leads en React Router en modo framework: middleware raíz con una cookie firmada de primer contacto, una action de ruta que valida y una redirección a tu agenda.
- Formulario de leads en SvelteKitUn formulario de leads en SvelteKit: el hook handle registra las UTM del primer contacto, una form action valida con Valibot y redirect() manda a los leads cualificados a un calendario.
- Formulario de leads en NuxtUn formulario de leads en Nuxt: un middleware de servidor registra las UTM del primer contacto, una server route validada con Zod atiende fetch y envíos normales, y los buenos leads reservan una llamada.
Comprueba que funciona
Repasa esto antes de enviar tráfico a la página. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para salir a producción, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Rellena el formulario en tu página y envíalo. Llegas a tu página de agradecimiento, o a la alojada de Ikiform.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Revisa la bandeja de cada destinatario. El asunto es
New submission:más el nombre del formulario, y Reply abre un mensaje a la dirección que introdujiste. - Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - Envía con el honeypot relleno,
_gotcha=bot. Igualmente recibe una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
- Cronométralo: envía desde tu móvil y anota cuánto tardan en llegar el email y el mensaje del chat.
Solución de problemas
| Problema | Qué hacer |
|---|---|
| Reply va a la dirección de Ikiform, no al lead | El formulario no tiene ningún campo que Ikiform reconozca como el email del lead. Renombra el campo a email, o envía un campo _replyto con la dirección. Nombres aceptados: email, email_address, your_email, reply_to, contact_email. |
422 con Unknown field para page o utm_source | El formulario tiene una definición de campos y el input oculto no está en ella. Añádelo como campo de texto opcional o quita el input. |
422 validation_failed | El envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo. |
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, y localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
403 challenge_required | La verificación antispam del formulario está en Ikiform y un envío con fetch no llevaba token. Consigue uno con challenge(formId) de @ikiform/sdk antes de cada envío, o desactiva la verificación. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
| Un destinatario no recibe emails de notificación | Una dirección de fuera del espacio de trabajo tiene que confirmar primero, desde el email que le envía Ikiform. Comprueba que se confirmó y luego revisa la carpeta de spam del destinatario. |
Preguntas
Un formulario en tu web que recoge los datos de contacto de un posible cliente y lo que quiere, para que tu equipo haga el seguimiento. Se diferencia de un formulario de contacto general en que cualifica al lead, normalmente con una pregunta de opciones fijas, y está pensado para llegar rápido a ventas.
Apunta el formulario a tu endpoint de Ikiform y deja activados los emails de notificación del formulario. Cada lead llega en segundos, a hasta 10 destinatarios, y al pulsar Responder se escribe al lead siempre que el campo de email se llame email o el formulario envíe _replyto.
Los mínimos que te permitan responder bien: normalmente nombre, email, una pregunta de cualificación y un mensaje opcional. Añade teléfono, presupuesto o tamaño del equipo solo cuando la respuesta cambie lo que hace tu equipo después.
Mediante un webhook, sí. No hay integraciones de un clic, así que un pequeño relay o una herramienta de automatización como Zapier, Make o n8n traduce cada webhook firmado a la API del CRM y crea el contacto o el lead.
No desde Ikiform. Ikiform envía emails a tu equipo, no a la persona que rellenó el formulario. Envía cualquier acuse de recibo desde tu CRM o tu herramienta de email, disparado por el webhook, o responde personalmente desde el email de notificación.
No con un ajuste integrado. La opción de dominios permitidos del campo de email es una lista de permitidos, que encaja con formularios internos, no con públicos. Acepta todas las direcciones y clasifica los leads con email gratuito en tu relay o en tu CRM; muchos compradores reales usan una dirección personal.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.