# 빠르게 연락받는 리드 수집 폼 만들기

> reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.

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

내 페이지에 이름, 업무용 이메일, 자격 확인 질문 하나를 폼 백엔드로 POST하는 짧은 폼을 두세요. 알림 이메일을 켜면 각 리드가 **Reply**가 리드를 가리키는 상태로 받은편지함에 도착하고, 서명된 웹훅으로 팀 채팅과 CRM에 보낼 수 있습니다. 그런 다음 제출부터 첫 답장까지 걸린 시간(분)을 재세요. 폼 디자인보다 그 숫자가 더 많은 거래를 결정합니다.

리드 폼은 할 일이 있는 문의 폼입니다. 영업 대화를 시작하는 것이죠. 폼을 작성하는 사람은 대개 다른 탭에서 다른 업체와 비교하고 있습니다. 실제 사람이 유용한 답장을 가장 먼저 보낸 팀이 보통 미팅을 따냅니다. 이 가이드는 내 사이트에 폼을 만들고, 각 리드가 몇 초 안에 도착하게 하며, CRM은 선택 사항으로 둡니다.

## 리드가 전달되는 과정

폼은 Ikiform으로 POST합니다. Ikiform은 POST의 스팸 여부를 검사하고, 저장한 뒤 세 가지를 동시에 합니다. 리드를 reply-to 주소로 해서 팀에 이메일을 보내고, 서명된 웹훅을 보내고, 대시보드에 리드를 표시합니다. 웹훅은 리드를 팀 채팅과 CRM에 보내는 수단입니다.

다이어그램: 리드가 폼에서 답할 사람에게까지 가는 과정

1. 방문자가 사이트의 리드 폼을 작성하면 Ikiform 엔드포인트로 POST됩니다.
2. Ikiform이 허니팟, 스팸 점수, 레이트 리밋을 검사한 뒤 리드를 저장합니다.
3. 몇 초 뒤 알림 수신자마다 이메일을 받고, 답장을 누르면 리드에게 씁니다.
4. 리드가 대시보드에 표시되고, 거기서 검색, 필터링, 내보내기를 할 수 있습니다.
5. Ikiform이 서명된 submission.created 웹훅을 보냅니다.
6. 릴레이나 자동화 도구가 리드를 팀 채팅에 올리고 CRM에 연락처를 만듭니다.

이메일은 설정 없이 가장 빠른 경로입니다. 웹훅은 팀이 이미 보고 있는 곳을 추가합니다.

## 리드를 수집하는 네 가지 방법

흔한 구성마다 잘하는 것이 있습니다. 리드를 어디에 두고 싶은지, 누가 폼을 관리하는지로 고르세요.

