# Event registration and RSVP forms on your own site

> Take RSVPs on your own event page: the form, guest limits, an honest headcount, closing when full and a check-in list, without a ticketing app.

Published 2026-10-25 · https://ikiform.com/guides/event-registration

Put an RSVP form on your event page that posts to a form backend, with a yes or no answer, a guest count capped on the server, and the details you need to plan. Count heads from the latest answer per email, using a CSV export or a short script, and pause the form when you're full. Ikiform stores and forwards every RSVP but has no ticketing, payments or automatic capacity cap, so use a ticketing platform when you need those.

Most events don't need a ticketing platform. A meetup, a workshop, a launch party, a reading group or a free conference day needs three things: a page that says what and when, a form that collects who is coming, and a number you can trust the day before. This guide builds that on your own site and is clear about the parts a form can't do.

## How an RSVP travels

The form lives on your event page. Each RSVP posts to Ikiform, which checks it against your field rules, filters spam, stores it and tells you. From there it reaches whatever you plan with: the dashboard, a spreadsheet, or a script that counts heads.

Diagram: How an RSVP gets from your event page to your headcount

1. A guest fills in the RSVP form on your event page, and it posts to your Ikiform endpoint.
2. Ikiform checks the fields, including the guest limit, and scores the post for spam before storing it.
3. Every RSVP appears in the dashboard, where you can filter it and export it as CSV.
4. The organisers get an email for each RSVP, and Reply goes straight to the guest.
5. Ikiform sends a signed submission.created webhook.
6. A sheet or a short script turns the RSVPs into a headcount, using each person's latest answer.

Ikiform stores answers; the headcount is a sum you run over them. That's the step a ticketing app would do for you.

## What Ikiform doesn't do for events

Be clear about this before you choose. A form backend collects answers. It isn't a ticketing system:

