# 내 사이트에서 받는 행사 등록과 참석 회신(RSVP) 폼

> 티켓 앱 없이 내 행사 페이지에서 RSVP를 받는 방법: 폼, 동반 인원 제한, 정확한 인원 집계, 마감, 체크인 명단.

2026-10-25 게시 · https://ikiform.com/ko/guides/event-registration

행사 페이지에 폼 백엔드로 POST하는 RSVP 폼을 두고, 참석 여부, 서버에서 상한을 거는 동반 인원 수, 준비에 필요한 정보를 받으세요. 인원은 CSV 내보내기나 짧은 스크립트로 이메일별 최신 응답을 기준으로 세고, 자리가 차면 폼을 일시 중지하세요. Ikiform은 모든 RSVP를 저장하고 전달하지만 티켓, 결제, 자동 정원 제한은 없으므로, 이런 기능이 필요하면 티켓 플랫폼을 쓰세요.

대부분의 행사에는 티켓 플랫폼이 필요 없습니다. 밋업, 워크숍, 출시 파티, 독서 모임, 무료 컨퍼런스 하루에 필요한 것은 세 가지입니다. 무엇을 언제 하는지 알려 주는 페이지, 누가 오는지 받는 폼, 그리고 행사 전날 믿을 수 있는 숫자입니다. 이 가이드는 이것을 여러분의 사이트에 만들고, 폼으로는 할 수 없는 부분도 분명히 짚습니다.

## RSVP가 전달되는 흐름

폼은 행사 페이지에 있습니다. 각 RSVP는 Ikiform으로 POST되고, Ikiform은 필드 규칙에 맞는지 확인하고, 스팸을 거르고, 저장한 뒤 알려 줍니다. 그다음에는 대시보드, 스프레드시트, 인원을 세는 스크립트 등 여러분이 준비에 쓰는 곳으로 이어집니다.

다이어그램: RSVP가 행사 페이지에서 인원 집계까지 가는 과정

1. 손님이 행사 페이지에서 RSVP 폼을 작성하면, 폼이 여러분의 Ikiform 엔드포인트로 POST합니다.
2. Ikiform은 동반 인원 제한을 포함해 필드를 확인하고, 저장하기 전에 POST의 스팸 점수를 매깁니다.
3. 모든 RSVP는 대시보드에 표시되며, 필터링하거나 CSV로 내보낼 수 있습니다.
4. 주최 측은 RSVP마다 이메일을 받고, 답장하면 바로 손님에게 갑니다.
5. Ikiform이 서명된 submission.created 웹훅을 보냅니다.
6. 시트나 짧은 스크립트가 각 사람의 최신 응답을 기준으로 RSVP를 인원수로 바꿉니다.

Ikiform은 응답을 저장하고, 인원 집계는 그 응답들을 대상으로 직접 계산하는 합계입니다. 티켓 앱이라면 대신 해 줄 단계입니다.

## Ikiform이 행사에 대해 하지 않는 일

고르기 전에 분명히 알아 두세요. 폼 백엔드는 응답을 모읍니다. 티켓 시스템이 아닙니다.

- **정원 제한 없음.** RSVP나 손님 수가 일정 수에 도달하면 폼을 닫는 설정은 없습니다. 숫자를 지켜보다가 직접 폼을 일시 중지해야 합니다.
- **티켓이나 결제 없음.** QR 코드, 체크인 앱, 유료 등급, 환불이 없습니다.
- **손님에게 보내는 이메일 없음.** Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 이런 이메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 트리거해 이메일 도구에서 보내세요.
- **대기자 명단 없음.** 폼을 일시 중지하면 새 POST는 거부됩니다. 대기자 명단이 필요하다면 그 자리에 두 번째 폼을 올려야 합니다.
- **중복 제거 없음.** Ikiform은 이메일 기준으로 반복 제출을 합치거나 거부하지 않습니다. 24시간 안에 똑같은 제출이 반복되면 스팸 점수만 올라갑니다. 명단을 보관하는 곳이나 내보낼 때 중복을 제거하세요.

각 항목에는 아래에 쓸 만한 해결책이 있습니다. 이 중 여러 개가 결정적인 문제라면 티켓 플랫폼이 더 나은 도구입니다.

## RSVP를 받는 방법

| 방식 | 잘하는 것 | 단점 |
| --- | --- | --- |
| Eventbrite나 Luma 같은 티켓 플랫폼 | 티켓, 결제, 정원 제한, 대기자 명단, 확인 이메일, 체크인까지 모두 포함 | 행사 페이지와 참석자 명단이 해당 플랫폼에 그들의 브랜딩으로 남습니다. 유료 티켓에는 수수료가 붙으니 요금 페이지를 확인하세요 |
| Google Forms | 무료이고 빠릅니다. 응답 받기를 중지하는 스위치가 있습니다 | 행사가 아니라 Google처럼 보입니다. 별도 페이지이고, 서버에서 확인하는 동반 인원 제한이 없습니다 |
| 캘린더 초대 또는 단체 채팅 | 친구 스무 명이라면 괜찮습니다 | 믿을 만한 숫자도, 식이 정보도, 당일 명단도 없습니다 |
| 내 폼과 Ikiform 같은 폼 백엔드 | 내 페이지, 내 디자인, 원하는 필드. 서버에서 동반 인원 제한 확인, 스팸 필터링, 주최 측 이메일 알림, CSV와 웹훅 | 인원 집계, 등록 마감, 확인 이메일 발송은 직접 하거나 이메일 도구로 가는 웹훅으로 처리해야 합니다 |

