Lead capture form6 min read
Build a demo request form that books the call
A book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
Post the demo request to a form backend first, so the lead is stored and your team is emailed even if the visitor never picks a time. Then send the browser to your booking page with name and email in the query string, which Calendly and Cal.com use to prefill their form. Send small teams somewhere else, such as a recorded tour, so your calendar holds calls that can close.
What's different about a demo request
Someone who asks for a demo wants a time on a calendar, not an email thread. Every round of "does Tuesday work?" is a chance for them to book with a competitor instead. So the form's job is short: capture enough to qualify, then put a calendar in front of them while they still have the tab open.
Three constraints shape the build:
- The lead must be stored before the scheduler opens. Many visitors open the booking page and close it. If the only record of them lives in the scheduler, those leads are gone. Post to Ikiform first and redirect only on success.
_redirectcan't carry what they typed. It is a fixed URL, and Ikiform follows it only for anhttps:URL on one of your Allowed domains. Addingcal.comorcalendly.comthere would also let posts from those sites through. Build the booking link in your own code instead.- Not every request deserves a live call. A solo founder asking for a demo of a team product is better served by a tour and a signup link. One fixed-choice question decides where the browser goes next.
- The visitor fills in name, work email, company and team size, and the form posts to Ikiform.
- Ikiform stores the request and emails your team. Nothing else happens until it answers ok.
- Your code reads the team size the visitor chose.
- Teams of 11 or more go to the booking page, with name and email in the query string.
- Teams of 1 to 10 go to a recorded tour with a signup link.
Four fields, one of them for routing
| Field | Type | Why |
|---|---|---|
name | text | Prefills the booking form, so the visitor types it once |
email | Prefills the booking form and becomes the reply-to of the notification email | |
company | text, required here | Your rep looks it up before the call |
team_size | select | Decides booking page or tour, and arrives as one of four known values |
Leave the use case, the timeline and the phone number for the call itself. The scheduler can ask one question of its own if you need context before the meeting, and both Calendly and Cal.com can prefill custom questions too.
Post, then hand off
Each version posts the request, waits for Ikiform's answer, and only then builds the next URL. The examples use Cal.com; for Calendly, swap in your event link. Calendly documents name and email as prefill parameters, and Cal.com prefills any booking field by its identifier, including name and email.
The form and its script together. Without JavaScript the form still posts and lands on your Redirect after submit page, so put the booking link there as a fallback.
<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>The server action calls redirect() with the booking URL once Ikiform answers ok. Next.js accepts absolute URLs there, and the form works before hydration because a server action answers a plain form post with a 303. Keep redirect() outside any try block, since it throws to do its work.
"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>
);
}The form action returns fail() with the first message on errors and calls redirect(303, url, { external: true }) on success; SvelteKit 3 refuses a redirect to another site without that option. Declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it. Pair it with any +page.svelte form that posts to the default action; without JavaScript the browser follows the 303 itself.
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;In a single-page app the browser posts to Ikiform directly and moves on with location.assign. Use Allowed domains to protect the form, never a secret key.
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 in server-only environment variables. The server versions forward the visitor's Origin header, because a server-side fetch sends none and Ikiform detects free localhost tests from it.Work email, without blocking Gmail
Ikiform has no setting that refuses free email providers. The email field's allowed_domains option is an allowlist: useful for an internal form limited to your own company's domain, wrong for a public demo form. That's fine, because blocking Gmail also blocks founders, consultants and buyers who fill in forms from their phone.
Tag those requests instead, where your team sees them. This function runs in the relay that posts each request to chat or your CRM, and gives the rep a hint before the call:
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";
}Call it with event.data.fields after verifyWebhook, then add the tag to the chat message or a CRM property. Leads to HubSpot or Pipedrive shows a full relay.
Check it works
- Add your site's domain and
localhostunder Allowed domains. - Submit with team size 51 to 200. The booking page opens with your name and email filled in.
- Close the booking page without picking a time. The request is still in Submissions, and the notification email is in your inbox.
- Submit again with 1 to 10 and check that you land on the tour.
- Submit with an invalid email. The error shows on the form, and the booking page doesn't open.
- Book a test meeting, then check that the scheduler's confirmation reaches the address you entered.
Gotchas
- Two records per booked demo. Ikiform holds the request; the scheduler holds the meeting. Match them by email in your CRM, not by name.
- No confirmation from Ikiform. Ikiform doesn't email the person who fills in the form. The scheduler's own confirmation and calendar invite cover booked calls. For requests that never book, reply from the notification email the same day.
- Query strings are visible. Name and email in the booking URL show up in the browser history and in the scheduler's analytics. Pass nothing else there.
- Embeds. If you embed the scheduler on your own
/bookpage instead, Cal.com forwards your page's query parameters only whenforwardQueryParamsis turned on. Calendly documents prefill for embeds through its JavaScript embed'sprefillobject, so use that rather than a plain iframe. - The spam check. With Spam check set to Ikiform, the browser versions must call
challenge()from@ikiform/sdkbefore posting, or they get403 challenge_required. The server versions skip the check.
Questions
Add them to the booking link's query string, as ?name=Ada%20Lovelace&email=ada%40example.com. Build the URL in your own code after the form backend answers ok, and send the browser there.
Many visitors open a scheduler and leave without booking, and the scheduler only records people who book. A short form first means your team has every request, with a reply-to address, and can follow up on the ones who didn't pick a time.
Only to a fixed URL on one of the form's allowed domains, and it can't include what the visitor typed. To prefill the booking form, build the URL in your page's script or your server action instead.
There is no built-in block list. The email field's allowed domains option only allows the listed domains, which suits internal forms. Accept every address and tag free-mail requests in your relay or CRM.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.