# Newsletter signup form without Mailchimp's embed

> Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup

Write the signup form in your own HTML and point it at a form backend instead of pasting your email provider's embed code. The backend validates the address and the consent checkbox, filters bots and sends a signed webhook, and a small relay adds the subscriber through your email tool's API. The email tool then sends the confirmation email, the newsletter and the unsubscribe links, so changing tools later means changing the relay, not every form.

Every email tool gives you an embed: a block of generated markup you paste into your page. It works, but the form now belongs to the tool. It posts straight to that provider, it carries the provider's markup and often its styles and scripts, and the day you move to another tool you hunt down every copy of it across your site. This guide builds a signup form you own, in the markup and framework you already use, and connects it to whichever tool sends your newsletter.

## How the signup travels

There are two jobs here, and they belong in different places. Collecting a signup is a form job: validate the address, check the consent box, keep bots out, record when it happened. Sending a newsletter is an email job: confirm the address, send issues, handle unsubscribes and bounces. Ikiform does the first. Your email tool does the second. A signed webhook connects them.

Diagram: How a newsletter signup travels from your page to your email tool

1. A reader fills in the signup form on your page, and it posts to your Ikiform endpoint.
2. Ikiform checks the address, the required consent checkbox, the honeypot, the spam score and the rate limit.
3. The accepted signup is stored in the dashboard with its timestamp, origin and referrer.
4. Ikiform can email you about each signup. It never emails the reader.
5. Ikiform sends a signed submission.created webhook.
6. A relay verifies the signature and adds the reader to your email tool, which sends the confirmation and, later, every issue.

The relay is the only part that knows which email tool you use. The form, the spam filtering and the stored record don't change when the tool does.

## Four ways to collect newsletter signups

