ZevCampaign Docs
Sign up

API Reference

Contacts

Reading and importing your audience.

Contacts are the people in your workspace. One per email address, no matter how many lists or topics they touch.

These endpoints need a secret key.

List contacts

GET /v1/contacts

curl "https://api.zevcampaign.com/v1/contacts?limit=20&status=subscribed" \
  -H "Authorization: Bearer sk_live_your_key_here"
QueryNotes
limit1–100, default 20
cursorFrom meta.next_cursor
statussubscribed, unsubscribed, bounced, complained
topic_idOnly contacts subscribed to this topic
{
  "data": [
    {
      "id": "ct_8vq2xk4m",
      "email": "ada@example.com",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "status": "subscribed",
      "attributes": { "city": "Lagos" },
      "created_at": "2026-03-14T09:30:00.000Z"
    }
  ],
  "meta": { "limit": 20, "has_more": false, "next_cursor": null }
}

status is derived, not stored: it reflects whether they have unsubscribed, bounced or complained. bounced and complained mean we will not send to them regardless of their topic subscriptions.

Get one contact

GET /v1/contacts/{id}

curl https://api.zevcampaign.com/v1/contacts/ct_8vq2xk4m \
  -H "Authorization: Bearer sk_live_your_key_here"

404 with contact_not_found if there is no such contact on your workspace.

Create or update a contact

POST /v1/contacts

curl https://api.zevcampaign.com/v1/contacts \
  -H "Authorization: Bearer sk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "first_name": "Ada",
    "attributes": { "city": "Lagos", "plan": "pro" },
    "topic_ids": ["tp_7xk2m9qv4w"]
  }'
FieldTypeRequired
emailstringYes
first_namestringNo
last_namestringNo
attributesobjectNo
topic_idsstring[]No
{
  "data": {
    "id": "ct_8vq2xk4m",
    "email": "ada@example.com",
    "status": "subscribed",
    "created_at": "2026-03-14T09:30:00.000Z"
  }
}

Idempotent on the address: calling twice merges rather than duplicating, so a retry after a timeout is safe.

Unlike /v1/subscribe, no confirmation email is sent. Passing topic_ids here subscribes them directly.

A secret key is you telling us you already have consent for these people, and you are accountable for that. If you are taking signups from a form, use /v1/subscribe and let us do the confirmation.

Unsubscribed contacts are refused

{
  "error": {
    "type": "invalid_request_error",
    "code": "contact_unsubscribed",
    "message": "That contact has unsubscribed from this workspace, so their details were not changed and no subscription was added. Only the contact can undo that, from a link in an email you sent them.",
    "param": "email"
  }
}

Nothing is changed, not even the name. This is not an error to work around: it is what makes an opt-out mean something. Skip them and move on.

A sync that re-posts your whole local list will hit this for everybody who has left, and that is the system working.

Merge behaviour

When an address already exists, your workspace’s merge policy decides what happens to conflicting values:

  • Keep existing — the stored value wins; new fields still fill blanks
  • Overwrite — the incoming value wins
  • Per field — the incoming value wins only where it is non-empty

Per field is the default and is usually what you want: an import with a blank last name does not erase a last name you already had.

Rate limit

120 requests per minute. For a bulk import, prefer the dashboard’s CSV import, which is built for volume and reports what merged and what was skipped.

Updated at, Saturday, October 10, 2026