# Mailchimp 임베드 없는 뉴스레터 가입 폼

> 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/newsletter-signup

이메일 서비스의 임베드 코드를 붙여 넣는 대신 가입 폼을 직접 HTML로 작성하고 폼 백엔드를 가리키게 하세요. 백엔드가 주소와 동의 체크박스를 검증하고, 봇을 걸러 내고, 서명된 웹훅을 보내면, 작은 릴레이가 이메일 도구의 API로 구독자를 추가합니다. 확인 이메일, 뉴스레터, 수신 거부 링크는 이메일 도구가 보내므로, 나중에 도구를 바꿔도 모든 폼이 아니라 릴레이만 바꾸면 됩니다.

모든 이메일 도구는 임베드를 제공합니다. 페이지에 붙여 넣는, 생성된 마크업 블록입니다. 동작은 하지만 폼이 그 도구의 것이 됩니다. 그 서비스로 바로 POST하고, 서비스의 마크업과 대개 스타일과 스크립트까지 담고 있으며, 다른 도구로 옮기는 날에는 사이트 곳곳의 사본을 모두 찾아야 합니다. 이 가이드는 이미 쓰고 있는 마크업과 프레임워크로 내 소유의 가입 폼을 만들고, 뉴스레터를 보내는 도구가 무엇이든 연결합니다.

## 가입이 전달되는 과정

여기에는 두 가지 일이 있고, 각각 다른 곳에 속합니다. 가입을 받는 것은 폼의 일입니다. 주소를 검증하고, 동의 체크박스를 확인하고, 봇을 막고, 언제 일어났는지 기록합니다. 뉴스레터를 보내는 것은 이메일의 일입니다. 주소를 확인하고, 호를 발송하고, 수신 거부와 반송을 처리합니다. Ikiform이 첫 번째를, 이메일 도구가 두 번째를 합니다. 서명된 웹훅이 둘을 연결합니다.

다이어그램: 뉴스레터 가입이 페이지에서 이메일 도구까지 전달되는 과정

1. 독자가 페이지의 가입 폼을 작성하면 Ikiform 엔드포인트로 POST됩니다.
2. Ikiform이 주소, 필수 동의 체크박스, 허니팟, 스팸 점수, 레이트 리밋을 검사합니다.
3. 받아들여진 가입이 타임스탬프, origin, referrer와 함께 대시보드에 저장됩니다.
4. Ikiform은 각 가입을 나에게 이메일로 알릴 수 있습니다. 독자에게는 절대 이메일을 보내지 않습니다.
5. Ikiform이 서명된 submission.created 웹훅을 보냅니다.
6. 릴레이가 서명을 검증하고 독자를 이메일 도구에 추가하면, 이메일 도구가 확인 이메일과 이후 모든 호를 보냅니다.

어떤 이메일 도구를 쓰는지 아는 것은 릴레이뿐입니다. 도구가 바뀌어도 폼, 스팸 필터링, 저장된 기록은 바뀌지 않습니다.

## 뉴스레터 가입을 받는 네 가지 방법

