# 통화 예약까지 이어지는 데모 요청 폼 만들기

> 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/lead-capture/demo-request

먼저 데모 요청을 폼 백엔드로 POST하세요. 그러면 방문자가 시간을 고르지 않더라도 리드가 저장되고 팀에 이메일이 갑니다. 그다음 쿼리 문자열에 `name`과 `email`을 담아 브라우저를 예약 페이지로 보내세요. Calendly와 Cal.com은 이 값으로 폼을 미리 채웁니다. 소규모 팀은 녹화된 투어 같은 다른 곳으로 보내 캘린더에는 성사될 수 있는 통화만 남기세요.

## 데모 요청이 다른 점

데모를 요청하는 사람은 이메일 스레드가 아니라 캘린더의 시간을 원합니다. "화요일 괜찮으세요?"가 오갈 때마다 경쟁사와 예약할 기회가 생깁니다. 그래서 폼의 일은 짧습니다. 자격 확인에 충분한 만큼만 받고, 탭이 열려 있는 동안 캘린더를 보여 주는 것입니다.

구현에 영향을 주는 제약은 세 가지입니다.

- **스케줄러가 열리기 전에 리드가 저장되어야 합니다.** 많은 방문자가 예약 페이지를 열고 닫아 버립니다. 그들의 기록이 스케줄러에만 있다면 그 리드는 사라집니다. 먼저 Ikiform으로 POST하고 성공했을 때만 리디렉션하세요.
- **`_redirect`는 입력한 내용을 담을 수 없습니다.** 고정 URL이고, Ikiform은 **Allowed domains** 중 하나에 있는 `https:` URL일 때만 따라갑니다. 거기에 `cal.com`이나 `calendly.com`을 추가하면 그 사이트에서 오는 POST도 통과하게 됩니다. 대신 내 코드에서 예약 링크를 만드세요.
- **모든 요청이 실시간 통화를 받을 만한 것은 아닙니다.** 팀용 제품의 데모를 요청한 1인 창업자에게는 투어와 가입 링크가 더 낫습니다. 선택형 질문 하나로 브라우저가 다음에 어디로 갈지 정합니다.

다이어그램: 데모 요청이 저장된 뒤 어디로 가는지

1. 방문자가 이름, 업무용 이메일, 회사, 팀 규모를 입력하면 폼이 Ikiform으로 POST합니다.
2. Ikiform이 요청을 저장하고 팀에 이메일을 보냅니다. ok로 응답하기 전까지는 아무 일도 일어나지 않습니다.
3. 내 코드가 방문자가 고른 팀 규모를 읽습니다.
4. 11명 이상인 팀은 쿼리 문자열에 이름과 이메일을 담아 예약 페이지로 갑니다.
5. 1~10명인 팀은 가입 링크가 있는 녹화된 투어로 갑니다.

분기는 Ikiform이 리드를 받은 뒤 내 코드에서 실행됩니다. 어느 쪽이든 팀은 모든 요청을 받으므로, 통화가 필요했던 소규모 팀도 놓치지 않습니다.

## 필드 네 개, 그중 하나는 라우팅용

| 필드 | 유형 | 이유 |
| --- | --- | --- |
| `name` | 텍스트 | 예약 폼을 미리 채우므로 방문자는 한 번만 입력합니다 |
| `email` | 이메일 | 예약 폼을 미리 채우고 알림 이메일의 reply-to가 됩니다 |
| `company` | 텍스트, 여기서는 필수 | 통화 전에 담당자가 조회합니다 |
| `team_size` | select | 예약 페이지와 투어 중 어디로 갈지 정하며, 정해진 네 값 중 하나로 도착합니다 |

사용 사례, 일정, 전화번호는 통화에서 물으세요. 미팅 전에 맥락이 필요하면 스케줄러가 자체 질문을 하나 할 수 있고, Calendly와 Cal.com 모두 커스텀 질문도 미리 채울 수 있습니다.

## POST한 뒤 넘겨주기

각 버전은 요청을 POST하고 Ikiform의 응답을 기다린 뒤에야 다음 URL을 만듭니다. 예시는 Cal.com을 씁니다. Calendly라면 이벤트 링크로 바꾸세요. Calendly는 `name`과 `email`을 미리 채우기 파라미터로 문서화하고 있고, Cal.com은 `name`과 `email`을 포함해 모든 예약 필드를 식별자로 미리 채웁니다.

**HTML**

폼과 스크립트를 함께 씁니다. JavaScript가 없어도 폼은 POST하고 **Redirect after submit** 페이지로 이동하므로, 대체 경로로 그 페이지에 예약 링크를 두세요.