| Approach | Good at | Trade-offs |
| --- | --- | --- |
| The email tool's embed, from Mailchimp, Kit, MailerLite or beehiiv | Nothing to run. Confirmation, tagging and the welcome email are wired up in the tool's own settings. | The markup and behaviour are the tool's. Mailchimp notes its embedded forms inherit your site's styles, so they can look different on your page, and turning on its reCAPTCHA for an existing form means generating and pasting new code. Every copy is tied to one provider. |
| Your own server route that calls the tool's API | Full control of the form and the request, with the API key kept on your server. | You build validation, bot filtering, rate limits and a record of consent yourself, and you need a server even if the site is static. |
| A hosted form builder plus an automation, such as [Typeform](https://ikiform.com/alternatives/typeform) with Zapier | No code, and the automation tool knows every email provider. | An iframe that doesn't match your page, plus two subscriptions. Webhooks and branding depend on the plan. |
| A form backend, such as Ikiform, plus a relay | Your own markup on any site, spam filtering, a stored consent record and signed webhooks, with no server for the form. | Ikiform sends no email to the reader. Confirmation and every newsletter come from your email tool, through a small relay or an automation. |

Happy with one site, one tool and its embed? Keep it. Build your own when the form must match your design, spans several sites, or may outlive the tool.

## The form

A good newsletter form is small: an email, an optional first name for the greeting, and a consent checkbox whose label is the promise you're making. A hidden `source` input records where on the site the signup happened. Pick your stack; the choice carries over to every code sample in these guides.

**HTML**

Paste the form into your footer, sidebar or the end of a post. It posts the plain way and lands on your own thank-you page, so it works with no JavaScript.

`footer.html`:

```html
<form id="newsletter" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="nl-email">Email</label>
  <input id="nl-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" />

  <label for="nl-name">First name (optional)</label>
  <input id="nl-name" name="first_name" type="text" maxlength="80" autocomplete="given-name" />

  <label>
    <input name="consent" type="checkbox" value="true" required />
    Email me the newsletter. I can unsubscribe at any time.
  </label>

  <input type="hidden" name="source" value="footer" />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />

  <button>Subscribe</button>
  <p id="nl-status" role="status" hidden></p>
</form>
```

To keep the reader on the article, add this script after the form. It posts with `fetch`, replaces the form with a line that tells them to check their inbox, and shows the server's message for the email or the checkbox.

`newsletter.js`:

```js
const form = document.querySelector("#newsletter");
const status = document.querySelector("#nl-status");
const button = form.querySelector("button");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const body = new FormData(form);
  button.disabled = true;
  status.hidden = false;
  status.textContent = "Subscribing";
  try {
    const response = await fetch(form.action, { method: "POST", headers: { Accept: "application/json" }, body });
    const result = await response.json();
    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Almost done. Open the email we sent you and confirm your address.";
      return;
    }
    const fields = result.error.fields ?? {};
    status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

**Next.js**

A server action forwards the signup with your secret key. It returns errors per field, so the email message sits under the email input and the consent message under the checkbox. The component takes a `source` prop, so the footer and an in-article box record different values.

`app/newsletter/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface SignupState {
  done: boolean;
  errors: { email?: string; consent?: string; form?: string };
  email: string;
}

export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "").trim();
  const consent = formData.get("consent") === "true";
  if (!consent) return { done: false, errors: { consent: "Tick the box so we know you want the emails." }, email };

  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: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      first_name: String(formData.get("first_name") ?? "").trim() || undefined,
      consent,
      source: String(formData.get("source") ?? "footer"),
      _gotcha: String(formData.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { done: true, errors: {}, email };
  const fields = result?.error?.fields ?? {};
  return {
    done: false,
    errors: {
      email: fields.email?.[0],
      consent: fields.consent?.[0],
      form: fields.email || fields.consent ? undefined : (result?.error?.message ?? "Couldn't subscribe. Try again."),
    },
    email,
  };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { type SignupState, subscribe } from "./actions";

const initial: SignupState = { done: false, errors: {}, email: "" };

export function NewsletterForm({ source = "footer" }: { source?: string }) {
  const [state, action, pending] = useActionState(subscribe, initial);

  if (state.done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;

  return (
    <form action={action} noValidate>
      <label htmlFor="nl-email">Email</label>
      <input
        id="nl-email"
        name="email"
        type="email"
        required
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={Boolean(state.errors.email)}
        aria-describedby="nl-email-error"
      />
      <p id="nl-email-error">{state.errors.email}</p>
      <label htmlFor="nl-name">First name (optional)</label>
      <input id="nl-name" name="first_name" type="text" maxLength={80} autoComplete="given-name" />
      <label>
        <input name="consent" type="checkbox" value="true" aria-describedby="nl-consent-error" />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <p id="nl-consent-error">{state.errors.consent}</p>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
      <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

Going further with Next.js? Read [Build a newsletter signup form in Next.js](https://ikiform.com/guides/newsletter-signup/nextjs.md).

**Astro**

An Astro Action with `accept: "form"` parses the checkbox as a boolean and the email with `z.email()`. `isInputError` tells a bad address apart from a refused post. Form actions need an on-demand page, so add an adapter and set `export const prerender = false` in every page that renders the component; the export does nothing inside a component.

`src/actions/index.ts`:

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

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      first_name: z.string().max(80).nullish(),
      consent: z.boolean(),
      source: z.string().default("footer"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      if (!input.consent) {
        throw new ActionError({ code: "BAD_REQUEST", message: "Tick the box so we know you want the emails." });
      }
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({ ...input, first_name: input.first_name ?? undefined }),
      });
      const result = await response.json();
      if (!result.ok) {
        throw new ActionError({
          code: "BAD_REQUEST",
          message: result.error.fields?.email?.[0] ?? result.error.message,
        });
      }
      return { subscribed: true };
    },
  }),
};
```

`src/components/NewsletterForm.astro`:

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

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
---

{result?.data?.subscribed ? (
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
  <form method="POST" action={actions.subscribe}>
    <label for="nl-email">Email</label>
    <input id="nl-email" name="email" type="email" required autocomplete="email" />
    {fields.email && <p>Enter a valid email address.</p>}
    <label for="nl-name">First name (optional)</label>
    <input id="nl-name" name="first_name" type="text" maxlength="80" />
    <label>
      <input name="consent" type="checkbox" required />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value="footer" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
    <button>Subscribe</button>
  </form>
)}
```

