Guides15 min de lecture
Formulaires d'inscription et de RSVP pour événements sur votre propre site
Recevez les RSVP sur votre page d'événement : formulaire, limite d'invités, décompte fiable, clôture quand c'est complet et liste d'accueil, sans billetterie.
Placez sur votre page d'événement un formulaire de RSVP qui envoie vers un backend de formulaires, avec une réponse oui ou non, un nombre d'invités plafonné côté serveur et les détails utiles à l'organisation. Comptez les participants à partir de la dernière réponse de chaque e-mail, avec un export CSV ou un petit script, et mettez le formulaire en pause quand c'est complet. Ikiform stocke et transmet chaque RSVP mais ne gère ni billetterie, ni paiements, ni plafond de places automatique : utilisez une plateforme de billetterie si vous en avez besoin.
La plupart des événements n'ont pas besoin d'une plateforme de billetterie. Un meetup, un atelier, une soirée de lancement, un club de lecture ou une journée de conférence gratuite demandent trois choses : une page qui dit quoi et quand, un formulaire qui recueille qui vient, et un chiffre fiable la veille. Ce guide met tout cela en place sur votre propre site, en étant clair sur ce qu'un formulaire ne sait pas faire.
Le parcours d'un RSVP
Le formulaire se trouve sur votre page d'événement. Chaque RSVP est envoyé à Ikiform, qui le vérifie selon vos règles de champs, filtre le spam, le stocke et vous prévient. Ensuite, il arrive dans l'outil avec lequel vous organisez : le tableau de bord, un tableur ou un script qui compte les participants.
- Un invité remplit le formulaire de RSVP sur votre page d'événement, qui l'envoie à votre endpoint Ikiform.
- Ikiform vérifie les champs, y compris la limite d'invités, et évalue le spam avant de stocker l'envoi.
- Chaque RSVP apparaît dans le tableau de bord, où vous pouvez le filtrer et l'exporter en CSV.
- Les organisateurs reçoivent un e-mail pour chaque RSVP, et Répondre écrit directement à l'invité.
- Ikiform envoie un webhook submission.created signé.
- Un tableur ou un petit script transforme les RSVP en décompte, à partir de la dernière réponse de chaque personne.
Ce qu'Ikiform ne fait pas pour les événements
Soyez au clair là-dessus avant de choisir. Un backend de formulaires recueille des réponses. Ce n'est pas un système de billetterie :
- Pas de plafond de places. Aucun réglage ne ferme le formulaire après un certain nombre de RSVP ou d'invités. Vous surveillez le décompte et mettez vous-même le formulaire en pause.
- Ni billets ni paiements. Pas de QR codes, pas d'appli d'accueil, pas de tarifs payants, pas de remboursements.
- Pas d'e-mail à l'invité. Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un webhook.
- Pas de liste d'attente. Quand vous mettez le formulaire en pause, les nouveaux envois sont refusés. Une liste d'attente serait un second formulaire que vous mettez à sa place.
- Pas de dédoublonnage. Ikiform ne fusionne ni ne rejette les soumissions répétées par e-mail. Une répétition identique dans les 24 heures ne fait qu'augmenter le score de spam. Dédoublonnez là où vit la liste, ou au moment de l'export.
Chacun de ces points a une solution praticable ci-dessous. Si plusieurs sont rédhibitoires, une plateforme de billetterie est le meilleur outil.
Les façons de recueillir des RSVP
| Approche | Points forts | Compromis |
|---|---|---|
| Plateforme de billetterie, comme Eventbrite ou Luma | Billets, paiements, limites de places, listes d'attente, e-mails de confirmation et accueil, tout est inclus | La page de l'événement et la liste des participants vivent sur leur plateforme, avec leur marque. Les billets payants ont des frais ; consultez leurs pages de tarifs |
| Google Forms | Gratuit et rapide. Un interrupteur pour ne plus accepter de réponses | Ressemble à Google, pas à votre événement. Une page à part, et pas de limite d'invités côté serveur |
| Invitation d'agenda ou groupe de discussion | Suffisant pour vingt amis | Aucun chiffre fiable, pas de régimes alimentaires et pas de liste le jour J |
| Votre propre formulaire et un backend de formulaires, comme Ikiform | Votre page, votre design, tous les champs voulus. Limite d'invités vérifiée côté serveur, spam filtré, organisateurs prévenus par e-mail, CSV et webhooks | Vous comptez les participants, fermez les inscriptions et envoyez les confirmations vous-même, ou via un webhook vers un outil d'e-mailing |
Pour un événement payant, utilisez une plateforme de billetterie. Pour un événement gratuit dont la page et la liste doivent rester à vous, la suite de ce guide est la mise en place. La page Eventbrite compare les deux plus en détail.
Des champs qui rendent l'organisation possible
Ne demandez que ce sur quoi vous allez agir. Chaque question ici change quelque chose que vous commandez, réservez ou organisez. Essayez : l'aperçu exécute le validateur d'Ikiform dans votre navigateur, vous voyez donc le 422 que recevrait un quatrième invité.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
| Champ | Pourquoi | À garder en tête |
|---|---|---|
attending | Un Non est aussi une information : il vous évite de relancer les gens | Un radio avec yes et no, pour que le décompte soit un filtre |
guests | Le nombre pour lequel vous commandez repas et chaises | Un number avec min: 0, max: 3, integer: true. Ikiform refuse 5 et 1.5 côté serveur |
dietary | Le décompte pour le traiteur | Un select s'additionne proprement. Le texte libre, non |
access_needs | Accès de plain-pied, places assises, salle calme | Facultatif. Lisez chaque réponse avant le jour J |
email | Prévenir les gens des changements et rapprocher les réponses modifiées | Sert aussi d'adresse de réponse pour chaque e-mail de notification |
Le formulaire de RSVP
Choisissez votre stack. Chaque version remercie différemment un Oui et un Non, masque les questions sur les invités pour un Non et envoie guests à 0 quand quelqu'un ne vient pas, pour que vos totaux restent justes.
Collez le formulaire dans la page de l'événement. Il envoie de façon classique et redirige vers votre page de confirmation, il fonctionne donc sans JavaScript. Le petit script masque les questions sur les invités quand quelqu'un répond Non et remet le nombre d'invités à 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>Une server action transmet le RSVP avec votre clé secrète et met guests à 0 pour un Non, quoi qu'ait envoyé le navigateur. Le composant remplace le formulaire par une phrase différente selon la réponse.
"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>
);
}Une Astro Action avec accept: "form" vérifie la réponse avec Zod, y compris la limite d'invités, avant de transmettre quoi que ce soit. z.coerce.number() convertit le texte du formulaire en nombre. La page est rendue à la demande : gardez prerender = false et un adaptateur.
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>
)}Une server function POST vérifie le nombre d'invités dans .validator() et le remet à zéro pour un Non. Le composant masque les questions sur les invités tant que la personne n'a pas répondu Oui.
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>
);
}L'action de la route transmet le RSVP et renvoie la réponse, pour que le composant remercie différemment un Oui et un Non. data() conserve le statut d'Ikiform en cas d'erreur.
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>
);
}Une form action par défaut vérifie la limite d'invités avec fail() avant de transmettre, et conserve le nom et l'e-mail saisis en cas d'erreur. bind:group n'affiche les questions sur les invités que pour un Oui. Déclarez IKIFORM_SECRET_KEY dans src/env.ts avec defineEnvVars pour que $app/env/private puisse la lire.
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}Une route serveur détient la clé secrète ; définissez NUXT_IKIFORM_SECRET_KEY et déclarez ikiformSecretKey sous runtimeConfig dans nuxt.config.ts. Le composant envoie un objet réactif avec $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>Une application monopage envoie directement depuis le navigateur. N'y mettez jamais de clé secrète ; Allowed domains (domaines autorisés) protège le formulaire. Un Non remet guests à zéro avant l'envoi, et la règle max d'Ikiform rattrape le reste.
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>
);
}Le même envoi côté navigateur dans un composant monofichier Vue, avec les questions sur les invités derrière 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 dans des variables d'environnement réservées au serveur. Les versions serveur transmettent aussi l'en-tête Origin du navigateur quand il existe, car le fetch d'un serveur n'en envoie aucun et Ikiform s'en sert pour compter les soumissions de test gratuites depuis localhost.Les guides par framework de la documentation vont plus loin : Next.js, Astro, SvelteKit, Nuxt, React, Vue et HTML simple. Sur un créateur de sites, la version HTML se colle dans un bloc d'intégration.
Compter à partir de la dernière réponse
Les gens changent d'avis. Quelqu'un répond Oui avec deux invités le lundi et Non le jeudi : cela fait deux soumissions, et c'est la seconde qui compte. La règle est donc : une réponse par e-mail, la plus récente, puis on compte un pour la personne plus ses invités.
- Lundi : Ada répond oui avec 2 invités. Additionner toutes les soumissions compterait 3 personnes.
- Mercredi : Ben répond oui sans invité.
- Jeudi : Ada renvoie le formulaire et répond non. Ikiform le stocke comme une deuxième soumission de sa part.
- En ne gardant que la soumission la plus récente de chaque e-mail, Ada a répondu non : le décompte est donc de 1, Ben.
Dans un tableur
- Dans Submissions, choisissez le formulaire de RSVP, réglez le statut sur Received et la période sur All time, puis cliquez sur Export CSV.
- Importez-le dans Google Sheets et triez par
created_at, du plus récent au plus ancien. - Sélectionnez les données et utilisez Données > Nettoyage des données > Supprimer les doublons sur la colonne
email. Sheets garde la première ligne qu'il rencontre, qui est désormais la plus récente. - Comptez, avec
attendingen colonne G etguestsen colonne H. Adaptez les lettres à votre export :
=COUNTIF(G2:G, "yes") + SUMIF(G2:G, "yes", H2:H)L'export contient d'abord id, created_at, form et status, puis une colonne par champ. Un fichier contient jusqu'à 100 000 lignes.
Avec un script
Pour un chiffre que vous pouvez recalculer à tout moment, lisez les RSVP via l'API REST. Les listes arrivent de la plus récente à la plus ancienne, donc la première soumission vue pour chaque e-mail est la dernière. Lancez ceci avec Node et une clé d'API secrète :
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));Le tableau des repas compte des RSVP, pas des personnes, car un seul formulaire ne peut pas dire quel invité mange quoi. Si les repas de chaque invité comptent, comme pour un mariage, demandez-les par personne ; la page sur les RSVP de mariage montre comment faire.
Fermer les inscriptions quand c'est complet
Ikiform n'arrête pas le formulaire à un chiffre donné : mettez donc en place deux choses, une alerte avant d'être complet et un moyen de fermer. Fermez à deux endroits, le formulaire et la page :
- Dans les réglages du formulaire, choisissez Pause form. Dès lors, les envois sont refusés avec
403 form_paused, ainsi un onglet resté ouvert ou un bot ne peut plus en ajouter. - Remplacez le formulaire de votre page d'événement par une phrase comme
Registration is closed. Email us to join the waitlist.Les visiteurs ne doivent jamais tomber sur l'erreur. - Si vous rouvrez, choisissez Resume form et remettez le formulaire en place.
L'alerte peut être un gestionnaire de webhook qui recompte à chaque Oui et poste sur le webhook d'une messagerie, comme celui de Slack, une fois 90 % de la capacité dépassés. Voici un route handler Next.js ; la même logique tourne sur n'importe quel serveur :
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;
}
}- Il recompte via l'API à chaque fois, donc une réponse modifiée est prise en compte. Chaque clé peut faire 100 requêtes d'API par minute, ce qui couvre plusieurs milliers de RSVP par décompte.
- L'alerte demande à une personne de mettre en pause. La pause est une action du tableau de bord, donc rien ne ferme par surprise.
- Ajoutez l'endpoint sous Webhooks et copiez son secret dans
IKIFORM_WEBHOOK_SECRET. Un espace de travail peut avoir 3 endpoints ; la documentation des webhooks couvre les nouvelles tentatives.
- Tant que les inscriptions sont ouvertes, chaque RSVP est envoyé à Ikiform.
- Votre gestionnaire de webhook recompte les dernières réponses à chaque Oui et vous alerte à 90 % de la capacité.
- Vous décidez quand c'est complet et choisissez Pause form, qui refuse les nouveaux envois avec 403 form_paused.
- Vous remplacez le formulaire de la page par un message de clôture et un moyen de rejoindre une liste d'attente.
Des confirmations sans e-mail de confirmation
Ikiform n'envoie pas d'e-mail à l'invité : donnez-lui donc ce dont il a besoin sur la page de remerciement, avec la date, l'heure, l'adresse et un fichier de calendrier. Un fichier .ics statique à côté de la page fonctionne dans toutes les applis d'agenda. Faites-y un lien depuis la page de confirmation sous le libellé 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:VCALENDARLes heures qui se terminent par Z sont en UTC, les applis d'agenda les affichent donc dans le fuseau horaire de chaque invité. Si vous voulez un vrai e-mail de confirmation, envoyez chaque RSVP par webhook à votre outil d'e-mailing, directement ou via Zapier, Make ou n8n, et envoyez-le depuis cet outil. La même liste reçoit ensuite le rappel la veille.
Réponses modifiées et annulations
Dites aux gens comment changer leur réponse : Plans changed? Send the form again with the same email. Votre décompte utilise déjà la réponse la plus récente, il n'y a donc rien d'autre à faire. Les notifications affichent chaque changement en temps réel, ce qui est utile la semaine de l'événement.
Deux personnes sur le même Wi-Fi partagent une adresse IP, et la limite de débit compte par IP. La limite par défaut (Standard, 5 par minute et par visiteur) convient à une page d'événement. Ne définissez pas de limite Custom d'un envoi par jour pour empêcher les répétitions : cela bloque un deuxième membre du foyer sur le même réseau, et la règle de la dernière réponse gère déjà les répétitions.
Le jour J
Exportez les RSVP, supprimez les doublons comme ci-dessus, filtrez sur attending = yes, triez par nom et imprimez. Ajoutez des colonnes pour cocher les arrivées et les invités présents. Une liste imprimée fonctionne quand le réseau du lieu ne passe pas. Gardez la colonne des besoins d'accessibilité sur l'exemplaire de l'équipe d'accueil, et la colonne alimentaire sur celui du traiteur.
Le spam n'atteint pas votre liste : le honeypot, le score de spam et les limites de débit le filtrent avant qu'il ne compte, et le spam n'envoie ni e-mail ni webhook. Vérifiez une fois le filtre Spam avant d'imprimer, au cas où un vrai RSVP avec un message inhabituel s'y serait retrouvé. Les adresses jetables ajoutent 2 au score de spam.
Choisissez votre cas
Un mariage, un événement associatif gratuit et un planning de bénévoles se comptent chacun différemment. Ces pages couvrent ce qui change.
- RSVP de mariageUn formulaire de RSVP pour un site de mariage fait maison : une réponse par invitation, un repas par invité nommé, une date limite et la liste de qui n'a pas répondu.
- Sans EventbriteGérez les inscriptions d'un événement associatif gratuit sur votre site : choix de sessions, e-mail de confirmation par votre outil, effectifs par salle et liste d'accueil.
- Inscription des bénévolesUn formulaire d'inscription de bénévoles avec choix de créneaux et téléphone pour le jour J, et un script qui repère les créneaux en manque et bâtit le planning.
Vérifiez que tout fonctionne
Testez toute la boucle une semaine avant d'annoncer l'événement. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Envoyez un Oui avec deux invités et un Non depuis deux e-mails différents. Les deux arrivent, et le Non affiche
guestsà0. - Envoyez trois invités, puis quatre. Le quatrième est refusé avec
Must be at most 3. - Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email. - Lancez le script de décompte ou la formule du tableur. Le résultat doit correspondre à ce que vous avez envoyé.
- Mettez le formulaire en pause et envoyez-le encore une fois. Vous recevez
403 form_paused. Relancez-le. - Envoyez avec le honeypot rempli,
_gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook. - Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.
Dépannage
| Problème | Que faire |
|---|---|
| Le décompte est trop élevé | Vous additionnez toutes les soumissions. Ne gardez que la plus récente par e-mail, et vérifiez qu'un Non envoie guests à 0. |
403 form_paused sur la page en ligne | Le formulaire est en pause dans le tableau de bord. Relancez-le, ou remplacez le formulaire de la page par un message de clôture. |
422 validation_failed mentionnant guests | Plus d'invités que le max du champ, ou un nombre décimal. Affichez le message d'Ikiform à côté du champ, comme le font les exemples. |
403 origin_not_allowed, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |
429 rate_limited | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de Retry-After, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
| Le navigateur arrive sur la page de remerciement d'Ikiform au lieu de la vôtre | _redirect est ignoré sauf si son domaine figure dans Allowed domains et qu'il s'agit d'une URL https:. Ajoutez le domaine, ou définissez plutôt Redirect after submit dans les réglages du formulaire. |
Questions
Pas automatiquement. Il n'existe pas de réglage de capacité. Surveillez le décompte, ou faites-vous alerter par un webhook, puis choisissez Pause form dans les réglages du formulaire. Un formulaire en pause refuse les nouveaux envois avec 403 form_paused.
Non. Ikiform n'a ni paiements, ni billets, ni accueil. Utilisez une plateforme de billetterie pour les événements payants, et un formulaire sur votre propre page pour les gratuits.
Faites du nombre d'invités un champ numérique avec min 0, un max et integer activé. Ikiform applique ces règles côté serveur, donc un envoi avec trop d'invités est refusé avec une erreur de champ que vous pouvez afficher.
Les deux soumissions sont stockées. Ne comptez que la plus récente par e-mail, dans un tableur après suppression des doublons ou dans un script, pour qu'une réponse modifiée remplace l'ancienne.
Non. Ikiform envoie des e-mails à vous et à votre équipe, pas à l'expéditeur : il n'y a donc ni réponse automatique ni e-mail de confirmation. Envoyez un webhook à votre outil d'e-mailing et envoyez la confirmation depuis celui-ci.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.