| 방식 | 잘하는 것 | 단점 |
| --- | --- | --- |
| HubSpot 폼이나 Pipedrive 웹 폼 같은 CRM 자체 폼 | 연결 작업 없이 리드가 CRM에 연락처로 들어갑니다. 추적과 리드 스코어링이 한 제품에 있습니다. | 폼이 CRM의 스크립트와 스타일로 임베드됩니다. CRM을 옮기면 모든 폼을 다시 만들어야 합니다. 일부 기능은 플랜에 따라 다릅니다. |
| [Typeform](https://ikiform.com/alternatives/typeform)이나 [Tally](https://ikiform.com/alternatives/tally) 같은 폼 빌더 임베드 | 코드 없이 로직이 있는 다듬어진 다단계 폼을 만들 수 있습니다. | 돈을 내고 스타일을 바꾸지 않으면 빌더처럼 보이는 임베드입니다. 웹훅과 응답 한도는 플랜에 따라 다릅니다. |
| 직접 만든 API 라우트와 데이터베이스 | 저장 전 리드 스코어링 등 원하는 로직을 무엇이든 넣을 수 있습니다. | 스팸 필터링, 레이트 리밋, 이메일 알림, 재시도, 내보내기를 직접 만들고, 폼 하나를 위해 서버를 계속 운영해야 합니다. |
| Ikiform 같은 폼 백엔드 | 어떤 사이트에서든 직접 작성한 HTML을 씁니다. 서버 없이 스팸 필터링, reply-to가 설정된 이메일 알림, 서명된 웹훅, CSV 내보내기를 제공합니다. | 리드 스코어링, CRM 레코드, 리드에게 보내는 이메일은 없습니다. 이런 것은 웹훅으로 CRM이나 이메일 도구에서 처리합니다. |

영업 프로세스 전체가 CRM 하나 안에서 돌아가고 그 폼에 만족한다면 그걸 쓰세요. 폼이 사이트와 어울려야 할 때, CRM을 바꿀 수도 있을 때, 또는 하나를 고르지 않고 같은 폼을 이메일, 채팅, CRM 모두로 보내고 싶을 때 폼 백엔드가 맞습니다.

## 적게 묻고, 자격 확인은 한 번만

필드를 하나 추가할 때마다 제출이 줄어듭니다. 대부분의 리드 폼에는 네 가지가 필요합니다. 누구에게 답할지, 어디서 일하는지, 무엇을 원하는지, 그리고 설명할 공간입니다. 자격 확인은 선택형 질문 하나로 합니다. 라우팅이 믿을 수 있는, 몇 가지 정해진 값 중 하나로 도착하기 때문입니다. 아래에서 시험해 보세요. 브라우저에서 Ikiform의 검증기를 실행해 저장될 내용이나 돌려받을 `422`를 보여 줍니다.

폼: Talk to sales

- **이름** (`name`, text, 필수)
- **업무용 이메일** (`email`, email, 필수)
- **회사** (`company`, text)
- **무엇을 도와드릴까요?** (`interest`, select, 필수)
- **저희가 알아야 할 것이 있나요?** (`message`, textarea)

폼의 **Fields** 탭에 이 정의를 붙여 넣으면 Ikiform이 서버에서 강제합니다. [문의 폼 템플릿](https://ikiform.com/forms/contact-form)에는 관심 분야 대신 주제를 묻는 더 가벼운 버전이 있습니다.

| 필드 | 이유 | 유의할 점 |
| --- | --- | --- |
| `name` | 이름으로 시작하는 답장 | ID는 `name`으로 유지하세요. 이름 필드의 링크는 강한 스팸 신호입니다 |
| `email` | **Reply**가 보내는 대상 | ID는 `email`로 유지하세요. **Work email**(업무용 이메일)이라는 레이블은 업무용 주소를 쓰도록 유도하지만 강제하지는 않습니다 |
| `company` | 답하기 전에 조회합니다 | 선택. 필수로 하면 프리랜서의 제출을 잃습니다 |
| `interest` | 라우팅과 우선순위 | `select`는 목록에 있는 값 하나를 저장하거나 POST를 거부하므로 자동화가 이 값으로 분기할 수 있습니다 |
| `message` | 첫 답장을 위한 맥락 | 선택. 필수 서술형 상자는 사람들이 이탈하는 필드입니다 |

전화번호, 팀 규모, 예산은 그 답이 다음 행동을 바꾸는 폼에 둡니다. [견적 요청 템플릿](https://ikiform.com/forms/quote-request)은 서비스, 예산, 일정을 선택형으로 묻는 더 완전한 버전입니다. 영업 담당자가 모든 리드에게 전화한다면 전화번호를 물으세요. 팀 규모로 라우팅한다면 `select`로 물으세요. 그 밖에는 첫 답장에서 물으세요.

## 폼

스택을 고르세요. 모든 버전은 서버의 메시지를 해당 필드 아래에 표시하고 방문자가 입력한 내용을 유지합니다. 오타 하나 때문에 쓰다 만 메시지를 잃으면 리드가 포기하기 때문입니다. 성공 상태는 누가 언제 답하는지 알려 주므로 제대로 전송됐는지 궁금해할 사람이 없습니다.

**HTML**

폼은 일반적인 방식으로 POST하고 감사 페이지로 리디렉션되므로 스크립트가 로드되기 전에도 동작합니다. 각 필드에는 오류를 위한 빈 자리가 있습니다.

`contact-sales.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="lead-form" novalidate>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <p class="error" data-error-for="name" hidden></p>

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <p class="error" data-error-for="email" hidden></p>

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" />

  <label for="interest">What can we help with?</label>
  <select id="interest" name="interest" required>
    <option value="pricing">Pricing for my team</option>
    <option value="demo">A live demo</option>
    <option value="custom">A custom plan or contract</option>
    <option value="other">Something else</option>
  </select>

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4"></textarea>
  <p class="error" data-error-for="message" hidden></p>

  <input type="hidden" name="page" value="pricing" />
  <input type="hidden" name="_redirect" value="https://example.com/thanks" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Talk to sales</button>
</form>
```

스크립트는 방문자를 페이지에 머물게 합니다. 서버가 보낸 각 필드의 오류를 그 필드 아래에 표시하고, 성공하면 폼을 누가 언제 답하는지 알려 주는 문구로 바꿉니다.

`lead-form.js`:

```js
const form = document.getElementById("lead-form");

function showErrors(fields) {
  form.querySelectorAll("[data-error-for]").forEach((slot) => {
    const message = fields[slot.dataset.errorFor]?.[0];
    slot.textContent = message ?? "";
    slot.hidden = !message;
  });
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  button.textContent = "Sending";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      const thanks = document.createElement("p");
      thanks.setAttribute("role", "status");
      thanks.textContent = `Thanks, ${form.elements.name.value.trim().split(" ")[0]}. A person on our team will reply to ${form.elements.email.value} within one working day.`;
      form.replaceWith(thanks);
      return;
    }
    showErrors(result.error.fields ?? {});
    if (!result.error.fields) alert(result.error.message);
  } catch {
    alert("Something went wrong. Email sales@example.com and we'll pick it up.");
  }
  button.disabled = false;
  button.textContent = "Talk to sales";
});
```

**Next.js**

서버 액션은 시크릿 키로 리드를 전달하고 필드별 첫 번째 오류와 방문자가 입력한 값을 반환하므로 `422`에서도 아무것도 잃지 않습니다.

`app/contact-sales/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface LeadState {
  sent: boolean;
  firstName?: string;
  errors: Record<string, string>;
  values: Record<string, string>;
}

const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"] as const;

export async function sendLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
  const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
  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(values),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, firstName: values.name.split(" ")[0], errors: {}, values: {} };

  const fields: Record<string, string[]> = result?.error?.fields ?? {};
  const errors = Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages[0] ?? ""]));
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
  return { sent: false, errors, values };
}
```

`app/contact-sales/lead-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type LeadState, sendLead } from "./actions";

const initial: LeadState = { sent: false, errors: {}, values: {} };

export function LeadForm() {
  const [state, action, pending] = useActionState(sendLead, initial);
  const { errors, values } = state;

  if (state.sent) {
    return <p role="status">Thanks, {state.firstName}. A person on our team will reply within one working day.</p>;
  }

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {errors.name ? <p role="alert">{errors.name}</p> : null}

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {errors.email ? <p role="alert">{errors.email}</p> : null}

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" defaultValue={values.company} />

      <label htmlFor="interest">What can we help with?</label>
      <select id="interest" name="interest" defaultValue={values.interest ?? "pricing"}>
        <option value="pricing">Pricing for my team</option>
        <option value="demo">A live demo</option>
        <option value="custom">A custom plan or contract</option>
        <option value="other">Something else</option>
      </select>

      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {errors.message ? <p role="alert">{errors.message}</p> : null}

      <input type="hidden" name="page" value="pricing" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {errors.form ? <p role="alert">{errors.form}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

Next.js에서 더 자세히 알아보려면 [Next.js로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/nextjs.md)을(를) 읽어 보세요.

**Astro**

Zod가 서버 밖으로 무엇이든 나가기 전에 필드를 확인하고, `isInputError`가 필드별 메시지를 줍니다. 폼이 액션으로 POST하려면 페이지가 요청 시 렌더링되어야 합니다.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  sendLead: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us who to reply to."),
      email: z.email("Enter a work email we can reply to."),
      company: z.string().trim().optional(),
      interest: z.enum(["pricing", "demo", "custom", "other"]),
      message: z.string().trim().max(5000).optional(),
      page: z.string().default("pricing"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
      return { firstName: input.name.split(" ")[0] };
    },
  }),
};
```

`src/pages/contact-sales.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendLead);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

{result?.data ? (
  <p role="status">Thanks, {result.data.firstName}. A person on our team will reply within one working day.</p>
) : (
  <form method="POST" action={actions.sendLead}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    {fields.name && <p role="alert">{fields.name[0]}</p>}

    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {fields.email && <p role="alert">{fields.email[0]}</p>}

    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" />

    <label for="interest">What can we help with?</label>
    <select id="interest" name="interest">
      <option value="pricing">Pricing for my team</option>
      <option value="demo">A live demo</option>
      <option value="custom">A custom plan or contract</option>
      <option value="other">Something else</option>
    </select>

    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4"></textarea>

    <input type="hidden" name="page" value="pricing" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
    <button>Talk to sales</button>
  </form>
)}
```

Astro에서 더 자세히 알아보려면 [Astro로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/astro.md)을(를) 읽어 보세요.

**TanStack Start**

서버 함수는 `.validator()`에서 `FormData`를 읽고 전달한 뒤, 이름이나 필드 오류 맵을 반환하며, 라우트가 이를 각 입력 아래에 렌더링합니다.

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Result = { ok: true; firstName: string } | { ok: false; errors: Record<string, string> };

const sendLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const read = (key: string) => String(data.get(key) ?? "").trim();
    return {
      name: read("name"),
      email: read("email"),
      company: read("company"),
      interest: read("interest"),
      message: read("message"),
      page: read("page"),
      _gotcha: read("_gotcha"),
    };
  })
  .handler(async ({ data }): Promise<Result> => {
    const origin = getRequestHeader("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(data),
    });
    const result = await response.json();
    if (result.ok) return { ok: true, firstName: data.name.split(" ")[0] ?? "" };
    const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
    return { ok: false, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""])) };
  });

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