`demo.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="demo-form">
  <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="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required />

  <label for="team_size">Team size</label>
  <select id="team_size" name="team_size" required>
    <option value="1_10">1 to 10</option>
    <option value="11_50">11 to 50</option>
    <option value="51_200">51 to 200</option>
    <option value="201_plus">201 or more</option>
  </select>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert" id="demo-error" hidden></p>
  <button>Pick a time</button>
</form>

<script>
  const BOOKING = "https://cal.com/acme/demo";
  const SELF_SERVE = "/tour";
  const form = document.getElementById("demo-form");
  const error = document.getElementById("demo-error");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const data = new FormData(form);
    form.querySelector("button").disabled = true;
    error.hidden = true;
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (!result.ok) throw new Error(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
      const small = data.get("team_size") === "1_10";
      const next = new URL(small ? SELF_SERVE : BOOKING, location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      location.assign(next);
    } catch (failure) {
      error.textContent = failure instanceof Error ? failure.message : "Couldn't send. Try again.";
      error.hidden = false;
      form.querySelector("button").disabled = false;
    }
  });
</script>
```

**Next.js**

서버 액션은 Ikiform이 ok로 응답하면 예약 URL로 `redirect()`를 호출합니다. Next.js는 거기서 절대 URL을 받으며, 서버 액션은 일반 폼 POST에 `303`으로 응답하므로 하이드레이션 전에도 폼이 동작합니다. `redirect()`는 예외를 던져서 동작하므로 `try` 블록 밖에 두세요.

`app/demo/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";

const BOOKING = "https://cal.com/acme/demo";

export async function requestDemo(_previous: string, formData: FormData): Promise<string> {
  const lead = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    company: String(formData.get("company") ?? "").trim(),
    team_size: String(formData.get("team_size") ?? ""),
    _gotcha: String(formData.get("_gotcha") ?? ""),
  };
  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(lead),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    return Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
  }

  const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : BOOKING);
  next.searchParams.set("name", lead.name);
  next.searchParams.set("email", lead.email);
  redirect(next.toString());
}
```

`app/demo/demo-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { requestDemo } from "./actions";

export function DemoForm() {
  const [error, action, pending] = useActionState(requestDemo, "");

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
    </form>
  );
}
```

**SvelteKit**

폼 액션은 오류 시 첫 번째 메시지와 함께 `fail()`을 반환하고, 성공하면 `redirect(303, url, { external: true })`를 호출합니다. SvelteKit 3는 그 옵션 없이는 다른 사이트로의 리디렉션을 거부합니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요. 기본 액션으로 POST하는 아무 `+page.svelte` 폼과 함께 쓰면 되며, JavaScript가 없으면 브라우저가 직접 `303`을 따라갑니다.

`src/routes/demo/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    const lead = Object.fromEntries(
      ["name", "email", "company", "team_size", "_gotcha"].map((key) => [key, String(data.get(key) ?? "").trim()]),
    );

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify(lead),
    });
    const result = await response.json();
    if (!result.ok) {
      const fields: Record<string, string[]> = result.error.fields ?? {};
      return fail(response.status, { lead, message: Object.values(fields)[0]?.[0] ?? result.error.message });
    }

    const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : "https://cal.com/acme/demo");
    next.searchParams.set("name", lead.name);
    next.searchParams.set("email", lead.email);
    redirect(303, next.toString(), { external: true });
  },
} satisfies Actions;
```

**React**

싱글 페이지 앱에서는 브라우저가 Ikiform으로 직접 POST하고 `location.assign`으로 이동합니다. 폼은 **Allowed domains**로 보호하고, 시크릿 키는 절대 쓰지 마세요.

`src/DemoForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const BOOKING = "https://cal.com/acme/demo";

export function DemoForm() {
  const [error, setError] = useState("");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    setError("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: data });
      const result = await response.json();
      if (!result.ok) {
        const fields: Record<string, string[]> = result.error.fields ?? {};
        setError(Object.values(fields)[0]?.[0] ?? result.error.message);
        setPending(false);
        return;
      }
      const next = new URL(data.get("team_size") === "1_10" ? "/tour" : BOOKING, window.location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      window.location.assign(next);
    } catch {
      setError("Couldn't reach the server. Try again.");
      setPending(false);
    }
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
    </form>
  );
}
```

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 리밋이 모든 방문자를 함께 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 버전은 방문자의 `Origin` 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다.

## Gmail을 막지 않는 업무용 이메일

Ikiform에는 무료 이메일 제공자를 거부하는 설정이 없습니다. 이메일 필드의 `allowed_domains` 옵션은 허용 목록입니다. 자사 도메인으로 제한한 내부 폼에는 유용하지만 공개 데모 폼에는 맞지 않습니다. 괜찮습니다. Gmail을 막으면 휴대폰으로 폼을 작성하는 창업자, 컨설턴트, 구매자도 막히니까요.

대신 팀이 보는 곳에서 그런 요청에 태그를 붙이세요. 이 함수는 각 요청을 채팅이나 CRM에 올리는 릴레이에서 실행되며, 통화 전에 담당자에게 힌트를 줍니다.

`lib/demo-tag.ts`:

```ts
const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "proton.me"]);

export function demoTag(fields: Record<string, unknown>) {
  const domain = String(fields.email ?? "").split("@")[1]?.toLowerCase() ?? "";
  const size = String(fields.team_size ?? "");
  if (FREE_MAIL.has(domain)) return "personal address, check the company";
  if (size === "51_200" || size === "201_plus") return "priority";
  return "standard";
}
```

`verifyWebhook` 후 `event.data.fields`로 호출하고, 태그를 채팅 메시지나 CRM 속성에 추가하세요. [HubSpot이나 Pipedrive로 리드 보내기](https://ikiform.com/ko/guides/lead-capture/leads-to-crm.md)에 전체 릴레이가 있습니다.

## 작동 확인하기

1. **Allowed domains**에 사이트 도메인과 `localhost`를 추가하세요.
2. 팀 규모 **51~200명**으로 제출하세요. 이름과 이메일이 채워진 예약 페이지가 열립니다.
3. 시간을 고르지 않고 예약 페이지를 닫으세요. 요청은 여전히 **Submissions**에 있고, 알림 이메일도 받은편지함에 있습니다.
4. **1~10명**으로 다시 제출하고 투어로 이동하는지 확인하세요.
5. 잘못된 이메일로 제출하세요. 폼에 오류가 표시되고 예약 페이지는 열리지 않습니다.
6. 테스트 미팅을 예약하고, 스케줄러의 확인 메일이 입력한 주소로 오는지 확인하세요.

## 주의할 점

- **예약된 데모마다 레코드가 두 개입니다.** Ikiform에는 요청이, 스케줄러에는 미팅이 있습니다. CRM에서 이름이 아니라 이메일로 맞추세요.
- **Ikiform의 확인 이메일은 없습니다.** Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 예약된 통화는 스케줄러의 확인 메일과 캘린더 초대로 충분합니다. 예약하지 않은 요청에는 그날 알림 이메일에서 답장하세요.
- **쿼리 문자열은 보입니다.** 예약 URL의 이름과 이메일은 브라우저 기록과 스케줄러의 분석에 나타납니다. 그 밖의 것은 넘기지 마세요.
- **임베드.** 대신 내 `/book` 페이지에 스케줄러를 임베드한다면, Cal.com은 `forwardQueryParams`를 켰을 때만 페이지의 쿼리 파라미터를 전달합니다. Calendly는 JavaScript 임베드의 `prefill` 객체로 미리 채우기를 지원하므로 일반 iframe 대신 그것을 쓰세요.
- **스팸 검사.** **Spam check**가 **Ikiform**으로 설정되어 있으면 브라우저 버전은 POST 전에 `@ikiform/sdk`의 `challenge()`를 호출해야 하며, 그러지 않으면 `403 challenge_required`를 받습니다. 서버 버전은 이 검사를 건너뜁니다.

## 질문

### 폼의 이름과 이메일을 Calendly나 Cal.com에 어떻게 넘기나요?

?name=Ada%20Lovelace&email=ada%40example.com처럼 예약 링크의 쿼리 문자열에 추가하세요. 폼 백엔드가 ok로 응답한 뒤 내 코드에서 URL을 만들고 브라우저를 거기로 보내세요.

### 폼 없이 바로 예약 페이지로 보내면 안 되나요?

많은 방문자가 스케줄러를 열고 예약하지 않은 채 떠나며, 스케줄러는 예약한 사람만 기록합니다. 짧은 폼을 먼저 두면 팀이 reply-to 주소와 함께 모든 요청을 갖게 되고, 시간을 고르지 않은 사람에게도 후속 연락을 할 수 있습니다.

### _redirect로 예약 페이지에 보낼 수 있나요?

폼의 허용 도메인 중 하나에 있는 고정 URL로만 가능하고, 방문자가 입력한 내용은 담을 수 없습니다. 예약 폼을 미리 채우려면 대신 페이지 스크립트나 서버 액션에서 URL을 만드세요.

### 데모 폼에서 Gmail 같은 무료 주소를 거부할 수 있나요?

기본 차단 목록은 없습니다. 이메일 필드의 허용 도메인 옵션은 나열한 도메인만 허용하므로 내부 폼에 맞습니다. 모든 주소를 받고 무료 메일 요청에는 릴레이나 CRM에서 태그를 붙이세요.

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [HubSpot이나 Pipedrive로 리드 보내기](https://ikiform.com/ko/guides/lead-capture/leads-to-crm.md): 서명된 Ikiform 웹훅을 HubSpot의 연락처 upsert나 Pipedrive의 person, lead 호출로 매핑하고, 전달이 재시도되어도 연락처가 중복되지 않게 합니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [프로젝트 문의 폼](https://ikiform.com/ko/guides/lead-capture/project-inquiry.md): 누군가 브리프를 읽기 전에 예산과 시기로 문의를 유력, 관심, 부적합으로 분류하는 스튜디오와 프리랜서용 프로젝트 의뢰 폼입니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