유료 행사라면 티켓 플랫폼을 쓰세요. 페이지와 명단을 직접 소유해야 하는 무료 행사라면, 이 가이드의 나머지가 바로 그 설정입니다. 둘의 자세한 비교는 [Eventbrite 페이지](https://ikiform.com/ko/guides/event-registration/without-eventbrite.md)를 참고하세요.

## 준비를 가능하게 하는 필드

실제로 활용할 것만 묻고 나머지는 묻지 마세요. 여기 있는 질문은 모두 주문하거나 예약하거나 준비하는 무언가를 바꿉니다. 직접 해 보세요. 미리보기는 브라우저에서 Ikiform의 검증기를 실행하므로, 네 번째 동반 손님이 받게 될 `422`를 확인할 수 있습니다.

폼: Meetup RSVP

- **이름** (`name`, text, 필수)
- **이메일** (`email`, email, 필수)
- **참석하시나요?** (`attending`, radio, 필수)
- **함께 오는 손님 수** (`guests`, number)
- **식이 제한** (`dietary`, select)
- **접근성 요청 또는 기타 사항** (`access_needs`, textarea)

이 정의를 폼의 **Fields** 탭에 붙여 넣으면 Ikiform이 어느 페이지에서 오든 모든 POST에 이를 적용합니다. [행사 RSVP 템플릿](https://ikiform.com/forms/event-rsvp)에는 코드가 생성된 더 짧은 버전이 있습니다.

| 필드 | 이유 | 유의할 점 |
| --- | --- | --- |
| `attending` | 불참도 정보입니다. 사람들을 쫓아다니며 묻지 않아도 됩니다 | `yes`와 `no`를 가진 `radio`이므로 집계는 필터 하나로 끝납니다 |
| `guests` | 음식과 의자를 준비하는 기준이 되는 숫자 | `min: 0`, `max: 3`, `integer: true`인 `number`. Ikiform은 서버에서 `5`와 `1.5`를 거부합니다 |
| `dietary` | 케이터링 수량 | `select`는 깔끔하게 합산됩니다. 자유 텍스트는 그렇지 않습니다 |
| `access_needs` | 계단 없는 동선, 좌석, 조용한 방 | 선택 항목. 행사 전에 모두 읽어 보세요 |
| `email` | 변경 사항 안내와 바뀐 응답 매칭 | 모든 알림 이메일의 회신 주소이기도 합니다 |

> 행사에 필요한 것만 물으세요. 식이와 접근성 응답은 건강 정보일 수 있습니다. 폼에 묻는 이유를 적고, 내보낸 파일은 비공개 장소에 보관하고, 행사가 끝나면 제출을 삭제하세요.

## RSVP 폼

스택을 고르세요. 각 버전은 참석과 불참에 서로 다른 감사 문구를 보여 주고, 불참이면 동반 손님 질문을 숨기며, 오지 않는 사람의 `guests`를 `0`으로 보내므로 합계가 정확하게 유지됩니다.

**HTML**

폼을 행사 페이지에 붙여 넣으세요. 일반적인 방식으로 POST하고 확인 페이지로 리디렉션하므로 JavaScript 없이도 작동합니다. 짧은 스크립트가 불참을 고르면 동반 손님 질문을 숨기고 동반 인원을 `0`으로 되돌립니다.

`event.html`:

```html
<form id="rsvp" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>
  <fieldset>
    <legend>Can you make it?</legend>
    <label><input type="radio" name="attending" value="yes" required> Yes</label>
    <label><input type="radio" name="attending" value="no"> No</label>
  </fieldset>
  <div id="if-going">
    <label for="guests">Guests you're bringing</label>
    <input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0">
    <label for="dietary">Dietary needs</label>
    <select id="dietary" name="dietary">
      <option value="none">None</option>
      <option value="vegetarian">Vegetarian</option>
      <option value="vegan">Vegan</option>
      <option value="gluten_free">Gluten free</option>
      <option value="other">Other, tell us below</option>
    </select>
    <label for="access_needs">Access needs or anything else</label>
    <textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
  </div>
  <input type="hidden" name="_redirect" value="https://example.com/meetup/registered">
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
  <button type="submit">Send RSVP</button>
</form>
<script>
  const rsvp = document.getElementById("rsvp");
  const extra = document.getElementById("if-going");
  rsvp.addEventListener("change", () => {
    const no = rsvp.elements.attending.value === "no";
    extra.hidden = no;
    if (no) rsvp.elements.guests.value = "0";
  });
</script>
```

**Next.js**

서버 액션이 시크릿 키로 RSVP를 전달하고, 브라우저가 무엇을 보냈든 불참이면 `guests`를 `0`으로 설정합니다. 컴포넌트는 응답에 따라 폼을 다른 문구로 바꿉니다.

`app/rsvp/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface RsvpState {
  status: "idle" | "going" | "declined" | "error";
  message: string;
}

export async function sendRsvp(_previous: RsvpState, formData: FormData): Promise<RsvpState> {
  const attending = formData.get("attending") === "no" ? "no" : "yes";
  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({
      name: String(formData.get("name") ?? "").trim(),
      email: String(formData.get("email") ?? "").trim(),
      attending,
      guests: attending === "yes" ? Number(formData.get("guests") || 0) : 0,
      dietary: String(formData.get("dietary") ?? "none"),
      access_needs: String(formData.get("access_needs") ?? ""),
      _gotcha: String(formData.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) {
    return attending === "yes"
      ? { status: "going", message: "You're registered. See you on 14 March at 6:30 pm." }
      : { status: "declined", message: "Thanks for letting us know. We'll miss you." };
  }
  const fields: Record<string, string[]> = result?.error?.fields ?? {};
  const [field, problems] = Object.entries(fields)[0] ?? [];
  return {
    status: "error",
    message: field ? `${field}: ${problems?.[0]}` : (result?.error?.message ?? "Couldn't save your RSVP. Try again."),
  };
}
```

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

```tsx
"use client";

import { useActionState, useState } from "react";
import { type RsvpState, sendRsvp } from "./actions";

const initial: RsvpState = { status: "idle", message: "" };

export function RsvpForm() {
  const [state, action, pending] = useActionState(sendRsvp, initial);
  const [attending, setAttending] = useState("yes");

  if (state.status === "going" || state.status === "declined") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <fieldset>
        <legend>Can you make it?</legend>
        <label>
          <input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
        </label>
        <label>
          <input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
        </label>
      </fieldset>
      {attending === "yes" ? (
        <>
          <label htmlFor="guests">Guests you're bringing</label>
          <input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
          <label htmlFor="dietary">Dietary needs</label>
          <select id="dietary" name="dietary" defaultValue="none">
            <option value="none">None</option>
            <option value="vegetarian">Vegetarian</option>
            <option value="vegan">Vegan</option>
            <option value="gluten_free">Gluten free</option>
            <option value="other">Other, tell us below</option>
          </select>
          <label htmlFor="access_needs">Access needs or anything else</label>
          <textarea id="access_needs" name="access_needs" rows={3} maxLength={1000} />
        </>
      ) : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send RSVP"}</button>
    </form>
  );
}
```

**Astro**

`accept: "form"`을 쓴 Astro Action이 무엇이든 전달하기 전에 동반 인원 제한을 포함해 Zod로 응답을 확인합니다. `z.coerce.number()`가 폼의 텍스트를 숫자로 바꿉니다. 페이지는 요청 시 렌더링되므로 `prerender = false`와 어댑터를 유지하세요.

`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 = {
  rsvp: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      attending: z.enum(["yes", "no"]),
      guests: z.coerce.number().int().min(0).max(3).default(0),
      dietary: z.enum(["none", "vegetarian", "vegan", "gluten_free", "other"]).default("none"),
      access_needs: z.string().max(1000).default(""),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const body = { ...input, guests: input.attending === "yes" ? input.guests : 0 };
      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(body),
      });
      const result = await response.json();
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
      return { attending: input.attending };
    },
  }),
};
```

`src/pages/rsvp.astro`:

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

export const prerender = false;

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

{result?.data ? (
  <p role="status">{result.data.attending === "yes" ? "You're registered. See you on 14 March at 6:30 pm." : "Thanks for letting us know. We'll miss you."}</p>
) : (
  <form method="POST" action={actions.rsvp}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <fieldset>
      <legend>Can you make it?</legend>
      <label><input type="radio" name="attending" value="yes" checked /> Yes</label>
      <label><input type="radio" name="attending" value="no" /> No</label>
    </fieldset>
    <label for="guests">Guests you're bringing</label>
    <input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
    {fieldErrors.guests && <p role="alert">{fieldErrors.guests.join(" ")}</p>}
    <label for="dietary">Dietary needs</label>
    <select id="dietary" name="dietary">
      <option value="none">None</option>
      <option value="vegetarian">Vegetarian</option>
      <option value="vegan">Vegan</option>
      <option value="gluten_free">Gluten free</option>
      <option value="other">Other, tell us below</option>
    </select>
    <label for="access_needs">Access needs or anything else</label>
    <textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
    <button>Send RSVP</button>
  </form>
)}
```

**TanStack Start**

POST 서버 함수가 `.validator()`에서 동반 인원을 확인하고 불참이면 0으로 만듭니다. 컴포넌트는 참석을 고르기 전까지 동반 손님 질문을 숨깁니다.

`src/routes/rsvp.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";

const sendRsvp = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const attending = data.get("attending") === "no" ? "no" : "yes";
    const guests = attending === "yes" ? Number(data.get("guests") || 0) : 0;
    if (!Number.isInteger(guests) || guests < 0 || guests > 3) throw new Error("Bring up to 3 guests.");
    return {
      name: String(data.get("name") ?? "").trim(),
      email: String(data.get("email") ?? "").trim(),
      attending,
      guests,
      dietary: String(data.get("dietary") ?? "none"),
      access_needs: String(data.get("access_needs") ?? ""),
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data }) => {
    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 { done: false, message: String(result.error.message) };
    return { done: true, message: data.attending === "yes" ? "You're registered. See you on 14 March at 6:30 pm." : "Thanks for letting us know. We'll miss you." };
  });

