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

Published 2026-10-25 · https://ikiform.com/guides/lead-capture/project-inquiry

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.

Diagram: How a relay sorts project inquiries into lanes

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](https://ikiform.com/forms/contact-form). This one adds the three sorting questions. Tick several services in the preview to see them stored as a list.

Form: Project inquiry

- **Name** (`name`, text, required)
- **Email** (`email`, email, required)
- **Your current website** (`website`, url)
- **What do you need?** (`services`, multi_select, required)
- **Budget** (`budget`, select, required)
- **When should it start?** (`start`, radio, required)
- **Tell us about the project** (`description`, textarea, required)

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

| Field | Why it's shaped this way |
| --- | --- |
| `services` | Checkboxes, because real projects combine services. Arrives as an array in the webhook |
| `budget` | Required here, unlike on a quote form. Your ranges should start where your minimum engagement starts |
| `start` | **Exploring for now** is an honest answer, and a useful one: it goes to the warm lane |
| `description` | At 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`:

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

```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](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).

## 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 contains | Score | Result |
| --- | --- | --- |
| "We need SEO services" and a website | +2 | Received |
| The same, plus two competitor links in the description | +2 and +2 | Received, one signal from the line |
| All of the above, sent twice within 24 hours | +2, +2 and +3 | Spam, 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](https://ikiform.com/guides/lead-capture.md#form).
- **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

### What should a project inquiry form ask?

Contact details, the services they need, a budget range, a start date and a description of the project. The three fixed-choice questions let you sort inquiries before anyone reads the description.

### How are checkbox answers stored?

Checkboxes that share a name are collected into one list. With a multi_select field defined, Ikiform only accepts listed values and stores them as an array, which is what the webhook carries.

### Should I require a budget on an inquiry form?

On a project inquiry, usually yes: it is the single best signal of fit, and ranges that start at your minimum engagement make the question feel normal. On a quote request, where you set the price, make it optional.

### Why did a real inquiry about SEO land in spam?

Phrases like seo services count as a spam signal, and links add to the score. Together with an identical repeat within 24 hours that reaches the threshold of 5. Mark it as not spam to send its email and webhook, and offer search work as a checkbox so clients don't need to type the phrase.

## 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.
- [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.
- [Consultation request form](https://ikiform.com/guides/lead-capture/consultation-request.md): A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
- [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.
- [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.