function ContactSales() {
  const send = useServerFn(sendLead);
  const [result, setResult] = useState<Result | null>(null);
  const [pending, setPending] = useState(false);
  const errors = result && !result.ok ? result.errors : {};

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setResult(await send({ data: new FormData(event.currentTarget) }));
    } catch {
      setResult({ ok: false, errors: { form: "Something went wrong. Email sales@example.com and we'll pick it up." } });
    } finally {
      setPending(false);
    }
  }

  if (result?.ok) {
    return <p role="status">Thanks, {result.firstName}. A person on our team will reply within one working day.</p>;
  }

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {errors.name ? <p role="alert">{errors.name}</p> : null}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p role="alert">{errors.email}</p> : null}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" />
      <label htmlFor="interest">What can we help with?</label>
      <select id="interest" name="interest">
        <option value="pricing">Pricing for my team</option>
        <option value="demo">A live demo</option>
        <option value="custom">A custom plan or contract</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input type="hidden" name="page" value="pricing" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {errors.form ? <p role="alert">{errors.form}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

TanStack Start에서 더 자세히 알아보려면 [TanStack Start로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/tanstack-start.md)을(를) 읽어 보세요.

**React Router**

라우트 액션은 POST를 전달하고 `data()`로 Ikiform의 상태 코드와 함께 필드 오류를 반환합니다. 컴포넌트는 `actionData`에서 읽습니다.

`app/routes/contact-sales.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact-sales";

const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
  const origin = request.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(values),
  });
  const result = await response.json();
  if (result.ok) return { sent: true as const, firstName: values.name.split(" ")[0] };
  const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
  return data(
    { sent: false as const, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]])) },
    { status: response.status },
  );
}

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.sent) {
    return <p role="status">Thanks, {actionData.firstName}. A person on our team will reply within one working day.</p>;
  }
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {errors.name ? <p role="alert">{errors.name}</p> : null}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p role="alert">{errors.email}</p> : null}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" />
      <label htmlFor="interest">What can we help with?</label>
      <select id="interest" name="interest">
        <option value="pricing">Pricing for my team</option>
        <option value="demo">A live demo</option>
        <option value="custom">A custom plan or contract</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input type="hidden" name="page" value="pricing" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {errors.form ? <p role="alert">{errors.form}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
```

React Router에서 더 자세히 알아보려면 [React Router로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/react-router.md)을(를) 읽어 보세요.

**SvelteKit**

폼 액션은 값과 오류를 담아 `fail()`을 반환하고, `update({ reset: false })`는 방문자가 필드 하나를 고치는 동안 입력한 내용을 화면에 유지합니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요.

`src/routes/contact-sales/+page.server.ts`:

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

const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    const values = Object.fromEntries(KEYS.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(values),
    });
    const result = await response.json();
    if (!result.ok) {
      const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
      const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]]));
      return fail(response.status, { values, errors });
    }
    return { firstName: values.name.split(" ")[0] };
  },
} satisfies Actions;
```

`src/routes/contact-sales/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
</script>

