---
title: "REST API: contacts, lists, tags and custom fields"
description: Every REST route for contacts, lists, tags, custom fields, import, export and background jobs, with capabilities, parameters and runnable examples.
url: "https://hiveplugins.com/docs/hive-mail-rest-api-contacts-lists-tags-fields/"
updated: "2026-10-28"
category: Hive Mail
---

# REST API: contacts, lists, tags and custom fields

All routes start with `/wp-json/hivemail/v1`. Sign in as described in [authentication](/docs/hive-mail-rest-api-authentication/); errors, paging and the three basic requests are on [the overview](/docs/hive-mail-rest-api-reference/). In the tables **V** is `hivemail_view_audience` and **A** is `hivemail_manage_audience`. Routes that accept `PUT` also accept `POST` and `PATCH`.

Every contact answer carries `fields` (`{ slug: typed value }`, only fields with a value), `lists` (`[{id, name, status}]`, where `status` is the status on that list) and `tags` (`[{id, name, color}]`).

## Contacts

Statuses you can set: `pending`, `subscribed`, `unsubscribed`, `transactional`. `bounced` and `complained` are set by Hive Mail only. Subscribing needs `consent: true`. Deleting moves a contact to the trash for 30 days.

| Method | Route | Cap | Purpose |
| --- | --- | --- | --- |
| GET | `/contacts` | V | List. `page`, `per_page` (max 500), `search` (2+ characters), `status`, `list`, `tag`, `segment`, `source`, `created_from`, `created_to`, `orderby` (`id`, `email`, `first_name`, `last_name`, `status`, `created_at`, `updated_at`, `subscribed_at`), `order`, `trashed` |
| POST | `/contacts` | A | Create. `email` (required, 190 characters), `first_name`, `last_name`, `phone`, `country` (2 letters), `company`, `source`, `status`, `consent`, `lists[]`, `tags[]` (ids), `fields` (`{ slug: value }`). Everything is validated before anything is written |
| GET | `/contacts/stats` | V | Counts per status, plus `all` and `trashed` |
| POST | `/contacts/bulk` | A | `action` (`trash`, `restore`, `delete_permanently`, `set_status`, `add_to_list`, `remove_from_list`, `add_tag`, `remove_tag`), `ids[]` (up to 1,000), and `status`, `consent`, `reason`, `list_id`, `tag_id` as the action needs. Answers `{ action, requested, succeeded, failed, errors[], details }` |
| GET | `/contacts/{id}` | V | One contact, trashed ones too. Adds `allowed_statuses` (legal next statuses) |
| PUT | `/contacts/{id}` | A | Change fields, `fields` (`null` or `""` clears a value) and/or `status`. All or nothing: an illegal status or an invalid custom value writes nothing |
| DELETE | `/contacts/{id}` | A | Move to the trash. `?permanent=1` deletes a trashed contact for good (409 otherwise) |
| POST | `/contacts/{id}/status` | A | `status`, `consent`, `permission_given`, `reason`, `note` |
| POST | `/contacts/{id}/memberships` | A | `add_lists[]`, `remove_lists[]`, `add_tags[]`, `remove_tags[]` (ids). Answers the contact; unknown ids are 404 |
| POST | `/contacts/{id}/reactivate` | A | Give a bounced address another chance |
| POST | `/contacts/{id}/restore` | A | Restore from the trash |
| POST | `/contacts/{id}/send-confirmation` | A | `list_id` optional. Send the double opt-in email to a pending contact. `202 { message_id }`; 409 if not pending or suppressed; 429 if one went out in the last 5 minutes |
| GET | `/contacts/{id}/timeline` | V | `limit`, `before`. Answers `{ items, next_cursor }` |
| GET | `/contacts/{id}/segments` | V | The segments the contact is in |
| GET | `/contacts/{id}/campaigns` | V | The campaign emails the contact received |
| GET | `/contacts/{id}/flows` | V | The contact's flow runs |
| GET | `/contacts/{id}/journey` | V and visitor reports | The contact's visits and timeline, merged ([visitor routes](/docs/hive-mail-rest-api-automation-and-commerce/)) |

