Guias15 min de leitura
Formulários de inscrição e RSVP para eventos no seu próprio site
Receba confirmações de presença na página do seu evento: formulário, limite de convidados, contagem honesta, encerramento ao lotar e lista de check-in, sem app de ingressos.
Coloque um formulário de RSVP na página do evento que envia para um backend de formulários, com resposta sim ou não, número de convidados limitado no servidor e os detalhes de que você precisa para planejar. Conte as pessoas a partir da resposta mais recente de cada e-mail, usando uma exportação CSV ou um script curto, e pause o formulário quando lotar. O Ikiform armazena e encaminha cada RSVP, mas não tem venda de ingressos, pagamentos nem limite automático de vagas; use uma plataforma de ingressos quando precisar disso.
A maioria dos eventos não precisa de uma plataforma de ingressos. Um meetup, um workshop, uma festa de lançamento, um clube de leitura ou um dia de conferência gratuito precisam de três coisas: uma página que diga o quê e quando, um formulário que registre quem vem e um número em que você possa confiar na véspera. Este guia monta isso no seu próprio site e deixa claro o que um formulário não consegue fazer.
O caminho de um RSVP
O formulário fica na página do evento. Cada RSVP é enviado ao Ikiform, que o confere com as regras dos seus campos, filtra spam, armazena e avisa você. Dali ele chega a onde você planeja: o painel, uma planilha ou um script que conta as pessoas.
- Um convidado preenche o formulário de RSVP na página do evento, e ele é enviado ao seu endpoint do Ikiform.
- O Ikiform confere os campos, incluindo o limite de convidados, e dá uma pontuação de spam ao envio antes de armazená-lo.
- Cada RSVP aparece no painel, onde você pode filtrá-lo e exportá-lo como CSV.
- Os organizadores recebem um e-mail para cada RSVP, e o Reply vai direto para o convidado.
- O Ikiform envia um webhook submission.created assinado.
- Uma planilha ou um script curto transforma os RSVPs em uma contagem, usando a resposta mais recente de cada pessoa.
O que o Ikiform não faz por eventos
Tenha isto claro antes de escolher. Um backend de formulários coleta respostas. Ele não é um sistema de ingressos:
- Sem limite de vagas. Não existe uma configuração que feche o formulário depois de um número de RSVPs ou de convidados. Você acompanha a contagem e pausa o formulário por conta própria.
- Sem ingressos nem pagamentos. Sem QR codes, sem app de check-in, sem categorias pagas, sem reembolsos.
- Sem e-mail para o convidado. O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um webhook.
- Sem lista de espera. Quando você pausa o formulário, novos envios são recusados. Uma lista de espera seria um segundo formulário colocado no lugar dele.
- Sem deduplicação. O Ikiform não mescla nem rejeita envios repetidos pelo e-mail. Uma repetição idêntica em até 24 horas só aumenta a pontuação de spam. Remova os duplicados onde a lista fica, ou na hora de exportar.
Cada um desses pontos tem uma solução viável mais abaixo. Se vários deles forem decisivos para você, uma plataforma de ingressos é a ferramenta certa.
Formas de receber RSVPs
| Abordagem | Bom em | Contras |
|---|---|---|
| Plataforma de ingressos, como Eventbrite ou Luma | Ingressos, pagamentos, limite de vagas, listas de espera, e-mails de confirmação e check-in, tudo incluído | A página do evento e a lista de participantes ficam na plataforma deles, com a marca deles. Ingressos pagos têm taxas; confira as páginas de preços |
| Google Forms | Gratuito e rápido. Um botão para parar de aceitar respostas | Parece o Google, não o seu evento. Uma página separada e nenhum limite de convidados no servidor |
| Convite de calendário ou grupo de mensagens | Serve para vinte amigos | Nenhum número confiável, nada de restrições alimentares e nenhuma lista no dia |
| Seu próprio formulário e um backend de formulários, como o Ikiform | Sua página, seu design, quaisquer campos. Limite de convidados conferido no servidor, spam filtrado, organizadores avisados por e-mail, CSV e webhooks | Você conta as pessoas, encerra as inscrições e envia as confirmações por conta própria, ou com um webhook para uma ferramenta de e-mail |
Para um evento pago, use uma plataforma de ingressos. Para um evento gratuito em que a página e a lista devem continuar suas, o resto deste guia é a configuração. A página sobre o Eventbrite compara os dois com mais detalhes.
Campos que tornam o planejamento possível
Pergunte só o que vai fazer você agir. Cada pergunta aqui muda algo que você encomenda, reserva ou organiza. Experimente: a prévia roda o validador do Ikiform no seu navegador, então você vê o 422 que um quarto convidado receberia.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
| Campo | Por quê | Lembre-se |
|---|---|---|
attending | Um Não também é informação: evita que você fique cobrando as pessoas | Um radio com yes e no, para a contagem ser um filtro |
guests | O número para o qual você encomenda comida e cadeiras | Um number com min: 0, max: 3, integer: true. O Ikiform recusa 5 e 1.5 no servidor |
dietary | Contagem para o buffet | Um select soma de forma limpa. Texto livre não |
access_needs | Acesso sem degraus, assentos, uma sala silenciosa | Opcional. Leia todas antes do dia |
email | Avisar as pessoas sobre mudanças e casar respostas alteradas | Também é o reply-to de cada e-mail de notificação |
O formulário de RSVP
Escolha sua stack. Cada versão agradece um Sim e um Não de forma diferente, esconde as perguntas sobre convidados para um Não e envia guests como 0 quando alguém não vem, para as suas somas continuarem certas.
Cole o formulário na página do evento. Ele envia do jeito tradicional e redireciona para a sua página de confirmação, então funciona sem JavaScript. O script curto esconde as perguntas sobre convidados quando alguém responde Não e volta o número de convidados para 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>Uma server action encaminha o RSVP com sua chave secreta e define guests como 0 para um Não, seja lá o que o navegador tenha enviado. O componente troca o formulário por uma frase diferente conforme a resposta.
"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>
);
}Uma Astro Action com accept: "form" confere a resposta com Zod, incluindo o limite de convidados, antes de encaminhar qualquer coisa. z.coerce.number() transforma o texto do formulário em número. A página é renderizada sob demanda, então mantenha prerender = false e um 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>
)}Uma server function POST confere o número de convidados em .validator() e o zera para um Não. O componente esconde as perguntas sobre convidados até alguém dizer Sim.
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>
);
}A action da rota encaminha o RSVP e devolve a resposta, para o componente agradecer um Sim e um Não de forma diferente. data() mantém o status do Ikiform nos erros.
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>
);
}Uma form action padrão confere o limite de convidados com fail() antes de encaminhar e mantém o nome e o e-mail digitados nos erros. bind:group mostra as perguntas sobre convidados só para um Sim. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la.
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}Uma server route guarda a chave secreta; defina NUXT_IKIFORM_SECRET_KEY e declare ikiformSecretKey em runtimeConfig no nuxt.config.ts. O componente envia um objeto reativo com $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>Uma single-page app envia direto do navegador. Nunca coloque uma chave secreta aqui; Allowed domains (domínios permitidos) protege o formulário. Um Não zera guests antes do envio, e a regra max do Ikiform pega todo o resto.
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>
);
}O mesmo envio pelo navegador em um single-file component do Vue, com as perguntas sobre convidados atrás de 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 em variáveis de ambiente só do servidor. As versões no servidor também repassam o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.Os guias de frameworks na documentação vão mais a fundo: Next.js, Astro, SvelteKit, Nuxt, React, Vue e HTML puro. Em um criador de sites, a versão HTML vai colada em um bloco de embed.
Conte as pessoas pela resposta mais recente
As pessoas mudam de ideia. Alguém diz Sim com dois convidados na segunda e Não na quinta; são dois envios, e o segundo é o que vale. Então a regra é: uma resposta por e-mail, a mais nova, e depois some um pela pessoa mais os convidados dela.
- Segunda: Ada confirma presença com 2 convidados. Somar todos os envios contaria 3 pessoas.
- Quarta: Ben confirma presença sem convidados.
- Quinta: Ada envia o formulário de novo e diz não. O Ikiform armazena isso como um segundo envio dela.
- Pegando só o envio mais novo de cada e-mail, Ada é um não, então a contagem é 1: Ben.
Em uma planilha
- Em Submissions, escolha o formulário de RSVP, defina o status como Received e o período como All time, depois clique em Export CSV.
- Importe no Google Sheets e ordene por
created_at, do mais novo para o mais antigo. - Selecione os dados e use Dados > Limpeza de dados > Remover cópias na coluna
email. O Sheets mantém a primeira linha que encontra, que agora é a mais nova. - Conte, com
attendingna coluna G eguestsna coluna H. Ajuste as letras à sua exportação:
=COUNTIF(G2:G, "yes") + SUMIF(G2:G, "yes", H2:H)A exportação traz primeiro id, created_at, form e status, e depois uma coluna por campo. Um arquivo tem até 100.000 linhas.
Com um script
Para ter um número que você pode recalcular a qualquer momento, leia os RSVPs pela API REST. As listas vêm da mais nova para a mais antiga, então o primeiro envio visto para cada e-mail é o mais recente. Rode isto com Node e uma chave de API secreta:
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));A tabela de refeições conta RSVPs, não pessoas, porque um único formulário não diz qual convidado come o quê. Se as refeições dos convidados importam, como em um casamento, pergunte por pessoa; a página de RSVP de casamento mostra como.
Encerrando as inscrições quando lotar
O Ikiform não para o formulário em um número, então prepare duas coisas: um alerta antes de lotar e um jeito de encerrar. Encerre em dois lugares, no formulário e na página:
- Nas configurações do formulário, escolha Pause form. A partir daí, os envios são recusados com
403 form_paused, então uma aba antiga ou um bot não conseguem adicionar mais ninguém. - Troque o formulário na página do evento por uma frase como
Registration is closed. Email us to join the waitlist.Os visitantes nunca devem dar de cara com o erro. - Se reabrir, escolha Resume form e coloque o formulário de volta.
O alerta pode ser um handler de webhook que refaz a contagem a cada Sim e envia uma mensagem para um webhook de chat, como o do Slack, quando você passa de 90% da capacidade. Este é um route handler do Next.js; a mesma lógica roda em qualquer servidor:
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;
}
}- Ele refaz a contagem pela API a cada vez, então uma resposta alterada é levada em conta. Cada chave pode fazer 100 requisições à API por minuto, o que cobre vários milhares de RSVPs por contagem.
- O alerta diz a uma pessoa que é hora de pausar. Pausar é uma ação no painel, então nada fecha de surpresa.
- Adicione o endpoint em Webhooks e copie o segredo dele para
IKIFORM_WEBHOOK_SECRET. Um workspace pode ter 3 endpoints; a documentação de webhooks explica as novas tentativas.
- Enquanto as inscrições estão abertas, cada RSVP é enviado ao Ikiform.
- Seu handler de webhook refaz a contagem das respostas mais recentes a cada Sim e avisa você ao atingir 90% da capacidade.
- Você decide quando lotou e escolhe Pause form, que recusa novos envios com 403 form_paused.
- Você troca o formulário na página por uma mensagem de encerramento e um jeito de entrar numa lista de espera.
Confirmações sem e-mail de confirmação
O Ikiform não envia e-mail ao convidado, então dê a ele o que precisa na página de agradecimento: data, horário, endereço e um arquivo de calendário. Um arquivo .ics estático ao lado da página funciona em todo app de calendário. Coloque um link para ele na página de confirmação como 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:VCALENDARHorários terminados em Z estão em UTC, então os apps de calendário mostram cada um no fuso horário do próprio convidado. Se você quer um e-mail de confirmação de verdade, envie cada RSVP por webhook para sua ferramenta de e-mail, direto ou via Zapier, Make ou n8n, e mande o e-mail de lá. A mesma lista depois recebe o lembrete na véspera.
Respostas alteradas e cancelamentos
Diga às pessoas como mudar a resposta: Plans changed? Send the form again with the same email. Sua contagem já usa a resposta mais nova, então não é preciso mais nada. As notificações mostram cada mudança no momento em que acontece, o que é útil na semana do evento.
Duas pessoas no mesmo Wi-Fi compartilham um endereço IP, e o limite de taxa conta por IP. O padrão Standard, de 5 por minuto por visitante, serve para uma página de evento. Não defina um limite Custom de um envio por dia para barrar repetições: isso bloqueia uma segunda pessoa da mesma casa na mesma rede, e a regra da resposta mais recente já cuida das repetições.
No dia
Exporte os RSVPs, remova os duplicados como acima, filtre por attending = yes, ordene por nome e imprima. Adicione colunas para marcar presença e para os convidados que chegaram. Uma lista impressa funciona quando o sinal do local não funciona. Deixe a coluna de acessibilidade na cópia da equipe da porta e a coluna de alimentação na do buffet.
Spam não chega à sua lista: o honeypot, a pontuação de spam e os limites de taxa filtram antes da contagem, e spam não envia e-mail nem webhook. Confira o filtro Spam uma vez antes de imprimir, caso um RSVP real com uma mensagem incomum tenha ido parar lá. Endereços descartáveis somam 2 à pontuação de spam.
Escolha o seu caso
Um casamento, um evento comunitário gratuito e uma escala de voluntários contam de formas diferentes. Estas páginas mostram o que muda.
- RSVP de casamentoUm formulário de RSVP para um site de casamento feito por você: uma resposta por convite, uma refeição por convidado, um prazo e a lista de quem não respondeu.
- Sem EventbriteFaça as inscrições de um evento comunitário gratuito no seu próprio site: escolha de sessões, e-mail de confirmação pela sua ferramenta, contagem por sala e lista de check-in.
- Inscrição de voluntáriosUm formulário de inscrição de voluntários em que as pessoas escolhem turnos, com telefone para o dia, e um script que mostra os turnos desfalcados e monta a escala.
Confira se funciona
Teste o ciclo completo uma semana antes de anunciar o evento. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Envie um Sim com dois convidados e um Não a partir de e-mails diferentes. Os dois chegam, e o Não mostra
guestscomo0. - Envie três convidados, depois quatro. O quarto é recusado com
Must be at most 3. - Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email. - Rode o script de contagem ou a fórmula da planilha. O resultado deve bater com o que você enviou.
- Pause o formulário e envie mais uma vez. Você recebe
403 form_paused. Retome o formulário. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
| A contagem está alta demais | Você está somando todos os envios. Mantenha só o mais novo por e-mail e confira se um Não envia guests como 0. |
403 form_paused na página publicada | O formulário está pausado no painel. Retome-o ou troque o formulário na página por uma mensagem de encerramento. |
422 validation_failed mencionando guests | Mais convidados que o max do campo, ou um número decimal. Mostre a mensagem do Ikiform ao lado do campo, como os exemplos fazem. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
| O navegador cai na página de agradecimento do Ikiform em vez da sua | O _redirect é ignorado, a menos que o domínio dele esteja em Allowed domains e ele seja uma URL https:. Adicione o domínio, ou defina Redirect after submit nas configurações do formulário. |
Perguntas
Não automaticamente. Não existe configuração de capacidade. Acompanhe a contagem, ou crie um alerta a partir de um webhook, e então escolha Pause form nas configurações do formulário. Um formulário pausado recusa novos envios com 403 form_paused.
Não. O Ikiform não tem pagamentos, ingressos nem check-in. Use uma plataforma de ingressos para eventos pagos e um formulário na sua própria página para os gratuitos.
Transforme os convidados em um campo numérico com min 0, max e integer definidos. O Ikiform aplica essas regras no servidor, então um envio com convidados demais é recusado com um erro de campo que você pode mostrar.
Os dois envios são armazenados. Conte só o envio mais novo por e-mail, numa planilha depois de remover os duplicados ou num script, para que uma resposta alterada substitua a antiga.
Não. O Ikiform envia e-mail para você e sua equipe, não para quem enviou, então não há resposta automática nem e-mail de confirmação. Envie um webhook para sua ferramenta de e-mail e mande a confirmação de lá.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.