export const Route = createFileRoute("/rsvp")({ component: Rsvp });

function Rsvp() {
  const send = useServerFn(sendRsvp);
  const [attending, setAttending] = useState("yes");
  const [state, setState] = useState({ done: false, message: "" });
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setState(await send({ data: new FormData(event.currentTarget) }));
    } catch (error) {
      setState({ done: false, message: error instanceof Error ? error.message : "Couldn't save your RSVP." });
    } finally {
      setPending(false);
    }
  }

  if (state.done) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <fieldset>
        <legend>Can you make it?</legend>
        <label>
          <input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
        </label>
        <label>
          <input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
        </label>
      </fieldset>
      {attending === "yes" ? (
        <>
          <label htmlFor="guests">Guests you're bringing</label>
          <input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
          <label htmlFor="dietary">Dietary needs</label>
          <select id="dietary" name="dietary" defaultValue="none">
            <option value="none">None</option>
            <option value="vegetarian">Vegetarian</option>
            <option value="vegan">Vegan</option>
            <option value="gluten_free">Gluten free</option>
            <option value="other">Other, tell us below</option>
          </select>
          <label htmlFor="access_needs">Access needs or anything else</label>
          <textarea id="access_needs" name="access_needs" rows={3} maxLength={1000} />
        </>
      ) : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send RSVP"}</button>
    </form>
  );
}
```

**React Router**

라우트 `action`이 RSVP를 전달하고 응답을 반환하므로, 컴포넌트가 참석과 불참에 서로 다르게 감사 인사를 할 수 있습니다. `data()`는 오류 시 Ikiform의 상태 코드를 유지합니다.

`app/routes/rsvp.tsx`:

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

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const attending = form.get("attending") === "no" ? "no" : "yes";
  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({
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      attending,
      guests: attending === "yes" ? Number(form.get("guests") || 0) : 0,
      dietary: String(form.get("dietary") ?? "none"),
      access_needs: String(form.get("access_needs") ?? ""),
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json();
  if (result.ok) return { attending, error: "" };
  const fields: Record<string, string[]> = result.error.fields ?? {};
  const first = Object.values(fields)[0]?.[0];
  return data({ attending: null, error: String(first ?? result.error.message) }, { status: response.status });
}

export default function Rsvp({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";
  if (actionData?.attending === "yes") return <p role="status">You're registered. See you on 14 March at 6:30 pm.</p>;
  if (actionData?.attending === "no") return <p role="status">Thanks for letting us know. We'll miss you.</p>;

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <fieldset>
        <legend>Can you make it?</legend>
        <label><input type="radio" name="attending" value="yes" defaultChecked /> Yes</label>
        <label><input type="radio" name="attending" value="no" /> No</label>
      </fieldset>
      <label htmlFor="guests">Guests you're bringing</label>
      <input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
      <label htmlFor="dietary">Dietary needs</label>
      <select id="dietary" name="dietary" defaultValue="none">
        <option value="none">None</option>
        <option value="vegetarian">Vegetarian</option>
        <option value="vegan">Vegan</option>
        <option value="gluten_free">Gluten free</option>
        <option value="other">Other, tell us below</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData?.error ? <p role="alert">{actionData.error}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send RSVP"}</button>
    </Form>
  );
}
```

