# Formulir pendaftaran acara dan RSVP di situs Anda sendiri

> Terima RSVP di halaman acara Anda sendiri: formulir, batas tamu, jumlah peserta yang jujur, menutup saat penuh, dan daftar check-in, tanpa aplikasi tiket.

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

Pasang formulir RSVP di halaman acara Anda yang mengirim ke form backend, dengan jawaban ya atau tidak, jumlah tamu yang dibatasi di server, dan detail yang Anda perlukan untuk merencanakan. Hitung peserta dari jawaban terbaru per email, lewat ekspor CSV atau skrip pendek, lalu jeda formulir saat sudah penuh. Ikiform menyimpan dan meneruskan setiap RSVP, tetapi tidak punya fitur tiket, pembayaran, atau batas kapasitas otomatis, jadi pakai platform tiket jika Anda membutuhkannya.

Kebanyakan acara tidak butuh platform tiket. Meetup, workshop, pesta peluncuran, klub baca, atau satu hari konferensi gratis butuh tiga hal: halaman yang menjelaskan apa dan kapan, formulir yang mencatat siapa yang datang, dan angka yang bisa Anda percaya sehari sebelumnya. Panduan ini membangun semua itu di situs Anda sendiri dan terus terang soal bagian yang tidak bisa dikerjakan formulir.

## Perjalanan sebuah RSVP

Formulir ada di halaman acara Anda. Setiap RSVP dikirim ke Ikiform, yang memeriksanya terhadap aturan field Anda, menyaring spam, menyimpannya, lalu memberi tahu Anda. Dari sana RSVP sampai ke alat apa pun yang Anda pakai untuk merencanakan: dashboard, spreadsheet, atau skrip yang menghitung peserta.

Diagram: Bagaimana RSVP berpindah dari halaman acara ke jumlah peserta Anda

1. Tamu mengisi formulir RSVP di halaman acara Anda, lalu formulir mengirimnya ke endpoint Ikiform Anda.
2. Ikiform memeriksa field, termasuk batas tamu, dan memberi skor spam pada kiriman sebelum menyimpannya.
3. Setiap RSVP muncul di dashboard, tempat Anda bisa memfilter dan mengekspornya sebagai CSV.
4. Panitia menerima email untuk setiap RSVP, dan Reply langsung terkirim ke tamu.
5. Ikiform mengirim webhook submission.created yang bertanda tangan.
6. Sheet atau skrip pendek mengubah RSVP menjadi jumlah peserta, memakai jawaban terbaru tiap orang.

Ikiform menyimpan jawaban; jumlah peserta adalah penjumlahan yang Anda jalankan atas jawaban itu. Itulah langkah yang biasanya dikerjakan aplikasi tiket untuk Anda.

## Yang tidak dilakukan Ikiform untuk acara

Pahami ini sebelum memilih. Form backend mengumpulkan jawaban. Ia bukan sistem tiket:

- **Tidak ada batas kapasitas.** Tidak ada pengaturan yang menutup formulir setelah jumlah RSVP atau tamu tertentu. Anda memantau angkanya dan menjeda formulir sendiri.
- **Tidak ada tiket atau pembayaran.** Tidak ada kode QR, aplikasi check-in, tingkatan berbayar, atau refund.
- **Tidak ada email ke tamu.** Ikiform tidak mengirim email ke orang yang mengisi formulir. Email notifikasi hanya dikirim ke Anda dan tim, jadi tidak ada autoresponder, email konfirmasi, atau double opt-in. Kirim semua itu dari alat email Anda, dipicu oleh [webhook](https://ikiform.com/id/docs/webhooks.md).
- **Tidak ada daftar tunggu.** Saat formulir dijeda, kiriman baru ditolak. Daftar tunggu berarti formulir kedua yang Anda pasang sebagai gantinya.
- **Tidak ada deduplikasi.** Ikiform tidak menggabungkan atau menolak kiriman berulang berdasarkan email. Kiriman identik yang berulang dalam 24 jam hanya menambah skor spam. Lakukan deduplikasi di tempat daftar itu disimpan, atau saat Anda mengekspor.

Masing-masing punya solusi yang bisa dipakai di bawah ini. Jika beberapa di antaranya tidak bisa Anda terima, platform tiket adalah alat yang lebih tepat.

## Cara menerima RSVP

| Pendekatan | Unggul dalam | Kekurangan |
| --- | --- | --- |
| Platform tiket, seperti Eventbrite atau Luma | Tiket, pembayaran, batas kapasitas, daftar tunggu, email konfirmasi, dan check-in, semuanya sudah termasuk | Halaman acara dan daftar peserta ada di platform mereka dengan branding mereka. Tiket berbayar dikenai biaya; cek halaman harga mereka |
| Google Forms | Gratis dan cepat. Ada tombol untuk berhenti menerima jawaban | Tampilannya Google, bukan acara Anda. Halaman terpisah, dan tidak ada batas tamu di sisi server |
| Undangan kalender atau grup chat | Cukup untuk dua puluh teman | Tidak ada angka yang bisa dipercaya, tidak ada kebutuhan makanan, dan tidak ada daftar di hari H |
| Formulir Anda sendiri dan form backend, seperti Ikiform | Halaman Anda, desain Anda, field apa saja. Batas tamu dicek di server, spam disaring, panitia dikirimi email, CSV dan webhook | Anda menghitung peserta, menutup pendaftaran, dan mengirim konfirmasi sendiri, atau lewat webhook ke alat email |

Untuk acara berbayar, pakai platform tiket. Untuk acara gratis yang halaman dan daftarnya sebaiknya tetap milik Anda, sisa panduan ini adalah cara menyiapkannya. [Halaman Eventbrite](https://ikiform.com/id/guides/event-registration/without-eventbrite.md) membandingkan keduanya lebih dalam.

## Field yang membuat perencanaan mungkin

Tanyakan hanya hal yang akan Anda tindak lanjuti. Setiap pertanyaan di sini mengubah sesuatu yang Anda pesan, booking, atau atur. Coba saja: pratinjau menjalankan validator Ikiform di browser Anda, jadi Anda bisa melihat `422` yang akan didapat tamu keempat.

Formulir: Meetup RSVP

- **Nama** (`name`, text, wajib)
- **Email** (`email`, email, wajib)
- **Apakah Anda bisa datang?** (`attending`, radio, wajib)
- **Tamu yang Anda ajak** (`guests`, number)
- **Kebutuhan makanan** (`dietary`, select)
- **Kebutuhan aksesibilitas atau hal lain** (`access_needs`, textarea)

Tempel definisi ini di tab **Fields** formulir dan Ikiform menerapkannya pada setiap kiriman, dari halaman mana pun. [Template RSVP acara](https://ikiform.com/forms/event-rsvp) punya versi lebih pendek dengan kode yang sudah dibuatkan.

| Field | Alasan | Perhatikan |
| --- | --- | --- |
| `attending` | Jawaban Tidak juga informasi: Anda tidak perlu mengejar orang itu lagi | `radio` dengan `yes` dan `no`, sehingga hitungannya cukup dengan filter |
| `guests` | Angka acuan untuk memesan makanan dan kursi | `number` dengan `min: 0`, `max: 3`, `integer: true`. Ikiform menolak `5` dan `1.5` di server |
| `dietary` | Hitungan katering | `select` mudah dijumlahkan. Teks bebas tidak |
| `access_needs` | Akses tanpa tangga, tempat duduk, ruang tenang | Opsional. Baca semuanya sebelum hari H |
| `email` | Memberi tahu orang soal perubahan, dan mencocokkan jawaban yang berubah | Juga menjadi alamat reply-to setiap email notifikasi |

> Tanyakan hanya yang dibutuhkan acara. Jawaban soal makanan dan aksesibilitas bisa termasuk informasi kesehatan; jelaskan di formulir mengapa Anda menanyakannya, simpan hasil ekspor di tempat privat, dan hapus kiriman setelah acara selesai.

## Formulir RSVP

Pilih stack Anda. Setiap versi mengucapkan terima kasih secara berbeda untuk Ya dan Tidak, menyembunyikan pertanyaan tamu untuk jawaban Tidak, dan mengirim `guests` sebagai `0` saat seseorang tidak datang, sehingga penjumlahan Anda tetap benar.

**HTML**

Tempel formulir ini di halaman acara. Formulir mengirim dengan cara biasa dan me-redirect ke halaman konfirmasi Anda, jadi tetap berfungsi tanpa JavaScript. Skrip pendeknya menyembunyikan pertanyaan tamu saat seseorang menjawab Tidak dan mengatur ulang jumlah tamu ke `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**

Server action meneruskan RSVP dengan secret key Anda dan mengatur `guests` ke `0` untuk jawaban Tidak, apa pun yang dikirim browser. Komponennya mengganti formulir dengan kalimat yang berbeda sesuai jawaban.

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

Astro Action dengan `accept: "form"` memeriksa jawaban dengan Zod, termasuk batas tamu, sebelum meneruskan apa pun. `z.coerce.number()` mengubah teks formulir menjadi angka. Halaman dirender sesuai permintaan, jadi pertahankan `prerender = false` dan sebuah 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**

Server function POST memeriksa jumlah tamu di `.validator()` dan mengubahnya menjadi nol untuk jawaban Tidak. Komponennya menyembunyikan pertanyaan tamu sampai seseorang menjawab Ya.

`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` route meneruskan RSVP dan mengembalikan jawabannya, sehingga komponen bisa berterima kasih secara berbeda untuk Ya dan Tidak. `data()` mempertahankan status dari Ikiform saat terjadi error.

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

Form action default memeriksa batas tamu dengan `fail()` sebelum meneruskan, dan mempertahankan nama dan email yang sudah diketik saat terjadi error. `bind:group` menampilkan pertanyaan tamu hanya untuk jawaban Ya. Deklarasikan `IKIFORM_SECRET_KEY` di `src/env.ts` dengan `defineEnvVars` agar `$app/env/private` bisa membacanya.

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

Server route menyimpan secret key; atur `NUXT_IKIFORM_SECRET_KEY` dan deklarasikan `ikiformSecretKey` di bawah `runtimeConfig` dalam `nuxt.config.ts`. Komponennya mengirim objek reaktif dengan `$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**

Single-page app mengirim langsung dari browser. Jangan pernah menaruh secret key di sini; **Allowed domains** yang melindungi formulir. Jawaban Tidak mengatur ulang `guests` sebelum dikirim, dan aturan `max` milik Ikiform menangkap sisanya.

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

Kiriman dari sisi browser yang sama dalam single-file component Vue, dengan pertanyaan tamu di balik `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>
```

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server. Versi server juga meneruskan header `Origin` dari browser jika ada, karena `fetch` milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu.

Panduan framework di dokumentasi membahas lebih dalam: [Next.js](https://ikiform.com/id/docs/guides/nextjs.md), [Astro](https://ikiform.com/id/docs/guides/astro.md), [SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/id/docs/guides/nuxt.md), [React](https://ikiform.com/id/docs/guides/react.md), [Vue](https://ikiform.com/id/docs/guides/vue.md), dan [HTML biasa](https://ikiform.com/id/docs/html.md). Di pembuat situs, versi HTML bisa ditempel ke blok embed.

## Hitung peserta dari jawaban terbaru

Orang bisa berubah pikiran. Seseorang menjawab Ya dengan dua tamu pada hari Senin lalu Tidak pada hari Kamis; itu dua kiriman, dan yang kedua adalah yang benar. Jadi aturannya: satu jawaban per email, yang terbaru, lalu tambahkan satu untuk orang itu ditambah tamunya.

Diagram: Mengapa jumlah peserta memakai RSVP terbaru tiap orang

1. Senin: Ada menjawab ya dengan 2 tamu. Menjumlahkan semua kiriman akan menghitung 3 orang.
2. Rabu: Ben menjawab ya tanpa tamu.
3. Kamis: Ada mengirim formulir lagi dan menjawab tidak. Ikiform menyimpannya sebagai kiriman kedua darinya.
4. Dengan hanya mengambil kiriman terbaru tiap email, Ada menjadi tidak, sehingga jumlah peserta 1: Ben.

Menjumlahkan setiap baris akan menghitung rombongan Ada yang bertiga. Menghitung baris terbaru per email memberi hasil yang benar.

### Di spreadsheet

1. Di **Submissions**, pilih formulir RSVP, atur status ke **Received** dan rentang tanggal ke **All time**, lalu **Export CSV**.
2. Impor ke Google Sheets dan urutkan berdasarkan `created_at`, terbaru di atas.
3. Pilih datanya dan gunakan **Data** > **Data cleanup** > **Remove duplicates** pada kolom `email`. Sheets menyimpan baris pertama yang ditemuinya, yang sekarang adalah yang terbaru.
4. Hitung, dengan `attending` di kolom G dan `guests` di kolom H. Sesuaikan hurufnya dengan hasil ekspor Anda:

`Google Sheets`:

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

Hasil ekspor berisi `id`, `created_at`, `form`, dan `status` di awal, lalu satu kolom per field. Satu file memuat hingga 100.000 baris.

### Dengan skrip

Untuk angka yang bisa Anda hitung ulang kapan saja, baca RSVP lewat [REST API](https://ikiform.com/id/docs/api.md). Daftar diurutkan dari yang terbaru, jadi kiriman pertama yang terlihat untuk tiap email adalah yang terbaru. Jalankan ini dengan Node dan 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));
```

Tabel makanan menghitung RSVP, bukan orang, karena satu formulir tidak bisa menyebutkan tamu mana makan apa. Jika makanan tiap tamu penting, seperti di pernikahan, tanyakan per orang; [halaman RSVP pernikahan](https://ikiform.com/id/guides/event-registration/wedding-rsvp.md) menunjukkan caranya.

## Menutup pendaftaran saat penuh

Ikiform tidak akan menghentikan formulir pada angka tertentu, jadi siapkan dua hal: peringatan sebelum penuh, dan cara menutupnya. Tutup di dua tempat, formulir dan halamannya:

1. Di pengaturan formulir, pilih **Pause form**. Sejak itu, kiriman ditolak dengan `403 form_paused`, sehingga tab lama atau bot tidak bisa menambah lagi.
2. Ganti formulir di halaman acara dengan kalimat seperti `Registration is closed. Email us to join the waitlist.` Pengunjung tidak boleh sampai melihat error.
3. Jika Anda membukanya lagi, pilih **Resume form** dan pasang kembali formulirnya.

Peringatannya bisa berupa webhook handler yang menghitung ulang di setiap Ya dan mengirim ke webhook chat, seperti milik Slack, begitu Anda melewati 90% kapasitas. Ini route handler Next.js; logika yang sama berjalan di server mana pun:

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

- Handler menghitung ulang lewat API setiap kali, sehingga jawaban yang berubah ikut tercermin. Setiap key bisa membuat 100 request API per menit, cukup untuk beberapa ribu RSVP per hitungan.
- Peringatan itu memberi tahu seseorang untuk menjeda. Menjeda adalah tindakan di dashboard, jadi tidak ada yang tertutup secara tiba-tiba.
- Tambahkan endpoint di **Webhooks** dan salin secret-nya ke `IKIFORM_WEBHOOK_SECRET`. Satu workspace bisa punya 3 endpoint; [dokumentasi webhook](https://ikiform.com/id/docs/webhooks.md) membahas percobaan ulang.

Diagram: Dari pendaftaran dibuka hingga ditutup, tanpa ada yang otomatis

1. Selama pendaftaran dibuka, setiap RSVP dikirim ke Ikiform.
2. Webhook handler Anda menghitung ulang jawaban terbaru di setiap Ya dan memperingatkan Anda pada 90% kapasitas.
3. Anda yang memutuskan kapan sudah penuh lalu memilih Pause form, yang menolak kiriman baru dengan 403 form_paused.
4. Anda mengganti formulir di halaman dengan pesan tutup dan cara untuk masuk daftar tunggu.

Seseorang yang memutuskan untuk menutup. Itu lebih lambat dari batas keras aplikasi tiket, tetapi berarti perubahan rencana di menit akhir tidak pernah mengunci orang yang sebenarnya masih akan Anda terima.

## Konfirmasi tanpa email konfirmasi

Ikiform tidak mengirim email ke tamu, jadi berikan yang mereka perlukan di halaman terima kasih: tanggal, jam, alamat, dan file kalender. File `.ics` statis di samping halaman itu bisa dipakai di semua aplikasi kalender. Tautkan dari halaman konfirmasi sebagai `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
```

Waktu yang diakhiri `Z` adalah UTC, sehingga aplikasi kalender menampilkannya dalam zona waktu masing-masing tamu. Jika Anda ingin email konfirmasi sungguhan, kirim setiap RSVP lewat webhook ke alat email Anda, langsung atau melalui [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md), atau [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md), lalu kirim dari sana. Daftar yang sama kemudian bisa menerima pengingat sehari sebelumnya.

## Jawaban yang berubah dan pembatalan

Beri tahu orang cara mengubah jawaban: `Plans changed? Send the form again with the same email.` Hitungan Anda sudah memakai jawaban terbaru, jadi tidak perlu apa-apa lagi. Notifikasi menampilkan setiap perubahan saat terjadi, yang berguna di minggu acara.

Dua orang di Wi-Fi yang sama berbagi alamat IP, dan rate limit dihitung per IP. Default Standard, 5 per menit per pengunjung, sudah cukup untuk halaman acara. Jangan pasang batas Custom satu kiriman per hari untuk mencegah pengulangan: itu memblokir anggota keluarga kedua di jaringan yang sama, padahal aturan jawaban terbaru sudah menangani pengulangan.

## Di hari H

Ekspor RSVP, hapus duplikat seperti di atas, filter `attending` = `yes`, urutkan berdasarkan nama, lalu cetak. Tambahkan kolom untuk centang dan untuk tamu yang datang. Daftar cetak tetap bisa dipakai saat sinyal di lokasi tidak ada. Simpan kolom kebutuhan aksesibilitas di salinan untuk tim pintu masuk, dan kolom makanan di salinan untuk katering.

Spam tidak masuk ke daftar Anda: honeypot, skor spam, dan rate limit menyaringnya sebelum ikut terhitung, dan spam tidak memicu email atau webhook. Cek filter **Spam** sekali sebelum mencetak, kalau-kalau ada RSVP asli dengan pesan tak biasa yang masuk ke sana. Alamat sekali pakai menambah 2 ke skor spam.

## Pilih situasi Anda

Pernikahan, acara komunitas gratis, dan jadwal relawan masing-masing dihitung dengan cara berbeda. Halaman-halaman ini membahas apa yang berubah.

- [RSVP pernikahan](https://ikiform.com/id/guides/event-registration/wedding-rsvp.md): Formulir RSVP untuk website pernikahan yang Anda buat sendiri: satu balasan per undangan, satu menu per tamu yang disebut namanya, tenggat, dan daftar siapa yang belum membalas.
- [Tanpa Eventbrite](https://ikiform.com/id/guides/event-registration/without-eventbrite.md): Jalankan pendaftaran acara komunitas gratis di situs Anda sendiri: pilihan sesi, email konfirmasi dari alat Anda sendiri, hitungan per ruangan, dan daftar check-in.
- [Pendaftaran relawan](https://ikiform.com/id/guides/event-registration/volunteer-signup.md): Formulir pendaftaran relawan tempat orang memilih shift, dengan nomor telepon untuk hari H, dan skrip yang menunjukkan shift mana yang kurang orang dan menyusun jadwalnya.

## Pastikan berfungsi

Uji seluruh alurnya seminggu sebelum Anda mengumumkan acara. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat [harga](https://ikiform.com/id/pricing).

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Kirim satu Ya dengan dua tamu dan satu Tidak dari email yang berbeda. Keduanya masuk, dan yang Tidak menampilkan `guests` sebagai `0`.
3. Kirim tiga tamu, lalu empat. Yang keempat ditolak dengan `Must be at most 3`.
4. Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan **Reply** terkirim ke pengirim formulir jika formulir punya field `email`.
5. Jalankan skrip jumlah peserta atau rumus sheet. Hasilnya harus cocok dengan yang Anda kirim.
6. Jeda formulir lalu kirim sekali lagi. Anda mendapat `403 form_paused`. Lanjutkan kembali.
7. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
8. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| Jumlah peserta terlalu tinggi | Anda menjumlahkan semua kiriman. Simpan hanya yang terbaru per email, dan pastikan jawaban Tidak mengirim `guests` sebagai `0`. |
| `403 form_paused` di halaman live | Formulir dijeda di dashboard. Lanjutkan kembali, atau ganti formulir di halaman dengan pesan tutup. |
| `422 validation_failed` yang menyebut `guests` | Tamu melebihi `max` field, atau berupa desimal. Tampilkan pesan Ikiform di samping field, seperti yang dilakukan contoh-contohnya. |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
| `429 rate_limited` | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai `Retry-After`, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
| Browser mendarat di halaman terima kasih Ikiform, bukan halaman Anda | `_redirect` diabaikan kecuali domainnya ada di **Allowed domains** dan berupa URL `https:`. Tambahkan domainnya, atau atur **Redirect after submit** di pengaturan formulir sebagai gantinya. |

## Pertanyaan

### Bisakah Ikiform berhenti menerima RSVP saat acara saya penuh?

Tidak secara otomatis. Tidak ada pengaturan kapasitas. Pantau jumlah peserta, atau beri peringatan untuk diri sendiri lewat webhook, lalu pilih Pause form di pengaturan formulir. Formulir yang dijeda menolak kiriman baru dengan 403 form_paused.

### Bisakah saya menjual tiket dengan formulir Ikiform?

Tidak. Ikiform tidak punya pembayaran, tiket, atau check-in. Pakai platform tiket untuk acara berbayar, dan formulir di halaman Anda sendiri untuk acara gratis.

### Bagaimana mencegah orang membawa terlalu banyak tamu?

Jadikan tamu sebagai field angka dengan min 0, max, dan integer diatur. Ikiform menerapkan aturan itu di server, sehingga kiriman dengan terlalu banyak tamu ditolak dengan error field yang bisa Anda tampilkan.

### Bagaimana jika seseorang mengirim RSVP dua kali?

Kedua kiriman disimpan. Hitung hanya kiriman terbaru per email, di sheet setelah menghapus duplikat atau di skrip, sehingga jawaban yang berubah menggantikan yang lama.

### Bisakah Ikiform mengirim email ke orang yang mengisi formulir?

Tidak. Ikiform mengirim email ke Anda dan tim, bukan ke pengirim formulir, jadi tidak ada autoresponder atau email konfirmasi. Kirim webhook ke alat email Anda dan kirim konfirmasinya dari sana.

### Apakah Ikiform gratis?

Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.

## Panduan lainnya

- [RSVP pernikahan](https://ikiform.com/id/guides/event-registration/wedding-rsvp.md): Formulir RSVP untuk website pernikahan yang Anda buat sendiri: satu balasan per undangan, satu menu per tamu yang disebut namanya, tenggat, dan daftar siapa yang belum membalas.
- [Tanpa Eventbrite](https://ikiform.com/id/guides/event-registration/without-eventbrite.md): Jalankan pendaftaran acara komunitas gratis di situs Anda sendiri: pilihan sesi, email konfirmasi dari alat Anda sendiri, hitungan per ruangan, dan daftar check-in.
- [Pendaftaran relawan](https://ikiform.com/id/guides/event-registration/volunteer-signup.md): Formulir pendaftaran relawan tempat orang memilih shift, dengan nomor telepon untuk hari H, dan skrip yang menunjukkan shift mana yang kurang orang dan menyusun jadwalnya.
- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [Formulir kontak di situs statis](https://ikiform.com/id/guides/contact-form-static-site.md): Pasang formulir kontak yang berfungsi di situs statis tanpa server: ke mana POST dikirim, spam, balasan lewat email, dan kode untuk setiap host dan framework.
