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

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

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.

Diagram: A file input compared with a link field on a quote request form

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](https://ikiform.com/forms/quote-request) 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.

Form: Quote request

- **Name** (`name`, text, required)
- **Work email** (`email`, email, required)
- **What do you need?** (`service`, select, required)
- **Budget** (`budget`, select)
- **Link to your files** (`files_url`, url)
- **What should the quote cover?** (`details`, textarea, required)

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

| Field | Why it's shaped this way |
| --- | --- |
| `files_url` | One link to a folder, not one field per file. The client controls what's inside and can add to it later |
| `service` | Decides who prices it. Values stay stable for routing even if you rename the labels |
| `budget` | Optional, with a **Not sure yet** choice that sends nothing. A required budget field loses leads |
| `details` | Required, 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`:

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

## Links and the spam score

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 contains | Score | Result |
| --- | --- | --- |
| The folder link | 0 | Received |
| The folder link and two more links in `details` | +2 | Received |
| The same three links, sent again within 24 hours with the same fields | +2 and +3 | Spam, 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`:

```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](https://ikiform.com/blog/form-submissions-to-slack.md) 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

### Can the form accept file uploads?

Not yet. A post that contains a file is refused with 422 files_not_supported. Ask for a link to the file instead, such as a shared drive URL.

### How do I collect drawings, briefs or photos with a quote form?

Ask for one link to a shared folder in a url field, and say in the help text that the folder must be viewable by anyone with the link. The client can add files to the folder after sending the request.

### Why did a quote request with several links end up in spam?

Three or more links add 2 to the spam score, and the same fields sent again within 24 hours add 3, which reaches the threshold of 5. Keep one link field, and mark the request as not spam to send its email and webhook.

### Can I make the budget field optional?

Yes. Leave required off and add a first option with an empty value, such as Not sure yet. An empty choice sends nothing, and Ikiform stores the request without a budget.

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