Getting started

Adding a subscribe form

Embed a subscribe form on your website so visitors can join your newsletter.

Visitors sign up by submitting their email and name to a public endpoint. Vlozi handles the confirmation email, double opt-in, and storing the subscriber.

IMPORTANT

The form must post from your server, not the browser.

Subscribing is a write, and it is authenticated with a secret API key (ls_…). Publishable keys (pk_…) are read-only and are rejected on any POST, so there is no key you can safely put in page source. Send the request from a route handler, serverless function, or backend endpoint that you own, and have your form post to that.

If you are looking for a drop-in <script> widget like the one Forms provides, it does not exist for Newsletter yet.

The subscribe endpoint

POST https://api.vlozi.app/newsletter/public/subscribe

Headers:

Content-Type: application/json
x-api-key: ls_your_secret_key

Create the key under Settings → API Keys, choose Secret, and grant it newsletter:subscribers.create. Keep it server-side.

The tenant is resolved from the key — you do not send it. A client-supplied x-tenant-id header is stripped at the gateway and will not be honoured.

Body:

{
  "email": "reader@example.com",
  "name": "Reader Name",
  "tags": ["website", "blog"],
  "source": "homepage"
}
Field Required Notes
email Yes The subscriber's email address
name No Display name; used in {{ subscriber.name }} template variable
tags No String array; used for segment targeting
source No Free-text label for tracking where the signup came from
metadata No Any flat or nested JSON object; accessible as {{ subscriber.metadata.key }}

Success (200):

{ "subscribed": true, "pending": true }

pending: true means the subscriber is created but not yet confirmed. A confirmation email has been sent. The subscriber won't receive campaigns until they click the link in that email.

Already subscribed (200):

{ "subscribed": false, "alreadySubscribed": true }

Rate limit (429): 10 subscribe requests per 60 seconds per IP per tenant.

Plain HTML form

The browser posts to your own endpoint, which holds the secret key:

<form id="subscribe-form">
  <input type="email" name="email" placeholder="Your email" required />
  <input type="text" name="name" placeholder="Your name" />
  <button type="submit">Subscribe</button>
  <p id="subscribe-status"></p>
</form>
 
<script>
  document.getElementById("subscribe-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const form = e.target;
    const status = document.getElementById("subscribe-status");
 
    try {
      // Your own route — never call api.vlozi.app directly from the browser,
      // because the key required to subscribe must stay server-side.
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: form.email.value,
          name: form.name.value,
          source: "website",
        }),
      });
 
      const data = await res.json();
 
      if (data.alreadySubscribed) {
        status.textContent = "You're already subscribed!";
      } else {
        status.textContent = "Thanks! Check your inbox to confirm your subscription.";
        form.reset();
      }
    } catch {
      status.textContent = "Something went wrong. Please try again.";
    }
  });
</script>

And the server side of it (Node / any backend):

// POST /api/subscribe
export async function POST(request) {
  const { email, name, source } = await request.json();
 
  const res = await fetch("https://api.vlozi.app/newsletter/public/subscribe", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // Secret key — server-side only. Never ship this to the browser.
      "x-api-key": process.env.VLOZI_API_KEY,
    },
    body: JSON.stringify({ email, name, source }),
  });
 
  return new Response(await res.text(), {
    status: res.status,
    headers: { "Content-Type": "application/json" },
  });
}

Because your endpoint is the one facing the internet, add your own abuse controls there — a CAPTCHA, or a rate limit per IP. Vlozi applies its own limit of 10 requests per 60 seconds per IP per tenant, but that is a backstop, not a substitute.

The double opt-in flow

  1. Visitor submits the form → POST /public/subscribe
  2. Vlozi creates the subscriber record with status=active and confirmed_at=null
  3. A confirmation email is sent to the address (via your configured sender in Comms settings)
  4. Visitor clicks Confirm in the email → POST /public/confirm is called
  5. Subscriber is now confirmed_at = NOW() and will receive future campaigns

Unconfirmed subscribers (confirmed_at = null) are excluded from all campaign sends. They can confirm at any time — the confirmation link is valid for 7 days.

NOTE

Double opt-in applies to every path, not just public sign-ups. Adding a subscriber from the dashboard, importing a list, or calling the MCP add_subscriber tool all create the row unconfirmed — so an upload is not treated as consent. Bulk imports deliberately do not blast confirmation emails; use a re-permission campaign for that.

Re-adding someone who previously unsubscribed also returns them to unconfirmed. Their earlier consent does not carry over — an opt-out is a standing instruction.

You can switch a workspace to single opt-in under Newsletter → Settings → Consent, but that is an explicit choice, not the default.

Every email you send includes an {{ unsubscribe_url }} token that resolves to a unique, HMAC-signed link for that subscriber. When they click it, they are immediately unsubscribed — no confirmation step.

Always include the unsubscribe link in your template HTML:

<p style="color: #999; font-size: 12px;">
  Don't want these emails?
  <a href="{{ unsubscribe_url }}">Unsubscribe</a>
</p>

WARNING

Vlozi does not block a campaign that omits {{ unsubscribe_url }}. The editor shows a deliverability warning, but it is advisory — the send proceeds. Leaving it out means recipients have no visible way to opt out, which is a CAN-SPAM problem and a fast route to spam complaints. Include it every time.

One-click unsubscribe headers (RFC 8058) are attached to campaigns automatically, so Gmail and Yahoo always show their own unsubscribe control — but that is not a substitute for a link in the body.

React example (Next.js)

Two files: a route handler holding the key, and a client component that calls it.

// app/api/subscribe/route.ts  — server only
export async function POST(request: Request) {
  const { email, name } = await request.json();
 
  const res = await fetch("https://api.vlozi.app/newsletter/public/subscribe", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // NOT NEXT_PUBLIC_* — that prefix ships the value to the browser.
      "x-api-key": process.env.VLOZI_API_KEY!,
    },
    body: JSON.stringify({ email, name, source: "next-website" }),
  });
 
  return Response.json(await res.json(), { status: res.status });
}
"use client";
 
import { useState } from "react";
 
export function SubscribeForm() {
  const [state, setState] = useState<"idle" | "pending" | "error">("idle");
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
 
    const res = await fetch("/api/subscribe", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: fd.get("email"),
        name: fd.get("name"),
      }),
    });
 
    setState(res.ok ? "pending" : "error");
  }
 
  if (state === "pending") {
    return <p>Thanks! Check your inbox to confirm your subscription.</p>;
  }
 
  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        name="email"
        type="email"
        placeholder="your@email.com"
        required
        className="border rounded px-3 py-2 flex-1"
      />
      <button type="submit" className="bg-black text-white px-4 py-2 rounded">
        Subscribe
      </button>
      {state === "error" && (
        <p className="text-red-500 text-sm mt-1">Something went wrong. Please try again.</p>
      )}
    </form>
  );
}
Newsletter · Getting startedEdit on GitHub