# Buat formulir permintaan demo yang langsung memesan panggilan

> Formulir pesan demo yang menyimpan lead, lalu mengarahkan pengunjung ke Cal.com atau Calendly dengan nama dan email yang sudah terisi.

Terbit 2026-10-25 · https://ikiform.com/id/guides/lead-capture/demo-request

Kirim permintaan demo ke backend formulir lebih dulu, agar lead tersimpan dan tim Anda menerima email meskipun pengunjung tidak pernah memilih waktu. Lalu arahkan browser ke halaman booking Anda dengan `name` dan `email` di query string, yang dipakai Calendly dan Cal.com untuk mengisi formulirnya. Arahkan tim kecil ke tempat lain, seperti tur rekaman, agar kalender Anda berisi panggilan yang bisa closing.

## Apa yang berbeda dari permintaan demo

Orang yang meminta demo menginginkan waktu di kalender, bukan utas email. Setiap putaran "Selasa bisa?" adalah kesempatan bagi mereka untuk memesan dengan kompetitor. Jadi tugas formulirnya singkat: tangkap cukup informasi untuk kualifikasi, lalu sodorkan kalender selagi tab-nya masih terbuka.

Tiga batasan membentuk penyiapannya:

- **Lead harus disimpan sebelum penjadwal terbuka.** Banyak pengunjung membuka halaman booking lalu menutupnya. Jika satu-satunya catatan tentang mereka ada di penjadwal, lead itu hilang. Kirim ke Ikiform lebih dulu dan redirect hanya jika berhasil.
- **`_redirect` tidak bisa membawa apa yang mereka ketik.** Nilainya URL tetap, dan Ikiform hanya mengikutinya untuk URL `https:` di salah satu **Allowed domains** Anda. Menambahkan `cal.com` atau `calendly.com` di sana juga akan meloloskan kiriman dari situs-situs itu. Susun tautan booking di kode Anda sendiri.
- **Tidak setiap permintaan layak mendapat panggilan langsung.** Founder solo yang meminta demo produk untuk tim lebih terbantu dengan tur dan tautan pendaftaran. Satu pertanyaan pilihan tetap menentukan ke mana browser pergi berikutnya.

Diagram: Ke mana permintaan demo pergi setelah disimpan

1. Pengunjung mengisi nama, email kantor, perusahaan, dan ukuran tim, lalu formulir mengirim ke Ikiform.
2. Ikiform menyimpan permintaan dan mengirim email ke tim Anda. Tidak ada hal lain yang terjadi sampai Ikiform menjawab ok.
3. Kode Anda membaca ukuran tim yang dipilih pengunjung.
4. Tim berisi 11 orang atau lebih diarahkan ke halaman booking, dengan nama dan email di query string.
5. Tim berisi 1 sampai 10 orang diarahkan ke tur rekaman dengan tautan pendaftaran.

Percabangan berjalan di kode Anda, setelah Ikiform memegang lead. Tim tetap mendapat setiap permintaan, jadi tim kecil yang seharusnya mendapat panggilan tetap terlihat.

## Empat field, satu untuk routing

| Field | Tipe | Alasan |
| --- | --- | --- |
| `name` | teks | Mengisi formulir booking, jadi pengunjung cukup mengetiknya sekali |
| `email` | email | Mengisi formulir booking dan menjadi reply-to di email notifikasi |
| `company` | teks, wajib di sini | Sales Anda mencarinya sebelum panggilan |
| `team_size` | select | Menentukan halaman booking atau tur, dan tiba sebagai salah satu dari empat nilai yang dikenal |

Simpan kasus penggunaan, jadwal, dan nomor telepon untuk panggilannya sendiri. Penjadwal bisa mengajukan satu pertanyaan sendiri jika Anda butuh konteks sebelum rapat, dan Calendly maupun Cal.com juga bisa mengisi pertanyaan kustom.

## Kirim, lalu serahkan

