ZevCampaign Docs
Sign up

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" }
  }'
FieldTypeRequiredNotes
topic_idstringYesWhat they are subscribing to
emailstringYesMax 320 characters
first_namestringNo1–120 characters
last_namestringNo1–120 characters
attributesobjectNoCustom attributes defined on your workspace
websitestringNoHoneypot. 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

CodeStatusMeaning
topic_not_found404No such topic, or it is archived
topic_not_on_key_brand400The key is scoped to a different brand
origin_not_allowed403This domain is not on the key’s allowlist
rate_limited429More 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