Formular zur Lead-Erfassung6 Min. Lesezeit
Ein Formular für Demo-Anfragen bauen, das den Termin bucht
Ein Formular zum Demo-Buchen, das den Lead speichert und den Besucher dann mit vorausgefülltem Namen und E-Mail zu Cal.com oder Calendly schickt.
Sende die Demo-Anfrage zuerst an ein Formular-Backend, damit der Lead gespeichert ist und dein Team eine E-Mail bekommt, auch wenn der Besucher nie einen Termin wählt. Schick den Browser dann zu deiner Buchungsseite, mit name und email im Query-String, mit denen Calendly und Cal.com ihr Formular vorausfüllen. Schick kleine Teams woandershin, etwa zu einer aufgezeichneten Tour, damit dein Kalender Gespräche enthält, die zum Abschluss führen können.
Was bei einer Demo-Anfrage anders ist
Wer eine Demo anfragt, will einen Termin im Kalender, keinen E-Mail-Verlauf. Jede Runde "Passt dir Dienstag?" ist eine Chance, stattdessen bei einem Mitbewerber zu buchen. Die Aufgabe des Formulars ist also kurz: genug erfassen, um zu qualifizieren, und dann einen Kalender zeigen, solange der Tab noch offen ist.
Drei Rahmenbedingungen prägen den Aufbau:
- Der Lead muss gespeichert sein, bevor sich der Terminplaner öffnet. Viele Besucher öffnen die Buchungsseite und schließen sie wieder. Wenn der einzige Datensatz von ihnen im Terminplaner liegt, sind diese Leads weg. Sende zuerst an Ikiform und leite nur bei Erfolg weiter.
_redirectkann nicht mitnehmen, was eingegeben wurde. Es ist eine feste URL, und Ikiform folgt ihr nur bei einerhttps:-URL auf einer deiner Allowed domains.cal.comodercalendly.comdort einzutragen, würde auch Posts von diesen Sites durchlassen. Baue den Buchungslink stattdessen in deinem eigenen Code.- Nicht jede Anfrage verdient ein Live-Gespräch. Einem Solo-Gründer, der eine Demo eines Teamprodukts anfragt, ist mit einer Tour und einem Anmeldelink besser gedient. Eine Frage mit festen Optionen entscheidet, wohin der Browser als Nächstes geht.
- Der Besucher gibt Name, geschäftliche E-Mail, Unternehmen und Teamgröße ein, und das Formular sendet an Ikiform.
- Ikiform speichert die Anfrage und schickt deinem Team eine E-Mail. Nichts anderes passiert, bis es mit ok antwortet.
- Dein Code liest die Teamgröße, die der Besucher gewählt hat.
- Teams ab 11 Personen gehen zur Buchungsseite, mit Name und E-Mail im Query-String.
- Teams mit 1 bis 10 Personen gehen zu einer aufgezeichneten Tour mit Anmeldelink.
Vier Felder, eines davon fürs Routing
| Feld | Typ | Warum |
|---|---|---|
name | Text | Füllt das Buchungsformular vor, sodass der Besucher es nur einmal eintippt |
email | Füllt das Buchungsformular vor und wird zum Reply-To der Benachrichtigungs-E-Mail | |
company | Text, hier Pflicht | Dein Vertriebler recherchiert es vor dem Gespräch |
team_size | select | Entscheidet zwischen Buchungsseite und Tour und kommt als einer von vier bekannten Werten an |
Heb dir Anwendungsfall, Zeitrahmen und Telefonnummer für das Gespräch selbst auf. Der Terminplaner kann selbst eine Frage stellen, wenn du vor dem Meeting Kontext brauchst, und sowohl Calendly als auch Cal.com können auch eigene Fragen vorausfüllen.
Erst senden, dann übergeben
Jede Version sendet die Anfrage, wartet auf Ikiforms Antwort und baut erst dann die nächste URL. Die Beispiele nutzen Cal.com; für Calendly setzt du deinen Event-Link ein. Calendly dokumentiert name und email als Parameter zum Vorausfüllen, und Cal.com füllt jedes Buchungsfeld über seinen Bezeichner vor, auch name und email.
Das Formular und sein Skript zusammen. Ohne JavaScript sendet das Formular trotzdem und landet auf deiner Seite aus Redirect after submit, also setze den Buchungslink dort als Fallback hin.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="demo-form">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" required />
<label for="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert" id="demo-error" hidden></p>
<button>Pick a time</button>
</form>
<script>
const BOOKING = "https://cal.com/acme/demo";
const SELF_SERVE = "/tour";
const form = document.getElementById("demo-form");
const error = document.getElementById("demo-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
form.querySelector("button").disabled = true;
error.hidden = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: data,
});
const result = await response.json();
if (!result.ok) throw new Error(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
const small = data.get("team_size") === "1_10";
const next = new URL(small ? SELF_SERVE : BOOKING, location.href);
next.searchParams.set("name", String(data.get("name")));
next.searchParams.set("email", String(data.get("email")));
location.assign(next);
} catch (failure) {
error.textContent = failure instanceof Error ? failure.message : "Couldn't send. Try again.";
error.hidden = false;
form.querySelector("button").disabled = false;
}
});
</script>Die Server Action ruft redirect() mit der Buchungs-URL auf, sobald Ikiform mit ok antwortet. Next.js akzeptiert dort absolute URLs, und das Formular funktioniert vor der Hydration, weil eine Server Action einen einfachen Formular-Post mit einem 303 beantwortet. Halte redirect() außerhalb jedes try-Blocks, weil es zum Arbeiten eine Exception wirft.
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const BOOKING = "https://cal.com/acme/demo";
export async function requestDemo(_previous: string, formData: FormData): Promise<string> {
const lead = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
company: String(formData.get("company") ?? "").trim(),
team_size: String(formData.get("team_size") ?? ""),
_gotcha: String(formData.get("_gotcha") ?? ""),
};
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(lead),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
return Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
}
const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : BOOKING);
next.searchParams.set("name", lead.name);
next.searchParams.set("email", lead.email);
redirect(next.toString());
}"use client";
import { useActionState } from "react";
import { requestDemo } from "./actions";
export function DemoForm() {
const [error, action, pending] = useActionState(requestDemo, "");
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required />
<label htmlFor="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
</form>
);
}Die Form Action gibt bei Fehlern fail() mit der ersten Meldung zurück und ruft bei Erfolg redirect(303, url, { external: true }) auf; SvelteKit 3 lehnt eine Weiterleitung auf eine andere Site ohne diese Option ab. Deklariere IKIFORM_SECRET_KEY in src/env.ts mit defineEnvVars, damit $app/env/private ihn lesen kann. Kombiniere es mit einem beliebigen +page.svelte-Formular, das an die Default-Action sendet; ohne JavaScript folgt der Browser dem 303 selbst.
import { fail, redirect } 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 data = await request.formData();
const lead = Object.fromEntries(
["name", "email", "company", "team_size", "_gotcha"].map((key) => [key, String(data.get(key) ?? "").trim()]),
);
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(lead),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { lead, message: Object.values(fields)[0]?.[0] ?? result.error.message });
}
const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : "https://cal.com/acme/demo");
next.searchParams.set("name", lead.name);
next.searchParams.set("email", lead.email);
redirect(303, next.toString(), { external: true });
},
} satisfies Actions;In einer Single-Page-App sendet der Browser direkt an Ikiform und geht mit location.assign weiter. Schütze das Formular mit Allowed domains, nie mit einem Secret Key.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const BOOKING = "https://cal.com/acme/demo";
export function DemoForm() {
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: data });
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
setError(Object.values(fields)[0]?.[0] ?? result.error.message);
setPending(false);
return;
}
const next = new URL(data.get("team_size") === "1_10" ? "/tour" : BOOKING, window.location.href);
next.searchParams.set("name", String(data.get("name")));
next.searchParams.set("email", String(data.get("email")));
window.location.assign(next);
} catch {
setError("Couldn't reach the server. Try again.");
setPending(false);
}
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required />
<label htmlFor="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
</form>
);
}IKIFORM_SECRET_KEY in Umgebungsvariablen, die nur der Server sieht. Die Serverversionen leiten den Origin-Header des Besuchers weiter, weil ein serverseitiger Fetch keinen sendet und Ikiform daran kostenlose localhost-Tests erkennt.Geschäftliche E-Mail, ohne Gmail zu blockieren
Ikiform hat keine Einstellung, die kostenlose E-Mail-Anbieter ablehnt. Die Option allowed_domains des E-Mail-Felds ist eine Allowlist: nützlich für ein internes Formular, das auf die Domain deines eigenen Unternehmens beschränkt ist, falsch für ein öffentliches Demo-Formular. Das ist in Ordnung, denn wer Gmail blockiert, blockiert auch Gründer, Berater und Käufer, die Formulare vom Handy ausfüllen.
Markiere diese Anfragen stattdessen dort, wo dein Team sie sieht. Diese Funktion läuft im Relay, das jede Anfrage in den Chat oder dein CRM postet, und gibt dem Vertriebler vor dem Gespräch einen Hinweis:
const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "proton.me"]);
export function demoTag(fields: Record<string, unknown>) {
const domain = String(fields.email ?? "").split("@")[1]?.toLowerCase() ?? "";
const size = String(fields.team_size ?? "");
if (FREE_MAIL.has(domain)) return "personal address, check the company";
if (size === "51_200" || size === "201_plus") return "priority";
return "standard";
}Ruf sie nach verifyWebhook mit event.data.fields auf und füge den Tag dann der Chat-Nachricht oder einer CRM-Eigenschaft hinzu. Leads an HubSpot oder Pipedrive zeigt ein vollständiges Relay.
Prüfen, ob es funktioniert
- Füge die Domain deiner Site und
localhostunter Allowed domains hinzu. - Sende mit Teamgröße 51 to 200 ab. Die Buchungsseite öffnet sich mit deinem Namen und deiner E-Mail vorausgefüllt.
- Schließ die Buchungsseite, ohne einen Termin zu wählen. Die Anfrage ist trotzdem unter Submissions, und die Benachrichtigungs-E-Mail liegt in deinem Posteingang.
- Sende erneut mit 1 to 10 ab und prüfe, dass du bei der Tour landest.
- Sende mit einer ungültigen E-Mail ab. Der Fehler erscheint im Formular, und die Buchungsseite öffnet sich nicht.
- Buche ein Test-Meeting und prüfe dann, ob die Bestätigung des Terminplaners an die eingegebene Adresse geht.
Stolperfallen
- Zwei Datensätze pro gebuchter Demo. Ikiform hält die Anfrage, der Terminplaner das Meeting. Ordne sie in deinem CRM per E-Mail zu, nicht per Name.
- Keine Bestätigung von Ikiform. Ikiform schreibt der Person, die das Formular ausfüllt, keine E-Mail. Die eigene Bestätigung und Kalendereinladung des Terminplaners decken gebuchte Gespräche ab. Auf Anfragen, die nie buchen, antwortest du noch am selben Tag aus der Benachrichtigungs-E-Mail.
- Query-Strings sind sichtbar. Name und E-Mail in der Buchungs-URL tauchen im Browserverlauf und in den Analytics des Terminplaners auf. Gib dort nichts anderes mit.
- Einbettungen. Bettest du den Terminplaner stattdessen auf deiner eigenen
/book-Seite ein, leitet Cal.com die Query-Parameter deiner Seite nur weiter, wennforwardQueryParamsaktiviert ist. Calendly dokumentiert das Vorausfüllen bei Einbettungen über dasprefill-Objekt seines JavaScript-Embeds, also nutze das statt eines einfachen iframes. - Die Spamprüfung. Ist Spam check auf Ikiform gesetzt, müssen die Browser-Versionen vor dem Senden
challenge()aus@ikiform/sdkaufrufen, sonst bekommen sie403 challenge_required. Die Server-Versionen überspringen die Prüfung.
Fragen
Hänge sie an den Query-String des Buchungslinks an, als ?name=Ada%20Lovelace&email=ada%40example.com. Baue die URL in deinem eigenen Code, nachdem das Formular-Backend mit ok geantwortet hat, und schick den Browser dorthin.
Viele Besucher öffnen einen Terminplaner und gehen, ohne zu buchen, und der Terminplaner erfasst nur Leute, die buchen. Ein kurzes Formular davor heißt, dass dein Team jede Anfrage mit einer Reply-To-Adresse hat und bei denen nachhaken kann, die keinen Termin gewählt haben.
Nur an eine feste URL auf einer der erlaubten Domains des Formulars, und sie kann nicht enthalten, was der Besucher eingegeben hat. Um das Buchungsformular vorauszufüllen, baue die URL stattdessen im Skript deiner Seite oder in deiner Server Action.
Es gibt keine eingebaute Sperrliste. Die Option für erlaubte Domains im E-Mail-Feld lässt nur die gelisteten Domains zu, was zu internen Formularen passt. Nimm jede Adresse an und markiere Anfragen von Freemail-Adressen in deinem Relay oder CRM.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.