**SvelteKit**

기본 폼 액션이 전달하기 전에 `fail()`로 동반 인원 제한을 확인하고, 오류가 나면 입력한 이름과 이메일을 유지합니다. `bind:group`은 참석일 때만 동반 손님 질문을 보여 줍니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요.

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

```ts
import { fail } 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 form = await request.formData();
    const attending = form.get("attending") === "no" ? "no" : "yes";
    const guests = attending === "yes" ? Number(form.get("guests") || 0) : 0;
    const name = String(form.get("name") ?? "").trim();
    const email = String(form.get("email") ?? "").trim();
    if (!Number.isInteger(guests) || guests < 0 || guests > 3) {
      return fail(400, { name, email, message: "You can bring up to 3 guests." });
    }

    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({
        name,
        email,
        attending,
        guests,
        dietary: String(form.get("dietary") ?? "none"),
        access_needs: String(form.get("access_needs") ?? ""),
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    });
    const result = await response.json();
    if (!result.ok) return fail(response.status, { name, email, message: String(result.error.message) });
    return { attending };
  },
} satisfies Actions;
```

`src/routes/rsvp/+page.svelte`:

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

  let { form }: PageProps = $props();
  let attending = $state("yes");
</script>

{#if form?.attending === "yes"}
  <p role="status">You're registered. See you on 14 March at 6:30 pm.</p>
{:else if form?.attending === "no"}
  <p role="status">Thanks for letting us know. We'll miss you.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <fieldset>
      <legend>Can you make it?</legend>
      <label><input type="radio" name="attending" value="yes" bind:group={attending} /> Yes</label>
      <label><input type="radio" name="attending" value="no" bind:group={attending} /> No</label>
    </fieldset>
    {#if attending === "yes"}
      <label for="guests">Guests you're bringing</label>
      <input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
      <label for="dietary">Dietary needs</label>
      <select id="dietary" name="dietary">
        <option value="none">None</option>
        <option value="vegetarian">Vegetarian</option>
        <option value="vegan">Vegan</option>
        <option value="gluten_free">Gluten free</option>
        <option value="other">Other, tell us below</option>
      </select>
    {/if}
    <label for="access_needs">Access needs or anything else</label>
    <textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}<p role="alert">{form.message}</p>{/if}
    <button>Send RSVP</button>
  </form>
{/if}
```

**Nuxt**

서버 라우트가 시크릿 키를 보관합니다. `NUXT_IKIFORM_SECRET_KEY`를 설정하고 `nuxt.config.ts`의 `runtimeConfig` 아래에 `ikiformSecretKey`를 선언하세요. 컴포넌트는 `$fetch`로 반응형 객체를 보냅니다.

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

```ts
interface RsvpBody {
  name?: string;
  email?: string;
  attending?: string;
  guests?: number;
  dietary?: string;
  _gotcha?: string;
}

export default defineEventHandler(async (event) => {
  const body = await readBody<RsvpBody>(event);
  const config = useRuntimeConfig(event);
  const attending = body.attending === "no" ? "no" : "yes";
  const origin = getRequestHeader(event, "origin");

  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({
      name: body.name?.trim() ?? "",
      email: body.email?.trim() ?? "",
      attending,
      guests: attending === "yes" ? Number(body.guests ?? 0) : 0,
      dietary: body.dietary ?? "none",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
    });
  }
  return { attending };
});
```

`app/components/RsvpForm.vue`:

```vue
<script setup lang="ts">
const rsvp = reactive({ name: "", email: "", attending: "yes", guests: 0, dietary: "none", _gotcha: "" });
const answer = ref<"yes" | "no" | null>(null);
const error = ref("");
const sending = ref(false);

