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/subscribeHeaders:
Content-Type: application/json
x-api-key: ls_your_secret_keyCreate 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
- Visitor submits the form →
POST /public/subscribe - Vlozi creates the subscriber record with
status=activeandconfirmed_at=null - A confirmation email is sent to the address (via your configured sender in Comms settings)
- Visitor clicks Confirm in the email →
POST /public/confirmis called - 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.
Unsubscribe links
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>
);
}