| 방식 | 잘하는 것 | 단점 |
| --- | --- | --- |
| Mailchimp, Kit, MailerLite, beehiiv 같은 이메일 도구의 임베드 | 운영할 것이 없습니다. 확인, 태그, 환영 이메일이 도구 자체 설정에 연결되어 있습니다. | 마크업과 동작이 도구의 것입니다. Mailchimp는 임베드 폼이 사이트의 스타일을 상속해 페이지에서 다르게 보일 수 있다고 안내하며, 기존 폼에 reCAPTCHA를 켜려면 새 코드를 생성해 다시 붙여 넣어야 합니다. 모든 사본이 한 서비스에 묶입니다. |
| 도구의 API를 호출하는 자체 서버 라우트 | API 키를 서버에 둔 채 폼과 요청을 완전히 제어할 수 있습니다. | 검증, 봇 필터링, 레이트 리밋, 동의 기록을 직접 만들어야 하고, 사이트가 정적이어도 서버가 필요합니다. |
| [Typeform](https://ikiform.com/alternatives/typeform)과 Zapier처럼 호스팅 폼 빌더와 자동화의 조합 | 코드가 없고, 자동화 도구가 모든 이메일 서비스를 압니다. | 페이지와 어울리지 않는 iframe에 구독이 두 개 생깁니다. 웹훅과 브랜딩은 플랜에 따라 다릅니다. |
| Ikiform 같은 폼 백엔드와 릴레이의 조합 | 어떤 사이트에서든 직접 작성한 마크업, 스팸 필터링, 저장된 동의 기록, 서명된 웹훅을 폼용 서버 없이 쓸 수 있습니다. | Ikiform은 독자에게 이메일을 보내지 않습니다. 확인 이메일과 모든 뉴스레터는 작은 릴레이나 자동화를 거쳐 이메일 도구에서 나갑니다. |

사이트 하나, 도구 하나, 그 임베드로 만족한다면 그대로 쓰세요. 폼이 디자인과 맞아야 하거나, 여러 사이트에 걸쳐 있거나, 도구보다 오래 쓸 수 있다면 직접 만드세요.

## 폼

좋은 뉴스레터 폼은 작습니다. 이메일, 인사말용 선택 입력인 이름, 그리고 레이블이 곧 약속인 동의 체크박스입니다. hidden `source` 입력이 사이트의 어디서 가입했는지 기록합니다. 스택을 고르세요. 선택한 스택은 이 가이드들의 모든 코드 예제에 이어집니다.

**HTML**

폼을 푸터, 사이드바, 글 끝에 붙여 넣으세요. 일반적인 방식으로 POST하고 내 감사 페이지로 이동하므로 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>
```

독자를 글에 머물게 하려면 폼 뒤에 이 스크립트를 추가하세요. `fetch`로 POST하고, 폼을 받은편지함을 확인하라는 문구로 바꾸며, 이메일이나 체크박스에 대한 서버의 메시지를 보여 줍니다.

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

서버 액션이 시크릿 키로 가입을 전달합니다. 필드별로 오류를 반환하므로 이메일 메시지는 이메일 입력 아래에, 동의 메시지는 체크박스 아래에 놓입니다. 컴포넌트가 `source` prop을 받으므로 푸터와 글 속 상자가 서로 다른 값을 기록합니다.

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

Next.js에서 더 자세히 알아보려면 [Next.js로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/nextjs.md)을(를) 읽어 보세요.

**Astro**

`accept: "form"`인 Astro Action은 체크박스를 boolean으로, 이메일을 `z.email()`로 파싱합니다. `isInputError`로 잘못된 주소와 거부된 POST를 구분합니다. 폼 액션에는 요청 시 렌더링 페이지가 필요하므로 어댑터를 추가하고 컴포넌트를 렌더링하는 모든 페이지에 `export const prerender = false`를 설정하세요. 컴포넌트 안의 export는 아무 효과가 없습니다.

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

Astro에서 더 자세히 알아보려면 [Astro로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/astro.md)을(를) 읽어 보세요.

**TanStack Start**

`createServerFn` POST 함수가 `.validator()`에서 `FormData`를 JSON으로 바꾸고, 필드 오류를 예외 대신 데이터로 반환하므로 컴포넌트가 각 메시지를 입력 옆에 둘 수 있습니다.

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

TanStack Start에서 더 자세히 알아보려면 [TanStack Start로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/tanstack-start.md)을(를) 읽어 보세요.

**React Router**

푸터 폼이 사람이 읽고 있는 페이지를 떠나게 하면 안 됩니다. 액션을 `/newsletter`의 리소스 라우트에 두고 `useFetcher`로 POST하세요. 내비게이션 없이 제출하고 결과를 현재 페이지에 유지합니다.

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

React Router에서 더 자세히 알아보려면 [React Router로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/react-router.md)을(를) 읽어 보세요.

**SvelteKit**

액션은 레이아웃이 아니라 `+page.server.ts`에 있으므로, 액션은 `/newsletter` 페이지에 두고 푸터는 `/newsletter?/subscribe`로 POST합니다. 커스텀 `use:enhance` 콜백이 `update()`를 호출하지 않고 결과를 직접 처리하므로 독자는 글에 머뭅니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요.

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

SvelteKit에서 더 자세히 알아보려면 [SvelteKit으로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/sveltekit.md)을(를) 읽어 보세요.

**Nuxt**

서버 라우트가 시크릿 키를 브라우저에서 떼어 두고 Ikiform의 필드 오류를 오류의 `data`로 돌려줍니다. `NUXT_IKIFORM_SECRET_KEY`를 설정하고, `nuxt.config.ts`의 `runtimeConfig` 아래에 `ikiformSecretKey`를 선언하세요.

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

Nuxt에서 더 자세히 알아보려면 [Nuxt로 뉴스레터 가입 폼 만들기](https://ikiform.com/ko/guides/newsletter-signup/nuxt.md)을(를) 읽어 보세요.

**React**

싱글 페이지 앱은 브라우저에서 POST합니다. `@ikiform/sdk`의 `submit()`이 `FormData`를 보내고 항상 `ok`와, 실패 시 필드 오류로 resolve되므로 `try` 블록을 쓸 필요가 없습니다. 여기에는 절대 시크릿 키를 넣지 마세요. 대신 **Allowed domains**가 폼을 보호합니다.

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

같은 `submit()` 호출을 Vue 싱글 파일 컴포넌트로 작성한 것입니다. 폼이 `action`을 유지하므로 스크립트 로드에 실패해도 POST됩니다.

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

- `frm_xxxxxxxxxxxxxxxx`를 대시보드에 있는 폼 ID로 바꾸세요.
- 이메일 입력의 이름은 `email`로 유지하세요. Ikiform이 검증하고, 도메인을 소문자로 바꾸고, 일회용 이메일 도메인인지 확인합니다.
- hidden `_gotcha` 입력이 허니팟입니다. 사람에게는 보이지 않고, 모든 입력을 채우는 봇은 스스로 스팸임을 드러냅니다.
- 체크박스는 체크되면 `true`를 POST합니다. 체크되지 않으면 아무것도 보내지 않으며, 필수 체크박스는 `This box must be checked`로 거부됩니다.
- 이메일 도구가 확인 이메일을 보내므로 성공 문구는 받은편지함을 확인하라고 안내합니다. 더블 옵트인을 껐다면 간단한 감사 문구로 바꾸세요.
- 서버 측 버전은 브라우저의 `Origin` 헤더를 전달합니다. Ikiform은 이 헤더로 무료 `localhost` 테스트를 알아보는데, 서버 자체의 `fetch`는 이를 보내지 않기 때문입니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

프레임워크 설정은 문서에 있습니다: [Next.js](https://ikiform.com/ko/docs/guides/nextjs.md), [Astro](https://ikiform.com/ko/docs/guides/astro.md), [SvelteKit](https://ikiform.com/ko/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/ko/docs/guides/nuxt.md), [React](https://ikiform.com/ko/docs/guides/react.md), [Vue](https://ikiform.com/ko/docs/guides/vue.md), [일반 HTML](https://ikiform.com/ko/docs/html.md). [뉴스레터 폼 템플릿](https://ikiform.com/forms/newsletter-signup)은 같은 필드를 HTML, React, Next.js 서버 액션으로 생성합니다.

## 필드와 정의

폼의 **Fields** 탭에 아래 정의를 붙여 넣으면 페이지가 무엇을 보내든 Ikiform이 서버에서 강제합니다. 미리보기는 같은 검증기를 브라우저에서 실행합니다. 체크박스를 비운 채 제출하면 봇이나 망가진 페이지가 받을 오류를 볼 수 있습니다.

폼: Newsletter

- **이메일** (`email`, email, 필수)
- **이름** (`first_name`, text)
- **뉴스레터를 이메일로 받겠습니다. 언제든 수신 거부할 수 있습니다.** (`consent`, checkbox, 필수)
- **가입 위치** (`source`, text)

실제 페이지에서 `source`는 hidden 입력입니다. 여기서는 시험해 볼 수 있도록 보이는 텍스트 필드로 두었습니다.

| 필드 | 이유 | 유의할 점 |
| --- | --- | --- |
| `email` | 구독자 | 필수. `type="email"`로 휴대폰에서 알맞은 키보드가 뜹니다 |
| `first_name` | 매 호의 인사말 | 선택. 대부분의 이메일 도구에 매핑할 이름 필드가 있습니다 |
| `consent` | 이메일을 요청했다는 기록 | 필수 체크박스. `true`로 저장됩니다 |
| `source` | 사이트의 어느 폼이 효과가 있었는지: 푸터, 글, 팝업 | hidden. 위치마다 다른 값을 설정하고 태그나 필드로 넘기세요 |

필드를 정의하면 Ikiform은 모르는 입력을 `unknown_field`로 거부하므로, 페이지에 입력을 추가하기 전에 정의에 필드를 추가하세요. `_redirect`처럼 `_`로 시작하는 이름은 Ikiform에 대한 지시이며 필드로 집계되지 않습니다.

## 동의 기록하기

뉴스레터는 누군가 요청한 이메일입니다. 체크박스가 요청을 명시적으로 만들고, 저장된 제출이 이를 기록합니다. Ikiform이 받아들인 각 가입에 대해 보관하는 것은 다음과 같습니다.

`data.fields`:

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

필드 외에 각 제출에는 `created_at` 타임스탬프, 페이지의 origin과 referrer, 국가, 브라우저의 user agent가 있습니다. 이를 합치면 그 사람이 언제 어디서 체크박스를 선택했는지 알 수 있습니다. 나중에 이 기록을 유용하게 만드는 습관이 두 가지 있습니다.

1. **레이블을 약속으로 쓰세요.** "뉴스레터를 이메일로 받겠습니다. 언제든 수신 거부할 수 있습니다."는 무엇을 받는지 말해 줍니다. 광고도 보낸다면 레이블에 밝히세요.
2. **문구가 바뀌면 날짜를 기록하세요.** 저장된 `true`는 그 사람이 동의했다는 뜻이지, 어떤 문구에 동의했는지는 말해 주지 않습니다. 레이블의 짧은 변경 기록을 날짜와 함께 남기면 어떤 가입이든 그때 본 문구와 맞춰 볼 수 있습니다.

Ikiform은 기록을 보관할 뿐, 나와 독자가 사는 곳의 규정이 무엇을 요구하는지는 알려 줄 수 없습니다. 그 부분은 잘 아는 사람에게 확인하세요.

## 확인 이메일은 이메일 도구에서 나갑니다

Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 나와 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 그런 이메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 트리거해 이메일 도구에서 보내세요. 이렇게 나눈 것은 의도적입니다. 뉴스레터를 보내는 도구는 이미 누가 확인했는지, 누가 수신 거부했는지, 어떤 주소가 반송되는지 추적합니다. 두 번째 시스템이 확인 이메일을 보내면 그 기록이 서로 어긋납니다.

그래서 릴레이는 이메일 도구가 자체 확인 이메일을 보내는 상태로 독자를 추가하고, 독자가 클릭하면 도구가 활성으로 표시합니다. 도구마다 켜는 방식이 다릅니다. 폼 설정, 계정 토글, 또는 API 요청의 플래그입니다. [더블 옵트인 가이드](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md)에서 세 가지 모두와, 기존 구독자가 다시 가입할 때 일어나는 일을 다룹니다.

## 릴레이

릴레이는 Ikiform의 웹훅을 받아 서명을 확인하고 이메일 도구의 API를 호출하는 짧은 함수입니다. 공개 HTTPS URL과 양쪽에 시크릿 하나씩이 필요합니다. 이것은 Next.js 라우트 핸들러이며, 같은 코드가 `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`이 HMAC 서명과 타임스탬프를 확인합니다. 이게 없으면 URL을 찾은 누구나 목록에 주소를 추가할 수 있습니다.
- 대시보드의 **Send test**에서 오는 `endpoint.test` 이벤트는 `204`로 응답하고 건너뜁니다.
- 동의 확인은 폼 정의 뒤의 두 번째 잠금입니다.
- 도구에 의존하는 부분은 `addSubscriber`뿐입니다. 도구별 본문은 [Kit](https://ikiform.com/ko/guides/newsletter-signup/kit.md), [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md), [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md) 페이지에 있습니다.

릴레이의 응답이 Ikiform의 다음 행동을 정합니다.

다이어그램: 이메일 도구의 응답별로 릴레이가 Ikiform에 보내는 응답

1. 이메일 도구가 구독자를 추가하거나 업데이트하면 릴레이가 204로 응답하고 전달이 성공으로 표시됩니다.
2. 이메일 도구가 레이트 리밋에 걸리면 릴레이가 Retry-After와 함께 503으로 응답하고, Ikiform은 그만큼 기다렸다가 다음 시도를 합니다.
3. 이메일 도구가 요청을 거부하거나 다운되면 릴레이가 502로 응답하고, Ikiform은 같은 webhook-id로 일정에 따라 재시도합니다.

모든 시도가 릴레이의 응답과 함께 엔드포인트의 전달 로그에 남으므로, 도구의 상태를 담은 502가 무엇을 고쳐야 할지 알려 줍니다.

대시보드의 **Webhooks**에서 릴레이의 URL을 추가하고, 뉴스레터 폼을 고르고, `whsec_` 시크릿을 `IKIFORM_WEBHOOK_SECRET`에 복사하세요. 워크스페이스에는 엔드포인트를 3개까지 둘 수 있습니다. URL은 443 포트의 공개 HTTPS여야 하며 `workers.dev`와 `pages.dev` 호스트 이름은 거부되므로 Worker에는 커스텀 도메인을 주세요. 재시도, 페이로드, 서명 방식은 [웹훅 문서](https://ikiform.com/ko/docs/webhooks.md)에 있고, [폼 제출을 자체 서버로 보내기](https://ikiform.com/blog/form-submissions-to-your-own-server.md)에는 각 전달을 한 번만 저장하는 버전이 있습니다.

> 코드 없이 하려면 웹훅을 자동화 도구로 보내고, 릴레이 대신 그 도구의 이메일 도구 액션을 쓰세요. [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md), [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)을 참고하세요. 원클릭 연동은 없습니다. 모든 도구는 서명된 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 직접, 또는 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) 같은 자동화 도구를 거쳐 연결합니다.

## 봇과 리스트 폭탄 막기

봇 가입은 대부분의 폼보다 뉴스레터에 더 큰 피해를 줍니다. 봇이 가입시킨 실제 주소는 요청한 적 없는 이메일을 받고, 그 사람들의 신고가 발송 도메인에 피해를 줍니다. 어떤 봇은 피해자의 받은편지함을 묻어 버리려고 그 주소를 수백 개 폼에 한꺼번에 가입시키며, 확인 이메일을 보내는 모든 폼이 여기에 가담하게 됩니다.

| 방어 | 막는 것 | 설정 |
| --- | --- | --- |
| 허니팟 | 모든 입력을 채우는 봇 | 숨겨진 `_gotcha` 입력 |
| 필수 동의 | 이메일만 POST하는 스크립트 | 정의에서 `required: true`인 `consent` 체크박스 |
| 스팸 점수 | 일회용 주소(+2), 동일한 반복(+3), 링크와 스팸 문구. 5점이면 가입이 스팸으로 저장됩니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 방문자 한 명 | 기본으로 켜짐: Standard, 방문자당 분당 5회 |
| 사람 확인 | 실제 브라우저를 구동하는 봇 | 키 관리 없이 Cloudflare Turnstile을 쓰려면 **Spam check**를 **Ikiform**으로 설정하세요 |
| 허용 도메인 | 다른 사이트에 복사된 폼 엔드포인트 | 폼이 있는 모든 도메인을 나열하세요 |

스팸은 실제 가입과 같은 성공 응답을 받지만 웹훅을 보내지 않으므로 이메일 도구에 닿지 않고 확인 이메일도 트리거하지 않습니다. 실제 독자가 스팸으로 분류되면 스팸 아님으로 표시하세요. 그때 웹훅이 나갑니다. 각 검사와 점수는 [스팸 문서](https://ikiform.com/ko/docs/spam.md)에서 설명합니다.

> 사람은 한 번만 구독하므로 뉴스레터 폼에는 방문자당 엄격한 한도가 맞습니다. 독자가 IP 하나를 공유하는 사무실이나 캠퍼스에서 읽는다면 Standard는 그들을 함께 세므로, 거기서는 방문자당 분당 20회인 Relaxed가 더 안전합니다. [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 참고하세요.

## 여러 페이지에 하나의 폼

엔드포인트 하나를 모든 페이지의 푸터, 각 글의 끝, `/newsletter` 페이지에 둘 수 있습니다. 어느 위치가 가입을 이끄는지 알 수 있도록 위치마다 고유한 `source` 값을 주세요.

- `footer`: 모든 페이지에 보입니다. 이메일과 체크박스만 두세요.
- `post`: 글을 끝까지 읽은 독자로, 대개 전환율이 가장 좋습니다.
- `newsletter-page`: 소셜 프로필이 링크하는 곳입니다. 샘플 호를 보여 줄 공간이 있습니다.

모든 제출에는 독자가 있던 정확한 페이지인 referrer도 기록되므로, `source`는 위치를, referrer는 글을 알려 줍니다. 폼이 있는 각 도메인을 **Allowed domains**에 추가하세요. 폼당 최대 20개이며, 나열한 도메인의 서브도메인은 자동으로 허용됩니다.

### 같은 독자가 두 번

Ikiform은 같은 이메일의 반복 제출을 합치거나 거부하지 않습니다. 24시간 안의 동일한 반복은 스팸 점수만 올립니다. 중복 제거는 목록이 있는 곳이나 내보낼 때 하세요. 뉴스레터에서는 거의 문제가 되지 않습니다. Kit, MailerLite, beehiiv 모두 이메일을 키로 구독자를 관리하므로, 반복 가입은 두 번째 연락처를 만들지 않고 기존 연락처를 업데이트합니다.

## 나중에 이메일 도구 바꾸기

폼을 소유한 덕을 보는 곳이 여기입니다. 이메일 도구를 바꾸려면 보통 사이트의 모든 임베드를 교체해야 합니다. 여기서는 폼이 뒤에 어떤 도구가 있는지 모릅니다.

다이어그램: 다른 이메일 도구로 옮길 때 바뀌는 것

1. 임베드를 쓰면 모든 페이지에 이전 도구로 바로 POST하는 코드가 있으므로, 옮기려면 사본을 하나하나 교체해야 합니다.
2. 내 폼을 쓰면 모든 페이지가 계속 Ikiform으로 POST하고, Ikiform은 계속 같은 웹훅을 보냅니다.
3. 새 도구의 API를 호출하도록 릴레이의 addSubscriber 함수만 바뀝니다.

원한다면 일주일 동안 두 도구를 함께 운영하세요. 웹훅 엔드포인트 둘, 릴레이 둘, 폼 하나입니다. 그런 다음 이전 엔드포인트를 제거하세요.

기존 구독자는 이전 도구 자체의 내보내기로 옮기세요. 누가 확인했고 누가 떠났는지는 그 도구만 압니다. 파일당 최대 100,000행인 Ikiform의 CSV 내보내기는 숫자를 확인하는 용도이지 가져오기용이 아닙니다.

## 상황별 가이드

위 폼은 어디서나 같습니다. 아래 페이지들은 확인 이메일, 서버 없는 사이트, 세 가지 이메일 도구의 정확한 API 호출을 다룹니다.

- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [정적 사이트](https://ikiform.com/ko/guides/newsletter-signup/static-site.md): Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
- [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md): 내 가입 폼을 MailerLite에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
- [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md): 내 가입 폼을 beehiiv에 연결합니다. 릴레이가 더블 옵트인을 켜고, 위치를 UTM source로, 페이지를 referrer로 해서 각 구독을 만듭니다.
- [Next.js 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nextjs.md): Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.
- [Astro 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/astro.md): 사전 렌더링 페이지에 그대로 두는 Astro 뉴스레터 폼입니다. Astro Action이 동의를 확인해 Ikiform으로 POST하고, 엔드포인트가 독자를 MailerLite에 추가합니다.
- [TanStack Start 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/tanstack-start.md): TanStack Start 뉴스레터 폼입니다. 페이지 내 가입용 서버 함수, JavaScript 없는 경우를 위한 서버 라우트, 그리고 실제 가입을 beehiiv로 보내는 구성입니다.
- [React Router 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/react-router.md): React Router 푸터 뉴스레터 폼입니다. useFetcher가 JavaScript 없이도 동작하는 /newsletter 라우트로 POST하고, 리소스 라우트가 MailerLite로 넘깁니다.
- [SvelteKit 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/sveltekit.md): 레이아웃에 둔 SvelteKit 3 뉴스레터 폼입니다. 동의와 IP 한도가 있는 폼 액션, 페이지에 머무는 use:enhance, Kit 웹훅 라우트로 구성됩니다.
- [Nuxt 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nuxt.md): JavaScript 유무와 관계없이 동작하는 Nuxt 뉴스레터 폼입니다. Turnstile 확인을 거치는 서버 라우트가 Ikiform으로 POST하고, Nitro 라우트가 beehiiv로 넘깁니다.

## 작동 확인하기

폼을 모든 페이지에 추가하기 전에 아래를 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 브라우저 개발자 도구에서 체크박스를 비운 채 폼을 제출하거나, 잠깐 `required`를 제거하세요. `This box must be checked`와 함께 `422`를 받고 아무것도 저장되지 않습니다.
3. 내 주소로 제대로 제출하세요. 받은편지함을 확인하라는 문구가 보이거나 감사 페이지로 이동합니다.
4. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
5. **Webhooks**에서 엔드포인트를 여세요. 전달에 `succeeded`와 릴레이의 응답이 표시됩니다.
6. 이메일 도구를 여세요. 구독자가 있고, 더블 옵트인이 켜져 있다면 미확인 상태이며, 확인 이메일이 받은편지함에 와 있습니다.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
8. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꾸세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| `consent`와 함께 `422 validation_failed` | 체크박스가 선택되지 않았거나 입력의 `name`이 `consent`가 아닙니다. 서버 측 버전은 `true`, `"true"`, `"on"` 중 하나를 보내야 하며, 체크되지 않은 상자는 아무것도 보내지 않습니다. |
| `unknown_field`와 함께 `422 validation_failed` | 페이지가 정의에 없는 입력을 보냅니다. 나중에 추가한 hidden `source`나 추적용 입력인 경우가 많습니다. 정의에 추가하거나 입력을 제거하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| 가입이 Ikiform에는 오는데 이메일 도구에는 오지 않음 | **Webhooks**에서 전달 로그를 읽으세요. `400`은 릴레이의 시크릿이 틀렸다는 뜻입니다. `502`에는 이메일 도구의 상태가 담겨 있으며, 대개 API 키, 리스트 ID, 폼 ID가 틀린 경우입니다. |
| 구독자는 이메일 도구에 있는데 확인 이메일을 받지 못함 | 도구에서 더블 옵트인이 꺼져 있거나, 릴레이가 구독자를 이미 확인된 상태로 만들었습니다. 도구별 설정은 [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md)을 참고하세요. |
| 브라우저가 내 페이지가 아니라 Ikiform의 감사 페이지로 이동합니다 | `_redirect`는 도메인이 **Allowed domains**에 있고 `https:` URL일 때만 따릅니다. 도메인을 추가하거나, 대신 폼 설정에서 **Redirect after submit**을 설정하세요. |

## 질문

### Mailchimp 임베드 코드 없이 뉴스레터 가입 폼을 만들 수 있나요?

네. 직접 HTML로 폼을 작성해 Ikiform 같은 폼 백엔드로 POST하고, 각 가입을 웹훅으로 Mailchimp나 다른 이메일 도구의 API에 전달하세요. 그러면 폼이 사이트의 나머지와 어울리고, 이메일 도구가 바뀌어도 폼은 바뀌지 않습니다.

### Ikiform이 확인 이메일이나 뉴스레터를 보내나요?

아니요. Ikiform은 가입을 받고, 검증하고, 저장하고, 웹훅으로 넘깁니다. 구독자에게는 절대 이메일을 보내지 않으므로 확인 이메일, 모든 호, 수신 거부 링크는 이메일 도구에서 나갑니다.

### 어떤 이메일 도구와 함께 쓸 수 있나요?

구독자를 추가하는 API가 있는 도구, 또는 Zapier, Make, n8n 같은 자동화 서비스가 지원하는 도구라면 무엇이든 됩니다. 원클릭 연동은 없으며 릴레이나 자동화가 연결을 담당합니다. Kit, MailerLite, beehiiv는 이 가이드에 단계별 페이지가 있습니다.

### 이메일 도구의 API 키는 어디에 두나요?

페이지가 아니라 릴레이의 서버 측 환경 변수에 두세요. 이메일 도구의 API 키는 대개 목록 전체를 읽고 바꿀 수 있으므로, 브라우저 JavaScript에 든 키는 누구나 복사할 수 있는 키입니다.

### 봇이 뉴스레터에 가입하는 것을 어떻게 막나요?

hidden _gotcha 허니팟과 필수 동의 체크박스를 유지하고, Allowed domains에 도메인을 나열하고, Cloudflare Turnstile 확인을 위해 스팸 검사를 Ikiform으로 설정하세요. 스팸으로 저장된 가입은 웹훅을 보내지 않으므로 확인 이메일도 트리거하지 않습니다.

### Ikiform이 중복 제출을 제거하나요?

아니요. 받아들인 POST는 모두 저장됩니다. 24시간 안에 똑같은 제출이 반복되면 스팸 점수가 올라가지만 그것만으로 스팸이 되지는 않으므로, 명단, 데이터베이스, CSV 내보내기에서 이메일 기준으로 중복을 제거하세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [정적 사이트](https://ikiform.com/ko/guides/newsletter-signup/static-site.md): Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
- [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md): 내 가입 폼을 MailerLite에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
- [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md): 내 가입 폼을 beehiiv에 연결합니다. 릴레이가 더블 옵트인을 켜고, 위치를 UTM source로, 페이지를 referrer로 해서 각 구독을 만듭니다.
- [Next.js 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nextjs.md): Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.
- [Astro 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/astro.md): 사전 렌더링 페이지에 그대로 두는 Astro 뉴스레터 폼입니다. Astro Action이 동의를 확인해 Ikiform으로 POST하고, 엔드포인트가 독자를 MailerLite에 추가합니다.
- [TanStack Start 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/tanstack-start.md): TanStack Start 뉴스레터 폼입니다. 페이지 내 가입용 서버 함수, JavaScript 없는 경우를 위한 서버 라우트, 그리고 실제 가입을 beehiiv로 보내는 구성입니다.
- [React Router 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/react-router.md): React Router 푸터 뉴스레터 폼입니다. useFetcher가 JavaScript 없이도 동작하는 /newsletter 라우트로 POST하고, 리소스 라우트가 MailerLite로 넘깁니다.
- [SvelteKit 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/sveltekit.md): 레이아웃에 둔 SvelteKit 3 뉴스레터 폼입니다. 동의와 IP 한도가 있는 폼 액션, 페이지에 머무는 use:enhance, Kit 웹훅 라우트로 구성됩니다.
- [Nuxt 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nuxt.md): JavaScript 유무와 관계없이 동작하는 Nuxt 뉴스레터 폼입니다. Turnstile 확인을 거치는 서버 라우트가 Ikiform으로 POST하고, Nitro 라우트가 beehiiv로 넘깁니다.
- [HTML 폼 제출](https://ikiform.com/ko/guides/html-form-submission.md): 폼이 제출될 때 브라우저가 하는 일: 어떤 필드를 보내는지, 어떻게 인코딩하는지, 서버가 어떻게 응답해야 하는지, 그리고 Ikiform이 어떻게 응답하는지 설명합니다.
- [행사 등록](https://ikiform.com/ko/guides/event-registration.md): 티켓 앱 없이 내 행사 페이지에서 RSVP를 받는 방법: 폼, 동반 인원 제한, 정확한 인원 집계, 마감, 체크인 명단.
- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
