Lead capture form

Lead capture form6 min read

Build a project inquiry form for an agency or freelancer

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.

Preet SutharFounder, Ikiform

Ask for services as checkboxes, budget and start date as fixed choices, and a description long enough to judge fit. Send each inquiry by webhook to a small relay that sorts it into hot, warm or no-fit lanes by budget and timing. Then answer the hot lane the same day, and decline the no-fit lane politely within a day, because both replies build your reputation.

What's different about a project inquiry

A quote request asks for a price on a known job. A project inquiry asks a bigger question: are we a fit? The visitor is often still shaping the project, and you're deciding whether to spend a discovery call on it. For a studio or a freelancer, the scarce thing isn't leads, it's partner hours spent reading briefs that were never going to happen.

So this form collects enough structure to sort before reading. Three answers do it: what they need, roughly what they can spend, and when. Everything you'd want to discuss sits in one long description that only gets read in full when the structured answers say it's worth it.

How a relay sorts project inquiries into lanesinquiryservices,budget, startrelayverifies,scoresbudgetandstart?hot lane$30k+,starting soonwarm lanereply withintwo daysno-fit lanepolite declinewebhookhighlow or laterHow a relay sorts project inquiries into lanesinquiryservices, budget,startrelayverifies, scoresbudget andstart?hot lane$30k+, starting soonwarm lanereply withintwo daysno-fitlanepolitedeclinewebhookhighlow or later
  1. The inquiry arrives as a signed webhook with services, budget and start date.
  2. The relay verifies the signature and scores budget and timing.
  3. It checks the score.
  4. A budget of $30,000 or more starting within three months goes to the hot lane.
  5. A lower budget or a later start goes to the warm lane.
  6. Under $10,000 for anything but a website goes to the no-fit lane, for a quick, kind decline.
The lanes are chat channels. Nobody is rejected automatically: a person still answers every lane, in a different order.

The fields

For a lighter form with a single topic, start from the contact form template. This one adds the three sorting questions. Tick several services in the preview to see them stored as a list.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

What do you need?
When should it start?

Goals, audience, and what success looks like.

services is a multi_select, stored as an array such as ["brand", "website"]. website is a url field, so example.com is stored as https://example.com/.
FieldWhy it's shaped this way
servicesCheckboxes, because real projects combine services. Arrives as an array in the webhook
budgetRequired here, unlike on a quote form. Your ranges should start where your minimum engagement starts
startExploring for now is an honest answer, and a useful one: it goes to the warm lane
descriptionAt least 40 characters, at most 4,000. Long enough to judge fit, short enough that people finish it

The form

Checkboxes share one name. The browser sends services=brand&services=website, and Ikiform collects the repeats into one list. Ending the name in [] works too, if your tooling expects it.

start-a-project.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <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 />
  <label for="website">Your current website</label>
  <input id="website" name="website" inputmode="url" placeholder="example.com" />

  <fieldset>
    <legend>What do you need?</legend>
    <label><input type="checkbox" name="services" value="brand" /> Brand identity</label>
    <label><input type="checkbox" name="services" value="website" /> Website</label>
    <label><input type="checkbox" name="services" value="app" /> Web or mobile app</label>
    <label><input type="checkbox" name="services" value="seo" /> Search and content</label>
  </fieldset>

  <label for="budget">Budget</label>
  <select id="budget" name="budget" required>
    <option value="under_10k">Under $10,000</option>
    <option value="10k_30k">$10,000 to $30,000</option>
    <option value="30k_75k">$30,000 to $75,000</option>
    <option value="over_75k">Over $75,000</option>
  </select>

  <fieldset>
    <legend>When should it start?</legend>
    <label><input type="radio" name="start" value="this_month" required /> This month</label>
    <label><input type="radio" name="start" value="next_quarter" /> In the next three months</label>
    <label><input type="radio" name="start" value="exploring" /> Exploring for now</label>
  </fieldset>

  <label for="description">Tell us about the project</label>
  <textarea id="description" name="description" rows="6" minlength="40" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://studio.example/inquiry/thanks" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send inquiry</button>
