Lead capture form

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.

Preet SutharFounder, Ikiform

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.
  • _redirect can't carry what they typed. It is a fixed URL, and Ikiform follows it only for an https: URL on one of your Allowed domains. Adding cal.com or calendly.com there 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.
Where a demo request goes after it is storeddemo formname, email,company,team sizeIkiformstored, teamemailedteamsize?bookingpagename andemailprefilledrecordedtourwith asignup linkpostok11 or more1 to 10Where a demo request goes after it is storeddemo formname, email, company,team sizeIkiformstored, team emailedteam size?booking pagename and emailprefilledrecordedtourwith asignup linkpostok11 or more1 to 10
  1. The visitor fills in name, work email, company and team size, and the form posts to Ikiform.
  2. Ikiform stores the request and emails your team. Nothing else happens until it answers ok.
  3. Your code reads the team size the visitor chose.
  4. Teams of 11 or more go to the booking page, with name and email in the query string.
  5. Teams of 1 to 10 go to a recorded tour with a signup link.
The branch runs in your code, after Ikiform has the lead. The team gets every request either way, so a small team that should have had a call is still visible.

Four fields, one of them for routing

FieldTypeWhy
nametextPrefills the booking form, so the visitor types it once
emailemailPrefills the booking form and becomes the reply-to of the notification email
companytext, required hereYour rep looks it up before the call
team_sizeselectDecides 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.

demo.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>
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep 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:

lib/demo-tag.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";
}

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

  1. Add your site's domain and localhost under Allowed domains.
  2. Submit with team size 51 to 200. The booking page opens with your name and email filled in.
  3. Close the booking page without picking a time. The request is still in Submissions, and the notification email is in your inbox.
  4. Submit again with 1 to 10 and check that you land on the tour.
  5. Submit with an invalid email. The error shows on the form, and the booking page doesn't open.
  6. 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 /book page instead, Cal.com forwards your page's query parameters only when forwardQueryParams is turned on. Calendly documents prefill for embeds through its JavaScript embed's prefill object, 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/sdk before posting, or they get 403 challenge_required. The server versions skip the check.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.