Guías15 min de lectura
Formularios de inscripción y RSVP para eventos en tu propio sitio
Recibe RSVP en la página de tu evento: el formulario, límite de acompañantes, un recuento fiable, cierre al llenarse y lista de entrada, sin app de entradas.
Pon en la página de tu evento un formulario de RSVP que envíe a un backend de formularios, con una respuesta sí o no, un número de acompañantes limitado en el servidor y los datos que necesitas para organizar. Cuenta asistentes a partir de la última respuesta de cada email, con una exportación CSV o un script corto, y pausa el formulario cuando se llene. Ikiform guarda y reenvía cada RSVP, pero no tiene venta de entradas, pagos ni límite de aforo automático, así que usa una plataforma de entradas cuando necesites eso.
La mayoría de los eventos no necesitan una plataforma de entradas. Un meetup, un taller, una fiesta de lanzamiento, un club de lectura o una jornada gratuita de conferencias necesitan tres cosas: una página que diga qué y cuándo, un formulario que recoja quién viene y un número en el que puedas confiar el día antes. Esta guía monta eso en tu propio sitio y deja claro lo que un formulario no puede hacer.
El recorrido de un RSVP
El formulario vive en la página de tu evento. Cada RSVP se envía a Ikiform, que lo comprueba con tus reglas de campos, filtra el spam, lo guarda y te avisa. Desde ahí llega a lo que uses para organizar: el panel, una hoja de cálculo o un script que cuenta asistentes.
- Un invitado rellena el formulario de RSVP en la página de tu evento y este se envía a tu endpoint de Ikiform.
- Ikiform comprueba los campos, incluido el límite de acompañantes, y puntúa el envío como posible spam antes de guardarlo.
- Cada RSVP aparece en el panel, donde puedes filtrarlo y exportarlo como CSV.
- Los organizadores reciben un email por cada RSVP, y al responder le escriben directamente al invitado.
- Ikiform envía un webhook submission.created firmado.
- Una hoja de cálculo o un script corto convierte los RSVP en un recuento de asistentes, usando la última respuesta de cada persona.
Lo que Ikiform no hace por tus eventos
Tenlo claro antes de elegir. Un backend de formularios recoge respuestas. No es un sistema de venta de entradas:
- Sin límite de aforo. No hay ningún ajuste que cierre el formulario tras cierto número de RSVP o de invitados. Tú vigilas el recuento y pausas el formulario.
- Sin entradas ni pagos. Sin códigos QR, sin app de control de acceso, sin niveles de pago, sin reembolsos.
- Sin email al invitado. Ikiform no envía emails a la persona que rellena el formulario. Los emails de notificación solo van a ti y a tu equipo, así que no hay respuesta automática, email de confirmación ni doble opt-in. Envíalos desde tu herramienta de email, activada por un webhook.
- Sin lista de espera. Cuando pausas el formulario, los nuevos envíos se rechazan. Una lista de espera sería un segundo formulario que pones en su lugar.
- Sin deduplicación. Ikiform no fusiona ni rechaza envíos repetidos por email. Una repetición idéntica en menos de 24 horas solo suma a la puntuación de spam. Elimina duplicados donde guardes la lista, o al exportar.
Cada uno de estos puntos tiene una solución viable más abajo. Si varios son decisivos para ti, una plataforma de entradas es mejor herramienta.
Formas de recibir RSVP
| Enfoque | Punto fuerte | Inconvenientes |
|---|---|---|
| Plataforma de entradas, como Eventbrite o Luma | Entradas, pagos, límites de aforo, listas de espera, emails de confirmación y control de acceso, todo incluido | La página del evento y la lista de asistentes viven en su plataforma con su marca. Las entradas de pago tienen comisiones; revisa sus páginas de precios |
| Google Forms | Gratis y rápido. Un interruptor para dejar de aceptar respuestas | Parece de Google, no de tu evento. Una página aparte y sin límite de acompañantes en el servidor |
| Invitación de calendario o chat de grupo | Suficiente para veinte amigos | Sin números fiables, sin necesidades alimentarias y sin lista el día del evento |
| Tu propio formulario y un backend de formularios, como Ikiform | Tu página, tu diseño, cualquier campo. Límite de acompañantes comprobado en el servidor, spam filtrado, aviso por email a los organizadores, CSV y webhooks | Cuentas asistentes, cierras la inscripción y envías confirmaciones tú mismo, o con un webhook a una herramienta de email |
Para un evento de pago, usa una plataforma de entradas. Para un evento gratuito en el que la página y la lista deben seguir siendo tuyas, el resto de esta guía es la configuración. La página sobre Eventbrite compara ambas opciones con más detalle.
Campos que hacen posible la organización
Pregunta solo lo que vayas a usar. Cada pregunta de aquí cambia algo que pides, reservas u organizas. Pruébalo: la vista previa ejecuta el validador de Ikiform en tu navegador, así puedes ver el 422 que recibiría un cuarto acompañante.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
| Campo | Por qué | Ten en cuenta |
|---|---|---|
attending | Un No también es información: te evita perseguir a la gente | Un radio con yes y no, para que el recuento sea un filtro |
guests | El número para el que pides comida y sillas | Un number con min: 0, max: 3, integer: true. Ikiform rechaza 5 y 1.5 en el servidor |
dietary | Recuento para el catering | Un select se suma sin problemas. El texto libre no |
access_needs | Acceso sin escalones, asientos, una sala tranquila | Opcional. Léelas todas antes del día |
email | Avisar de cambios y relacionar respuestas modificadas | También es la dirección de respuesta de cada email de notificación |
El formulario de RSVP
Elige tu stack. Cada versión agradece de forma distinta un Sí y un No, oculta las preguntas sobre acompañantes ante un No y envía guests como 0 cuando alguien no viene, para que tus sumas cuadren.
Pega el formulario en la página del evento. Envía de la forma clásica y redirige a tu página de confirmación, así que funciona sin JavaScript. El script corto oculta las preguntas sobre acompañantes cuando alguien responde No y pone el número de acompañantes en 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>Una server action reenvía el RSVP con tu clave secreta y pone guests en 0 para un No, envíe lo que envíe el navegador. El componente sustituye el formulario por una frase distinta según la respuesta.
"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>
);
}Una Astro Action con accept: "form" comprueba la respuesta con Zod, incluido el límite de acompañantes, antes de reenviar nada. z.coerce.number() convierte el texto del formulario en un número. La página se renderiza bajo demanda, así que mantén prerender = false y un adaptador.
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>
)}Una server function POST comprueba el número de acompañantes en .validator() y lo pone a cero para un No. El componente oculta las preguntas sobre acompañantes hasta que alguien dice Sí.
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>
);
}La action de la ruta reenvía el RSVP y devuelve la respuesta, para que el componente pueda agradecer de forma distinta un Sí y un No. data() conserva el estado de Ikiform en los errores.
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>
);
}Una form action por defecto comprueba el límite de acompañantes con fail() antes de reenviar, y conserva el nombre y el email escritos cuando hay errores. bind:group muestra las preguntas sobre acompañantes solo para un Sí. Declara IKIFORM_SECRET_KEY en src/env.ts con defineEnvVars para que $app/env/private pueda leerla.
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}Una server route guarda la clave secreta; define NUXT_IKIFORM_SECRET_KEY y declara ikiformSecretKey en runtimeConfig dentro de nuxt.config.ts. El componente envía un objeto reactivo con $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>Una single-page app envía directamente desde el navegador. Nunca pongas aquí una clave secreta; Allowed domains (dominios permitidos) protege el formulario. Un No pone guests a cero antes del envío, y la regla max de Ikiform detecta todo lo demás.
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>
);
}El mismo envío desde el navegador en un single-file component de Vue, con las preguntas sobre acompañantes tras 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 en variables de entorno solo del servidor. Las versiones de servidor también reenvían la cabecera Origin del navegador cuando existe, porque el fetch propio de un servidor no envía ninguna e Ikiform la usa para contar los envíos de prueba gratuitos desde localhost.Las guías por framework de la documentación profundizan más: Next.js, Astro, SvelteKit, Nuxt, React, Vue y HTML simple. En un creador de sitios, la versión HTML se pega en un bloque de embed.
Cuenta asistentes a partir de la última respuesta
La gente cambia de opinión. Alguien dice Sí con dos acompañantes el lunes y No el jueves; son dos envíos, y el segundo es el que vale. Así que la regla es: una respuesta por email, la más reciente, y luego cuentas uno por la persona más sus acompañantes.
- Lunes: Ada responde que sí con 2 acompañantes. Sumar todos los envíos contaría 3 personas.
- Miércoles: Ben responde que sí sin acompañantes.
- Jueves: Ada vuelve a enviar el formulario y dice que no. Ikiform lo guarda como un segundo envío suyo.
- Tomando solo el envío más reciente de cada email, Ada es un no, así que el recuento es 1: Ben.
En una hoja de cálculo
- En Submissions (envíos), elige el formulario de RSVP, pon el estado en Received y el rango de fechas en All time, y luego Export CSV.
- Impórtalo en Google Sheets y ordénalo por
created_at, del más reciente al más antiguo. - Selecciona los datos y usa Data > Data cleanup > Remove duplicates sobre la columna
email. Sheets conserva la primera fila que encuentra, que ahora es la más reciente. - Cuenta, con
attendingen la columna G yguestsen la columna H. Ajusta las letras a tu exportación:
=COUNTIF(G2:G, "yes") + SUMIF(G2:G, "yes", H2:H)La exportación tiene primero id, created_at, form y status, y luego una columna por campo. Un archivo contiene hasta 100.000 filas.
Con un script
Para obtener un número que puedas recalcular en cualquier momento, lee los RSVP con la API REST. Las listas vienen de la más reciente a la más antigua, así que el primer envío que aparece para cada email es el último. Ejecuta esto con Node y una clave 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));La tabla de comidas cuenta RSVP, no personas, porque un formulario no puede decir qué come cada acompañante. Si las comidas de los invitados importan, como en una boda, pregunta por persona; la página de RSVP para bodas muestra cómo.
Cerrar la inscripción cuando se llena
Ikiform no detiene el formulario al llegar a un número, así que prepara dos cosas: un aviso antes de llenarte y una forma de cerrar. Cierra en dos sitios, el formulario y la página:
- En los ajustes del formulario, elige Pause form. A partir de ahí, los envíos se rechazan con
403 form_paused, así que ni una pestaña antigua ni un bot pueden añadir más. - Sustituye el formulario de la página del evento por una frase como
Registration is closed. Email us to join the waitlist.Los visitantes nunca deberían toparse con el error. - Si reabres, elige Resume form y vuelve a poner el formulario.
El aviso puede ser un manejador de webhook que recuenta con cada Sí y publica en un webhook de chat, como el de Slack, cuando superas el 90 % del aforo. Esto es un route handler de Next.js; la misma lógica funciona en cualquier 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;
}
}- Recuenta mediante la API cada vez, así que un cambio de respuesta se refleja. Cada clave puede hacer 100 peticiones a la API por minuto, lo que alcanza para varios miles de RSVP por recuento.
- El aviso le dice a una persona que pause. Pausar es una acción del panel, así que nada se cierra por sorpresa.
- Añade el endpoint en Webhooks y copia su secreto en
IKIFORM_WEBHOOK_SECRET. Un espacio de trabajo puede tener 3 endpoints; la documentación de webhooks explica los reintentos.
- Mientras la inscripción está abierta, cada RSVP se envía a Ikiform.
- Tu manejador de webhook recuenta las últimas respuestas con cada Sí y te avisa al 90 % del aforo.
- Tú decides cuándo estás lleno y eliges Pause form, que rechaza los nuevos envíos con 403 form_paused.
- Sustituyes el formulario de la página por un mensaje de cierre y una forma de apuntarse a una lista de espera.
Confirmaciones sin email de confirmación
Ikiform no envía emails al invitado, así que dale lo que necesita en la página de agradecimiento: la fecha, la hora, la dirección y un archivo de calendario. Un archivo .ics estático junto a la página funciona en cualquier app de calendario. Enlázalo desde la página de confirmación 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:VCALENDARLas horas que terminan en Z están en UTC, así que las apps de calendario las muestran en la zona horaria de cada invitado. Si quieres un email de confirmación de verdad, envía cada RSVP por webhook a tu herramienta de email, directamente o a través de Zapier, Make o n8n, y mándalo desde ahí. La misma lista recibe después el recordatorio del día antes.
Respuestas modificadas y cancelaciones
Explica a la gente cómo cambiar su respuesta: Plans changed? Send the form again with the same email. Tu recuento ya usa la respuesta más reciente, así que no hace falta nada más. Las notificaciones muestran cada cambio en el momento, lo que resulta útil la semana del evento.
Dos personas en la misma Wi-Fi comparten dirección IP, y el límite de frecuencia cuenta por IP. El preset por defecto, Standard, con 5 por minuto por visitante, basta para la página de un evento. No pongas un límite Custom de un envío al día para frenar repeticiones: bloquea a un segundo miembro del hogar en la misma red, y la regla de la última respuesta ya resuelve las repeticiones.
El día del evento
Exporta los RSVP, elimina duplicados como arriba, filtra por attending = yes, ordena por nombre e imprímelo. Añade columnas para marcar la llegada y para los acompañantes que llegaron. Una lista impresa funciona cuando en el local no hay cobertura. Deja la columna de accesibilidad en la copia del equipo de la puerta, y la de alimentación en la del catering.
El spam no llega a tu lista: el honeypot, la puntuación de spam y los límites de frecuencia lo filtran antes de que cuente, y el spam no envía emails ni webhooks. Revisa una vez el filtro Spam antes de imprimir, por si un RSVP real con un mensaje poco habitual acabó ahí. Las direcciones desechables suman 2 a la puntuación de spam.
Elige tu caso
Una boda, un evento comunitario gratuito y un turno de voluntarios se cuentan de forma distinta. Estas páginas explican qué cambia.
- RSVP de bodaUn formulario de RSVP para la web de boda que hiciste tú: una respuesta por invitación, un menú por invitado, una fecha límite y la lista de quién no ha respondido.
- Sin EventbriteGestiona la inscripción a un evento comunitario gratuito en tu sitio: elección de sesiones, email de confirmación desde tu herramienta, aforos por sala y lista de entrada.
- Inscripción de voluntariosUn formulario de voluntarios donde se eligen turnos, con un teléfono para el día, y un script que muestra qué turnos faltan cubrir y arma el cuadrante.
Comprueba que funciona
Prueba el circuito completo una semana antes de anunciar el evento. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Envía un Sí con dos acompañantes y un No desde emails distintos. Llegan ambos, y el No muestra
guestscomo0. - Envía tres acompañantes y luego cuatro. El cuarto se rechaza con
Must be at most 3. - Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y Reply va a quien envió el formulario cuando este tiene un campo
email. - Ejecuta el script de recuento o la fórmula de la hoja. Debería coincidir con lo que enviaste.
- Pausa el formulario y envía una vez más. Recibes
403 form_paused. Reanúdalo. - Envía con el honeypot relleno,
_gotcha=bot. Igualmente recibe una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
Solución de problemas
| Problema | Qué hacer |
|---|---|
| El recuento sale demasiado alto | Estás sumando todos los envíos. Quédate solo con el más reciente por email, y comprueba que un No envía guests como 0. |
403 form_paused en la página publicada | El formulario está en pausa en el panel. Reanúdalo, o sustituye el formulario de la página por un mensaje de cierre. |
422 validation_failed que menciona guests | Más acompañantes que el max del campo, o un decimal. Muestra el mensaje de Ikiform junto al campo, como hacen los ejemplos. |
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, y localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
| El navegador acaba en la página de agradecimiento de Ikiform en lugar de la tuya | _redirect se ignora salvo que su dominio esté en Allowed domains y sea una URL https:. Añade el dominio, o configura Redirect after submit en los ajustes del formulario. |
Preguntas
No de forma automática. No hay ajuste de aforo. Vigila el recuento, o avísate con un webhook, y luego elige Pause form en los ajustes del formulario. Un formulario en pausa rechaza los nuevos envíos con 403 form_paused.
No. Ikiform no tiene pagos, entradas ni control de acceso. Usa una plataforma de entradas para eventos de pago, y un formulario en tu propia página para los gratuitos.
Haz que los acompañantes sean un campo numérico con min 0, max e integer configurados. Ikiform aplica esas reglas en el servidor, así que un envío con demasiados acompañantes se rechaza con un error de campo que puedes mostrar.
Se guardan ambos envíos. Cuenta solo el envío más reciente por email, en una hoja tras eliminar duplicados o en un script, para que una respuesta modificada sustituya a la anterior.
No. Ikiform te envía emails a ti y a tu equipo, no a quien envía el formulario, así que no hay respuesta automática ni email de confirmación. Envía un webhook a tu herramienta de email y manda la confirmación desde ahí.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.