---
title: Subscribe
description: 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

```bash
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

```json
{
  "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.

```html
<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

```html
<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.