리드 수집 폼4분 분량
통화 예약까지 이어지는 데모 요청 폼 만들기
리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
먼저 데모 요청을 폼 백엔드로 POST하세요. 그러면 방문자가 시간을 고르지 않더라도 리드가 저장되고 팀에 이메일이 갑니다. 그다음 쿼리 문자열에 name과 email을 담아 브라우저를 예약 페이지로 보내세요. Calendly와 Cal.com은 이 값으로 폼을 미리 채웁니다. 소규모 팀은 녹화된 투어 같은 다른 곳으로 보내 캘린더에는 성사될 수 있는 통화만 남기세요.
데모 요청이 다른 점
데모를 요청하는 사람은 이메일 스레드가 아니라 캘린더의 시간을 원합니다. "화요일 괜찮으세요?"가 오갈 때마다 경쟁사와 예약할 기회가 생깁니다. 그래서 폼의 일은 짧습니다. 자격 확인에 충분한 만큼만 받고, 탭이 열려 있는 동안 캘린더를 보여 주는 것입니다.
구현에 영향을 주는 제약은 세 가지입니다.
- 스케줄러가 열리기 전에 리드가 저장되어야 합니다. 많은 방문자가 예약 페이지를 열고 닫아 버립니다. 그들의 기록이 스케줄러에만 있다면 그 리드는 사라집니다. 먼저 Ikiform으로 POST하고 성공했을 때만 리디렉션하세요.
_redirect는 입력한 내용을 담을 수 없습니다. 고정 URL이고, Ikiform은 Allowed domains 중 하나에 있는https:URL일 때만 따라갑니다. 거기에cal.com이나calendly.com을 추가하면 그 사이트에서 오는 POST도 통과하게 됩니다. 대신 내 코드에서 예약 링크를 만드세요.- 모든 요청이 실시간 통화를 받을 만한 것은 아닙니다. 팀용 제품의 데모를 요청한 1인 창업자에게는 투어와 가입 링크가 더 낫습니다. 선택형 질문 하나로 브라우저가 다음에 어디로 갈지 정합니다.
- 방문자가 이름, 업무용 이메일, 회사, 팀 규모를 입력하면 폼이 Ikiform으로 POST합니다.
- Ikiform이 요청을 저장하고 팀에 이메일을 보냅니다. ok로 응답하기 전까지는 아무 일도 일어나지 않습니다.
- 내 코드가 방문자가 고른 팀 규모를 읽습니다.
- 11명 이상인 팀은 쿼리 문자열에 이름과 이메일을 담아 예약 페이지로 갑니다.
- 1~10명인 팀은 가입 링크가 있는 녹화된 투어로 갑니다.
필드 네 개, 그중 하나는 라우팅용
| 필드 | 유형 | 이유 |
|---|---|---|
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을 포함해 모든 예약 필드를 식별자로 미리 채웁니다.
폼과 스크립트를 함께 씁니다. JavaScript가 없어도 폼은 POST하고 Redirect after submit 페이지로 이동하므로, 대체 경로로 그 페이지에 예약 링크를 두세요.
<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>서버 액션은 Ikiform이 ok로 응답하면 예약 URL로 redirect()를 호출합니다. Next.js는 거기서 절대 URL을 받으며, 서버 액션은 일반 폼 POST에 303으로 응답하므로 하이드레이션 전에도 폼이 동작합니다. redirect()는 예외를 던져서 동작하므로 try 블록 밖에 두세요.
"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());
}"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>
);
}폼 액션은 오류 시 첫 번째 메시지와 함께 fail()을 반환하고, 성공하면 redirect(303, url, { external: true })를 호출합니다. SvelteKit 3는 그 옵션 없이는 다른 사이트로의 리디렉션을 거부합니다. $app/env/private가 읽을 수 있도록 src/env.ts에서 defineEnvVars로 IKIFORM_SECRET_KEY를 선언하세요. 기본 액션으로 POST하는 아무 +page.svelte 폼과 함께 쓰면 되며, JavaScript가 없으면 브라우저가 직접 303을 따라갑니다.
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;싱글 페이지 앱에서는 브라우저가 Ikiform으로 직접 POST하고 location.assign으로 이동합니다. 폼은 Allowed domains로 보호하고, 시크릿 키는 절대 쓰지 마세요.
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>
);
}IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 버전은 방문자의 Origin 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 localhost 테스트를 판별하기 때문입니다.Gmail을 막지 않는 업무용 이메일
Ikiform에는 무료 이메일 제공자를 거부하는 설정이 없습니다. 이메일 필드의 allowed_domains 옵션은 허용 목록입니다. 자사 도메인으로 제한한 내부 폼에는 유용하지만 공개 데모 폼에는 맞지 않습니다. 괜찮습니다. Gmail을 막으면 휴대폰으로 폼을 작성하는 창업자, 컨설턴트, 구매자도 막히니까요.
대신 팀이 보는 곳에서 그런 요청에 태그를 붙이세요. 이 함수는 각 요청을 채팅이나 CRM에 올리는 릴레이에서 실행되며, 통화 전에 담당자에게 힌트를 줍니다.
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로 리드 보내기에 전체 릴레이가 있습니다.
작동 확인하기
- Allowed domains에 사이트 도메인과
localhost를 추가하세요. - 팀 규모 51~200명으로 제출하세요. 이름과 이메일이 채워진 예약 페이지가 열립니다.
- 시간을 고르지 않고 예약 페이지를 닫으세요. 요청은 여전히 Submissions에 있고, 알림 이메일도 받은편지함에 있습니다.
- 1~10명으로 다시 제출하고 투어로 이동하는지 확인하세요.
- 잘못된 이메일로 제출하세요. 폼에 오류가 표시되고 예약 페이지는 열리지 않습니다.
- 테스트 미팅을 예약하고, 스케줄러의 확인 메일이 입력한 주소로 오는지 확인하세요.
주의할 점
- 예약된 데모마다 레코드가 두 개입니다. Ikiform에는 요청이, 스케줄러에는 미팅이 있습니다. CRM에서 이름이 아니라 이메일로 맞추세요.
- Ikiform의 확인 이메일은 없습니다. Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 예약된 통화는 스케줄러의 확인 메일과 캘린더 초대로 충분합니다. 예약하지 않은 요청에는 그날 알림 이메일에서 답장하세요.
- 쿼리 문자열은 보입니다. 예약 URL의 이름과 이메일은 브라우저 기록과 스케줄러의 분석에 나타납니다. 그 밖의 것은 넘기지 마세요.
- 임베드. 대신 내
/book페이지에 스케줄러를 임베드한다면, Cal.com은forwardQueryParams를 켰을 때만 페이지의 쿼리 파라미터를 전달합니다. Calendly는 JavaScript 임베드의prefill객체로 미리 채우기를 지원하므로 일반 iframe 대신 그것을 쓰세요. - 스팸 검사. Spam check가 Ikiform으로 설정되어 있으면 브라우저 버전은 POST 전에
@ikiform/sdk의challenge()를 호출해야 하며, 그러지 않으면403 challenge_required를 받습니다. 서버 버전은 이 검사를 건너뜁니다.
질문
?name=Ada%20Lovelace&email=ada%40example.com처럼 예약 링크의 쿼리 문자열에 추가하세요. 폼 백엔드가 ok로 응답한 뒤 내 코드에서 URL을 만들고 브라우저를 거기로 보내세요.
많은 방문자가 스케줄러를 열고 예약하지 않은 채 떠나며, 스케줄러는 예약한 사람만 기록합니다. 짧은 폼을 먼저 두면 팀이 reply-to 주소와 함께 모든 요청을 갖게 되고, 시간을 고르지 않은 사람에게도 후속 연락을 할 수 있습니다.
폼의 허용 도메인 중 하나에 있는 고정 URL로만 가능하고, 방문자가 입력한 내용은 담을 수 없습니다. 예약 폼을 미리 채우려면 대신 페이지 스크립트나 서버 액션에서 URL을 만드세요.
기본 차단 목록은 없습니다. 이메일 필드의 허용 도메인 옵션은 나열한 도메인만 허용하므로 내부 폼에 맞습니다. 모든 주소를 받고 무료 메일 요청에는 릴레이나 CRM에서 태그를 붙이세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.