</form>

Sort inquiries by webhook

This Express server receives each inquiry, verifies it, picks a lane and posts a short summary to that lane's Slack channel. It reads the raw body with express.raw, because the signature covers the exact bytes Ikiform sent, and passes Node's req.headers to verifyWebhook as they are.

server.ts
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const BUDGET: Record<string, number> = { under_10k: 0, "10k_30k": 1, "30k_75k": 2, over_75k: 3 };
const START: Record<string, number> = { this_month: 2, next_quarter: 1, exploring: 0 };
const CHANNEL = {
  hot: process.env.SLACK_HOT_LEADS!,
  warm: process.env.SLACK_LEADS!,
  "no-fit": process.env.SLACK_NO_FIT!,
};

function lane(fields: Record<string, unknown>): keyof typeof CHANNEL {
  const budget = BUDGET[String(fields.budget)] ?? 0;
  const start = START[String(fields.start)] ?? 0;
  const services = Array.isArray(fields.services) ? fields.services : [];
  if (budget === 0 && !services.includes("website")) return "no-fit";
  if (budget >= 2 && start >= 1) return "hot";
  return "warm";
}

const app = express();

app.post("/webhooks/inquiries", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook({
      payload: req.body.toString("utf8"),
      headers: req.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
    throw error;
  }
  if (event.type !== "submission.created") return res.sendStatus(204);

  const fields = event.data.fields;
  const which = lane(fields);
  const summary = String(fields.description ?? "").slice(0, 280);
  const response = await fetch(CHANNEL[which], {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      text: `[${which}] ${fields.name}: ${(fields.services as string[]).join(", ")}, ${fields.budget}, ${fields.start}\n> ${summary}\nmailto:${fields.email}`,
    }),
  });
  res.sendStatus(response.ok ? 204 : 502);
});

app.listen(3000);
  • Change lane() to match how you sell. The thresholds above are an example, not advice.
  • The summary is the first 280 characters of the description. The full text is in the dashboard and in event.data.fields.
  • A 502 when Slack fails makes Ikiform retry the same delivery for about 3 days, so an inquiry isn't lost to a chat outage.

Add the server's public https: URL under Webhooks. If you'd rather not run a server, the same branching works as a router step in Zapier, Make or n8n.

When your own services sound like spam

Spam scoring includes known sales-pitch phrases, such as seo services, backlinks and rank your website, each adding 2. If you sell search work, a real client may write exactly that. Links add up too: website is stored as a link, and three or more links add 2.

A real inquiry that containsScoreResult
"We need SEO services" and a website+2Received
The same, plus two competitor links in the description+2 and +2Received, one signal from the line
All of the above, sent twice within 24 hours+2, +2 and +3Spam, over 5

A search-and-content option in services lets clients say what they need without typing the phrase. Check the Spam filter weekly all the same; a lost $50,000 inquiry costs more than reading ten spam posts.

Check it works

  1. Submit with two services ticked. The submission stores services as a two-item list.
  2. Submit with Over $75,000 and This month. The summary lands in the hot channel.
  3. Submit with Under $10,000 and only Brand identity. It lands in the no-fit channel.
  4. Paste a 3,000-character description. The notification email cuts the value at 500 characters; the dashboard shows all of it.
  5. Stop your server and submit. The delivery shows failed attempts under Webhooks, then succeeds when you start it again and choose Retry.

Gotchas

  • No checkbox ticked. An empty checkbox group sends nothing, so HTML required can't enforce it. Ikiform refuses the post with Choose at least one option; show that error on the page with the fetch version from the lead capture guide.
  • Renaming options. Change labels freely. Change values only together with the relay, or inquiries fall into the wrong lane.
  • Long briefs in email. Notification emails show up to 20 fields and 500 characters per value. The description is cut there. Workspace members get a link to the full submission in the email.
  • The decline. Ikiform sends nothing to the person who filled in the form, so a no-fit lead hears nothing unless someone replies. A two-line decline with a referral keeps the door open.

Questions

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