Guides

Guides17 min read

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.

Preet SutharFounder, Ikiform

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.

How a newsletter signup travels from your page to your email toolyour signupformfooter, sidebaror postIkiformchecks consentand spamdashboardevery signupwith itsconsentnotificationemailto you, optionalsignedwebhooksubmission.createdemail toolconfirms, sends,unsubscribespostrelayHow a newsletter signup travels from your page to your email toolyour signupformfooter, sidebaror postIkiformchecks consentand spamdashboardevery signup withits consentnotificationemailto you, optionalsigned webhooksubmission.createdemail toolconfirms, sends,unsubscribespostrelay
  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

ApproachGood atTrade-offs
The email tool's embed, from Mailchimp, Kit, MailerLite or beehiivNothing 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 APIFull 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 with ZapierNo 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 relayYour 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.

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
<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
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;
});
  • 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 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, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. The newsletter form template 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.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

In your page, source is a hidden input. It's a visible text field here only so you can try it.
FieldWhyKeep in mind
emailThe subscriberRequired. type="email" brings up the right phone keyboard
first_nameA greeting in each issueOptional. Most email tools have a first name field to map it to
consentA record that the person asked for emailRequired checkbox. Stored as true
sourceWhich form on the site worked: footer, post, popupHidden. 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.

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
{
  "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. 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 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
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, MailerLite and beehiiv pages.

What the relay answers decides what Ikiform does next:

What the relay answers Ikiform for each answer from the email tool200 or 201added or updated204no contentdonedelivery succeeded429rate limited503with Retry-Afterwaitsthen tries again4xx or 5xxrefused or down502with the tool's statusretriessame webhook-idemail tool answersrelay answersIkiformWhat the relay answers Ikiform for each answer from the email tool200 or201added orupdated204nocontentdonedeliverysucceeded429ratelimited503withRetry-Afterwaitsthentriesagain4xx or5xxrefusedor down502with thetool'sstatusretriessamewebhook-idemail toolanswersrelayanswersIkiform
  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, and form submissions to your own server 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, Make or n8n. There are no one-click integrations. Every tool is reached through a signed webhook, directly or through an automation tool such as Zapier or n8n.

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.

DefenceWhat it stopsSetup
HoneypotBots that fill in every inputThe hidden _gotcha input
Required consentScripts that post only an emailThe consent checkbox with required: true in the definition
Spam scoringDisposable addresses (+2), identical repeats (+3), links and spam phrases. At 5 a signup is stored as spamNone
Rate limitsOne visitor posting in a loopOn by default: Standard, 5 a minute per visitor
Human checkBots that run a real browserSet Spam check to Ikiform for Cloudflare Turnstile with no keys to manage
Allowed domainsYour form's endpoint copied onto other sitesList 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 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.

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:

What changes when you move to another email toolwith an embedwith your own formevery pagepasted embed codeold toolform posts herenew toolnew embed on everypageevery pageunchangedIkiformunchangedrelayone functionchangespostreplace allpostwebhookWhat changes when you move to another email toolwith an embedwith your own formevery pagepasted embed codeold toolform posts herenew toolnew embed on every pageevery pageunchangedIkiformunchangedrelayone function changespostreplace allpostwebhook
  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.

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.

  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

ProblemWhat to do
422 validation_failed with consentThe 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_fieldThe 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 spamThe 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_limitedOne 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 arrivedIt 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 toolRead 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 emailDouble opt-in is off in the tool, or the relay created the subscriber as already confirmed. See double opt-in 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.