Lead capture form6 min read
Build a consultation request form for a service business
A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
Ask for name, email, how to reach them and roughly when, plus a broad topic, and keep every detail of their situation for the call. Record their time zone from the browser so a requested morning means their morning. Enforce rules the form schema can't express, such as a phone number only when they chose a phone call, in your own code.
What's different about a consultation request
A consultation request comes from a person with a problem: a tax letter, a lease dispute, a business they want to start. They want to explain it, and the form invites them to. That is the trap. Whatever they type is stored with the submission, shown in notification emails and sent in webhooks to wherever you point them. Account numbers and case details don't belong in any of those.
So the form's job is narrower than it looks: who they are, how and when to reach them, and roughly what it's about. Everything else waits for the call, where you control what gets written down and where it is kept.
- The form asks who they are: name and email.
- It asks how to reach them: email, phone or video.
- It asks when: a preferred day, a time window and their time zone.
- It asks what it is about, as one broad topic.
- The call covers their situation in their own words.
- Documents go through a channel you choose, not the form.
- Account numbers, ids and records are never typed into the form.
- The call ends with your answer on fit and fees.
The fields
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
| Field | Why it's shaped this way |
|---|---|
contact_method | A radio, so the first contact happens the way they asked |
phone | Optional in the schema, required by your code when they pick a phone call |
preferred_date | A date field stores YYYY-MM-DD. Its schema min is fixed, so the page sets tomorrow as min in the browser |
timezone | Filled from the browser with Intl.DateTimeFormat().resolvedOptions().timeZone, such as Europe/Berlin |
note | Capped at 500 characters. Notification emails show up to 500 characters per value, so the whole note fits |
Conditional rules in your own code
Ikiform validates each field on its own. Rules that depend on another field, such as phone only for phone calls, run in your code. Server versions can enforce them for real; browser versions guide the visitor and send nothing they didn't need to.
The script sets required on the phone input when Phone call is picked, sets tomorrow as the earliest day in the visitor's own calendar, and fills the hidden time zone.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="consult">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<fieldset>
<legend>How should we reach you?</legend>
<label><input type="radio" name="contact_method" value="email" checked /> Email</label>
<label><input type="radio" name="contact_method" value="phone" /> Phone call</label>
<label><input type="radio" name="contact_method" value="video" /> Video call</label>
</fieldset>
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" autocomplete="tel" />
<label for="topic">What is it about?</label>
<select id="topic" name="topic" required>
<option value="new_business">Starting a business</option>
<option value="tax">Tax and bookkeeping</option>
<option value="other">Something else</option>
</select>
<label for="preferred_date">Preferred day</label>
<input id="preferred_date" name="preferred_date" type="date" />
<fieldset>
<legend>Best time</legend>
<label><input type="radio" name="time_window" value="morning" /> Morning</label>
<label><input type="radio" name="time_window" value="afternoon" /> Afternoon</label>
<label><input type="radio" name="time_window" value="evening" /> Evening</label>
</fieldset>
<input type="hidden" name="timezone" />
<label for="note">Anything we should know first?</label>
<textarea id="note" name="note" rows="3" maxlength="500"></textarea>
<p>A sentence or two. Please don't include account numbers or personal records.</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Request a consultation</button>
</form>
<script>
const form = document.getElementById("consult");
const phone = form.elements.phone;
const tomorrow = new Date(Date.now() + 86400000);
const local = new Date(tomorrow.getTime() - tomorrow.getTimezoneOffset() * 60000);
form.elements.timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
form.elements.preferred_date.min = local.toISOString().slice(0, 10);
form.addEventListener("change", () => {
phone.required = form.elements.contact_method.value === "phone";
});
</script>The server action refuses a phone call with no number before posting, clears a number they typed and then switched away from, and redirects to a thank-you page. Render the form with useActionState and show state.error.
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const KEYS = ["name", "email", "contact_method", "phone", "topic", "preferred_date", "time_window", "timezone", "note", "_gotcha"];
export async function requestConsultation(_previous: Record<string, string>, formData: FormData) {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
if (values.contact_method === "phone" && !values.phone) {
return { ...values, error: "Add a phone number, or choose email or video." };
}
if (values.contact_method !== "phone") values.phone = "";
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) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const [field, messages] = Object.entries(fields)[0] ?? [];
return { ...values, error: field ? `${field}: ${messages?.[0]}` : (result?.error?.message ?? "Couldn't send. Try again.") };
}
redirect("/consultation/thanks");
}The phone input only exists when a phone call is chosen, and empty values are dropped before the post, so optional fields arrive absent rather than blank.
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const request = reactive({
name: "",
email: "",
contact_method: "email",
phone: "",
topic: "new_business",
preferred_date: "",
time_window: "",
note: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
const wantsCall = computed(() => request.contact_method === "phone");
async function send() {
status.value = "sending";
error.value = "";
const body = Object.fromEntries(Object.entries(request).filter(([, value]) => value !== ""));
if (!wantsCall.value) delete body.phone;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const first = Object.entries(result.error.fields ?? {})[0] as [string, string[]] | undefined;
error.value = first ? `${first[0]}: ${first[1][0]}` : result.error.message;
} catch {
error.value = "Couldn't reach us. Try again, or call the office.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ request.name }}. We'll contact you by {{ request.contact_method }} within one working day.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="request.name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" v-model="request.email" type="email" autocomplete="email" required />
<fieldset>
<legend>How should we reach you?</legend>
<label><input v-model="request.contact_method" type="radio" value="email" /> Email</label>
<label><input v-model="request.contact_method" type="radio" value="phone" /> Phone call</label>
<label><input v-model="request.contact_method" type="radio" value="video" /> Video call</label>
</fieldset>
<template v-if="wantsCall">
<label for="phone">Phone</label>
<input id="phone" v-model="request.phone" type="tel" autocomplete="tel" required />
</template>
<label for="topic">What is it about?</label>
<select id="topic" v-model="request.topic">
<option value="new_business">Starting a business</option>
<option value="tax">Tax and bookkeeping</option>
<option value="other">Something else</option>
</select>
<label for="preferred_date">Preferred day</label>
<input id="preferred_date" v-model="request.preferred_date" type="date" />
<label for="note">Anything we should know first?</label>
<textarea id="note" v-model="request.note" rows="3" maxlength="500" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Request a consultation" }}</button>
</form>
</template>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.
Keep sensitive details off the form
- Say it in the help text, as the form above does. People follow a plain instruction more often than you'd expect.
- Don't add fields for identity numbers, account numbers or medical details. If your profession has rules about client data, check them before collecting anything beyond contact details, and collect the rest through a channel that meets them.
- Field names matter too. Ikiform holds the notification email for review when a field name looks like a credential, including names containing
password,ssn,cvvorseed. A field calledseed_roundon a startup consultancy's form trips this. Name itfunding_stage. - Point webhooks only where the whole team is allowed to read client names, such as a private channel, not a company-wide one.
The first reply
The notification email arrives with Reply pointed at the visitor, so the first reply can come straight from your inbox. Make it do one job: confirm a time. Two concrete slots inside their window are easier to say yes to than a question about when suits them.
- Convert their window to your time before you write. A
morninginAustralia/Sydneyis the previous evening in London. - Offer two slots in their time zone, and name the zone in the email, so nobody shows up an hour early.
- Say how you'll call: the number they gave, or a video link you send with the invite.
- If the topic is outside what you do, say so in the first reply and suggest someone who does. That reply is still worth sending.
Check it works
- Pick Phone call, leave the phone empty and submit. The browser or your server stops it with a message.
- Pick Email, submit, and open the submission. It has no
phonevalue and has your time zone, such asAmerica/Chicago. - Try choosing today as the preferred day. The date picker doesn't offer it.
- Check the notification email. The note shows in full, and Reply writes to the visitor.
- List your field ids and check that none contains
password,ssn,cvvorseed.
Gotchas
- Date without time zone.
preferred_dateis a calendar day, not a moment. Read it together withtimezoneandtime_windowbefore you call. - Radios with nothing picked. An unchecked radio group sends nothing. That is fine for
time_window, which is optional, and the reasoncontact_methodstarts with Email checked. - No booking confirmation. Ikiform doesn't email the person who fills in the form. Your first reply confirms the time. If you want people to pick a slot themselves, hand off to a scheduler as in Demo request form.
- Phone format. The phone field accepts 6 to 15 digits as typed. Turn on
e164if you dial from a system that needs+14155550123, and numbers without a country code are then refused.
Questions
Name, email, how they want to be contacted, a preferred day and time, and a broad topic. Leave their full situation, documents and any numbers for the consultation itself.
Not in the field definition, which validates each field on its own. Set required on the input in the browser when phone is chosen, and check it in your server code before posting.
Fill a hidden timezone field from Intl.DateTimeFormat().resolvedOptions().timeZone in the browser. It stores an IANA name such as Europe/London next to the requested day and time window.
No. Ikiform emails you and your team, not the submitter, so there is no autoresponder or confirmation email. Send a webhook to your email tool and send the confirmation from there.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.