# 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.

Published 2026-10-25 · https://ikiform.com/guides/lead-capture/consultation-request

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.

Diagram: What a consultation form asks, and what waits for the call

1. The form asks who they are: name and email.
2. It asks how to reach them: email, phone or video.
3. It asks when: a preferred day, a time window and their time zone.
4. It asks what it is about, as one broad topic.
5. The call covers their situation in their own words.
6. Documents go through a channel you choose, not the form.
7. Account numbers, ids and records are never typed into the form.
8. The call ends with your answer on fit and fees.

The top row is enough to book the call and prepare for it. The bottom row is what the call is for.

## The fields

Form: Free consultation

- **Name** (`name`, text, required)
- **Email** (`email`, email, required)
- **How should we reach you?** (`contact_method`, radio, required)
- **Phone** (`phone`, phone)
- **What is it about?** (`topic`, select, required)
- **Preferred day** (`preferred_date`, date)
- **Best time** (`time_window`, radio)
- **Time zone** (`timezone`, text)
- **Anything we should know first?** (`note`, textarea)

Pick **Phone call** and leave the phone empty: the schema accepts it, because a field can't be required only sometimes. The code below closes that gap.

| 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.

**HTML**

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.

`consultation.html`:

```html
<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>
```

**Next.js**

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`.

`app/consultation/actions.ts`:

```ts
"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");
}
```

**Vue**

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.

`src/components/ConsultationForm.vue`:

```vue
<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](https://ikiform.com/docs/rate-limits.md) 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`, `cvv` or `seed`. A field called `seed_round` on a startup consultancy's form trips this. Name it `funding_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 `morning` in `Australia/Sydney` is 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

1. Pick **Phone call**, leave the phone empty and submit. The browser or your server stops it with a message.
2. Pick **Email**, submit, and open the submission. It has no `phone` value and has your time zone, such as `America/Chicago`.
3. Try choosing today as the preferred day. The date picker doesn't offer it.
4. Check the notification email. The note shows in full, and **Reply** writes to the visitor.
5. List your field ids and check that none contains `password`, `ssn`, `cvv` or `seed`.

## Gotchas

- **Date without time zone.** `preferred_date` is a calendar day, not a moment. Read it together with `timezone` and `time_window` before you call.
- **Radios with nothing picked.** An unchecked radio group sends nothing. That is fine for `time_window`, which is optional, and the reason `contact_method` starts 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](https://ikiform.com/guides/lead-capture/demo-request.md).
- **Phone format.** The phone field accepts 6 to 15 digits as typed. Turn on `e164` if you dial from a system that needs `+14155550123`, and numbers without a country code are then refused.

## Questions

### What should a free consultation form ask?

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.

### Can I make the phone number required only when someone picks a phone call?

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.

### How do I know which time zone the requested time is in?

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.

### Can Ikiform email the person who submitted the form?

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.

## More guides

- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): 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.
- [Project inquiry form](https://ikiform.com/guides/lead-capture/project-inquiry.md): A start-a-project form for studios and freelancers that sorts inquiries into hot, warm and no-fit by budget and timing before anyone reads the brief.
- [Quote request form](https://ikiform.com/guides/lead-capture/quote-request.md): A request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- [Leads to HubSpot or Pipedrive](https://ikiform.com/guides/lead-capture/leads-to-crm.md): Map each signed Ikiform webhook to HubSpot's contact upsert or Pipedrive's person and lead calls, without duplicate contacts when a delivery is retried.
- [Next.js lead form](https://ikiform.com/guides/lead-capture/nextjs.md): A Next.js App Router lead form that validates with Zod in a server action, keeps first-touch UTMs in a cookie and sends qualified leads to your calendar.
