# Contacts

Reading and editing individual contacts. For keeping a whole audience in sync
from your application, use [`POST /api/v1/contacts/sync`](/docs/contact-sync-api)
instead — it is idempotent, takes up to 5,000 contacts at a time, and respects
suppressions.

Every endpoint here names its project with `projectId` (slug or `prj_…` id).
See [API conventions](/docs/conventions).

## `GET /api/contacts`

List contacts with their subscription status. Filtering happens server-side.

**Scope:** `contacts:read`

| Parameter | In | Type | Notes |
| --- | --- | --- | --- |
| `projectId` | query | string | **Required.** |
| `limit` | query | integer | Max rows returned. |
| `search` | query | string | Case-insensitive substring match on email. |
| `status` | query | enum | `subscribed`, `unsubscribed`, or `no list`. |

```bash
curl "https://pharosbase.com/api/contacts?projectId=acme-app&status=subscribed&limit=50" \
  -H "Authorization: Bearer $PHAROS_API_KEY"
```

```json
{
  "contacts": [
    {
      "id": "cnt_…",
      "email": "reader@example.com",
      "firstName": "Sam",
      "lastName": null,
      "language": "en",
      "tags": ["beta"],
      "source": "website_signup",
      "status": "subscribed",
      "createdAt": "2026-07-14T09:12:00.000Z"
    }
  ],
  "total": 4821,
  "matched": 4102,
  "returned": 50
}
```

`total` is the whole project. `matched` is how many rows the filters
selected. `returned` is how many came back after `limit`. The three differ on
purpose — a count you can trust is worth more than a page you have to
reassemble.

`status` is derived from list subscriptions, not stored on the contact.
`no list` means the contact belongs to no list at all.

## `POST /api/contacts`

Create one contact. Auto-subscribes it to the project's default list if there
is one. For more than a handful, use the sync endpoint.

**Scope:** `contacts:write`

| Field | Type | Notes |
| --- | --- | --- |
| `projectId` | string | **Required.** |
| `email` | string | **Required.** Normalised to lowercase. |
| `firstName` | string | |
| `lastName` | string | |
| `language` | enum | `en` or `pt-BR`. Defaults to `en`. |
| `tags` | string[] | |

`201`:

```json
{ "id": "cnt_…", "subscribed": true }
```

`subscribed` reports whether a default list existed to subscribe to.

## `PATCH /api/contacts/{id}`

Update a contact. Only the fields you pass change.

**Scope:** `contacts:write`

| Field | Type | Notes |
| --- | --- | --- |
| `email` | string | |
| `firstName` | string \| null | |
| `lastName` | string \| null | |
| `language` | enum | `en` or `pt-BR`. |
| `tags` | string[] | **Replaces** the whole list — include existing tags to keep them. |

```json
{ "success": true }
```

> Tags are actions, not annotations. Adding one can fire a `tag_added`
> automation and start a drip sequence. Read the current tags first, and add
> to them rather than overwriting.

Passing no recognised field returns `400`. An id in another project returns
`404`.

## `DELETE /api/contacts/{id}`

Delete a contact and its subscriptions.

**Scope:** `contacts:write`

```json
{ "success": true }
```

This is a hard delete, and it is not a substitute for unsubscribing:
deleting a contact discards the record that they opted out, so a later
sync can re-add them. To stop mailing someone, unsubscribe them.

## `PATCH /api/contacts/{id}/subscriptions/{listId}`

Change one contact's status on one list — the granular consent operation.

**Scope:** `contacts:write`

| Field | Type | Notes |
| --- | --- | --- |
| `status` | enum | `subscribed`, `unsubscribed` or `pending`. |

```json
{ "success": true }
```

Consent is per list, so this is the endpoint to use when someone opts out of
one thing and not everything.

## `POST /api/contacts/import`

Bulk import of CSV-shaped rows onto one list. Backs the dashboard's import
screen.

**Scope:** `contacts:write`

| Field | Type | Notes |
| --- | --- | --- |
| `projectId` | string | **Required.** |
| `contacts` | object[] | **Required.** Email plus optional name, language, tags. |
| `listSlug` | string | Target list. Defaults to the project's default list. |
| `defaultLanguage` | string | Language for rows that do not carry one. Falls back to `pt-BR`. |

The target list is created if it does not exist.

```json
{
  "success": true,
  "imported": 812,
  "updated": 104,
  "skipped": 3,
  "skippedUnsubscribed": 27,
  "total": 946,
  "listName": "Newsletter"
}
```

`skippedUnsubscribed` counts rows left alone because the contact had
explicitly opted out. A spreadsheet upload does not reverse an opt-out.

Prefer [`POST /api/v1/contacts/sync`](/docs/contact-sync-api) for anything
recurring: it is versioned, expresses desired state so retries are free, and
protects hard bounces and complaints as well as opt-outs.

## `GET /api/contacts/export`

Export contacts as CSV.

**Scope:** `contacts:read`

| Parameter | In | Type | Notes |
| --- | --- | --- | --- |
| `projectId` | query | string | **Required.** |
| `ids` | query | string | Comma-separated contact ids. Omit to export everything. |

Returns `text/csv` as a file attachment, not JSON — one row per contact, with
columns `email, first_name, last_name, language, status, tags, source,
created_at`. Tags are pipe-separated inside their cell. A contact on no list
exports with status `no list`.

```bash
curl "https://pharosbase.com/api/contacts/export?projectId=acme-app" \
  -H "Authorization: Bearer $PHAROS_API_KEY" -o contacts.csv
```