async function send() {
  sending.value = true;
  error.value = "";
  try {
    const result = await $fetch<{ attending: "yes" | "no" }>("/api/rsvp", { method: "POST", body: rsvp });
    answer.value = result.attending;
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't save your RSVP.";
  }
  sending.value = false;
}
</script>

<template>
  <p v-if="answer === 'yes'" role="status">You're registered. See you on 14 March at 6:30 pm.</p>
  <p v-else-if="answer === 'no'" role="status">Thanks for letting us know. We'll miss you.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="rsvp.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="rsvp.email" type="email" autocomplete="email" required />
    <fieldset>
      <legend>Can you make it?</legend>
      <label><input v-model="rsvp.attending" type="radio" value="yes" /> Yes</label>
      <label><input v-model="rsvp.attending" type="radio" value="no" /> No</label>
    </fieldset>
    <template v-if="rsvp.attending === 'yes'">
      <label for="guests">Guests you're bringing</label>
      <input id="guests" v-model.number="rsvp.guests" type="number" min="0" max="3" step="1" />
      <label for="dietary">Dietary needs</label>
      <select id="dietary" v-model="rsvp.dietary">
        <option value="none">None</option>
        <option value="vegetarian">Vegetarian</option>
        <option value="vegan">Vegan</option>
        <option value="gluten_free">Gluten free</option>
        <option value="other">Other</option>
      </select>
    </template>
    <input v-model="rsvp._gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send RSVP" }}</button>
  </form>
</template>
```

**React**

싱글 페이지 앱은 브라우저에서 바로 POST합니다. 여기에는 절대 시크릿 키를 넣지 마세요. 폼은 **Allowed domains**(허용 도메인)가 보호합니다. 불참이면 POST 전에 `guests`를 초기화하고, 나머지는 Ikiform의 `max` 규칙이 잡아냅니다.

`src/RsvpForm.tsx`:

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

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

export function RsvpForm() {
  const [attending, setAttending] = useState("yes");
  const [answer, setAnswer] = useState<"yes" | "no" | null>(null);
  const [error, setError] = useState("");
  const [sending, setSending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    if (body.get("attending") === "no") body.set("guests", "0");
    setSending(true);
    setError("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) setAnswer(body.get("attending") === "no" ? "no" : "yes");
      else setError(result.error.fields?.guests?.[0] ?? result.error.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    }
    setSending(false);
  }

  if (answer === "yes") return <p role="status">You're registered. See you on 14 March at 6:30 pm.</p>;
  if (answer === "no") return <p role="status">Thanks for letting us know. We'll miss you.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <fieldset>
        <legend>Can you make it?</legend>
        <label>
          <input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
        </label>
        <label>
          <input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
        </label>
      </fieldset>
      <div hidden={attending === "no"}>
        <label htmlFor="guests">Guests you're bringing</label>
        <input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
        <label htmlFor="dietary">Dietary needs</label>
        <select id="dietary" name="dietary" defaultValue="none">
          <option value="none">None</option>
          <option value="vegetarian">Vegetarian</option>
          <option value="vegan">Vegan</option>
          <option value="gluten_free">Gluten free</option>
          <option value="other">Other</option>
        </select>
      </div>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send RSVP"}</button>
    </form>
  );
}
```

**Vue**

같은 브라우저 측 POST를 Vue 싱글 파일 컴포넌트로 작성한 버전으로, 동반 손님 질문은 `v-show` 뒤에 있습니다.

`src/components/RsvpForm.vue`:

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

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const attending = ref("yes");
const answer = ref<"yes" | "no" | null>(null);
const error = ref("");
const sending = ref(false);

async function send(event: Event) {
  const body = new FormData(event.target as HTMLFormElement);
  if (attending.value === "no") body.set("guests", "0");
  sending.value = true;
  error.value = "";
  try {
    const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
    const result = await response.json();
    if (result.ok) answer.value = attending.value === "no" ? "no" : "yes";
    else error.value = result.error.fields?.guests?.[0] ?? result.error.message;
  } catch {
    error.value = "Couldn't reach the server. Try again.";
  }
  sending.value = false;
}
</script>

<template>
  <p v-if="answer === 'yes'" role="status">You're registered. See you on 14 March at 6:30 pm.</p>
  <p v-else-if="answer === 'no'" role="status">Thanks for letting us know. We'll miss you.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <fieldset>
      <legend>Can you make it?</legend>
      <label><input v-model="attending" type="radio" name="attending" value="yes" /> Yes</label>
      <label><input v-model="attending" type="radio" name="attending" value="no" /> No</label>
    </fieldset>
    <div v-show="attending === 'yes'">
      <label for="guests">Guests you're bringing</label>
      <input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
      <label for="dietary">Dietary needs</label>
      <select id="dietary" name="dietary">
        <option value="none">None</option>
        <option value="vegetarian">Vegetarian</option>
        <option value="vegan">Vegan</option>
        <option value="gluten_free">Gluten free</option>
        <option value="other">Other</option>
      </select>
    </div>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send RSVP" }}</button>
  </form>