Setiap versi mengirim permintaan, menunggu jawaban Ikiform, dan baru kemudian menyusun URL berikutnya. Contohnya memakai Cal.com; untuk Calendly, ganti dengan tautan event Anda. Calendly mendokumentasikan `name` dan `email` sebagai parameter prefill, dan Cal.com mengisi field booking apa pun berdasarkan identifier-nya, termasuk `name` dan `email`.

**HTML**

Formulir dan script-nya sekaligus. Tanpa JavaScript, formulir tetap mengirim dan mendarat di halaman **Redirect after submit** Anda, jadi taruh tautan booking di sana sebagai cadangan.

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

Server action memanggil `redirect()` dengan URL booking begitu Ikiform menjawab ok. Next.js menerima URL absolut di sana, dan formulir bekerja sebelum hydration karena server action menjawab form post biasa dengan `303`. Jaga `redirect()` di luar blok `try`, karena fungsi itu melempar exception untuk menjalankan tugasnya.

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

Form action mengembalikan `fail()` dengan pesan pertama saat error dan memanggil `redirect(303, url, { external: true })` saat berhasil; SvelteKit 3 menolak redirect ke situs lain tanpa opsi itu. Deklarasikan `IKIFORM_SECRET_KEY` di `src/env.ts` dengan `defineEnvVars` agar `$app/env/private` bisa membacanya. Pasangkan dengan formulir `+page.svelte` mana pun yang mengirim ke default action; tanpa JavaScript browser mengikuti `303` dengan sendirinya.

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