Error codes: `hivemail_invalid_contact` (400), `hivemail_invalid_fields` (400, `errors["fields.{slug}"]`), `hivemail_consent_required` (400), `hivemail_invalid_status` (400), `hivemail_status_not_allowed` and `hivemail_invalid_transition` (400), `hivemail_list_not_found` and `hivemail_tag_not_found` (400 on create, 404 on memberships), `hivemail_contact_not_found` (404), `hivemail_contact_exists` and `hivemail_contact_suppressed` (409).

### Update, change status, bulk

```
# Change the name and set a custom field with the slug "plan"
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -X PUT -H "Content-Type: application/json" 
  -d '{"first_name":"Ada","fields":{"plan":"Starter"}}' 
  "https://example.com/wp-json/hivemail/v1/contacts/42"

# Unsubscribe
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -X POST -H "Content-Type: application/json" 
  -d '{"status":"unsubscribed","reason":"asked by email"}' 
  "https://example.com/wp-json/hivemail/v1/contacts/42/status"

# Tag 3 contacts at once
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -X POST -H "Content-Type: application/json" 
  -d '{"action":"add_tag","ids":[41,42,43],"tag_id":7}' 
  "https://example.com/wp-json/hivemail/v1/contacts/bulk"
# {"action":"add_tag","requested":3,"succeeded":3,"failed":0,"errors":[],"details":{"added":1,"existing":2,"missing":0}}
```

### From PHP

```
<?php
$response = wp_remote_request(
    'https://example.com/wp-json/hivemail/v1/contacts',
    array(
        'method'  => 'POST',
        'headers' => array(
            'Authorization' => 'Basic ' . base64_encode( 'admin:abcd efgh ijkl mnop qrst uvwx' ),
            'Content-Type'  => 'application/json',
        ),
        'body'    => wp_json_encode( array( 'email' => 'ada@example.com', 'first_name' => 'Ada', 'consent' => true, 'lists' => array( 1 ) ) ),
        'timeout' => 20,
    )
);
$code = wp_remote_retrieve_response_code( $response ); // 201 created, 409 already a contact
```

## Lists

A contact's membership of a list has its own status. Adding someone who left a list does not resubscribe them. Names are unique, case-insensitive (409 `hivemail_list_exists` with `existing_id`).

| Method | Route | Cap | Purpose |
| --- | --- | --- | --- |
| GET | `/lists` | V | `search`, `orderby` (`id`, `name`, `created_at`, `updated_at`), `order`, `page`, `per_page` (max 100). Items carry `member_count`, `unsubscribed_count` |
| POST | `/lists` | A | `name` (150), `description` (500), `is_public`. Answers `201` |
| GET | `/lists/{id}` | V | One list |
| PUT | `/lists/{id}` | A | Change it |
| DELETE | `/lists/{id}` | A | Answers `{ removed_members }`. The contacts stay |

```
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -X POST -H "Content-Type: application/json" 
  -d '{"name":"Newsletter","is_public":true}' 
  "https://example.com/wp-json/hivemail/v1/lists"
```

## Tags

| Method | Route | Cap | Purpose |
| --- | --- | --- | --- |
| GET | `/tags` | V | Same query arguments as lists. Items carry `contact_count`, `color` |
| POST | `/tags` | A | `name`, `color` (`#rrggbb` or empty), `find_or_create`. With `find_or_create` an existing name answers `200`, a new one `201`; without it an existing name is `409 hivemail_tag_exists` |
| GET | `/tags/{id}` | V | One tag |
| PUT | `/tags/{id}` | A | Change it |
| DELETE | `/tags/{id}` | A | Answers `{ removed_assignments }` |

## Custom fields

Types: `text` (255), `textarea` (5,000), `number`, `date` (`YYYY-MM-DD`), `select`, `multiselect`, `checkbox`. A field's `slug` is its merge tag and its key in `fields`. It matches `^[a-z][a-z0-9_]{0,63}$`, cannot be a reserved key (`email`, `first_name`, ...), and cannot change after creation, like the type. Values come back typed (number as a number, checkbox as a boolean, multiselect as an array). A bad value in a write is `400` with `errors["fields.{slug}"]`, and nothing is written.