</template>
```

> 서버 측 버전은 시크릿 키로 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` 테스트 제출을 집계하기 때문입니다.

문서의 프레임워크 가이드에서 더 자세히 다룹니다: [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), [일반 HTML](https://ikiform.com/ko/docs/html.md). 사이트 빌더에서는 HTML 버전을 임베드 블록에 붙여 넣으면 됩니다.

## 최신 응답으로 인원 세기

사람들은 마음을 바꿉니다. 월요일에 동반 2명과 함께 참석한다고 했다가 목요일에 불참한다고 할 수 있습니다. 제출은 두 건이고, 사실은 두 번째입니다. 그래서 규칙은 이렇습니다. 이메일당 가장 최신 응답 하나만 남기고, 본인 1명에 동반 인원을 더합니다.

다이어그램: 인원 집계에 각 사람의 최신 RSVP를 쓰는 이유

1. 월요일: Ada가 동반 2명과 참석으로 응답합니다. 모든 제출을 더하면 3명으로 셉니다.
2. 수요일: Ben이 동반 없이 참석으로 응답합니다.
3. 목요일: Ada가 폼을 다시 보내 불참이라고 합니다. Ikiform은 이를 Ada의 두 번째 제출로 저장합니다.
4. 이메일마다 가장 최신 제출만 취하면 Ada는 불참이므로 인원은 Ben 1명입니다.

모든 행을 더하면 Ada 일행 3명이 집계됩니다. 이메일별 최신 행을 세면 정확합니다.

### 스프레드시트에서

1. **Submissions**에서 RSVP 폼을 고르고, 상태를 **Received**, 기간을 **All time**으로 설정한 다음 **Export CSV**를 누르세요.
2. Google Sheets로 가져와 `created_at` 기준 최신순으로 정렬하세요.
3. 데이터를 선택하고 `email` 열에 **데이터** > **데이터 정리** > **중복 항목 삭제**를 사용하세요. Sheets는 처음 만나는 행을 남기는데, 이제 그 행이 가장 최신 행입니다.
4. `attending`이 G열, `guests`가 H열일 때 다음처럼 셉니다. 열 문자는 내보낸 파일에 맞게 바꾸세요.

`Google Sheets`:

```text
=COUNTIF(G2:G, "yes") + SUMIF(G2:G, "yes", H2:H)
```

내보낸 파일은 `id`, `created_at`, `form`, `status`가 먼저 오고, 그다음 필드마다 열이 하나씩 있습니다. 파일 하나에 최대 100,000행이 들어갑니다.

### 스크립트로

언제든 다시 계산할 수 있는 숫자가 필요하면 [REST API](https://ikiform.com/ko/docs/api.md)로 RSVP를 읽으세요. 목록은 최신순으로 오므로 각 이메일에서 처음 보이는 제출이 최신입니다. Node와 시크릿 API 키로 실행하세요.

`headcount.ts`:

```ts
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });
const latest = new Map<string, Record<string, unknown>>();

for await (const submission of ikiform.submissions.iterate({
  formId: "frm_xxxxxxxxxxxxxxxx",
  status: "received",
  limit: 100,
})) {
  const email = String(submission.fields.email ?? "").trim().toLowerCase();
  if (email && !latest.has(email)) latest.set(email, submission.fields);
}

let people = 0;
const meals = new Map<string, number>();
for (const fields of latest.values()) {
  if (fields.attending !== "yes") continue;
  people += 1 + Number(fields.guests ?? 0);
  const meal = String(fields.dietary ?? "none");
  meals.set(meal, (meals.get(meal) ?? 0) + 1);
}

console.log(`${latest.size} people replied. ${people} are coming, guests included.`);
console.table(Object.fromEntries(meals));
```

식사 표는 사람이 아니라 RSVP를 셉니다. 폼 하나로는 어느 손님이 무엇을 먹는지 알 수 없기 때문입니다. 결혼식처럼 손님별 식사가 중요하다면 사람마다 물어보세요. [결혼식 RSVP 페이지](https://ikiform.com/ko/guides/event-registration/wedding-rsvp.md)에서 방법을 보여 줍니다.

## 자리가 차면 등록 마감하기

Ikiform은 특정 숫자에서 폼을 멈추지 않으므로 두 가지를 준비하세요. 자리가 차기 전에 받는 알림과 마감하는 방법입니다. 마감은 폼과 페이지 두 곳에서 하세요.

1. 폼 설정에서 **Pause form**(폼 일시 중지)을 고르세요. 그때부터 POST는 `403 form_paused`로 거부되므로, 오래 열어 둔 탭이나 봇이 더 추가할 수 없습니다.
2. 행사 페이지의 폼을 `Registration is closed. Email us to join the waitlist.` 같은 문구로 바꾸세요. 방문자가 오류를 보게 해서는 안 됩니다.
3. 다시 열려면 **Resume form**을 고르고 폼을 되돌려 놓으세요.

알림은 참석 응답이 올 때마다 다시 집계하고, 정원의 90%를 넘으면 Slack 같은 채팅 웹훅으로 POST하는 웹훅 핸들러로 만들 수 있습니다. 아래는 Next.js 라우트 핸들러이며, 같은 로직은 어느 서버에서든 실행됩니다.

`app/api/rsvp-alert/route.ts`:

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

const CAPACITY = 80;
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });

async function peopleComing(formId: string) {
  const seen = new Set<string>();
  let people = 0;
  for await (const submission of ikiform.submissions.iterate({ formId, status: "received", limit: 100 })) {
    const email = String(submission.fields.email ?? "").trim().toLowerCase();
    if (!email || seen.has(email)) continue;
    seen.add(email);
    if (submission.fields.attending === "yes") people += 1 + Number(submission.fields.guests ?? 0);
  }
  return people;
}

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type !== "submission.created" || event.data.fields.attending !== "yes") {
      return new Response(null, { status: 204 });
    }
    const people = await peopleComing(event.data.form.id);
    if (people >= CAPACITY * 0.9) {
      await fetch(process.env.ALERT_WEBHOOK_URL!, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          text: `${people} of ${CAPACITY} places taken. Pause the RSVP form in Ikiform when you're full.`,
        }),
      });
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