Di single-page app, browser mengirim langsung ke Ikiform dan berpindah dengan `location.assign`. Gunakan **Allowed domains** untuk melindungi formulir, jangan pernah 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>
  );
}
```

> Versi sisi server mengirim dengan secret key, jadi Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Setiap kiriman kemudian berasal dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda bersama-sama. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server. Versi server meneruskan header `Origin` pengunjung, karena fetch di sisi server tidak mengirimnya dan Ikiform mendeteksi uji `localhost` gratis dari header itu.

## Email kantor, tanpa memblokir Gmail

Ikiform tidak punya pengaturan yang menolak penyedia email gratis. Opsi `allowed_domains` pada field email adalah allowlist: berguna untuk formulir internal yang dibatasi ke domain perusahaan Anda sendiri, keliru untuk formulir demo publik. Itu tidak masalah, karena memblokir Gmail juga memblokir founder, konsultan, dan pembeli yang mengisi formulir dari ponsel.

Beri tag pada permintaan itu saja, di tempat tim Anda melihatnya. Fungsi ini berjalan di relay yang mengirim setiap permintaan ke chat atau CRM Anda, dan memberi sales petunjuk sebelum panggilan:

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

Panggil dengan `event.data.fields` setelah `verifyWebhook`, lalu tambahkan tag ke pesan chat atau properti CRM. [Lead ke HubSpot atau Pipedrive](https://ikiform.com/id/guides/lead-capture/leads-to-crm.md) menunjukkan relay lengkapnya.

## Pastikan berfungsi

1. Tambahkan domain situs Anda dan `localhost` di **Allowed domains**.
2. Kirim dengan ukuran tim **51 to 200**. Halaman booking terbuka dengan nama dan email Anda sudah terisi.
3. Tutup halaman booking tanpa memilih waktu. Permintaan tetap ada di **Submissions**, dan email notifikasi ada di inbox Anda.
4. Kirim lagi dengan **1 to 10** dan pastikan Anda mendarat di tur.
5. Kirim dengan email yang tidak valid. Error tampil di formulir, dan halaman booking tidak terbuka.
6. Pesan rapat uji, lalu pastikan konfirmasi dari penjadwal sampai ke alamat yang Anda masukkan.

## Hal yang sering menjebak

- **Dua record per demo yang dipesan.** Ikiform menyimpan permintaannya; penjadwal menyimpan rapatnya. Cocokkan berdasarkan email di CRM Anda, bukan nama.
- **Tidak ada konfirmasi dari Ikiform.** Ikiform tidak mengirim email ke orang yang mengisi formulir. Konfirmasi dan undangan kalender dari penjadwal mencakup panggilan yang dipesan. Untuk permintaan yang tidak pernah memesan, balas dari email notifikasi di hari yang sama.
- **Query string terlihat.** Nama dan email di URL booking muncul di riwayat browser dan di analitik penjadwal. Jangan kirim hal lain di sana.
- **Embed.** Jika Anda meng-embed penjadwal di halaman `/book` Anda sendiri, Cal.com hanya meneruskan parameter query halaman Anda saat `forwardQueryParams` diaktifkan. Calendly mendokumentasikan prefill untuk embed lewat objek `prefill` di embed JavaScript-nya, jadi gunakan itu alih-alih iframe biasa.
- **Pemeriksaan spam.** Dengan **Spam check** diatur ke **Ikiform**, versi browser harus memanggil `challenge()` dari `@ikiform/sdk` sebelum mengirim, atau mendapat `403 challenge_required`. Versi server melewati pemeriksaan ini.

## Pertanyaan

### Bagaimana cara meneruskan nama dan email dari formulir ke Calendly atau Cal.com?

Tambahkan ke query string tautan booking, seperti ?name=Ada%20Lovelace&email=ada%40example.com. Susun URL di kode Anda sendiri setelah backend formulir menjawab ok, lalu arahkan browser ke sana.

### Mengapa tidak langsung mengarahkan orang ke halaman booking tanpa formulir?

Banyak pengunjung membuka penjadwal lalu pergi tanpa memesan, dan penjadwal hanya mencatat orang yang memesan. Formulir singkat di awal berarti tim Anda punya setiap permintaan, dengan alamat reply-to, dan bisa menindaklanjuti yang tidak memilih waktu.

### Bisakah saya memakai _redirect untuk mengarahkan orang ke halaman booking?

Hanya ke URL tetap di salah satu allowed domains formulir, dan URL itu tidak bisa menyertakan apa yang diketik pengunjung. Untuk mengisi formulir booking, susun URL di script halaman atau server action Anda.

### Bisakah formulir demo menolak Gmail dan alamat gratis lainnya?

Tidak ada daftar blokir bawaan. Opsi allowed domains pada field email hanya mengizinkan domain yang terdaftar, yang cocok untuk formulir internal. Terima setiap alamat dan beri tag pada permintaan dengan email gratis di relay atau CRM Anda.

## Panduan lainnya

- [Formulir lead capture](https://ikiform.com/id/guides/lead-capture.md): Formulir lead capture di situs Anda sendiri yang mengirimi Anda email dengan reply-to, memberi tahu chat tim, dan mengisi CRM, agar Anda menjawab selagi lead masih ada.
- [Lead ke HubSpot atau Pipedrive](https://ikiform.com/id/guides/lead-capture/leads-to-crm.md): Petakan setiap webhook Ikiform bertanda tangan ke contact upsert HubSpot atau panggilan person dan lead Pipedrive, tanpa kontak duplikat saat pengiriman dicoba ulang.
- [Formulir permintaan konsultasi](https://ikiform.com/id/guides/lead-capture/consultation-request.md): Formulir permintaan konsultasi untuk akuntan, pengacara, coach, dan klinik yang menanyakan cara dan waktu menghubungi, dan menyimpan detail sensitif untuk panggilannya.
- [Formulir permintaan penawaran](https://ikiform.com/id/guides/lead-capture/quote-request.md): Formulir minta penawaran yang mengumpulkan brief sebagai tautan bersama alih-alih unggahan, memberi tenggat pada setiap permintaan, dan menjauhkan lead yang penuh tautan dari spam.
- [Formulir inquiry proyek](https://ikiform.com/id/guides/lead-capture/project-inquiry.md): Formulir mulai proyek untuk studio dan freelancer yang memilah inquiry menjadi hot, warm, dan tidak cocok berdasarkan anggaran dan waktu sebelum ada yang membaca brief-nya.
- [Formulir lead Next.js](https://ikiform.com/id/guides/lead-capture/nextjs.md): Formulir lead App Router Next.js yang memvalidasi dengan Zod di server action, menyimpan UTM first-touch di cookie, dan mengirim lead yang lolos kualifikasi ke kalender Anda.
