# Cara menghentikan spam di formulir kontak

> Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.

Terbit 2026-10-25 · https://ikiform.com/id/guides/spam-protection

Tumpuk pemeriksaan murah yang tak terlihat sebelum menambahkan hambatan apa pun: field honeypot tersembunyi, waktu minimum mengisi formulir, skor untuk link dan frasa spam, serta rate limit per pengunjung. Tambahkan pemeriksaan manusia seperti Cloudflare Turnstile hanya jika spam masih lolos, dan verifikasi token-nya di server. Simpan yang tertangkap alih-alih membuangnya, agar Anda bisa menyelamatkan pesan asli yang sesekali ikut tersaring.

Formulir kontak di halaman publik mulai kebanjiran sampah dalam beberapa hari setelah diindeks. Sebagian besar tidak ditujukan ke Anda. Skrip menjelajahi web mencari `<form>` apa pun, mengisi setiap input, lalu mengirimnya, berharap ada link yang tertanam di suatu tempat. Sebagian kecil datang dari browser sungguhan yang dikendalikan software, dan sebagian lagi dari orang yang dibayar untuk menempelkan tawaran yang sama ke ribuan formulir. Setiap jenis dihentikan oleh pemeriksaan yang berbeda, itulah sebabnya satu trik saja tidak pernah cukup.

Panduan ini menjelaskan setiap pertahanan, apa yang ditangkapnya, dan apa biayanya bagi pengunjung sungguhan, dengan kode yang bisa dijalankan di stack apa pun. Lalu panduan ini menunjukkan bagaimana Ikiform, sebuah form backend, menjalankan lapisan yang sama untuk Anda.

## Lapisan, dari yang termurah

Urutkan pemeriksaan berdasarkan biayanya. Menghitung request hampir gratis, jadi dijalankan sebelum body dibaca. Pemeriksaan yang memanggil layanan lain, seperti captcha, dijalankan setelah yang murah lolos. Penilaian konten berjalan terakhir, karena butuh field yang sudah di-parse. Ikiform menjalankan pemeriksaannya dalam urutan ini untuk setiap kiriman.

Diagram: Urutan pemeriksaan spam, dari kiriman masuk hingga disimpan atau dianggap spam

1. Sebuah kiriman tiba di endpoint formulir.
2. Rate limit menghitung kiriman per pengunjung terlebih dulu. Banjir kiriman ditolak dengan 429 sebelum body dibaca.
3. Domain yang diizinkan membandingkan halaman asal kiriman dengan domain yang Anda daftarkan.
4. Jika pemeriksaan manusia aktif, kiriman harus membawa token Turnstile yang valid.
5. Honeypot menambah 5 ke skor jika terisi.
6. Waktu pengisian, link, frasa spam yang dikenal, alamat sekali pakai, dan kiriman berulang menambah nilai yang lebih kecil.
7. Pada skor 5 atau lebih, kiriman disimpan sebagai spam. Di bawahnya, kiriman diterima dan email serta webhook Anda terkirim.

Baris atas menolak kiriman. Baris bawah tidak pernah menolak: ia memberi skor, sehingga false positive cukup dipulihkan dengan satu klik, bukan pesan yang hilang.

## Perbandingan pertahanan

Setiap pertahanan mengorbankan sesuatu. Tabel ini jujur tentang apa yang terlewat oleh masing-masing dan apa yang dimintanya dari orang yang ingin Anda dengar.