- 매번 API로 다시 집계하므로 바뀐 응답도 반영됩니다. 키마다 분당 100회 API 요청을 할 수 있어, 한 번 집계할 때 RSVP 수천 건을 처리할 수 있습니다.
- 알림은 사람에게 일시 중지하라고 알려 줍니다. 일시 중지는 대시보드에서 하는 작업이므로 갑자기 닫히는 일은 없습니다.
- **Webhooks**에서 엔드포인트를 추가하고 시크릿을 `IKIFORM_WEBHOOK_SECRET`에 복사하세요. 워크스페이스에는 엔드포인트를 3개까지 둘 수 있으며, 재시도는 [웹훅 문서](https://ikiform.com/ko/docs/webhooks.md)에서 다룹니다.

다이어그램: 등록 시작부터 마감까지, 자동으로 일어나는 일 없이

1. 등록이 열려 있는 동안 모든 RSVP는 Ikiform으로 POST됩니다.
2. 웹훅 핸들러가 참석 응답이 올 때마다 최신 응답을 다시 집계하고, 정원의 90%에서 알려 줍니다.
3. 자리가 찼는지는 여러분이 판단하고 Pause form을 고르면, 새 POST는 403 form_paused로 거부됩니다.
4. 페이지의 폼을 마감 안내와 대기자 명단에 들어가는 방법으로 바꿉니다.

마감은 사람이 결정합니다. 티켓 앱의 강제 상한보다 느리지만, 막판에 계획이 바뀌어도 받아 줬을 사람을 막아 버리는 일이 없습니다.

## 확인 이메일 없이 확인 안내하기

Ikiform은 손님에게 이메일을 보내지 않으므로, 필요한 정보는 감사 페이지에서 주세요. 날짜, 시간, 주소, 캘린더 파일입니다. 페이지 옆에 둔 정적 `.ics` 파일은 모든 캘린더 앱에서 작동합니다. 확인 페이지에서 `Add to calendar`로 링크하세요.

`meetup.ics`:

```text
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Meetup//RSVP//EN
BEGIN:VEVENT
UID:meetup-2027-03-14@example.com
DTSTAMP:20261025T090000Z
DTSTART:20270314T183000Z
DTEND:20270314T210000Z
SUMMARY:Example meetup
LOCATION:The Old Library, 12 High Street, London
DESCRIPTION:Doors at 6:30 pm. Reply to the organiser if your plans change.
END:VEVENT
END:VCALENDAR
```

`Z`로 끝나는 시간은 UTC이므로 캘린더 앱이 손님 각자의 시간대로 보여 줍니다. 진짜 확인 이메일이 필요하면 각 RSVP를 웹훅으로 이메일 도구에 직접 보내거나 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md), [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)을 거쳐 보내고, 거기서 이메일을 발송하세요. 그러면 같은 명단으로 전날 리마인더도 보낼 수 있습니다.

## 응답 변경과 취소

응답을 바꾸는 방법을 알려 주세요: `Plans changed? Send the form again with the same email.` 집계는 이미 최신 응답을 쓰므로 다른 작업은 필요 없습니다. 알림은 모든 변경을 실시간으로 보여 주므로 행사 주간에 유용합니다.

같은 Wi-Fi를 쓰는 두 사람은 IP 주소를 공유하고, 레이트 리밋은 IP마다 셉니다. 기본값인 Standard, 방문자당 분당 5회는 행사 페이지에 충분합니다. 반복 제출을 막으려고 하루 1회 POST 같은 Custom 제한을 걸지 마세요. 같은 네트워크의 다른 가족 구성원이 막히고, 반복 제출은 이미 최신 응답 규칙이 처리합니다.

## 행사 당일

RSVP를 내보내고, 위처럼 중복을 제거하고, `attending` = `yes`로 필터링한 뒤 이름순으로 정렬해 인쇄하세요. 체크 표시와 도착한 동반 손님 열을 추가하세요. 인쇄한 명단은 행사장 신호가 약해도 쓸 수 있습니다. 접근성 요청 열은 입구 담당 팀의 사본에, 식이 열은 케이터링 업체의 사본에 남겨 두세요.

스팸은 명단에 들어오지 않습니다. 허니팟, 스팸 점수, 레이트 리밋이 집계 전에 걸러 내고, 스팸은 이메일이나 웹훅을 보내지 않습니다. 특이한 메시지가 담긴 진짜 RSVP가 들어가 있을 수 있으니 인쇄 전에 **Spam** 필터를 한 번 확인하세요. 일회용 주소는 스팸 점수를 2점 올립니다.

## 상황별 가이드

