API Reference
Subscribe
The one endpoint a browser may call, for signup forms on your own site.
POST /v1/subscribe
Takes a signup from a form on your own website. This is the only endpoint a publishable key may reach, so it is the only one you can safely call from a browser.
Request
curl https://api.zevcampaign.com/v1/subscribe \
-H "Authorization: Bearer pk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"topic_id": "tp_7xk2m9qv4w",
"email": "ada@example.com",
"first_name": "Ada",
"attributes": { "city": "Lagos" }
}'
| Field | Type | Required | Notes |
|---|---|---|---|
topic_id | string | Yes | What they are subscribing to |
email | string | Yes | Max 320 characters |
first_name | string | No | 1–120 characters |
last_name | string | No | 1–120 characters |
attributes | object | No | Custom attributes defined on your workspace |
website | string | No | Honeypot. See below |
Response
{
"data": { "status": "confirmation_sent" }
}
status is one of:
confirmation_sent— the topic uses double opt-in. We have emailed them a confirmation link.subscribed— the topic does not require confirmation, and they are on the list now.
The response is deliberately vague
You get the same answer whether the address was brand new, already subscribed, still pending confirmation, or previously unsubscribed.
This is on purpose. A more specific answer would turn your public form into a way for anyone to check whether a given person is subscribed to a given brand — which is that person’s business, and not something we will disclose on your behalf to whoever can read your page source.
Behind that identical response we do the right thing: an already confirmed address is not re-mailed, and an unsubscribed one is not resurrected.
So write one success path. There is no “already subscribed” branch to handle, and code that waits for one is waiting for a case that never arrives.
The honeypot
Add a website field to your form, hide it from humans, and pass
whatever comes back.
<input
type="text"
name="website"
tabindex="-1"
autocomplete="off"
style="position:absolute;left:-9999px"
aria-hidden="true"
/>
No person sees it, so no person fills it. Bots fill every input they find. A filled one is answered exactly like a real submission and silently dropped, so the bot learns nothing and does not adapt.
Use position:absolute;left:-9999px rather than display:none or
hidden — the more sophisticated bots skip inputs that are obviously
hidden.
A complete form
<form id="signup">
<input type="email" name="email" required placeholder="you@example.com" />
<input type="text" name="first_name" placeholder="First name" />
<input type="text" name="website" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true" />
<button type="submit">Subscribe</button>
</form>
<script type="module">
const form = document.getElementById('signup');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = Object.fromEntries(new FormData(form));
const res = await fetch('https://api.zevcampaign.com/v1/subscribe', {
method: 'POST',
headers: {
Authorization: 'Bearer pk_live_your_key_here',
'Content-Type': 'application/json',
},
body: JSON.stringify({ topic_id: 'tp_7xk2m9qv4w', ...body }),
});
// One message for every success, because the API deliberately
// does not tell you which case you are in.
form.outerHTML = res.ok
? '<p>Thanks. Check your inbox to confirm.</p>'
: '<p>Something went wrong. Please try again.</p>';
});
</script>
Errors
| Code | Status | Meaning |
|---|---|---|
topic_not_found | 404 | No such topic, or it is archived |
topic_not_on_key_brand | 400 | The key is scoped to a different brand |
origin_not_allowed | 403 | This domain is not on the key’s allowlist |
rate_limited | 429 | More than 30 a minute on this key |
Rate limit
30 requests per minute per key, and we will not send a second confirmation email to the same address within a short window however often the form is submitted.
Updated at, Saturday, October 10, 2026