| Pertahanan | Menghentikan | Melewatkan | Biaya bagi pengunjung sungguhan |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md) | Skrip yang mengisi setiap input | Bot yang membaca CSS atau melewati field tersembunyi, dan manusia | Tidak ada, asalkan tersembunyi dari screen reader dan autofill |
| Jebakan waktu | Kiriman yang lebih cepat dari kecepatan siapa pun mengetik | Bot lambat, dan bot yang sengaja menunggu | Tidak ada, selama batas minimumnya beberapa detik |
| Penilaian konten, seperti jumlah link, frasa spam, atau Akismet | Tawaran SEO, sebar link, spam kasino dan obat | Spam yang tampak bersih, dan bisa salah menandai pesan asli yang berisi link | Tidak ada, asalkan Anda meninjau yang tertangkap |
| [Rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md) | Banjir kiriman dan skrip yang berulang | Spam lambat yang tersebar | Kantor dan acara di balik satu IP bisa terkena batas yang ketat |
| Domain yang diizinkan | Kiriman yang dikirim langsung ke endpoint Anda dari tempat lain | Skrip yang memalsukan header `Origin` | Tidak ada |
| [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md) | Sebagian besar browser otomatis | Spammer manusia bayaran | Biasanya tak terlihat. Kadang perlu satu klik, dan butuh skrip |
| [reCAPTCHA atau hCaptcha](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md) | Sebagian besar browser otomatis | Spammer manusia bayaran dan layanan pemecah captcha | Teka-teki gambar untuk sebagian pengunjung, ditambah skrip pihak ketiga |

Mulailah dengan lima yang pertama. Semuanya tak terlihat dan menangkap sebagian besar spam yang diterima situs kecil. Tambahkan pemeriksaan manusia saat dashboard menunjukkan spam masih lolos, jangan sebelumnya: itu satu-satunya lapisan yang biayanya terasa oleh setiap pengunjung. Halaman tentang [menghentikan spam tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md) menunjukkan seberapa jauh lapisan tak terlihat bisa bekerja sendiri.

## Baca spamnya sebelum melawannya

Lihat sepuluh pesan spam sebelum memilih solusi. Kesamaan di antara mereka memberi tahu siapa pengirimnya, dan itu menunjuk ke lapisan yang bisa menghentikannya.

Diagram: Dari tampilan spam ke lapisan yang menghentikannya

1. Teks acak di setiap field berasal dari skrip yang mengisi semua input. Honeypot menangkapnya.
2. Puluhan kiriman per menit dari satu sumber berasal dari skrip yang berulang. Rate limit menghentikannya.
3. Kiriman rapi yang dikirim dalam satu detik setelah halaman dimuat berasal dari headless browser. Jebakan waktu atau pemeriksaan manusia menghentikannya.
4. Tawaran sopan berisi link berasal dari orang yang dibayar per formulir. Hanya penilaian konten dan tinjauan Anda sendiri yang bisa menangkapnya.

Tidak ada captcha yang bisa menghentikan baris terakhir. Manusia bisa memecahkannya, jadi penilaian konten dan pengecekan folder spam tiap minggu adalah satu-satunya pertahanan.

## Formulir kontak dengan semua lapisan terpasang

Berikut satu formulir kontak, dibuat untuk setiap stack. Setiap versi punya honeypot di luar layar dan pemeriksaan manusia. Versi khusus browser memakai pemeriksaan Turnstile terkelola dari Ikiform, yang tidak butuh key. Versi server menunjukkan cara mandiri: server Anda memverifikasi token Turnstile dengan key Cloudflare Anda sendiri, lalu meneruskan field-nya. Pilih stack Anda di bawah; pilihan itu berlaku untuk setiap contoh kode di panduan ini.

Tab server berbagi dua helper kecil. `verifyTurnstile` menanyakan ke Cloudflare apakah token itu asli, dan `sendToIkiform` meneruskan field dengan secret key Anda dan header `Origin` milik pengunjung. Fetch dari sisi server tidak mengirim `Origin` sendiri, dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`. Keduanya gagal secara tertutup: jika Cloudflare tidak bisa dihubungi, pengunjung melihat error, bukan lolos diam-diam.

`lib/spam.ts`:

```ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

Formulir biasa tidak butuh skrip. Atur **Spam check** formulir Ikiform ke **Ikiform**, dan kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile lalu meneruskan field-nya. Orang yang lolos tanpa terlihat tidak akan menyadarinya.

`contact.html`:

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

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

Honeypot diletakkan di luar layar, bukan di balik `display: none`, karena sebagian bot melewati field yang disembunyikan dengan cara itu. `aria-hidden` pada wrapper dan `tabindex` pada input menjauhkan screen reader dan keyboard darinya.