결혼식, 무료 커뮤니티 행사, 자원봉사 근무표는 세는 방식이 각각 다릅니다. 아래 페이지에서 무엇이 달라지는지 다룹니다.

- [결혼식 RSVP](https://ikiform.com/ko/guides/event-registration/wedding-rsvp.md): 직접 만든 웨딩 웹사이트용 RSVP 폼: 초대장당 응답 하나, 이름이 있는 손님별 식사, 마감일, 아직 응답하지 않은 사람 목록.
- [Eventbrite 없이](https://ikiform.com/ko/guides/event-registration/without-eventbrite.md): 무료 커뮤니티 행사의 등록을 내 사이트에서 운영하세요: 세션 선택, 내 도구로 보내는 확인 이메일, 강의실별 인원, 체크인 명단.
- [자원봉사자 신청](https://ikiform.com/ko/guides/event-registration/volunteer-signup.md): 사람들이 근무 시간을 고르고 당일 연락처를 남기는 자원봉사자 신청 폼과, 인원이 부족한 근무 시간을 보여 주고 근무표를 만드는 스크립트.

## 작동 확인하기

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

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 서로 다른 이메일로 동반 2명과 참석, 그리고 불참을 보내 보세요. 둘 다 도착하고, 불참은 `guests`가 `0`으로 표시됩니다.
3. 동반 3명, 그다음 4명을 보내 보세요. 네 번째는 `Must be at most 3`으로 거부됩니다.
4. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.
5. 인원 집계 스크립트나 시트 수식을 실행하세요. 보낸 내용과 일치해야 합니다.
6. 폼을 일시 중지하고 한 번 더 제출하세요. `403 form_paused`가 옵니다. 다시 재개하세요.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
8. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꾸세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| 인원이 너무 많이 집계됩니다 | 모든 제출을 더하고 있습니다. 이메일별로 최신 제출만 남기고, 불참이 `guests`를 `0`으로 보내는지 확인하세요. |
| 실제 페이지에서 `403 form_paused` | 대시보드에서 폼이 일시 중지되어 있습니다. 재개하거나 페이지의 폼을 마감 안내로 바꾸세요. |
| `guests`를 언급하는 `422 validation_failed` | 필드의 `max`보다 동반 인원이 많거나 소수입니다. 예제처럼 Ikiform의 메시지를 필드 옆에 보여 주세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| 브라우저가 내 페이지가 아니라 Ikiform의 감사 페이지로 이동합니다 | `_redirect`는 도메인이 **Allowed domains**에 있고 `https:` URL일 때만 따릅니다. 도메인을 추가하거나, 대신 폼 설정에서 **Redirect after submit**을 설정하세요. |

## 질문

### 행사 정원이 차면 Ikiform이 RSVP 받기를 멈출 수 있나요?

자동으로는 안 됩니다. 정원 설정이 없습니다. 인원 집계를 지켜보거나 웹훅으로 알림을 받은 뒤, 폼 설정에서 Pause form을 고르세요. 일시 중지된 폼은 새 POST를 403 form_paused로 거부합니다.

### Ikiform 폼으로 티켓을 판매할 수 있나요?

아니요. Ikiform에는 결제, 티켓, 체크인이 없습니다. 유료 행사에는 티켓 플랫폼을, 무료 행사에는 내 페이지의 폼을 쓰세요.

### 동반 손님을 너무 많이 데려오지 못하게 하려면 어떻게 하나요?

동반 인원을 min 0, max, integer를 설정한 숫자 필드로 만드세요. Ikiform이 서버에서 이 규칙을 적용하므로, 동반 인원이 너무 많은 POST는 화면에 보여 줄 수 있는 필드 오류와 함께 거부됩니다.

### 누군가 RSVP를 두 번 보내면 어떻게 되나요?

두 제출 모두 저장됩니다. 중복을 제거한 시트나 스크립트에서 이메일별 최신 제출만 세면, 바뀐 응답이 이전 응답을 대신합니다.

### Ikiform이 폼을 제출한 사람에게 이메일을 보낼 수 있나요?

아니요. Ikiform은 제출자가 아니라 여러분과 팀에게 이메일을 보내므로, 자동 응답이나 확인 이메일이 없습니다. 이메일 도구로 웹훅을 보내고 거기서 확인 이메일을 발송하세요.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [결혼식 RSVP](https://ikiform.com/ko/guides/event-registration/wedding-rsvp.md): 직접 만든 웨딩 웹사이트용 RSVP 폼: 초대장당 응답 하나, 이름이 있는 손님별 식사, 마감일, 아직 응답하지 않은 사람 목록.
- [Eventbrite 없이](https://ikiform.com/ko/guides/event-registration/without-eventbrite.md): 무료 커뮤니티 행사의 등록을 내 사이트에서 운영하세요: 세션 선택, 내 도구로 보내는 확인 이메일, 강의실별 인원, 체크인 명단.
- [자원봉사자 신청](https://ikiform.com/ko/guides/event-registration/volunteer-signup.md): 사람들이 근무 시간을 고르고 당일 연락처를 남기는 자원봉사자 신청 폼과, 인원이 부족한 근무 시간을 보여 주고 근무표를 만드는 스크립트.
- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [정적 사이트의 문의 폼](https://ikiform.com/ko/guides/contact-form-static-site.md): 서버 없이 정적 사이트에 작동하는 문의 폼을 추가하세요. POST가 가는 곳, 스팸, 이메일 답장, 호스트와 프레임워크별 코드까지 다룹니다.
