Panduan12 menit baca
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.
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.
- Tamu mengisi formulir RSVP di halaman acara Anda, lalu formulir mengirimnya ke endpoint Ikiform Anda.
- Ikiform memeriksa field, termasuk batas tamu, dan memberi skor spam pada kiriman sebelum menyimpannya.
- Setiap RSVP muncul di dashboard, tempat Anda bisa memfilter dan mengekspornya sebagai CSV.
- Panitia menerima email untuk setiap RSVP, dan Reply langsung terkirim ke tamu.
- Ikiform mengirim webhook submission.created yang bertanda tangan.
- Sheet atau skrip pendek mengubah RSVP menjadi jumlah peserta, memakai jawaban terbaru tiap orang.
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.
- 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 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.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
| 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 |
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.
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.
<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>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.
"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."),
};
}"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 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.
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 };
},
}),
};---
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>
)}Server function POST memeriksa jumlah tamu di .validator() dan mengubahnya menjadi nol untuk jawaban Tidak. Komponennya menyembunyikan pertanyaan tamu sampai seseorang menjawab Ya.
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>
);
}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.
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>
);
}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.
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;<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}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.
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 };
});<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>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.
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>
);
}Kiriman dari sisi browser yang sama dalam single-file component Vue, dengan pertanyaan tamu di balik v-show.
<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>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, Astro, SvelteKit, Nuxt, React, Vue, dan HTML biasa. 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.
- Senin: Ada menjawab ya dengan 2 tamu. Menjumlahkan semua kiriman akan menghitung 3 orang.
- Rabu: Ben menjawab ya tanpa tamu.
- Kamis: Ada mengirim formulir lagi dan menjawab tidak. Ikiform menyimpannya sebagai kiriman kedua darinya.
- Dengan hanya mengambil kiriman terbaru tiap email, Ada menjadi tidak, sehingga jumlah peserta 1: Ben.
Di spreadsheet
- Di Submissions, pilih formulir RSVP, atur status ke Received dan rentang tanggal ke All time, lalu Export CSV.
- Impor ke Google Sheets dan urutkan berdasarkan
created_at, terbaru di atas. - Pilih datanya dan gunakan Data > Data cleanup > Remove duplicates pada kolom
email. Sheets menyimpan baris pertama yang ditemuinya, yang sekarang adalah yang terbaru. - Hitung, dengan
attendingdi kolom G danguestsdi kolom H. Sesuaikan hurufnya dengan hasil ekspor Anda:
=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. Daftar diurutkan dari yang terbaru, jadi kiriman pertama yang terlihat untuk tiap email adalah yang terbaru. Jalankan ini dengan Node dan secret API key:
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 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:
- Di pengaturan formulir, pilih Pause form. Sejak itu, kiriman ditolak dengan
403 form_paused, sehingga tab lama atau bot tidak bisa menambah lagi. - Ganti formulir di halaman acara dengan kalimat seperti
Registration is closed. Email us to join the waitlist.Pengunjung tidak boleh sampai melihat error. - 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:
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 membahas percobaan ulang.
- Selama pendaftaran dibuka, setiap RSVP dikirim ke Ikiform.
- Webhook handler Anda menghitung ulang jawaban terbaru di setiap Ya dan memperingatkan Anda pada 90% kapasitas.
- Anda yang memutuskan kapan sudah penuh lalu memilih Pause form, yang menolak kiriman baru dengan 403 form_paused.
- Anda mengganti formulir di halaman dengan pesan tutup dan cara untuk masuk daftar tunggu.
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:
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:VCALENDARWaktu 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, Make, atau n8n, 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 pernikahanFormulir 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 EventbriteJalankan pendaftaran acara komunitas gratis di situs Anda sendiri: pilihan sesi, email konfirmasi dari alat Anda sendiri, hitungan per ruangan, dan daftar check-in.
- Pendaftaran relawanFormulir 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.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Kirim satu Ya dengan dua tamu dan satu Tidak dari email yang berbeda. Keduanya masuk, dan yang Tidak menampilkan
guestssebagai0. - Kirim tiga tamu, lalu empat. Yang keempat ditolak dengan
Must be at most 3. - Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan Reply terkirim ke pengirim formulir jika formulir punya field
email. - Jalankan skrip jumlah peserta atau rumus sheet. Hasilnya harus cocok dengan yang Anda kirim.
- Jeda formulir lalu kirim sekali lagi. Anda mendapat
403 form_paused. Lanjutkan kembali. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - 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
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.
Tidak. Ikiform tidak punya pembayaran, tiket, atau check-in. Pakai platform tiket untuk acara berbayar, dan formulir di halaman Anda sendiri untuk acara gratis.
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.
Kedua kiriman disimpan. Hitung hanya kiriman terbaru per email, di sheet setelah menghapus duplikat atau di skrip, sehingga jawaban yang berubah menggantikan yang lama.
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.
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.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.