{#if form?.firstName}
  <p role="status">Thanks, {form.firstName}. A person on our team will reply within one working day.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required value={form?.values?.name ?? ""} />
    {#if errors.name}<p role="alert">{errors.name}</p>{/if}

    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    {#if errors.email}<p role="alert">{errors.email}</p>{/if}

    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" value={form?.values?.company ?? ""} />

    <label for="interest">What can we help with?</label>
    <select id="interest" name="interest" value={form?.values?.interest ?? "pricing"}>
      <option value="pricing">Pricing for my team</option>
      <option value="demo">A live demo</option>
      <option value="custom">A custom plan or contract</option>
      <option value="other">Something else</option>
    </select>

    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4">{form?.values?.message ?? ""}</textarea>

    <input type="hidden" name="page" value="pricing" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if errors.form}<p role="alert">{errors.form}</p>{/if}
    <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
  </form>
{/if}
```

SvelteKit에서 더 자세히 알아보려면 [SvelteKit으로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/sveltekit.md)을(를) 읽어 보세요.

**Nuxt**

서버 라우트는 키를 클라이언트에서 떼어 두고, Ikiform의 필드 오류를 오류의 `data`로 돌려줍니다. 환경에 `NUXT_IKIFORM_SECRET_KEY`를 설정하세요.

`server/api/lead.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");
  const keys = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(body[key] ?? "").trim()]));

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(values),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: result.error.message,
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { firstName: values.name?.split(" ")[0] };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`app/components/LeadForm.vue`:

```vue
<script setup lang="ts">
const lead = reactive({ name: "", email: "", company: "", interest: "pricing", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const failure = ref("");
const sending = ref(false);
const firstName = ref("");

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const result = await $fetch("/api/lead", { method: "POST", body: { ...lead, page: "pricing" } });
    firstName.value = result.firstName ?? "there";
  } catch (error) {
    const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    if (!Object.keys(errors.value).length) failure.value = data?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="firstName" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="lead.name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" v-model="lead.company" name="company" autocomplete="organization" />
    <label for="interest">What can we help with?</label>
    <select id="interest" v-model="lead.interest" name="interest">
      <option value="pricing">Pricing for my team</option>
      <option value="demo">A live demo</option>
      <option value="custom">A custom plan or contract</option>
      <option value="other">Something else</option>
    </select>
    <label for="message">Anything we should know?</label>
    <textarea id="message" v-model="lead.message" name="message" rows="4" />
    <input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="failure" role="alert">{{ failure }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

Nuxt에서 더 자세히 알아보려면 [Nuxt로 자격 확인 리드 폼 만들기](https://ikiform.com/ko/guides/lead-capture/nuxt.md)을(를) 읽어 보세요.

**React**

자체 서버가 없으면 폼이 브라우저에서 Ikiform으로 바로 POST합니다. 이 코드에는 시크릿 키를 넣지 마세요. 대신 **Allowed domains**가 폼을 보호합니다.

`src/LeadForm.tsx`:

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

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export function LeadForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [firstName, setFirstName] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setStatus("sending");
    setErrors({});
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) {
        setFirstName(String(body.get("name")).trim().split(" ")[0] ?? "");
        setStatus("sent");
        return;
      }
      setErrors(result.error.fields ?? { form: [result.error.message] });
    } catch {
      setErrors({ form: ["Something went wrong. Email sales@example.com and we'll pick it up."] });
    }
    setStatus("idle");
  }

  if (status === "sent") {
    return <p role="status">Thanks, {firstName}. A person on our team will reply within one working day.</p>;
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {errors.name ? <p role="alert">{errors.name[0]}</p> : null}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p role="alert">{errors.email[0]}</p> : null}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" />
      <label htmlFor="interest">What can we help with?</label>
      <select id="interest" name="interest">
        <option value="pricing">Pricing for my team</option>
        <option value="demo">A live demo</option>
        <option value="custom">A custom plan or contract</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input type="hidden" name="page" value="pricing" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {errors.form ? <p role="alert">{errors.form[0]}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

**Vue**

같은 브라우저 측 POST를 Vue 컴포넌트로 작성한 것입니다. 폼이 `action`을 유지하므로 스크립트 로드에 실패한 방문자도 연락할 수 있습니다.

`src/components/LeadForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const status = ref<"idle" | "sending" | "sent">("idle");
const errors = ref<Record<string, string[]>>({});
const firstName = ref("");