Going further with Astro? Read [Build a newsletter signup form in Astro](https://ikiform.com/guides/newsletter-signup/astro.md).

**TanStack Start**

A `createServerFn` POST function turns the `FormData` into JSON in `.validator()` and returns field errors as data rather than throwing, so the component can place each message next to its input.

`src/components/newsletter-form.tsx`:

```tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Errors = { email?: string; consent?: string; form?: string };

const subscribe = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return {
      email: String(data.get("email") ?? "").trim(),
      first_name: String(data.get("first_name") ?? "").trim() || undefined,
      consent: data.get("consent") === "true",
      source: String(data.get("source") ?? "footer"),
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data }): Promise<{ ok: boolean; errors: Errors }> => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(data),
    });
    const result = await response.json();
    if (result.ok) return { ok: true, errors: {} };
    const fields = result.error.fields ?? {};
    return {
      ok: false,
      errors: { email: fields.email?.[0], consent: fields.consent?.[0], form: result.error.message },
    };
  });

export function NewsletterForm() {
  const send = useServerFn(subscribe);
  const [done, setDone] = useState(false);
  const [errors, setErrors] = useState<Errors>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setDone(result.ok);
      setErrors(result.errors);
    } catch {
      setErrors({ form: "Couldn't subscribe. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="nl-email">Email</label>
      <input id="nl-email" name="email" type="email" required autoComplete="email" />
      {errors.email ? <p>{errors.email}</p> : null}
      <label htmlFor="nl-name">First name (optional)</label>
      <input id="nl-name" name="first_name" type="text" maxLength={80} />
      <label>
        <input name="consent" type="checkbox" value="true" required />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      {errors.consent ? <p>{errors.consent}</p> : null}
      <input type="hidden" name="source" value="footer" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {errors.form && !errors.email && !errors.consent ? <p role="alert">{errors.form}</p> : null}
      <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

Going further with TanStack Start? Read [Build a newsletter signup form in TanStack Start](https://ikiform.com/guides/newsletter-signup/tanstack-start.md).

**React Router**

A footer form shouldn't navigate away from the page someone is reading. Put the action in a resource route at `/newsletter` and post to it with `useFetcher`, which submits without a navigation and keeps the result on the current page.

`app/routes/newsletter.tsx`:

```ts
import { data } from "react-router";
import type { Route } from "./+types/newsletter";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email: String(formData.get("email") ?? "").trim(),
      first_name: String(formData.get("first_name") ?? "").trim() || undefined,
      consent: formData.get("consent") === "true",
      source: String(formData.get("source") ?? "footer"),
      _gotcha: String(formData.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json();
  if (result.ok) return { ok: true as const };
  return data(
    { ok: false as const, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) },
    { status: response.status },
  );
}
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [index("routes/home.tsx"), route("newsletter", "routes/newsletter.tsx")] satisfies RouteConfig;
```

`app/components/newsletter-form.tsx`:

```tsx
import { useFetcher } from "react-router";
import type { action } from "~/routes/newsletter";

export function NewsletterForm() {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <label htmlFor="nl-email">Email</label>
      <input id="nl-email" name="email" type="email" required autoComplete="email" />
      {result?.fields.email ? <p>{result.fields.email[0]}</p> : null}
      <label htmlFor="nl-name">First name (optional)</label>
      <input id="nl-name" name="first_name" type="text" maxLength={80} />
      <label>
        <input name="consent" type="checkbox" value="true" required />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      {result?.fields.consent ? <p>{result.fields.consent[0]}</p> : null}
      <input type="hidden" name="source" value="footer" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result && !result.fields.email && !result.fields.consent ? <p role="alert">{result.message}</p> : null}
      <button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

Going further with React Router? Read [Build a newsletter signup form in React Router](https://ikiform.com/guides/newsletter-signup/react-router.md).

**SvelteKit**

Actions live in a `+page.server.ts`, never in a layout, so the action sits on a `/newsletter` page and the footer posts to `/newsletter?/subscribe`. The custom `use:enhance` callback handles the result itself instead of calling `update()`, so the reader stays on the article. Declare `IKIFORM_SECRET_KEY` in `src/env.ts` with `defineEnvVars` so `$app/env/private` can read it.

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

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  subscribe: async ({ request, fetch, url }) => {
    const form = await request.formData();
    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({
        email: String(form.get("email") ?? "").trim(),
        first_name: String(form.get("first_name") ?? "").trim() || undefined,
        consent: form.get("consent") === "true",
        source: String(form.get("source") ?? "footer"),
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    });
    const result = await response.json();
    if (!result.ok) {
      const fields = result.error.fields ?? {};
      return fail(response.status, { message: String(fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message) });
    }
    return { subscribed: true };
  },
} satisfies Actions;
```

`src/lib/NewsletterForm.svelte`:

```svelte
<script lang="ts">
  import { applyAction, enhance } from "$app/forms";

  let done = $state(false);
  let message = $state("");
  let pending = $state(false);