| Method | Route | Cap | Purpose |
| --- | --- | --- | --- |
| GET | `/fields` | V | `{ items, total, types }` in display order. Items carry `merge_tag`, `value_count`, `options` |
| POST | `/fields` | A | `label` (required), `type`, `options[]`, `slug` (default: made from the label, with `_2`, `_3` added when taken). 409 `hivemail_field_exists` when you send a slug that is taken; 400 `hivemail_invalid_field` for a reserved one |
| POST | `/fields/reorder` | A | `ids[]`: every field exactly once |
| GET | `/fields/{id}` | V | One field |
| PUT | `/fields/{id}` | A | `label` and `options` only |
| DELETE | `/fields/{id}` | A | Also removes the stored values. Answers `{ removed_values }` |

```
// Create a "Plan" dropdown (slug: plan)
await fetch( 'https://example.com/wp-json/hivemail/v1/fields', {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify( { label: 'Plan', type: 'select', options: [ 'Starter', 'Team' ] } ),
} );
```

## Import, export and background jobs

Everything here needs **A**. Long work runs as a job: `staged` (an uploaded file waiting for a mapping), `queued`, `running`, then `completed`, `failed` or `cancelled`. The minute tick works jobs, and `POST /jobs/{id}/step` works one slice at once.

| Method | Route | Purpose |
| --- | --- | --- |
| GET | `/import/options` | Mapping targets (`email`, `first_name`, ..., `field:{slug}`), upload size limit, WordPress roles |
| POST | `/import/upload` | Multipart `file` (.csv, .tsv, .txt). Answers a `staged` job whose `info` has `headers`, `sample`, `rows`, `delimiter`, `suggested` |
| POST | `/import/{id}/preview` | `mapping[]` (one entry per column), `options{}`. A dry run of the first 200 rows; writes nothing |
| POST | `/import/{id}/start` | Same body. Queues the import and answers the job (`status: "queued"`) |
| POST | `/import/wp-users` | `roles[]`, `options{}`. Queues "import WordPress users" |
| POST | `/export` | `status`, `list`, `tag`, `search`, `trashed`. Queues a CSV export (all contacts when empty). `201`, the job |
| GET | `/jobs` | Recent jobs, `?type=import|wp_users|export` |
| GET | `/jobs/{id}` | `{ status, total, processed, percent, result, error, info, downloadable }` |
| POST | `/jobs/{id}/step` | Work one slice now |
| POST | `/jobs/{id}/cancel` | Stop a queued or running job |
| DELETE | `/jobs/{id}` | Delete a finished job and its files (409 while running) |
| GET | `/jobs/{id}/download` | The export, or an import's error report (authenticated, never a public address) |
| GET | `/migrate/sources` | The importable plugins and what each holds |
| POST | `/migrate/{source}/preview` | `options` in, dry run and plan out |
| POST | `/migrate/{source}/start` | `options` in. Queues the copy (201) |
| PUT, DELETE | `/migrate/{source}/sync` | Switch live sync of new customers on or off (`edd`, `woocommerce`, `affiliatewp`) |

`mapping` has one entry per CSV column: `""` (ignore), `email`, `first_name`, `last_name`, `full_name`, `phone`, `country`, `company`, `status` or `field:{slug}`. Exactly one column is `email`. Import `options`: `duplicates` (`skip`, `update` or `fill`), `lists[]`, `tags[]` (ids), `consent` (you attest the people gave permission: new contacts are `subscribed`, otherwise `pending`). An import never changes the status of an existing contact.

```
# 1. Upload (answers a staged job, say id 15)
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -F "file=@people.csv" 
  "https://example.com/wp-json/hivemail/v1/import/upload"

# 2. Start it (email in column 0, first name in column 1)
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" -X POST -H "Content-Type: application/json" 
  -d '{"mapping":["email","first_name"],"options":{"duplicates":"skip","lists":[1],"consent":false}}' 
  "https://example.com/wp-json/hivemail/v1/import/15/start"

# 3. Follow it
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" "https://example.com/wp-json/hivemail/v1/jobs/15"
```

When it is done, `status` is `completed` and `result` reads `{"created":2,"updated":0,"skipped":0,"failed":0,"failures":[]}`. Contacts made without `consent` are `pending`.

Next: [Campaigns, segments, templates and reports](/docs/hive-mail-rest-api-campaigns-segments-reports/), [Database tables](/docs/hive-mail-database-tables/).