async function send(event: SubmitEvent) {
  const body = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  errors.value = {};
  try {
    const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
    const result = await response.json();
    if (result.ok) {
      firstName.value = String(body.get("name")).trim().split(" ")[0] ?? "";
      status.value = "sent";
      return;
    }
    errors.value = result.error.fields ?? { form: [result.error.message] };
  } catch {
    errors.value = { form: ["Something went wrong. Email sales@example.com and we'll pick it up."] };
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" />
    <label for="interest">What can we help with?</label>
    <select id="interest" name="interest">
      <option value="pricing">Pricing for my team</option>
      <option value="demo">A live demo</option>
      <option value="custom">A custom plan or contract</option>
      <option value="other">Something else</option>
    </select>
    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4" />
    <input type="hidden" name="page" value="pricing" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="errors.form" role="alert">{{ errors.form[0] }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

- `frm_xxxxxxxxxxxxxxxx`를 대시보드에 있는 폼 ID로 바꾸세요.
- 숨겨진 `_gotcha` 입력은 허니팟입니다. 사람에게는 보이지 않고, 모든 입력을 채우는 봇은 스스로 스팸으로 표시됩니다.
- hidden `page` 입력은 리드가 어느 페이지에서 왔는지 기록합니다. 요금, 기능 페이지, 캠페인 랜딩 페이지 등입니다. 필드를 정의한다면 `page`를 선택 입력 텍스트 필드로 추가하세요. 그러지 않으면 POST가 `Unknown field`로 거부됩니다.
- 서버 버전은 방문자의 `Origin` 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다.
- 일반 HTML 폼이 내 감사 페이지로 이동하려면 **Redirect after submit**이나, **Allowed domains** 중 하나를 가리키는 `_redirect` 입력이 필요합니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

문서에서 각 프레임워크의 설정을 자세히 다룹니다: [Next.js](https://ikiform.com/ko/docs/guides/nextjs.md), [Astro](https://ikiform.com/ko/docs/guides/astro.md), [SvelteKit](https://ikiform.com/ko/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/ko/docs/guides/nuxt.md), [React](https://ikiform.com/ko/docs/guides/react.md), [Vue](https://ikiform.com/ko/docs/guides/vue.md), [서버에서 POST하기](https://ikiform.com/ko/docs/guides/server.md), [일반 HTML](https://ikiform.com/ko/docs/html.md).

## 몇 분 안에 리드에게 연락하기

폼은 쉬운 부분입니다. 매출을 내는 리드 폼과 그냥 모으기만 하는 리드 폼을 가르는 것은 제출부터 사람의 답장까지 걸리는 시간입니다. 잘 동작할 때 그 경로는 이렇습니다.

다이어그램: 리드의 제출부터 첫 답장까지의 시간

1. 리드가 폼을 제출합니다.
2. Ikiform이 저장하고 1초도 안 되어 브라우저에 응답합니다.
3. 알림 이메일과 웹훅이 몇 초 뒤 큐에서 나갑니다.
4. 팀의 누군가가 받은편지함에서 답장을 누르거나 채팅의 mailto 링크를 누르면 답장이 리드에게 갑니다.

이메일까지는 몇 초면 됩니다. 마지막 화살표가 내가 제어하는 부분이고, 거래를 따내는 부분입니다.

### 받은편지함에서 답장하기

각 알림 이메일은 reply-to 주소를 리드로 설정합니다. Ikiform은 먼저 `_replyto` 필드에서, 그다음 `email`, `email_address`, `your_email`, `reply_to`, `contact_email`이라는 이름의 필드에서 주소를 가져옵니다. 필드 ID를 `email`로 유지하면 **Reply**가 Ikiform이 아니라 리드에게 갑니다. 제목은 `New submission:` 뒤에 폼 이름이 붙으므로, 폼 이름을 **Talk to sales**처럼 용도대로 짓고 그 제목으로 필터링해 리드에 라벨이나 별표를 붙이세요.

폼의 알림 설정에서 폼당 최대 10명의 수신자를 추가하세요. 워크스페이스 멤버이거나, 먼저 이메일로 확인을 마친 외부 주소입니다. 아무도 맡지 않는 공유 별칭이 아니라 답장할 사람에게 보내세요. 다섯 개 받은편지함에 있지만 아무도 맡지 않은 리드는 한 받은편지함에 있는 리드보다 오래 기다립니다.

> 알림 이메일은 폼당 시간당 50통, 생성된 지 7일이 안 된 워크스페이스는 10통으로 제한되며, 그 시간의 나머지는 다이제스트 한 통으로 갑니다. 일반적인 리드 폼은 근처에도 가지 않습니다. 그럴 수 있는 캠페인을 운영한다면 웹훅으로 리드를 채팅에도 보내세요.

### 팀이 있는 곳으로 알리기

이메일은 누군가 받은편지함을 맡고 있을 때 효과가 있습니다. 채팅에서 일하는 팀은 리드가 거기 나타날 때 더 빨리 답합니다. [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Microsoft Teams](https://ikiform.com/blog/form-submissions-to-microsoft-teams.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md)를 따라 Slack, Teams, Discord로 가는 웹훅을 추가하세요. 메시지에 `mailto:` 링크를 넣으면 맡은 사람이 클릭 한 번으로 답장할 수 있습니다.

### 요청 내용에 따라 라우팅하기

`interest`는 목록에 있는 값만 받으므로 작은 릴레이가 안심하고 이 값으로 분기할 수 있습니다. 이 라우트는 데모 요청과 계약 문의를 각자의 채널로, 나머지는 메인 영업 채널로 보냅니다.

`app/api/leads/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const CHANNELS: Record<string, string | undefined> = {
  demo: process.env.SLACK_DEMO_WEBHOOK,
  custom: process.env.SLACK_ENTERPRISE_WEBHOOK,
};

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.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 lead = event.data.fields;
  const interest = String(lead.interest ?? "other");
  const target = CHANNELS[interest] ?? process.env.SLACK_SALES_WEBHOOK!;
  const text = [
    `*${lead.name}* from ${lead.company || "an unknown company"} wants: ${interest}`,
    `Reply: mailto:${lead.email}`,
    lead.message ? `> ${String(lead.message).slice(0, 300)}` : "",
  ]
    .filter(Boolean)
    .join("\n");

  const response = await fetch(target, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- `verifyWebhook`이 서명과 타임스탬프를 확인하므로 다른 사람이 가짜 리드를 채널에 올릴 수 없습니다.
- Slack이 다운되어 `502`가 나면 Ikiform이 같은 `webhook-id`로 같은 전달을 약 3일 동안 재시도합니다.
- 옵션의 레이블은 자유롭게 바꾸세요. 릴레이나 자동화가 의존하기 시작하면 값은 바꾸지 마세요.

## 파이프라인에서 쓰레기 걸러 내기

리드 폼에는 특유의 스팸이 몰립니다. SEO, 백링크, 외주 개발을 팔려는 사람들입니다. 가장 빠른 답장을 낭비하게 만드니 누군가에게 닿기 전에 걸러 내세요.

| 방어 | 막는 것 | 설정 |
| --- | --- | --- |
| 허니팟 | 모든 입력을 채우는 봇 | 숨겨진 `_gotcha` 입력 |
| 스팸 점수 | `seo services` 같은 알려진 영업 문구(+2), 이름의 링크(+3), 링크 3개 이상(+2), 반복(+3). 5점이면 스팸입니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 스크립트 | 폼을 방문자당 10분에 2회인 Strict로 설정하세요. 실제 리드가 10분 안에 두 번 제출하는 일은 드뭅니다 |
| 허용 도메인 | 다른 사이트에서 온 POST | **Allowed domains**에 도메인을 추가하세요 |
| 사람 확인 | 실제 브라우저 속 봇 | **Spam check**를 **Ikiform**으로 설정하세요. 그러면 `fetch` 버전에 `challenge()`의 토큰이 필요합니다 |

스팸은 실제 리드와 같은 성공 응답을 받지만 이메일이나 웹훅을 보내지 않고 사용량에도 포함되지 않습니다. 잘못 판정된 리드도 조용히 묻힌다는 뜻입니다. 일주일에 한 번 **Spam** 필터를 확인하세요. 스팸 아님으로 표시하면 그때 이메일과 웹훅이 전송됩니다. [스팸 문서](https://ikiform.com/ko/docs/spam.md#scoring)에 모든 신호가 나와 있습니다.

## 리드가 어디서 오는지 알기

각 제출에는 이미 페이지의 origin과 referrer가 기록되며, 웹훅에 `meta.origin`과 `meta.referrer`로 나타납니다. 캠페인 태그는 약간의 도움이 필요합니다. 방문자는 한 페이지에 도착해 다른 페이지에서 제출하는 경우가 많기 때문입니다. 이 스니펫은 `utm_` 파라미터를 hidden 입력에 복사하고 세션 동안 기억합니다.

`attribution.html`:

```html
<input type="hidden" name="utm_source" />
<input type="hidden" name="utm_medium" />
<input type="hidden" name="utm_campaign" />

<script>
  const params = new URLSearchParams(location.search);
  for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
    let value = params.get(key);
    try {
      if (value) sessionStorage.setItem(key, value);
      else value = sessionStorage.getItem(key);
    } catch {}
    const input = document.querySelector(`#lead-form [name="${key}"]`);
    if (input && value) input.value = value.slice(0, 200);
  }
</script>
```

프레임워크에서는 서버에서 URL의 같은 파라미터를 읽어 hidden 입력으로 렌더링하세요. 필드를 정의했다면 각 `utm_` ID를 선택 입력 텍스트 필드로 추가하세요. `_`로 시작하는 이름은 저장되지 않으므로 남기고 싶은 데이터에는 쓰지 마세요.

## 리드가 다음에 보는 것

Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 나와 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 그런 이메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 트리거해 이메일 도구에서 보내세요. 리드 폼에서는 그래도 괜찮은 경우가 많습니다. 빠른 개인 답장이 자동 답장보다 낫습니다. 그래도 즉시 접수 확인을 보내고 싶다면 웹훅이 연락처를 만들 때 CRM이나 이메일 도구에서 보내세요. 그러면 답장과 수신 거부가 한곳에서 추적됩니다.

무엇을 보내든 페이지의 성공 메시지가 약속입니다. 누가 언제까지 답하는지 말하고 지키세요. 영업일 하루라고 하고 10분 만에 답하는 폼은 신뢰를 얻고, 10분이라고 하고 다음 날 답하는 폼은 신뢰를 잃습니다.

## CRM의 리드

원클릭 연동은 없습니다. 모든 도구는 서명된 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 직접, 또는 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) 같은 자동화 도구를 거쳐 연결합니다. CRM이라면 릴레이가 Ikiform의 고정된 웹훅 본문을 CRM의 연락처 생성 호출로 매핑합니다. [HubSpot이나 Pipedrive로 리드 보내기](https://ikiform.com/ko/guides/lead-capture/leads-to-crm.md)에서 중복과 재시도를 포함해 둘 다 다룹니다. CRM이 필요해지기 전까지는 대시보드와 주간 [CSV 내보내기](https://ikiform.com/ko/docs/guides/export.md)로도 충분한 파이프라인입니다.

## 상황별 가이드

위 구성은 어떤 리드에도 맞습니다. 아래 페이지들은 특정 종류의 리드에서 달라지는 점과, 리드를 CRM에 넣는 방법을 다룹니다.

- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
- [프로젝트 문의 폼](https://ikiform.com/ko/guides/lead-capture/project-inquiry.md): 누군가 브리프를 읽기 전에 예산과 시기로 문의를 유력, 관심, 부적합으로 분류하는 스튜디오와 프리랜서용 프로젝트 의뢰 폼입니다.
- [HubSpot이나 Pipedrive로 리드 보내기](https://ikiform.com/ko/guides/lead-capture/leads-to-crm.md): 서명된 Ikiform 웹훅을 HubSpot의 연락처 upsert나 Pipedrive의 person, lead 호출로 매핑하고, 전달이 재시도되어도 연락처가 중복되지 않게 합니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
- [Astro 리드 폼](https://ikiform.com/ko/guides/lead-capture/astro.md): Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.
- [TanStack Start 리드 폼](https://ikiform.com/ko/guides/lead-capture/tanstack-start.md): TanStack Start 리드 폼입니다. 첫 유입 UTM을 위한 전역 요청 미들웨어, Zod를 쓰는 서버 함수, 스케줄러로 던지는 리디렉션으로 구성합니다.
- [React Router 리드 폼](https://ikiform.com/ko/guides/lead-capture/react-router.md): React Router 프레임워크 모드 리드 폼입니다. 서명된 첫 유입 쿠키를 쓰는 루트 미들웨어, 검증하는 라우트 액션, 스케줄러로의 리디렉션으로 구성합니다.
- [SvelteKit 리드 폼](https://ikiform.com/ko/guides/lead-capture/sveltekit.md): SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.
- [Nuxt 리드 폼](https://ikiform.com/ko/guides/lead-capture/nuxt.md): Nuxt 리드 폼입니다. 서버 미들웨어가 첫 유입 UTM을 기록하고, Zod로 검증하는 서버 라우트 하나가 fetch와 일반 POST를 모두 처리하며, 좋은 리드는 통화를 예약합니다.

## 작동 확인하기

페이지에 트래픽을 보내기 전에 아래를 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 페이지에서 폼을 작성해 제출하세요. 여러분의 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
3. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
4. 각 수신자의 받은편지함을 확인하세요. 제목은 `New submission:` 뒤에 폼 이름이 붙고, **Reply**를 누르면 입력한 주소로 메시지가 열립니다.
5. 웹훅을 추가했다면 **Webhooks**에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 `succeeded`로 표시됩니다.
6. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
7. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꾸세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.
8. 시간을 재세요. 휴대폰에서 제출하고 이메일과 채팅 메시지가 도착하기까지 얼마나 걸리는지 기록하세요.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| **Reply**가 리드가 아니라 Ikiform 주소로 감 | 폼에 Ikiform이 리드의 이메일로 인식하는 필드가 없습니다. 필드 이름을 `email`로 바꾸거나 주소를 담은 `_replyto` 필드를 보내세요. 인식되는 이름: `email`, `email_address`, `your_email`, `reply_to`, `contact_email`. |
| `page`나 `utm_source`에 대해 `Unknown field`와 함께 `422` | 폼에 필드 정의가 있는데 hidden 입력이 거기에 없습니다. 선택 입력 텍스트 필드로 추가하거나 입력을 제거하세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| `403 challenge_required` | 폼의 스팸 검사가 **Ikiform**으로 설정되어 있는데 `fetch` POST에 토큰이 없었습니다. 제출할 때마다 `@ikiform/sdk`의 `challenge(formId)`로 토큰을 받거나 검사를 끄세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| 수신자가 알림 이메일을 받지 못함 | 워크스페이스 밖의 주소는 Ikiform이 보내는 이메일로 먼저 확인해야 합니다. 확인되었는지 보고, 그다음 수신자의 스팸함을 확인하세요. |

## 질문

### 리드 수집 폼이란 무엇인가요?

팀이 후속 연락을 할 수 있도록 잠재 고객의 연락처와 원하는 것을 모으는 웹사이트의 폼입니다. 일반 문의 폼과 달리 대개 선택형 질문 하나로 리드의 자격을 확인하고, 영업팀에 빠르게 닿도록 연결되어 있습니다.

### 리드 폼 제출을 이메일로 받으려면 어떻게 하나요?

폼을 Ikiform 엔드포인트로 지정하고 폼의 알림 이메일을 켜 두세요. 각 리드가 최대 10명의 수신자에게 몇 초 안에 도착하며, 이메일 필드 이름이 email이거나 폼이 _replyto를 보내면 답장이 리드에게 갑니다.

### 리드 수집 폼에는 필드가 몇 개 있어야 하나요?

잘 답할 수 있을 만큼만 두세요. 보통 이름, 이메일, 자격 확인 질문 하나, 선택 입력인 메시지입니다. 전화번호, 예산, 팀 규모는 그 답이 팀의 다음 행동을 바꿀 때만 추가하세요.

### Ikiform이 HubSpot, Pipedrive, Salesforce로 리드를 보낼 수 있나요?

웹훅을 통해서라면 가능합니다. 원클릭 연동은 없으므로 작은 릴레이나 Zapier, Make, n8n 같은 자동화 도구가 서명된 웹훅을 CRM의 API로 매핑해 연락처나 리드를 만듭니다.

### 리드가 확인 이메일을 받나요?

Ikiform에서는 받지 않습니다. Ikiform은 폼을 작성한 사람이 아니라 팀에 이메일을 보냅니다. 접수 확인은 웹훅으로 트리거해 CRM이나 이메일 도구에서 보내거나, 알림 이메일에서 직접 답장하세요.

### Gmail 같은 무료 이메일 주소를 차단할 수 있나요?

기본 설정으로는 안 됩니다. 이메일 필드의 허용 도메인 옵션은 허용 목록이라서 공개 폼이 아니라 내부 폼에 맞습니다. 모든 주소를 받고 무료 메일 리드는 릴레이나 CRM에서 분류하세요. 실제 구매자도 개인 주소를 많이 씁니다.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
- [프로젝트 문의 폼](https://ikiform.com/ko/guides/lead-capture/project-inquiry.md): 누군가 브리프를 읽기 전에 예산과 시기로 문의를 유력, 관심, 부적합으로 분류하는 스튜디오와 프리랜서용 프로젝트 의뢰 폼입니다.
- [HubSpot이나 Pipedrive로 리드 보내기](https://ikiform.com/ko/guides/lead-capture/leads-to-crm.md): 서명된 Ikiform 웹훅을 HubSpot의 연락처 upsert나 Pipedrive의 person, lead 호출로 매핑하고, 전달이 재시도되어도 연락처가 중복되지 않게 합니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
- [Astro 리드 폼](https://ikiform.com/ko/guides/lead-capture/astro.md): Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.
- [TanStack Start 리드 폼](https://ikiform.com/ko/guides/lead-capture/tanstack-start.md): TanStack Start 리드 폼입니다. 첫 유입 UTM을 위한 전역 요청 미들웨어, Zod를 쓰는 서버 함수, 스케줄러로 던지는 리디렉션으로 구성합니다.
- [React Router 리드 폼](https://ikiform.com/ko/guides/lead-capture/react-router.md): React Router 프레임워크 모드 리드 폼입니다. 서명된 첫 유입 쿠키를 쓰는 루트 미들웨어, 검증하는 라우트 액션, 스케줄러로의 리디렉션으로 구성합니다.
- [SvelteKit 리드 폼](https://ikiform.com/ko/guides/lead-capture/sveltekit.md): SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.
- [Nuxt 리드 폼](https://ikiform.com/ko/guides/lead-capture/nuxt.md): Nuxt 리드 폼입니다. 서버 미들웨어가 첫 유입 UTM을 기록하고, Zod로 검증하는 서버 라우트 하나가 fetch와 일반 POST를 모두 처리하며, 좋은 리드는 통화를 예약합니다.
- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [HTML 폼 제출](https://ikiform.com/ko/guides/html-form-submission.md): 폼이 제출될 때 브라우저가 하는 일: 어떤 필드를 보내는지, 어떻게 인코딩하는지, 서버가 어떻게 응답해야 하는지, 그리고 Ikiform이 어떻게 응답하는지 설명합니다.
- [행사 등록](https://ikiform.com/ko/guides/event-registration.md): 티켓 앱 없이 내 행사 페이지에서 RSVP를 받는 방법: 폼, 동반 인원 제한, 정확한 인원 집계, 마감, 체크인 명단.
