---
title: Contacts
description: 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`

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

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

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

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

```json
{
  "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`](/api/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

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