</script>

{#if done}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ result }) => {
        pending = false;
        if (result.type === "success") done = true;
        else if (result.type === "failure") message = String(result.data?.message ?? "Couldn't subscribe.");
        else await applyAction(result);
      };
    }}
  >
    <label for="nl-email">Email</label>
    <input id="nl-email" name="email" type="email" required autocomplete="email" />
    <label for="nl-name">First name (optional)</label>
    <input id="nl-name" name="first_name" type="text" maxlength="80" />
    <label>
      <input name="consent" type="checkbox" value="true" required />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value="footer" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if message}
      <p role="alert">{message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
```

Going further with SvelteKit? Read [Build a newsletter signup form in SvelteKit](https://ikiform.com/guides/newsletter-signup/sveltekit.md).

**Nuxt**

A server route keeps the secret key out of the browser and passes Ikiform's field errors back in the error's `data`. Set `NUXT_IKIFORM_SECRET_KEY`, and declare `ikiformSecretKey` under `runtimeConfig` in `nuxt.config.ts`.

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

```ts
interface Signup {
  email?: string;
  first_name?: string;
  consent?: boolean;
  source?: string;
  _gotcha?: string;
}

export default defineEventHandler(async (event) => {
  const body = await readBody<Signup>(event);
  const config = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: body.email?.trim() ?? "",
      first_name: body.first_name?.trim() || undefined,
      consent: body.consent === true,
      source: body.source ?? "footer",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { subscribed: true };
});
```

`app/components/NewsletterForm.vue`:

```vue
<script setup lang="ts">
const email = ref("");
const firstName = ref("");
const consent = ref(false);
const trap = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function subscribe() {
  status.value = "sending";
  errors.value = {};
  message.value = "";
  try {
    await $fetch("/api/newsletter", {
      method: "POST",
      body: { email: email.value, first_name: firstName.value, consent: consent.value, source: "footer", _gotcha: trap.value },
    });
    status.value = "done";
  } catch (failure) {
    const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    message.value = data?.message ?? "Couldn't subscribe. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
  <form v-else @submit.prevent="subscribe">
    <label for="nl-email">Email</label>
    <input id="nl-email" v-model="email" name="email" type="email" required autocomplete="email" />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="nl-name">First name (optional)</label>
    <input id="nl-name" v-model="firstName" name="first_name" type="text" maxlength="80" />
    <label>
      <input v-model="consent" name="consent" type="checkbox" required />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <p v-if="errors.consent">{{ errors.consent[0] }}</p>
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
  </form>
</template>
```

Going further with Nuxt? Read [Build a newsletter signup form in Nuxt](https://ikiform.com/guides/newsletter-signup/nuxt.md).

**React**

A single-page app posts from the browser. `submit()` from `@ikiform/sdk` sends the `FormData` and always resolves with `ok` and, on failure, the field errors, so there is no `try` block to write. Never put a secret key here; **Allowed domains** protects the form instead.

`src/NewsletterForm.tsx`:

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

type FieldErrors = Record<string, string[]>;

export function NewsletterForm() {
  const [done, setDone] = useState(false);
  const [errors, setErrors] = useState<FieldErrors>({});
  const [message, setMessage] = useState("");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setErrors({});
    setMessage("");
    const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.currentTarget));
    setPending(false);
    if (result.ok) {
      setDone(true);
      return;
    }
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
  }

  if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;

  return (
    <form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" onSubmit={onSubmit}>
      <label htmlFor="nl-email">Email</label>
      <input id="nl-email" name="email" type="email" required autoComplete="email" />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="nl-name">First name (optional)</label>
      <input id="nl-name" name="first_name" type="text" maxLength={80} />
      <label>
        <input name="consent" type="checkbox" value="true" required />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      {errors.consent ? <p>{errors.consent[0]}</p> : null}
      <input type="hidden" name="source" value="footer" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {message && !errors.email && !errors.consent ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

**Vue**

The same `submit()` call in a Vue single-file component. The form keeps its `action`, so it still posts if the script fails to load.

`src/components/NewsletterForm.vue`:

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

const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function subscribe(event: SubmitEvent) {
  status.value = "sending";
  const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.target as HTMLFormElement));
  if (result.ok) {
    status.value = "done";
    return;
  }
  errors.value = result.error.fields ?? {};
  message.value = result.error.message;
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
  <form v-else action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" @submit.prevent="subscribe">
    <label for="nl-email">Email</label>
    <input id="nl-email" name="email" type="email" required autocomplete="email" />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="nl-name">First name (optional)</label>
    <input id="nl-name" name="first_name" type="text" maxlength="80" />
    <label>
      <input name="consent" type="checkbox" value="true" required />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <p v-if="errors.consent">{{ errors.consent[0] }}</p>
    <input type="hidden" name="source" value="footer" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
  </form>
</template>
```

- Replace `frm_xxxxxxxxxxxxxxxx` with your form's id from the dashboard.
- Keep the email input's name as `email`. Ikiform validates it, lowercases the domain, and checks it against disposable email domains.
- The hidden `_gotcha` input is the honeypot. People never see it, and bots that fill in every input mark themselves as spam.
- The checkbox posts `true` when ticked. Unticked, it posts nothing, and a required checkbox is refused with `This box must be checked`.
- The success line says to check the inbox, because your email tool sends a confirmation. If you turned double opt-in off, change it to a plain thank you.
- The server-side versions pass the browser's `Origin` header on, because Ikiform recognises free `localhost` tests by it and a server's own `fetch` sends none.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

Framework setup lives in the docs: [Next.js](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [plain HTML](https://ikiform.com/docs/html.md). The [newsletter form template](https://ikiform.com/forms/newsletter-signup) generates the same fields as HTML, React and a Next.js server action.

## Fields and the definition

Paste the definition below on the form's **Fields** tab and Ikiform enforces it on the server, whatever the page sends. The preview runs the same validator in your browser: leave the box unticked and submit to see the error a bot or a broken page would get.

Form: Newsletter

- **Email** (`email`, email, required)
- **First name** (`first_name`, text)
- **Email me the newsletter. I can unsubscribe at any time.** (`consent`, checkbox, required)
- **Signup location** (`source`, text)

In your page, `source` is a hidden input. It's a visible text field here only so you can try it.

| Field | Why | Keep in mind |
| --- | --- | --- |
| `email` | The subscriber | Required. `type="email"` brings up the right phone keyboard |
| `first_name` | A greeting in each issue | Optional. Most email tools have a first name field to map it to |
| `consent` | A record that the person asked for email | Required checkbox. Stored as `true` |
| `source` | Which form on the site worked: footer, post, popup | Hidden. Set a different value per placement, and send it on as a tag or field |

With fields defined, Ikiform refuses any input it doesn't know with `unknown_field`, so add a field to the definition before you add an input to the page. Names that start with `_`, such as `_redirect`, are instructions to Ikiform and never count as fields.

## Recording consent

A newsletter is email someone asked for. The checkbox makes the asking explicit, and the stored submission records it. This is what Ikiform keeps for each accepted signup:

`data.fields`:

```json
{
  "email": "ada@example.com",
  "first_name": "Ada",
  "consent": true,
  "source": "footer"
}
```

Next to the fields, each submission has its `created_at` timestamp, the page's origin and referrer, the country, and the browser's user agent. Together they show when and where the person ticked the box. Two habits make that record useful later:

1. **Write the label as the promise.** "Email me the newsletter. I can unsubscribe at any time." says what they get. If you also send offers, say so in the label.
2. **Note the date when the wording changes.** A stored `true` says the person agreed, not to which text. Keep a short changelog of the label with dates, so any signup can be matched to the words it saw.

Ikiform keeps the record; it can't tell you what the rules where you and your readers live require. Check those with someone who knows them.

## Confirmation emails come from your email tool

Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a [webhook](https://ikiform.com/docs/webhooks.md). That split is on purpose. The tool that sends your newsletter already tracks who confirmed, who unsubscribed and which addresses bounce. If a second system sent confirmations, those records would drift apart.

So the relay adds the reader in a state that makes the email tool send its own confirmation, and the tool marks them active when they click. Each tool switches this on differently: a form setting, an account toggle, or a flag on the API request. The [double opt-in guide](https://ikiform.com/guides/newsletter-signup/double-opt-in.md) covers all three, and what happens when a known subscriber signs up again.

## The relay

A relay is a short function that receives Ikiform's webhook, checks its signature and calls your email tool's API. It needs a public HTTPS URL and one secret per side. This one is a Next.js route handler; the same code runs in any runtime with `fetch`.

`app/api/newsletter/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { addSubscriber } from "@/lib/email-tool";

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const fields = event.data.fields;
  if (fields.consent !== true) return new Response("No consent, skipped", { status: 200 });

  const answer = await addSubscriber({
    email: String(fields.email),
    firstName: typeof fields.first_name === "string" ? fields.first_name : undefined,
    source: typeof fields.source === "string" ? fields.source : "unknown",
    referrer: typeof event.data.meta.referrer === "string" ? event.data.meta.referrer : undefined,
  });
  if (answer.status === 429) {
    return new Response("Email tool rate limit", {
      status: 503,
      headers: { "retry-after": answer.headers.get("retry-after") ?? "60" },
    });
  }
  if (!answer.ok) return new Response(`Email tool answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- `verifyWebhook` checks the HMAC signature and the timestamp. Without it, anyone who finds the URL can add addresses to your list.
- `endpoint.test` events, from **Send test** in the dashboard, are answered `204` and skipped.
- The consent check is a second lock behind the form definition.
- `addSubscriber` is the only part that depends on the tool. Its body for each tool is on the [Kit](https://ikiform.com/guides/newsletter-signup/kit.md), [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md) and [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md) pages.

What the relay answers decides what Ikiform does next:

Diagram: What the relay answers Ikiform for each answer from the email tool

1. The email tool adds or updates the subscriber: the relay answers 204 and the delivery is marked succeeded.
2. The email tool is rate limited: the relay answers 503 with Retry-After, and Ikiform waits that long before the next attempt.
3. The email tool refuses the request or is down: the relay answers 502, and Ikiform retries on its schedule with the same webhook-id.

Every attempt is in the endpoint's delivery log with your relay's answer, so a 502 that names the tool's status tells you what to fix.

Add the relay's URL under **Webhooks** in the dashboard, pick the newsletter form, and copy the `whsec_` secret into `IKIFORM_WEBHOOK_SECRET`. A workspace has up to 3 endpoints. The URL has to be public HTTPS on port 443; `workers.dev` and `pages.dev` hostnames are refused, so give a Worker a custom domain. Retries, payloads and the signature scheme are on the [webhooks docs](https://ikiform.com/docs/webhooks.md), and [form submissions to your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) has a version that stores each delivery once.

> No code? Point the webhook at an automation tool and use its action for your email tool instead of a relay. See [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). There are no one-click integrations. Every tool is reached through a signed [webhook](https://ikiform.com/docs/webhooks.md), directly or through an automation tool such as [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).

## Keep bots and list bombing out

Bot signups hurt a newsletter more than most forms. A real address signed up by a bot gets email it never asked for, and complaints from those people hurt the domain you send from. Some bots sign one victim's address up to hundreds of forms at once to bury their inbox, and every form with a confirmation email takes part.

| Defence | What it stops | Setup |
| --- | --- | --- |
| Honeypot | Bots that fill in every input | The hidden `_gotcha` input |
| Required consent | Scripts that post only an email | The `consent` checkbox with `required: true` in the definition |
| Spam scoring | Disposable addresses (+2), identical repeats (+3), links and spam phrases. At 5 a signup is stored as spam | None |
| Rate limits | One visitor posting in a loop | On by default: Standard, 5 a minute per visitor |
| Human check | Bots that run a real browser | Set **Spam check** to **Ikiform** for Cloudflare Turnstile with no keys to manage |
| Allowed domains | Your form's endpoint copied onto other sites | List every domain the form appears on |

Spam gets the same success answer as a real signup and sends no webhook, so it never reaches your email tool and never triggers a confirmation email. If a real reader lands in spam, mark it as not spam and its webhook goes out then. The [spam docs](https://ikiform.com/docs/spam.md) explain each check and its score.

> A strict per-visitor limit suits a newsletter form, because a person subscribes once. If your audience reads from offices or campuses that share one IP, Standard counts them together; Relaxed, 20 a minute per visitor is the safer choice there. See [rate limits](https://ikiform.com/docs/rate-limits.md).

## One form on many pages

One endpoint can sit in the footer of every page, at the end of each post and on a `/newsletter` page. Give each placement its own `source` value to learn which one earns signups:

- `footer`: seen on every page. Keep it to the email and the checkbox.
- `post`: readers who finished an article, often the best converter.
- `newsletter-page`: where social profiles link. Room for a sample issue.

Every submission also records the referrer, which is the exact page the reader was on, so `source` tells you the placement and the referrer tells you the article. Add each domain that shows the form under **Allowed domains**, up to 20 per form; subdomains of a listed domain are allowed automatically.

### The same reader twice

Ikiform doesn't merge or reject repeat submissions by email. An identical repeat within 24 hours only adds to the spam score. Deduplicate where the list lives, or when you export. For a newsletter that's rarely a problem: Kit, MailerLite and beehiiv each key subscribers by email, so a repeat updates one contact instead of creating a second.

## Changing email tools later

This is where owning the form pays off. Moving from one email tool to another normally means replacing every embed on the site. Here the form doesn't know which tool sits behind it:

Diagram: What changes when you move to another email tool

1. With an embed, every page carries code that posts straight to the old tool, so moving means replacing each copy.
2. With your own form, every page keeps posting to Ikiform, and Ikiform keeps sending the same webhook.
3. Only the relay's addSubscriber function changes, to call the new tool's API.

Run both tools for a week if you like: two webhook endpoints, two relays, one form. Then remove the old endpoint.

Move existing subscribers with the old tool's own export, since only it knows who confirmed and who left. Ikiform's CSV export, up to 100,000 rows per file, is for checking the numbers, not for importing.

## Pick your situation

The form above is the same everywhere. These pages cover confirmation emails, sites with no server, and the exact API calls for three email tools.

- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [Next.js newsletter form](https://ikiform.com/guides/newsletter-signup/nextjs.md): A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
- [Astro newsletter form](https://ikiform.com/guides/newsletter-signup/astro.md): An Astro newsletter form that stays on prerendered pages: an Astro Action checks consent, posts to Ikiform, and an endpoint adds readers to MailerLite.
- [TanStack Start newsletter form](https://ikiform.com/guides/newsletter-signup/tanstack-start.md): A TanStack Start newsletter form: a server function for in-page signups, a server route as the no-JavaScript fallback, and real signups sent to beehiiv.
- [React Router newsletter form](https://ikiform.com/guides/newsletter-signup/react-router.md): A React Router footer newsletter form: useFetcher posts to a /newsletter route that also works without JavaScript, and a resource route feeds MailerLite.
- [SvelteKit newsletter form](https://ikiform.com/guides/newsletter-signup/sveltekit.md): A SvelteKit 3 newsletter form in the layout: a form action with consent and an IP limit, use:enhance that stays on the page, and a Kit webhook route.
- [Nuxt newsletter form](https://ikiform.com/guides/newsletter-signup/nuxt.md): A Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.

## Check it works

Run through this before you add the form to every page. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Submit the form with the box unticked from the browser's developer tools, or remove `required` for a moment. You get `422` with `This box must be checked` and nothing is stored.
3. Submit it properly with an address you own. You see the check-your-inbox line, or land on your thank-you page.
4. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
5. Open the endpoint under **Webhooks**. The delivery shows `succeeded` and your relay's answer.
6. Open your email tool. The subscriber is there, unconfirmed if double opt-in is on, and the confirmation email is in your inbox.
7. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
8. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `422 validation_failed` with `consent` | The checkbox wasn't ticked, or its input `name` isn't `consent`. A server-side version must send `true`, `"true"` or `"on"`; an unticked box sends nothing. |
| `422 validation_failed` with `unknown_field` | The page sends an input the definition doesn't list, often a hidden `source` or a tracking input added later. Add it to the definition, or remove the input. |
| `403 origin_not_allowed`, or every post lands in spam | The page's domain isn't in the form's **Allowed domains**. Add it, plus `localhost` for local tests. Subdomains of a listed domain are allowed. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the **Spam** filter to see the reasons, and mark it as not spam to send its email and webhooks. |
| Signups arrive in Ikiform but not in the email tool | Read the delivery log under **Webhooks**. `400` means the relay's secret is wrong. `502` carries the email tool's status: usually a wrong API key, list id or form id. |
| The subscriber is in the email tool but got no confirmation email | Double opt-in is off in the tool, or the relay created the subscriber as already confirmed. See [double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md) for each tool's switch. |
| The browser lands on Ikiform's thank-you page instead of yours | `_redirect` is ignored unless its domain is in **Allowed domains** and it is an `https:` URL. Add the domain, or set **Redirect after submit** in the form's settings instead. |

## Questions

### Can I build a newsletter signup form without Mailchimp's embed code?

Yes. Write the form in your own HTML, post it to a form backend such as Ikiform, and forward each signup by webhook to Mailchimp or any other email tool through its API. The form then looks like the rest of your site and doesn't change when the email tool does.

### Does Ikiform send the confirmation email or the newsletter?

No. Ikiform collects, validates and stores signups and sends them on by webhook. It never emails the subscriber, so the confirmation email, every issue and the unsubscribe link come from your email tool.

### Which email tools does this work with?

Any tool with an API to add subscribers, or any tool an automation service such as Zapier, Make or n8n supports. There are no one-click integrations; the relay or the automation does the connecting. Kit, MailerLite and beehiiv have step-by-step pages in this guide.

### Where do I keep my email tool's API key?

In the relay's server-side environment variables, never in the page. An email tool's API key can usually read and change the whole list, so a key in browser JavaScript is a key anyone can copy.

### How do I stop bots from signing up to my newsletter?

Keep the hidden _gotcha honeypot and the required consent checkbox, list your domains under Allowed domains, and set the spam check to Ikiform for a Cloudflare Turnstile check. Signups stored as spam never send a webhook, so they never trigger a confirmation email.

### Does Ikiform remove duplicate submissions?

No. Every accepted post is stored. An identical repeat within 24 hours adds to the spam score but isn't spam on its own, so deduplicate by email in your list, database or CSV export.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [Next.js newsletter form](https://ikiform.com/guides/newsletter-signup/nextjs.md): A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
- [Astro newsletter form](https://ikiform.com/guides/newsletter-signup/astro.md): An Astro newsletter form that stays on prerendered pages: an Astro Action checks consent, posts to Ikiform, and an endpoint adds readers to MailerLite.
- [TanStack Start newsletter form](https://ikiform.com/guides/newsletter-signup/tanstack-start.md): A TanStack Start newsletter form: a server function for in-page signups, a server route as the no-JavaScript fallback, and real signups sent to beehiiv.
- [React Router newsletter form](https://ikiform.com/guides/newsletter-signup/react-router.md): A React Router footer newsletter form: useFetcher posts to a /newsletter route that also works without JavaScript, and a resource route feeds MailerLite.
- [SvelteKit newsletter form](https://ikiform.com/guides/newsletter-signup/sveltekit.md): A SvelteKit 3 newsletter form in the layout: a form action with consent and an IP limit, use:enhance that stays on the page, and a Kit webhook route.
- [Nuxt newsletter form](https://ikiform.com/guides/newsletter-signup/nuxt.md): A Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.
- [HTML form submission](https://ikiform.com/guides/html-form-submission.md): What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.
- [Event registration](https://ikiform.com/guides/event-registration.md): Take RSVPs on your own event page: the form, guest limits, an honest headcount, closing when full and a check-in list, without a ticketing app.
- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
