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"
| Query | Notes |
|---|---|
limit | 1–100, default 20 |
cursor | From meta.next_cursor |
status | subscribed, unsubscribed, bounced, complained |
topic_id | Only 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"]
}'
| Field | Type | Required |
|---|---|---|
email | string | Yes |
first_name | string | No |
last_name | string | No |
attributes | object | No |
topic_ids | string[] | 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.
This asserts consent
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