Lead capture form

Lead capture form6 min read

Build a quote request form without file uploads

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.

Preet SutharFounder, Ikiform

Ask for the brief as a link to a shared folder in a url field, because Ikiform refuses file uploads with 422 files_not_supported. Keep service and budget as fixed choices so every request arrives in the same shape, and promise a turnaround in the success message. Then send each request to the team with its due date attached, so the promise gets kept.

What's different about a quote request

A quote request comes with material: a brief, a floor plan, a spreadsheet of parts, last year's design. The usual answer is a file input. That doesn't work here, and it is worth knowing why before you design around it.

File uploads aren't supported yet. A post with a file gets 422 files_not_supported. The whole post is refused, not only the file, so a visitor who attaches a PDF loses everything they typed unless your form shows the error and keeps their input. A shared link avoids the problem and has advantages of its own: the client can add files after sending, there is no size limit, and nothing large sits in your inbox.

A file input compared with a link field on a quote request formfile inputlink fieldbrief.pdf attachedmultipart post422files_not_supportednothing storedno email, no webhookfolder linkurl field201validated as httpsquote requestlink in email and chatwhat the visitor sendswhat Ikiform doeswhat you getA file input compared with a link field on a quote request formfile inputlink fieldbrief.pdfattachedmultipartpost422files_not_supportednothingstorednoemail,nowebhookfolderlinkurlfield201validatedashttpsquoterequestlink inemailandchatwhat thevisitorsendswhatIkiformdoeswhat youget
  1. With a file input, the visitor attaches brief.pdf and the browser sends a multipart post.
  2. Ikiform refuses the whole post with 422 files_not_supported.
  3. Nothing is stored, so no email or webhook goes out.
  4. With a link field, the visitor pastes a link to a shared folder.
  5. Ikiform checks that it is a valid https URL and stores the request.
  6. The link arrives in the notification email and in chat, where anyone on the team can open it.
Same request, two outcomes. Ask for a folder link and the files never pass through the form.

The fields

The quote request template has the full field set with phone, company and timeline. The version below trims it and adds the link field. Try an http: link, or a bare drive.google.com link, to see how the url field answers.

Preview

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

A shared folder with the brief, mockups or specs. Set it so anyone with the link can view.

files_url is a url field with https_only, so a bare drive.google.com/... is stored as https://drive.google.com/... and an http: link is refused.
FieldWhy it's shaped this way
files_urlOne link to a folder, not one field per file. The client controls what's inside and can add to it later
serviceDecides who prices it. Values stay stable for routing even if you rename the labels
budgetOptional, with a Not sure yet choice that sends nothing. A required budget field loses leads
detailsRequired, at least 20 characters. A quote needs scope, and this is where it comes from

The form

Plain HTML. type="url" brings up the right keyboard on phones, and the help text says how to share the folder, which saves a round trip when the first link turns out to be private.

quote.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">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="service">What do you need?</label>
  <select id="service" name="service" required>
    <option value="design">Design</option>
    <option value="development">Development</option>
    <option value="both">Design and development</option>
  </select>

  <label for="budget">Budget</label>
  <select id="budget" name="budget">
    <option value="">Not sure yet</option>
    <option value="under_5k">Under $5,000</option>
    <option value="5k_20k">$5,000 to $20,000</option>
    <option value="over_20k">Over $20,000</option>
  </select>

  <label for="files_url">Link to your files</label>
  <input id="files_url" name="files_url" type="url" inputmode="url" placeholder="https://drive.google.com/..." aria-describedby="files-help" />
  <p id="files-help">A shared folder with the brief, mockups or specs. Set it so anyone with the link can view.</p>

  <label for="details">What should the quote cover?</label>
  <textarea id="details" name="details" rows="5" minlength="20" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/quote/thanks" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Request a quote</button>
</form>
Remove any <input type="file"> you inherited from an old form. An empty one is skipped. With a file chosen, a multipart or FormData post fails as a whole, and a plain urlencoded form sends only the file's name. Either way the file never arrives.

A quote form is the one place where real leads send links on purpose, and links are a spam signal. Three or more links across all fields add 2 to the score. A url field is stored as a full https:// link, so it counts.

The request containsScoreResult
The folder link0Received
The folder link and two more links in details+2Received
The same three links, sent again within 24 hours with the same fields+2 and +3Spam, at 5

The third row is a real person who thought the first submit failed. That's why the form should have one link field, not three, and why the success state must be unmistakable. Check the Spam filter weekly; marking a request as not spam sends its email and webhook then.

Put a due date on every request

The success message promises a quote within two working days. This Cloudflare Worker turns that promise into a date the team can see. It verifies the webhook, works out the due date from created_at, and posts the request to a quotes channel with the files link:

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

function addWorkingDays(from: Date, days: number) {
  const date = new Date(from);
  while (days > 0) {
    date.setUTCDate(date.getUTCDate() + 1);
    const day = date.getUTCDay();
    if (day !== 0 && day !== 6) days -= 1;
  }
  return date.toISOString().slice(0, 10);
}

export default {
  async fetch(request: Request, env: { IKIFORM_WEBHOOK_SECRET: string; SLACK_QUOTES_WEBHOOK: string }) {
    const payload = await request.text();
    let event;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    if (event.type !== "submission.created") return new Response(null, { status: 204 });

    const quote = event.data.fields;
    const due = addWorkingDays(new Date(event.data.created_at), 2);
    const lines = [
      `Quote due *${due}*: ${quote.service} for ${quote.name} (${quote.budget ?? "budget not given"})`,
      quote.files_url ? `Files: ${quote.files_url}` : "No files linked. Ask for them in the first reply.",
      `Reply: mailto:${quote.email}`,
    ];

    const response = await fetch(env.SLACK_QUOTES_WEBHOOK, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text: lines.join("\n") }),
    });
    if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
  • Working days skip Saturday and Sunday in UTC. Add your holidays if you quote around them.
  • A missing files_url produces a reminder to ask for files in the first reply, rather than an empty line.
  • Answering 502 when Slack fails makes Ikiform retry the same delivery, so a request isn't dropped.

Add the Worker's URL under Webhooks, copy the whsec_ secret into IKIFORM_WEBHOOK_SECRET, and store the Slack URL as a secret. The Slack guide covers creating the incoming webhook.

Check it works

  1. Submit with a Google Drive or Dropbox folder link. Open the link from the notification email in a private window to confirm it's viewable.
  2. Submit with an http: link. The post is refused with 422 and the message names files_url.
  3. Search your templates for type="file" and remove every match from quote forms that post to Ikiform.
  4. Check the quotes channel. The message shows the due date two working days out and the files link.
  5. Press Reply on the notification email. It writes to the address on the form.

Gotchas

  • Private links. The most common failure is a link only its owner can open. Say how to share it in the help text, and ask for access in the first reply instead of waiting for it.
  • Clients with files and no folder. Tell them to reply to your first email with attachments. Reply-to puts their files in a normal email thread with your team.
  • Long scopes. Notification emails show values up to 500 characters. The full details text is in the dashboard and the webhook.
  • No acknowledgement email. Ikiform doesn't email the person who fills in the form. The on-page message is the acknowledgement, so make the turnaround concrete.

Questions

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