- **No capacity cap.** There's no setting that closes the form after a number of RSVPs or guests. You watch the count and pause the form yourself.
- **No tickets or payments.** No QR codes, no check-in app, no paid tiers, no refunds.
- **No email to the guest.** Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a [webhook](https://ikiform.com/docs/webhooks.md).
- **No waitlist.** When you pause the form, new posts are refused. A waitlist would be a second form you put up in its place.
- **No deduplication.** Ikiform doesn't merge or reject repeat submissions by email. An identical repeat within 24 hours only adds to the spam score. Deduplicate where the list lives, or when you export.

Each of these has a workable answer below. If several of them are deal-breakers, a ticketing platform is the better tool.

## Ways to take RSVPs

| Approach | Good at | Trade-offs |
| --- | --- | --- |
| Ticketing platform, such as Eventbrite or Luma | Tickets, payments, capacity limits, waitlists, confirmation emails and check-in, all included | The event page and attendee list live on their platform with their branding. Paid tickets carry fees; check their pricing pages |
| Google Forms | Free and quick. A switch to stop accepting responses | Looks like Google, not your event. A separate page, and no server-side guest limit |
| Calendar invite or group chat | Fine for twenty friends | No numbers you can trust, no dietary needs, and no list on the day |
| Your own form and a form backend, such as Ikiform | Your page, your design, any fields. Guest limits checked on the server, spam filtered, organisers emailed, CSV and webhooks | You count heads, close registration and send confirmations yourself, or with a webhook to an email tool |

For a paid event, use a ticketing platform. For a free event where the page and the list should stay yours, the rest of this guide is the setup. The [Eventbrite page](https://ikiform.com/guides/event-registration/without-eventbrite.md) compares the two in more depth.

## Fields that make planning possible

Ask what you'll act on and nothing else. Every question here changes something you order, book or arrange. Try it: the preview runs Ikiform's validator in your browser, so you can see the `422` a fourth guest would get.

Form: Meetup RSVP

- **Name** (`name`, text, required)
- **Email** (`email`, email, required)
- **Can you make it?** (`attending`, radio, required)
- **Guests you're bringing** (`guests`, number)
- **Dietary needs** (`dietary`, select)
- **Access needs or anything else** (`access_needs`, textarea)

Paste this definition on the form's **Fields** tab and Ikiform enforces it on every post, from any page. The [event RSVP template](https://ikiform.com/forms/event-rsvp) has a shorter version with generated code.

| Field | Why | Keep in mind |
| --- | --- | --- |
| `attending` | A No is information too: it stops you chasing people | A `radio` with `yes` and `no`, so the count is a filter |
| `guests` | The number you order food and chairs for | A `number` with `min: 0`, `max: 3`, `integer: true`. Ikiform refuses `5` and `1.5` on the server |
| `dietary` | Catering counts | A `select` adds up cleanly. Free text doesn't |
| `access_needs` | Step-free access, seating, a quiet room | Optional. Read every one before the day |
| `email` | Telling people about changes, and matching changed answers | Also the reply-to of every notification email |

> Ask only for what the event needs. Dietary and access answers can be health information; say on the form why you ask, keep the export somewhere private, and delete the submissions once the event is over.

## The RSVP form

Pick your stack. Each version thanks a Yes and a No differently, hides the guest questions for a No, and sends `guests` as `0` when someone isn't coming, so your sums stay right.

**HTML**

Paste the form into the event page. It posts the plain way and redirects to your confirmation page, so it works without JavaScript. The short script hides the guest questions when someone answers No and resets the guest count to `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**

A server action forwards the RSVP with your secret key and sets `guests` to `0` for a No, whatever the browser sent. The component swaps the form for a different line depending on the answer.

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

An Astro Action with `accept: "form"` checks the answer with Zod, including the guest limit, before it forwards anything. `z.coerce.number()` turns the form's text into a number. The page renders on demand, so keep `prerender = false` and an adapter.

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

A POST server function checks the guest count in `.validator()` and zeroes it for a No. The component hides the guest questions until someone says Yes.

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

The route `action` forwards the RSVP and returns the answer, so the component can thank a Yes and a No differently. `data()` keeps Ikiform's status on errors.

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

A default form action checks the guest limit with `fail()` before forwarding, and keeps the typed name and email on errors. `bind:group` shows the guest questions only for a Yes. Declare `IKIFORM_SECRET_KEY` in `src/env.ts` with `defineEnvVars` so `$app/env/private` can read it.

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

A server route holds the secret key; set `NUXT_IKIFORM_SECRET_KEY` and declare `ikiformSecretKey` under `runtimeConfig` in `nuxt.config.ts`. The component sends a reactive object with `$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**

A single-page app posts straight from the browser. Never put a secret key here; **Allowed domains** protects the form. A No resets `guests` before the post, and Ikiform's `max` rule catches anything else.

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

The same browser-side post in a Vue single-file component, with the guest questions behind `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>
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables. The server versions also pass on the browser's `Origin` header when there is one, because a server's own `fetch` sends none and Ikiform counts free `localhost` test submissions by it.

Framework guides in the docs go deeper: [Next.js](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [plain HTML](https://ikiform.com/docs/html.md). On a site builder, the HTML version pastes into an embed block.

## Count heads from the latest answer

People change their minds. Someone says Yes with two guests on Monday and No on Thursday; that's two submissions, and the second is the truth. So the rule is: one answer per email, the newest one, then add one for the person plus their guests.

Diagram: Why the headcount uses each person's latest RSVP

1. Monday: Ada RSVPs yes with 2 guests. Adding every submission would count 3 people.
2. Wednesday: Ben RSVPs yes with no guests.
3. Thursday: Ada sends the form again and says no. Ikiform stores it as a second submission from her.
4. Taking only each email's newest submission, Ada is a no, so the headcount is 1: Ben.

Adding every row would count Ada's party of three. Counting the newest row per email gets it right.

### In a spreadsheet

1. In **Submissions**, pick the RSVP form, set the status to **Received** and the date range to **All time**, then **Export CSV**.
2. Import it into Google Sheets and sort by `created_at`, newest first.
3. Select the data and use **Data** > **Data cleanup** > **Remove duplicates** on the `email` column. Sheets keeps the first row it sees, which is now the newest.
4. Count, with `attending` in column G and `guests` in column H. Adjust the letters to your export:

`Google Sheets`:

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

The export has `id`, `created_at`, `form` and `status` first, then one column per field. One file holds up to 100,000 rows.

### With a script

For a number you can rerun any time, read the RSVPs through the [REST API](https://ikiform.com/docs/api.md). Lists come newest first, so the first submission seen for each email is the latest. Run this with Node and a secret API key:

`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));
```

The meal table counts RSVPs, not people, because one form can't say which guest eats what. If guests' meals matter, as at a wedding, ask per person; the [wedding RSVP page](https://ikiform.com/guides/event-registration/wedding-rsvp.md) shows how.

## Closing registration when you're full

Ikiform won't stop the form at a number, so set up two things: an alert before you're full, and a way to close. Close in two places, the form and the page:

1. In the form's settings, choose **Pause form**. From then on, posts are refused with `403 form_paused`, so a stale tab or a bot can't add more.
2. Replace the form on your event page with a line such as `Registration is closed. Email us to join the waitlist.` Visitors should never meet the error.
3. If you reopen, choose **Resume form** and put the form back.

The alert can be a webhook handler that recounts on every Yes and posts to a chat webhook, such as Slack's, once you pass 90% of capacity. This is a Next.js route handler; the same logic runs on any server:

`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;
  }
}
```

- It recounts through the API each time, so a changed answer is reflected. Each key can make 100 API requests a minute, which covers several thousand RSVPs per count.
- The alert tells a person to pause. Pausing is a dashboard action, so nothing closes by surprise.
- Add the endpoint under **Webhooks** and copy its secret into `IKIFORM_WEBHOOK_SECRET`. A workspace can have 3 endpoints; the [webhooks docs](https://ikiform.com/docs/webhooks.md) cover retries.

Diagram: From open registration to closed, with nothing automatic

1. While registration is open, every RSVP posts to Ikiform.
2. Your webhook handler recounts the latest answers on every Yes and alerts you at 90% of capacity.
3. You decide when you're full and choose Pause form, which refuses new posts with 403 form_paused.
4. You replace the form on the page with a closed message and a way to join a waitlist.

A person makes the call to close. That's slower than a ticketing app's hard cap, and it means a late change of plans never locks out someone you'd have let in.

## Confirmations without a confirmation email

Ikiform doesn't email the guest, so give them what they need on the thank-you page: the date, time, address and a calendar file. A static `.ics` file next to the page works in every calendar app. Link to it from the confirmation page as `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
```

Times ending in `Z` are UTC, so calendar apps show them in each guest's own time zone. If you want a real confirmation email, send each RSVP by webhook to your email tool, directly or through [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md), and send it from there. The same list then gets the reminder the day before.

## Changed answers and cancellations

Tell people how to change their answer: `Plans changed? Send the form again with the same email.` Your count already uses the newest answer, so nothing else is needed. Notifications show every change as it happens, which is useful the week of the event.

Two people on the same Wi-Fi share an IP address, and the rate limit counts per IP. The default Standard, 5 a minute per visitor is fine for an event page. Don't set a Custom limit of one post per day to stop repeats: it blocks a second household member on the same network, and the latest-answer rule already handles repeats.

## On the day

Export the RSVPs, remove duplicates as above, filter to `attending` = `yes`, sort by name and print it. Add columns for a tick and for guests who arrived. A printed list works when the venue's signal doesn't. Keep the access needs column on the copy the door team uses, and the dietary column on the caterer's.

Spam doesn't reach your list: the honeypot, spam scoring and rate limits filter it before it counts, and spam sends no email or webhook. Check the **Spam** filter once before you print, in case a real RSVP with an unusual message ended up there. Disposable addresses add 2 to the spam score.

## Pick your situation

A wedding, a free community event and a volunteer rota each count differently. These pages cover what changes.

- [Wedding RSVP](https://ikiform.com/guides/event-registration/wedding-rsvp.md): An RSVP form for a wedding website you built yourself: one reply per invitation, a meal per named guest, a deadline and a list of who hasn't replied.
- [Without Eventbrite](https://ikiform.com/guides/event-registration/without-eventbrite.md): Run registration for a free community event on your own site: session choices, a confirmation email from your own tool, room counts and a check-in list.
- [Volunteer signup](https://ikiform.com/guides/event-registration/volunteer-signup.md): A volunteer signup form where people pick shifts, with a phone number for the day, and a script that shows which shifts are short and builds the rota.

## Check it works

Test the whole loop a week before you announce the event. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Send a Yes with two guests and a No from different emails. Both arrive, and the No shows `guests` as `0`.
3. Send three guests, then four. The fourth is refused with `Must be at most 3`.
4. Check the inbox of each notification recipient. Each gets an email with the fields, and **Reply** goes to the submitter when the form has an `email` field.
5. Run the headcount script or the sheet formula. It should match what you sent.
6. Pause the form and submit once more. You get `403 form_paused`. Resume it.
7. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
8. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| The headcount is too high | You're adding every submission. Keep only the newest per email, and check a No sends `guests` as `0`. |
| `403 form_paused` on the live page | The form is paused in the dashboard. Resume it, or replace the form on the page with a closed message. |
| `422 validation_failed` mentioning `guests` | More guests than the field's `max`, or a decimal. Show Ikiform's message next to the field, as the samples do. |
| `403 origin_not_allowed`, or every post lands in spam | The page's domain isn't in the form's **Allowed domains**. Add it, plus `localhost` for local tests. Subdomains of a listed domain are allowed. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the **Spam** filter to see the reasons, and mark it as not spam to send its email and webhooks. |
| The browser lands on Ikiform's thank-you page instead of yours | `_redirect` is ignored unless its domain is in **Allowed domains** and it is an `https:` URL. Add the domain, or set **Redirect after submit** in the form's settings instead. |

## Questions

### Can Ikiform stop accepting RSVPs when my event is full?

Not automatically. There's no capacity setting. Watch the headcount, or alert yourself from a webhook, then choose Pause form in the form's settings. A paused form refuses new posts with 403 form_paused.

### Can I sell tickets with an Ikiform form?

No. Ikiform has no payments, tickets or check-in. Use a ticketing platform for paid events, and a form on your own page for free ones.

### How do I stop people bringing too many guests?

Make guests a number field with min 0, max and integer set. Ikiform enforces those rules on the server, so a post with too many guests is refused with a field error you can show.

### What if someone RSVPs twice?

Both submissions are stored. Count only the newest submission per email, in a sheet after removing duplicates or in a script, so a changed answer replaces the old one.

### Can Ikiform email the person who submitted the form?

No. Ikiform emails you and your team, not the submitter, so there is no autoresponder or confirmation email. Send a webhook to your email tool and send the confirmation from there.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Wedding RSVP](https://ikiform.com/guides/event-registration/wedding-rsvp.md): An RSVP form for a wedding website you built yourself: one reply per invitation, a meal per named guest, a deadline and a list of who hasn't replied.
- [Without Eventbrite](https://ikiform.com/guides/event-registration/without-eventbrite.md): Run registration for a free community event on your own site: session choices, a confirmation email from your own tool, room counts and a check-in list.
- [Volunteer signup](https://ikiform.com/guides/event-registration/volunteer-signup.md): A volunteer signup form where people pick shifts, with a phone number for the day, and a script that shows which shifts are short and builds the rota.
- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.