**Next.js**

Server action memverifikasi token Turnstile dengan key Anda sendiri, lalu meneruskan field dengan secret key Ikiform Anda. Honeypot ikut diteruskan apa adanya, jadi yang terisi disimpan sebagai spam dan bisa Anda tinjau. Simpan helper di atas sebagai `lib/spam.ts`. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) 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} defaultValue={state.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

Widget dirender secara eksplisit dan dirender ulang setiap kali action mengembalikan hasil, karena token Turnstile hanya berlaku sekali. `defaultValue` mengembalikan teks pengunjung setelah error, karena React mengosongkan formulir setelah action-nya berjalan.

Ingin lebih jauh dengan Next.js? Baca [Perlindungan spam formulir Next.js dengan Turnstile](https://ikiform.com/id/guides/spam-protection/nextjs.md).

**Astro**

Astro Action memvalidasi field dengan Zod, memeriksa token terhadap `context.clientAddress`, dan meneruskan kiriman. Simpan helper di atas sebagai `src/lib/spam.ts` dan baca kedua key dengan `getSecret` dari `astro:env/server`. Formulir mengirim tanpa JavaScript di client, jadi setiap percobaan memuat ulang halaman dan widget dirender baru. Tambahkan adapter dan pertahankan `prerender = false`. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <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 />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Ingin lebih jauh dengan Astro? Baca [Perlindungan spam formulir Astro dengan Turnstile](https://ikiform.com/id/guides/spam-protection/astro.md).

**TanStack Start**

Function POST `createServerFn` membaca IP pengunjung dengan `getRequestHeader`, memverifikasi token, dan meneruskan field. Komponen widget-nya sama dengan yang ada di tab Next.js, di `src/components/turnstile.tsx`. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

`src/routes/contact.tsx`:

```tsx
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";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) 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 />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Ingin lebih jauh dengan TanStack Start? Baca [Perlindungan spam formulir TanStack Start dengan Turnstile](https://ikiform.com/id/guides/spam-protection/tanstack-start.md).

**React Router**

`action` route memverifikasi token dan meneruskan kiriman. Memberikan `actionData` sebagai `reset` merender widget baru setelah setiap jawaban. Komponen widget-nya adalah yang dari tab Next.js. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</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 />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

Ingin lebih jauh dengan React Router? Baca [Perlindungan spam formulir React Router dengan Turnstile](https://ikiform.com/id/guides/spam-protection/react-router.md).

**SvelteKit**

Form action default memverifikasi token dengan `getClientAddress()` dan meneruskan kiriman. Versi ini sengaja tidak memakai `use:enhance`: setiap percobaan memuat ulang halaman, sehingga Turnstile merender widget baru dengan token baru. Simpan helper di atas sebagai `src/lib/spam.ts`. Deklarasikan key di `src/env.ts` dengan `defineEnvVars` dan beri `PUBLIC_TURNSTILE_SITE_KEY` nilai `public: true`, agar halaman bisa mengimpornya dari `$app/env/public`. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

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

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

<svelte:head>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

Ingin lebih jauh dengan SvelteKit? Baca [Perlindungan spam formulir SvelteKit dengan Turnstile](https://ikiform.com/id/guides/spam-protection/sveltekit.md).

**Nuxt**

Server route memverifikasi token dan meneruskan kiriman. Simpan helper di atas sebagai `server/utils/spam.ts` dan Nuxt mengimpornya secara otomatis. Komponennya merender Turnstile secara eksplisit dan mengatur ulang setelah percobaan gagal. Atur `TURNSTILE_SECRET_KEY` dan `IKIFORM_SECRET_KEY` sebagai environment variable khusus server, dan biarkan **Spam check** formulir Ikiform diatur ke **Off**: server Anda sudah menjalankan pemeriksaan manusia.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Ingin lebih jauh dengan Nuxt? Baca [Perlindungan spam formulir Nuxt dengan Turnstile](https://ikiform.com/id/guides/spam-protection/nuxt.md).

**React**

Single-page app tidak punya server untuk menyimpan secret Turnstile, jadi memakai pemeriksaan terkelola dari Ikiform. `challenge()` dari `@ikiform/sdk` memasang frame kecil ikiform.com yang tetap tak terlihat kecuali Turnstile butuh klik, lalu mengembalikan token untuk `submit()`. Atur **Spam check** ke **Ikiform** dan tambahkan domain Anda di **Allowed domains**.

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form 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 />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

Pemeriksaan terkelola yang sama dalam single-file component Vue. Frame muncul di dalam `div` kosong hanya saat pengunjung harus mengklik.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @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 />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Setiap versi memakai class `.hp` dari blok style di tab HTML. Taruh di CSS global Anda.
- Honeypot diberi nama `_gotcha`, default Ikiform. Versi server meneruskannya apa adanya, jadi yang terisi tetap masuk ke folder spam Anda dengan alasan `honeypot`.
- Turnstile menambahkan input tersembunyi `cf-turnstile-response` ke formulir. Token berlaku 300 detik dan hanya bisa dipakai sekali, jadi setiap percobaan butuh token baru.
- Key uji dari Cloudflare memungkinkan Anda menjalankan semua ini secara lokal. [Panduan Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md) mencantumkannya.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. 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.

Coba definisi field di bawah. Ini menjalankan validator Ikiform di browser Anda, dengan aturan yang sama yang diterapkan server sebelum skor spam dihitung.

Formulir: Contact

- **Nama** (`name`, text, wajib)
- **Email** (`email`, email, wajib)
- **Topik** (`topic`, select)
- **Pesan** (`message`, textarea, wajib)

Field-nya sama dengan [template formulir kontak](https://ikiform.com/forms/contact-form). Tempel definisinya di tab **Fields** formulir dan Ikiform menolak kiriman yang tidak cocok dengan `422 validation_failed`.

## Honeypot

Honeypot adalah input yang tidak pernah dilihat orang tetapi diisi oleh bot. Jika ada nilainya, kiriman itu berasal dari software. Tidak ada biaya bagi pengunjung, dan honeypot menangkap bot tertua dan paling umum, yang mem-parse HTML dan mengisi setiap input yang ditemukannya.

Menyembunyikannya dengan baik lebih penting daripada triknya sendiri. Tiga hal sering salah pada honeypot yang dibuat sembarangan:

- **Screen reader membacakannya.** Pengunjung tunanetra mendengar field tambahan, mengisinya, lalu ditandai sebagai spam. Bungkus dengan `aria-hidden="true"` dan beri label yang meminta field itu dikosongkan.
- **Keyboard berhenti di sana.** Menekan Tab di formulir berhenti di input yang tak terlihat. `tabindex="-1"` mengeluarkannya dari urutan tab.
- **Autofill mengisinya.** Browser dan password manager mengisi field berdasarkan nama. Beri nama field `email2` atau `phone` dan sebagian akan mengisinya untuk orang sungguhan. Pakai nama yang tidak dikenali autofill, seperti `_gotcha`, dan tambahkan `autocomplete="off"`.

[Panduan honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md) membahas teknik menyembunyikan, cara bot mendeteksinya, dan pemeriksaan sisi server untuk setiap stack. Dengan Ikiform, honeypot yang terisi menambah 5 ke skor, yang sudah mencapai ambang 5 dengan sendirinya. Anda bisa mengganti nama field per formulir di **Honeypot field**.

## Waktu pengisian

Orang butuh beberapa detik untuk mengisi formulir kontak. Banyak bot hanya butuh milidetik. Jebakan waktu mencatat kapan formulir dirender dan memeriksa selisihnya saat dikirim. Versi naifnya menaruh timestamp di field tersembunyi:

`Jebakan waktu, versi naif`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

Bot bisa mengirim angka apa saja, jadi timestamp biasa hanya menghentikan bot yang tidak repot-repot. Tanda tangani di server dengan secret, dan bot tidak bisa memalsukan waktu render yang lebih lambat. Halaman tentang [menghentikan spam tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md#signed-timestamp) membangun versi bertanda tangan.

Ikiform menandatangani waktu render untuk Anda saat formulir berasal dari SDK atau [komponen shadcn/ui](https://ikiform.com/id/docs/shadcn.md): keduanya mengirim `_ikiform_ts`, dan kiriman kurang dari 2 detik setelah render menambah 3 ke skor. Timestamp palsu atau yang berumur sehari juga menambah 3. Formulir HTML biasa tidak mengirimnya dan tidak dikenai penalti.

## Menilai konten

Sebagian spam diketik atau ditempel oleh manusia, jadi lolos dari setiap pemeriksaan bot. Yang membongkarnya adalah kontennya: link, frasa jualan, alamat sekali pakai, dan teks yang sama dua kali. Beri skor pada setiap sinyal dan anggap total yang tinggi sebagai spam, alih-alih memblokir berdasarkan satu sinyal saja. Satu link dalam pesan itu wajar; link di field nama tidak pernah wajar.

| Sinyal | Ikiform menambah |
| --- | --- |
| Honeypot terisi | +5 |
| Link di field nama atau subjek | +3 |
| Field yang sama dikirim ke formulir ini dalam 24 jam terakhir | +3 |
| Dikirim kurang dari 2 detik setelah waktu render bertanda tangan | +3 |
| Tiga link atau lebih di semua field | +2 |
| Salah satu dari 22 frasa spam yang dikenal, seperti "seo services" | +2 |
| Alamat di domain email sekali pakai | +2 |

Pada skor 5 atau lebih, kiriman dianggap spam. Tidak ada satu sinyal lunak yang mencapainya sendirian: pesan asli dengan tiga link mendapat skor 2. Dua sinyal bersamaan sering mencapainya, seperti link di nama dan frasa spam. Jika Anda menilai konten sendiri, Akismet adalah opsi hosted yang sudah lama ada; banyak tim memulai dengan beberapa regular expression seperti di atas.

## Pemeriksaan manusia

Pemeriksaan manusia meminta browser membuktikan bahwa ia dikendalikan oleh manusia. Yang modern, seperti Cloudflare Turnstile, sebagian besar berjalan tanpa terlihat dan hanya menampilkan checkbox jika ragu. Widget memberi browser sebuah token; server Anda harus mengirim token itu ke penyedia untuk diverifikasi. Token yang tidak pernah diverifikasi di server tidak menghentikan apa pun, karena bot bisa melewati widget dan mengirim tanpanya.

Ikiform punya tiga pengaturan di **Spam check** pada setiap formulir:

| Pengaturan | Yang terjadi | Perubahan kode |
| --- | --- | --- |
| **Off** (the default) | Tanpa tantangan. Honeypot, waktu pengisian, dan skor tetap berjalan. | Tidak ada |
| **Ikiform** | Turnstile dijalankan Ikiform di ikiform.com, tanpa akun atau key Cloudflare | Tidak ada untuk kiriman HTML. Kiriman `fetch` memanggil `challenge()` dari `@ikiform/sdk` terlebih dulu |
| **Your own Turnstile keys** (key Turnstile Anda sendiri) | Widget dan secret key Anda. Ikiform memverifikasi token dan mencocokkan hostname-nya dengan domain yang diizinkan | Render widget di formulir Anda agar mengirim `cf-turnstile-response` |

Kiriman `fetch` tanpa token mendapat `403 challenge_required`. Jika Cloudflare tidak bisa dihubungi, Ikiform tetap menerima kiriman dan menyimpannya sebagai spam dengan alasan `captcha_unavailable`, sehingga Anda bisa memulihkannya. [Dokumentasi spam](https://ikiform.com/id/docs/spam.md#human-check) menunjukkan kedua alur. Jika Anda menimbang Turnstile dibandingkan reCAPTCHA dari Google atau hCaptcha, lihat [alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md).

## Rate limit dan domain yang diizinkan

Rate limit membatasi seberapa sering satu pengunjung bisa mengirim. Ia tidak membedakan spam dari pesan asli; ia menghentikan volume. Hitung per IP dan formulir, izinkan lonjakan kecil, dan jawab `429` dengan header `Retry-After`. [Panduan rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md) membangunnya untuk setiap stack dan menjelaskan mengapa penghitung in-memory gagal di hosting serverless.

Ikiform menghitung setiap kiriman secara tepat, sebelum membaca body. Setiap formulir memilih preset: Standard, 5 per menit per pengunjung, Relaxed, 20 per menit per pengunjung, atau Strict, 2 per 10 menit per pengunjung. Standard juga mengizinkan maksimal 3 per 10 detik, dan seluruh formulir menerima 300 per menit. Formulir kontak cocok dengan Strict, karena tidak ada orang jujur yang menulis dua kali dalam sepuluh menit. Angkanya ada di [Rate limit](https://ikiform.com/id/docs/rate-limits.md).

**Allowed domains** mencantumkan situs tempat formulir Anda berada. Kiriman dari halaman lain ditandai sebagai spam, atau ditolak dengan `403 origin_not_allowed` jika Anda memilih **Reject**. Ini mencegah orang mengarahkan formulir mereka sendiri ke endpoint Anda. Skrip yang gigih bisa memalsukan header yang diperiksanya, jadi anggap ini satu lapisan, bukan kunci.

## Apa yang terjadi pada spam

Jangan pernah memberi tahu bot bahwa ia tertangkap. Error mengajarinya apa yang harus diubah; halaman sukses biasa tidak mengajarinya apa-apa. Dan jangan pernah menghapus spam begitu masuk: setiap filter sesekali menangkap pesan asli, dan yang sudah dihapus hilang selamanya.

Kiriman yang dinilai spam mendapat jawaban sukses yang sama dengan kiriman asli, jadi bot tidak belajar apa-apa. Kiriman itu disimpan dengan status `spam`, tidak mengirim email notifikasi maupun webhook, tidak dihitung dalam pemakaian Anda, dan dihapus setelah 30 hari. Setiap Senin, workspace yang memblokir spam menerima satu email ringkasan berisi total, formulir tersibuk, dan alasan teratas.

Buka filter status di dashboard dan pilih **Spam** untuk melihat masing-masing beserta skor dan alasannya. **Not spam** memindahkannya kembali, dan email serta webhook-nya terkirim saat itu. Matikan email hari Senin di **Weekly spam summary** pada pengaturan workspace. Spam disimpan 30 hari, jadi cek minimal sebulan sekali.

> Jika ada satu pesan asli yang terus tertangkap, baca alasannya. Link di field bernama `business_name` dinilai sebagai link di nama; ganti nama field menjadi `business` dan field itu tidak lagi dihitung sebagai field nama.

## Pilih situasi Anda

Setiap halaman ini membahas lebih dalam satu pertahanan atau satu jenis situs, dengan kode untuk kasus itu.

- [Field honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md): Tambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md): Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- [Tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md): Hentikan sebagian besar spam formulir tanpa captcha sama sekali: honeypot, waktu render bertanda tangan, skor konten, dan rate limit, digabungkan alih-alih dipakai sendiri-sendiri.
- [Alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md): Bandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
- [Rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md): Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
- [Situs statis](https://ikiform.com/id/guides/spam-protection/static-site.md): Hentikan spam di formulir situs statis meskipun situs itu tidak bisa menyimpan secret atau menghitung kiriman, dengan partial honeypot, skrip yang aman, dan pemeriksaan sisi server.
- [Spam formulir Webflow](https://ikiform.com/id/guides/spam-protection/webflow.md): Lindungi formulir Webflow yang mengirim ke form backend, tempat pengaturan spam Webflow tidak pernah berjalan, dengan honeypot di luar layar dan pemeriksaan manusia.
- [Next.js](https://ikiform.com/id/guides/spam-protection/nextjs.md): Lindungi formulir Next.js dari spam: honeypot, Turnstile yang dicek di server action, limit per IP di Vercel, Netlify, atau Cloudflare, dan CSP-nya.
- [Astro](https://ikiform.com/id/guides/spam-protection/astro.md): Lindungi formulir Astro dari spam dengan Action: honeypot, Turnstile yang dicek dengan secret astro:env, limit per IP, dan CSP bawaan Astro.
- [TanStack Start](https://ikiform.com/id/guides/spam-protection/tanstack-start.md): Lindungi formulir TanStack Start dari spam: function middleware untuk IP client, token bucket, Turnstile yang diverifikasi di server function, dan CSP.
- [React Router](https://ikiform.com/id/guides/spam-protection/react-router.md): Lindungi formulir React Router dari spam: route action dengan Turnstile, rate limit Workers berdasarkan cf-connecting-ip, modul .server, dan header CSP.
- [SvelteKit](https://ikiform.com/id/guides/spam-protection/sveltekit.md): Lindungi formulir SvelteKit dari spam: form action dengan getClientAddress, limit per IP, Turnstile di balik use:enhance, dan CSP yang mengizinkannya.
- [Nuxt](https://ikiform.com/id/guides/spam-protection/nuxt.md): Lindungi formulir Nuxt dari spam: server route Nitro dengan Turnstile, limit per IP di Nitro storage, secret di runtimeConfig, dan CSP yang diatur di routeRules.

## Pastikan berfungsi

Jalankan ini pada formulir uji sebelum Anda mengandalkannya. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat [harga](https://ikiform.com/id/pricing).

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Isi formulir di halaman Anda dan kirim. Anda mendarat di halaman terima kasih Anda, atau halaman hosted milik Ikiform.
3. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
4. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
5. Kirim pesan dengan link di field nama dan frasa `seo services` di pesan. Kiriman disimpan sebagai spam dengan alasan `url_in_name` dan `keyword`.
6. Ubah **Spam check** ke **Ikiform** lalu kirim formulir HTML biasa. Anda melihat halaman pemeriksaan sebentar, lalu halaman terima kasih Anda.
7. Kirim formulir beberapa kali berturut-turut. Setelah melewati batas, formulir menjawab `429` dan pengunjung melihat halaman perlambat dari Ikiform.
8. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
| `403 challenge_required` | Spam check formulir diatur ke **Ikiform** dan kiriman `fetch` tidak membawa token. Dapatkan token dengan `challenge(formId)` dari `@ikiform/sdk` sebelum setiap pengiriman, atau matikan pemeriksaannya. |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
| `429 rate_limited` | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai `Retry-After`, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
| `403 captcha_failed` | Token Turnstile tidak ada, kedaluwarsa, atau dipakai dua kali. Token berlaku 300 detik dan hanya sekali pakai, jadi render widget baru, atau panggil `challenge()` lagi, setelah setiap percobaan. |
| Spam masih masuk sebagai diterima | Buka salah satunya dan cari kesamaannya dengan yang lain. Spam dari skrip perlu **Spam check** diatur ke **Ikiform**; tawaran yang diketik manusia perlu rate limit yang lebih ketat dan tinjauan mingguan. |
| Pengunjung sungguhan melaporkan tidak bisa mengirim | Pastikan honeypot tersembunyi dari screen reader dan diberi nama yang dilewati autofill, dan rate limit yang ketat tidak menjebak orang di balik satu IP kantor. |

## Pertanyaan

### Apa cara terbaik menghentikan spam formulir kontak?

Lapisan, bukan satu trik. Honeypot tersembunyi, waktu minimum mengisi formulir, skor konten, dan rate limit menghentikan sebagian besar spam tanpa biaya bagi pengunjung sungguhan. Tambahkan pemeriksaan manusia seperti Cloudflare Turnstile jika spam masih lolos, dan verifikasi token-nya di server.

### Apakah formulir kontak saya butuh captcha?

Tidak di awal. Sebagian besar spam formulir kontak berasal dari bot sederhana yang bisa dihentikan honeypot dan rate limit. Tambahkan captcha saat folder spam menunjukkan bot masih lolos, dan pilih yang tak terlihat seperti Turnstile daripada teka-teki gambar.

### Mengapa saya menerima spam dari orang sungguhan?

Sebagian spam ditempel oleh orang yang dibayar per formulir, biasanya tawaran SEO atau jasa pengembangan. Pemeriksaan bot tidak bisa menghentikan manusia. Penilaian konten menangkap banyak di antaranya dari link dan frasanya; sisanya Anda hapus di folder spam.

### Apakah Ikiform memberi tahu pengirim bahwa pesannya ditandai spam?

Tidak. Spam mendapat jawaban sukses yang sama dengan pesan asli, jadi bot tidak belajar apa-apa. Spam disimpan 30 hari, tidak mengirim email atau webhook, dan Anda bisa menandainya sebagai bukan spam dari dashboard.

### Apakah saya butuh akun Cloudflare untuk memakai Turnstile dengan Ikiform?

Tidak. Atur Spam check formulir ke Ikiform dan Turnstile berjalan di ikiform.com tanpa key yang perlu dikelola. Pakai key Turnstile sendiri hanya jika Anda ingin widget sendiri di halaman Anda.

### Bisakah honeypot merusak aksesibilitas?

Bisa, jika disembunyikan sembarangan. Sembunyikan di luar layar dengan aria-hidden pada wrapper-nya, keluarkan dari urutan tab dengan tabindex -1, dan beri nama yang tidak diisi otomatis oleh browser.

### Apakah Ikiform gratis?

Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.

## Panduan lainnya

- [Field honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md): Tambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md): Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- [Tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md): Hentikan sebagian besar spam formulir tanpa captcha sama sekali: honeypot, waktu render bertanda tangan, skor konten, dan rate limit, digabungkan alih-alih dipakai sendiri-sendiri.
- [Alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md): Bandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
- [Rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md): Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
- [Situs statis](https://ikiform.com/id/guides/spam-protection/static-site.md): Hentikan spam di formulir situs statis meskipun situs itu tidak bisa menyimpan secret atau menghitung kiriman, dengan partial honeypot, skrip yang aman, dan pemeriksaan sisi server.
- [Spam formulir Webflow](https://ikiform.com/id/guides/spam-protection/webflow.md): Lindungi formulir Webflow yang mengirim ke form backend, tempat pengaturan spam Webflow tidak pernah berjalan, dengan honeypot di luar layar dan pemeriksaan manusia.
- [Next.js](https://ikiform.com/id/guides/spam-protection/nextjs.md): Lindungi formulir Next.js dari spam: honeypot, Turnstile yang dicek di server action, limit per IP di Vercel, Netlify, atau Cloudflare, dan CSP-nya.
- [Astro](https://ikiform.com/id/guides/spam-protection/astro.md): Lindungi formulir Astro dari spam dengan Action: honeypot, Turnstile yang dicek dengan secret astro:env, limit per IP, dan CSP bawaan Astro.
- [TanStack Start](https://ikiform.com/id/guides/spam-protection/tanstack-start.md): Lindungi formulir TanStack Start dari spam: function middleware untuk IP client, token bucket, Turnstile yang diverifikasi di server function, dan CSP.
- [React Router](https://ikiform.com/id/guides/spam-protection/react-router.md): Lindungi formulir React Router dari spam: route action dengan Turnstile, rate limit Workers berdasarkan cf-connecting-ip, modul .server, dan header CSP.
- [SvelteKit](https://ikiform.com/id/guides/spam-protection/sveltekit.md): Lindungi formulir SvelteKit dari spam: form action dengan getClientAddress, limit per IP, Turnstile di balik use:enhance, dan CSP yang mengizinkannya.
- [Nuxt](https://ikiform.com/id/guides/spam-protection/nuxt.md): Lindungi formulir Nuxt dari spam: server route Nitro dengan Turnstile, limit per IP di Nitro storage, secret di runtimeConfig, dan CSP yang diatur di routeRules.
- [Melampaui formulir builder](https://ikiform.com/id/guides/website-builder-forms.md): Saat formulir Webflow, Netlify, Carrd, atau Framer mentok di batas atau kekurangan fitur, pertahankan formulir yang Anda desain dan arahkan ke backend formulir.
- [Formulir feedback untuk website atau aplikasi](https://ikiform.com/id/guides/feedback-form.md): Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
- [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.
