# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/lead-capture/demo-request

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.
- **`_redirect` kann nicht mitnehmen, was eingegeben wurde.** Es ist eine feste URL, und Ikiform folgt ihr nur bei einer `https:`-URL auf einer deiner **Allowed domains**. `cal.com` oder `calendly.com` dort 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.

Diagramm: Wohin eine Demo-Anfrage geht, nachdem sie gespeichert ist

1. Der Besucher gibt Name, geschäftliche E-Mail, Unternehmen und Teamgröße ein, und das Formular sendet an Ikiform.
2. Ikiform speichert die Anfrage und schickt deinem Team eine E-Mail. Nichts anderes passiert, bis es mit ok antwortet.
3. Dein Code liest die Teamgröße, die der Besucher gewählt hat.
4. Teams ab 11 Personen gehen zur Buchungsseite, mit Name und E-Mail im Query-String.
5. Teams mit 1 bis 10 Personen gehen zu einer aufgezeichneten Tour mit Anmeldelink.

Die Verzweigung läuft in deinem Code, nachdem Ikiform den Lead hat. Das Team bekommt in jedem Fall jede Anfrage, sodass ein kleines Team, das ein Gespräch hätte bekommen sollen, trotzdem sichtbar ist.

## 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` | E-Mail | 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`.

**HTML**

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.

`demo.html`:

```html
<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>
```

**Next.js**

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.

`app/demo/actions.ts`:

```ts
"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());
}
```

`app/demo/demo-form.tsx`:

```tsx
"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>
  );
}
```

**SvelteKit**

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.

`src/routes/demo/+page.server.ts`:

```ts
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;
```

**React**

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.

`src/DemoForm.tsx`:

```tsx
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>
  );
}
```

> Die serverseitigen Versionen senden mit einem Secret Key, also überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom für ein Formular, das du über einen Server sendest, und halte `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:

`lib/demo-tag.ts`:

```ts
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](https://ikiform.com/de/guides/lead-capture/leads-to-crm.md) zeigt ein vollständiges Relay.

## Prüfen, ob es funktioniert

1. Füge die Domain deiner Site und `localhost` unter **Allowed domains** hinzu.
2. Sende mit Teamgröße **51 to 200** ab. Die Buchungsseite öffnet sich mit deinem Namen und deiner E-Mail vorausgefüllt.
3. Schließ die Buchungsseite, ohne einen Termin zu wählen. Die Anfrage ist trotzdem unter **Submissions**, und die Benachrichtigungs-E-Mail liegt in deinem Posteingang.
4. Sende erneut mit **1 to 10** ab und prüfe, dass du bei der Tour landest.
5. Sende mit einer ungültigen E-Mail ab. Der Fehler erscheint im Formular, und die Buchungsseite öffnet sich nicht.
6. 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, wenn `forwardQueryParams` aktiviert ist. Calendly dokumentiert das Vorausfüllen bei Einbettungen über das `prefill`-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/sdk` aufrufen, sonst bekommen sie `403 challenge_required`. Die Server-Versionen überspringen die Prüfung.

## Fragen

### Wie übergebe ich Name und E-Mail aus dem Formular an Calendly oder Cal.com?

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.

### Warum Leute nicht ohne Formular direkt zur Buchungsseite schicken?

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.

### Kann ich mit _redirect Leute auf meine Buchungsseite schicken?

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.

### Kann das Demo-Formular Gmail und andere kostenlose Adressen ablehnen?

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.

## Weitere Anleitungen

- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
- [Leads an HubSpot oder Pipedrive](https://ikiform.com/de/guides/lead-capture/leads-to-crm.md): Bilde jeden signierten Ikiform-Webhook auf HubSpots Kontakt-Upsert oder die Personen- und Lead-Aufrufe von Pipedrive ab, ohne doppelte Kontakte bei einer wiederholten Zustellung.
- [Formular für Beratungsanfragen](https://ikiform.com/de/guides/lead-capture/consultation-request.md): Ein Formular für Beratungsanfragen für Steuerberater, Anwälte, Coaches und Praxen, das fragt, wie und wann angerufen werden soll, und heikle Details für das Gespräch lässt.
- [Formular für Angebotsanfragen](https://ikiform.com/de/guides/lead-capture/quote-request.md): Ein Formular für Angebotsanfragen, das Briefings als geteilte Links statt als Uploads sammelt, jeder Anfrage ein Fälligkeitsdatum gibt und linklastige Leads aus dem Spam hält.
- [Formular für Projektanfragen](https://ikiform.com/de/guides/lead-capture/project-inquiry.md): Ein Formular für neue Projekte für Studios und Freelancer, das Anfragen nach Budget und Zeitpunkt in heiß, warm und unpassend sortiert, bevor jemand das Briefing liest.
- [Lead-Formular mit Next.js](https://ikiform.com/de/guides/lead-capture/nextjs.md): Ein Lead-Formular für den Next.js App Router, das in einer Server Action mit Zod validiert, UTMs des ersten Kontakts in einem Cookie hält und qualifizierte Leads in deinen Kalender schickt.
