> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bluereacher.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Manage contacts

> Create, read, update, delete, and bulk import contacts, and control the opt-out flag that BlueReacher enforces on every send.

## Overview

Contacts are the people you message. A contact holds a phone number, optional profile fields, tags, custom fields, your own `external_id`, and an `opted_out` flag.

Use these endpoints to keep your contact list in sync with your CRM, filter recipients for a campaign, and record opt-outs. Contacts are stored per workspace and shared by every API key in that workspace.

Contacts are not bound to a `line_id` or a `channel`. You pick the sending line and the channel (`imessage`, `rcs`, or `sms`) on the send call, not on the contact record.

**Base URL**

```
https://api.bluereacher.com/functions/v1/
```

**Endpoints on this page**

| Method | Path                    | What it does                                          |
| ------ | ----------------------- | ----------------------------------------------------- |
| POST   | `/create-contact`       | Create one contact                                    |
| GET    | `/get-contact`          | Fetch one contact by id, phone, or external id        |
| GET    | `/list-contacts`        | Search, filter, and page through contacts             |
| POST   | `/update-contact`       | Change fields on an existing contact                  |
| DELETE | `/delete-contact`       | Permanently remove a contact record                   |
| POST   | `/bulk-create-contacts` | Import up to 1000 contacts and get a duplicate report |
| POST   | `/set-opt-out`          | Set or clear the opt-out flag                         |

## Authentication

Every request carries your API key as a bearer token.

```
Authorization: Bearer bluereacher_your_api_key
Content-Type: application/json
```

Keys are workspace scoped. A contact created with one key is readable and writable by every key in the same workspace. `GET` requests send no body, so the `Content-Type` header is optional there and ignored if present.

A missing or malformed key returns `401` with code `unauthorized`. A key that has been revoked returns `401` with code `invalid_api_key`.

## Opt-out is enforced platform wide at send time

This is the most important behavior on this page. Read it before you build anything.

When a contact has `opted_out: true`, BlueReacher rejects any send addressed to that phone number **before the message leaves the platform**. The block is not advisory and it is not per campaign. It applies:

* across every line in your workspace, including lines you add later
* across every channel: `imessage`, `rcs`, and `sms`
* across every send path: single sends, sequences, campaigns, and retries
* across every API key in the workspace

A send call to an opted-out number returns `403` with code `contact_opted_out` and the message is never queued. You are not billed for it.

**How a contact becomes opted out**

1. **Automatically, from a reply.** When an inbound reply arrives, BlueReacher checks the trimmed message body against the stop keyword list: `STOP`, `STOPALL`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `REMOVE`, `OPT OUT`, `OPTOUT`. Matching is case insensitive and applies when the keyword is the entire message. The flag flips within seconds of the reply landing, and a `contact.opted_out` webhook fires.
2. **Manually, through the API.** Call `POST /set-opt-out`.
3. **Manually, in the dashboard.** A team member can toggle it on the contact record.

**Suppression outlives the contact record**

The opt-out is stored on a workspace suppression list keyed by phone number, not only on the contact row. Two consequences:

* Deleting a contact does not clear its opt-out. Send attempts to that number stay blocked.
* Re-creating the same phone number returns a new contact with `opted_out` already set to `true`.

That is deliberate. It means a bad CRM sync cannot resurrect somebody who asked you to stop.

**Turning opt-out back off**

`POST /set-opt-out` with `opted_out: false` clears the suppression, and it requires a `reason` string describing the consent you collected. Store that reason honestly. BlueReacher lines need no A2P registration, so nothing in the carrier stack is tracking consent on your behalf. This suppression list is your record.

## The contact object

| Field            | Type             | Notes                                                                                                                                                                                         |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_id`     | string           | BlueReacher id, prefixed `ctc_`. Immutable.                                                                                                                                                   |
| `phone`          | string           | E.164, for example `+15551234567`. Unique per workspace.                                                                                                                                      |
| `email`          | string or null   | Optional. Validated for format only.                                                                                                                                                          |
| `first_name`     | string or null   | Max 100 characters. Available in message content as `{{first_name}}`.                                                                                                                         |
| `last_name`      | string or null   | Max 100 characters. Available as `{{last_name}}`.                                                                                                                                             |
| `tags`           | array of strings | Max 50 tags. Each tag is trimmed and lowercased on write, max 64 characters.                                                                                                                  |
| `custom_fields`  | object           | Max 50 keys. Keys are strings up to 64 characters. Values are string, number, or boolean, serialized to at most 1000 characters. Available in message content as `{{custom_fields.company}}`. |
| `external_id`    | string or null   | Your own id. Max 128 characters. Unique per workspace when set.                                                                                                                               |
| `opted_out`      | boolean          | `true` blocks all sends to this number. Read only on create and update. Set it through `/set-opt-out`.                                                                                        |
| `opted_out_at`   | string or null   | ISO 8601 UTC timestamp of the most recent opt-out, `null` if never opted out.                                                                                                                 |
| `opt_out_source` | string or null   | One of `reply_keyword`, `api`, `dashboard`, `import`.                                                                                                                                         |
| `created_at`     | string           | ISO 8601 UTC.                                                                                                                                                                                 |
| `updated_at`     | string           | ISO 8601 UTC.                                                                                                                                                                                 |

**Phone normalization.** Send E.164. A 10 digit US number with no country code is normalized to `+1` and stored in E.164. Anything else that cannot be parsed returns `400` with code `invalid_phone`.

**Merge tags.** Wrap a field name in double braces in your message `content`. A missing value renders as an empty string, so guard your copy accordingly.

## Create a contact

Creates one contact. Use this for single record writes such as a form submission or a CRM webhook. For imports of more than a handful of records, use `/bulk-create-contacts`.

```
POST https://api.bluereacher.com/functions/v1/create-contact
```

**Auth.** Bearer API key in the `Authorization` header. The contact is created in the workspace that owns the key.

**Request fields**

| Field           | Type             | Required | Description                                            |
| --------------- | ---------------- | -------- | ------------------------------------------------------ |
| `phone`         | string           | Yes      | E.164 phone number. Unique per workspace.              |
| `email`         | string           | No       | Contact email address.                                 |
| `first_name`    | string           | No       | Given name, max 100 characters.                        |
| `last_name`     | string           | No       | Family name, max 100 characters.                       |
| `tags`          | array of strings | No       | Up to 50 tags. Lowercased and deduplicated on write.   |
| `custom_fields` | object           | No       | Up to 50 key value pairs.                              |
| `external_id`   | string           | No       | Your identifier for this person. Unique per workspace. |

`opted_out` is rejected here. Sending it returns `422` with code `validation_failed`. A new contact inherits `opted_out: true` automatically if its phone number is already on the workspace suppression list.

**Response fields**

| Field           | Type             | Description                                      |
| --------------- | ---------------- | ------------------------------------------------ |
| `contact_id`    | string           | New contact id, prefixed `ctc_`.                 |
| `phone`         | string           | Normalized E.164 number.                         |
| `email`         | string or null   | As submitted.                                    |
| `first_name`    | string or null   | As submitted.                                    |
| `last_name`     | string or null   | As submitted.                                    |
| `tags`          | array of strings | Normalized tags.                                 |
| `custom_fields` | object           | As submitted.                                    |
| `external_id`   | string or null   | As submitted.                                    |
| `opted_out`     | boolean          | `true` if the number is on the suppression list. |
| `opted_out_at`  | string or null   | Timestamp of the existing suppression, if any.   |
| `created_at`    | string           | ISO 8601 UTC.                                    |
| `updated_at`    | string           | ISO 8601 UTC.                                    |

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/create-contact \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+15551234567",
    "email": "dana@northwindlabs.com",
    "first_name": "Dana",
    "last_name": "Reyes",
    "tags": ["saas", "q3-outbound"],
    "custom_fields": {
      "company": "Northwind Labs",
      "seats": 42,
      "trial_active": true
    },
    "external_id": "hubspot_88213"
  }'
```

**Example response**

```json theme={null}
{
  "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
  "phone": "+15551234567",
  "email": "dana@northwindlabs.com",
  "first_name": "Dana",
  "last_name": "Reyes",
  "tags": ["saas", "q3-outbound"],
  "custom_fields": {
    "company": "Northwind Labs",
    "seats": 42,
    "trial_active": true
  },
  "external_id": "hubspot_88213",
  "opted_out": false,
  "opted_out_at": null,
  "opt_out_source": null,
  "created_at": "2026-07-24T16:41:09Z",
  "updated_at": "2026-07-24T16:41:09Z"
}
```

**Errors**

| Status | Code                | When                                                                                                                 |
| ------ | ------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_phone`     | `phone` missing or not parseable as E.164.                                                                           |
| 409    | `contact_exists`    | The phone number or `external_id` already exists in this workspace. The response includes the existing `contact_id`. |
| 422    | `validation_failed` | A field exceeds its limit, or you sent `opted_out`.                                                                  |

A `409` is a normal outcome for CRM syncs. Catch it, read `error.details.contact_id`, and call `/update-contact` instead of retrying the create.

## Retrieve a contact

Fetches one contact. Look it up by BlueReacher id, by phone number, or by your own `external_id`.

```
GET https://api.bluereacher.com/functions/v1/get-contact
```

**Auth.** Bearer API key in the `Authorization` header. You can only read contacts in your own workspace.

**Request fields (query string)**

| Parameter     | Type   | Required           | Description                                        |
| ------------- | ------ | ------------------ | -------------------------------------------------- |
| `contact_id`  | string | One of these three | BlueReacher contact id.                            |
| `phone`       | string | One of these three | E.164 number. URL encode the leading `+` as `%2B`. |
| `external_id` | string | One of these three | Your identifier.                                   |

Supply exactly one identifier. Sending two or more returns `400` with code `invalid_request`.

**Response fields**

The full contact object. See [The contact object](#the-contact-object) for every field and its type.

**Example request**

```bash theme={null}
curl -G https://api.bluereacher.com/functions/v1/get-contact \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  --data-urlencode "phone=+15551234567"
```

**Example response**

```json theme={null}
{
  "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
  "phone": "+15551234567",
  "email": "dana@northwindlabs.com",
  "first_name": "Dana",
  "last_name": "Reyes",
  "tags": ["saas", "q3-outbound"],
  "custom_fields": {
    "company": "Northwind Labs",
    "seats": 42,
    "trial_active": true
  },
  "external_id": "hubspot_88213",
  "opted_out": false,
  "opted_out_at": null,
  "opt_out_source": null,
  "created_at": "2026-07-24T16:41:09Z",
  "updated_at": "2026-07-24T16:41:09Z"
}
```

**Errors**

| Status | Code                | When                                        |
| ------ | ------------------- | ------------------------------------------- |
| 400    | `invalid_request`   | No identifier, or more than one identifier. |
| 400    | `invalid_phone`     | `phone` is not parseable.                   |
| 404    | `contact_not_found` | No contact in this workspace matches.       |

## List contacts

Returns a page of contacts, newest first by default. Combine search, tag filters, and the opt-out filter to build a send list.

```
GET https://api.bluereacher.com/functions/v1/list-contacts
```

**Auth.** Bearer API key in the `Authorization` header. Results are scoped to the key's workspace.

**Request fields (query string)**

| Parameter        | Type    | Required | Default      | Description                                                                                                                                                   |
| ---------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `search`         | string  | No       | none         | Case insensitive partial match on `first_name`, `last_name`, `email`, and `external_id`. Digits in the term also match against `phone`. Minimum 2 characters. |
| `tags`           | string  | No       | none         | Comma separated tag list, for example `saas,q3-outbound`. Values are lowercased before matching.                                                              |
| `tag_match`      | string  | No       | `any`        | `any` returns contacts with at least one listed tag. `all` returns contacts carrying every listed tag.                                                        |
| `opted_out`      | boolean | No       | none         | `true` returns only opted-out contacts. `false` returns only sendable contacts. Omit to return both.                                                          |
| `external_id`    | string  | No       | none         | Exact match filter.                                                                                                                                           |
| `created_after`  | string  | No       | none         | ISO 8601 UTC. Returns contacts created strictly after this time.                                                                                              |
| `created_before` | string  | No       | none         | ISO 8601 UTC. Returns contacts created strictly before this time.                                                                                             |
| `sort`           | string  | No       | `created_at` | `created_at` or `updated_at`.                                                                                                                                 |
| `order`          | string  | No       | `desc`       | `desc` or `asc`.                                                                                                                                              |
| `limit`          | integer | No       | `50`         | Page size, 1 to 200.                                                                                                                                          |
| `cursor`         | string  | No       | none         | Pass `next_cursor` from the previous page. Filters must stay identical across pages of the same cursor chain.                                                 |

**Response fields**

| Field         | Type             | Description                                                                              |
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
| `contacts`    | array of objects | Contact objects for this page. Empty array when nothing matches.                         |
| `total`       | integer          | Count of contacts matching the filters, ignoring pagination.                             |
| `has_more`    | boolean          | `true` when another page exists.                                                         |
| `next_cursor` | string or null   | Opaque cursor for the next page. `null` on the last page. Cursors expire after 24 hours. |

**Example request**

Every sendable contact tagged both `saas` and `q3-outbound`:

```bash theme={null}
curl -G https://api.bluereacher.com/functions/v1/list-contacts \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  --data-urlencode "tags=saas,q3-outbound" \
  --data-urlencode "tag_match=all" \
  --data-urlencode "opted_out=false" \
  --data-urlencode "limit=100"
```

**Example response**

```json theme={null}
{
  "contacts": [
    {
      "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
      "phone": "+15551234567",
      "email": "dana@northwindlabs.com",
      "first_name": "Dana",
      "last_name": "Reyes",
      "tags": ["saas", "q3-outbound"],
      "custom_fields": { "company": "Northwind Labs" },
      "external_id": "hubspot_88213",
      "opted_out": false,
      "opted_out_at": null,
      "opt_out_source": null,
      "created_at": "2026-07-24T16:41:09Z",
      "updated_at": "2026-07-24T16:41:09Z"
    },
    {
      "contact_id": "ctc_01k9m4tt7c5b2v0jr9naq3xhme",
      "phone": "+15557654321",
      "email": null,
      "first_name": "Marcus",
      "last_name": "Ali",
      "tags": ["saas", "q3-outbound"],
      "custom_fields": {},
      "external_id": null,
      "opted_out": false,
      "opted_out_at": null,
      "opt_out_source": null,
      "created_at": "2026-07-23T11:02:44Z",
      "updated_at": "2026-07-23T11:02:44Z"
    }
  ],
  "total": 218,
  "has_more": true,
  "next_cursor": "eyJjIjoiY3RjXzAxazltNHR0N2M1YjJ2MGpyOW5hcTN4aG1lIn0"
}
```

**Paging through every match**

```javascript theme={null}
async function listAll(params) {
  const contacts = [];
  let cursor = null;

  do {
    const query = new URLSearchParams({ ...params, limit: "200" });
    if (cursor) query.set("cursor", cursor);

    const res = await fetch(
      `https://api.bluereacher.com/functions/v1/list-contacts?${query}`,
      { headers: { Authorization: "Bearer bluereacher_your_api_key" } }
    );

    if (!res.ok) throw new Error(`list-contacts failed: ${res.status}`);

    const page = await res.json();
    contacts.push(...page.contacts);
    cursor = page.next_cursor;
  } while (cursor);

  return contacts;
}

const sendable = await listAll({ tags: "q3-outbound", opted_out: "false" });
```

Filtering with `opted_out=false` keeps opted-out people out of your send loop, which saves you a round trip. The platform still blocks them at send time if one slips through.

**Errors**

| Status | Code              | When                                                                                                                      |
| ------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request` | `limit` outside 1 to 200, `search` under 2 characters, bad `sort`, `order`, or `tag_match` value, or an unparseable date. |
| 400    | `invalid_cursor`  | The cursor expired or the filters changed mid chain. Restart from page one.                                               |

## Update a contact

Applies a partial update. Only the fields you send change. Everything you leave out stays as it is.

```
POST https://api.bluereacher.com/functions/v1/update-contact
```

**Auth.** Bearer API key in the `Authorization` header.

**Request fields**

| Field             | Type             | Required                | Description                                                                                            |
| ----------------- | ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `contact_id`      | string           | One identifier required | Contact to update.                                                                                     |
| `external_id`     | string           | One identifier required | Alternative lookup key. When used as the lookup, it is not modified.                                   |
| `phone`           | string           | No                      | New E.164 number. Must not collide with another contact.                                               |
| `email`           | string           | No                      | New email. Send `null` to clear.                                                                       |
| `first_name`      | string           | No                      | Send `null` to clear.                                                                                  |
| `last_name`       | string           | No                      | Send `null` to clear.                                                                                  |
| `tags`            | array of strings | No                      | Replaces the whole tag array. Send `[]` to clear all tags.                                             |
| `add_tags`        | array of strings | No                      | Adds tags without touching existing ones. Cannot be combined with `tags`.                              |
| `remove_tags`     | array of strings | No                      | Removes the listed tags. Missing tags are ignored. Cannot be combined with `tags`.                     |
| `custom_fields`   | object           | No                      | Merges by key. Keys you send are overwritten, keys you omit survive. Set a key to `null` to delete it. |
| `set_external_id` | string           | No                      | Changes the stored `external_id`. Send `null` to clear it.                                             |

`opted_out` cannot be changed here. Sending it returns `422` with code `validation_failed`. Use `/set-opt-out` so every consent change lands on the suppression list with a source and a timestamp.

**Response fields**

The full updated contact object, same shape as [The contact object](#the-contact-object). `updated_at` reflects the change.

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/update-contact \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
    "first_name": "Dana",
    "add_tags": ["demo-booked"],
    "remove_tags": ["q3-outbound"],
    "custom_fields": {
      "seats": 60,
      "trial_active": null
    }
  }'
```

**Example response**

```json theme={null}
{
  "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
  "phone": "+15551234567",
  "email": "dana@northwindlabs.com",
  "first_name": "Dana",
  "last_name": "Reyes",
  "tags": ["saas", "demo-booked"],
  "custom_fields": {
    "company": "Northwind Labs",
    "seats": 60
  },
  "external_id": "hubspot_88213",
  "opted_out": false,
  "opted_out_at": null,
  "opt_out_source": null,
  "created_at": "2026-07-24T16:41:09Z",
  "updated_at": "2026-07-24T18:12:33Z"
}
```

**Errors**

| Status | Code                | When                                                                |
| ------ | ------------------- | ------------------------------------------------------------------- |
| 400    | `invalid_request`   | No identifier, or `tags` combined with `add_tags` or `remove_tags`. |
| 400    | `invalid_phone`     | The new `phone` is not parseable.                                   |
| 404    | `contact_not_found` | No contact matches the identifier.                                  |
| 409    | `contact_exists`    | The new `phone` or `set_external_id` belongs to another contact.    |
| 422    | `validation_failed` | A limit was exceeded, or you sent `opted_out`.                      |

## Delete a contact

Permanently removes the contact record. This cannot be undone.

```
DELETE https://api.bluereacher.com/functions/v1/delete-contact
```

**Auth.** Bearer API key in the `Authorization` header. The request carries a JSON body.

Message history for the number is retained for reporting and is no longer linked to a contact record. **The opt-out is retained too.** If the contact was opted out, its number stays on the workspace suppression list and sends to it stay blocked, whether or not you re-create the contact later.

**Request fields**

| Field         | Type   | Required                | Description             |
| ------------- | ------ | ----------------------- | ----------------------- |
| `contact_id`  | string | One identifier required | Contact to delete.      |
| `external_id` | string | One identifier required | Alternative lookup key. |

**Response fields**

| Field                  | Type    | Description                                                          |
| ---------------------- | ------- | -------------------------------------------------------------------- |
| `deleted`              | boolean | `true` when the record was removed.                                  |
| `contact_id`           | string  | Id of the deleted contact.                                           |
| `phone`                | string  | Number that was on the record.                                       |
| `suppression_retained` | boolean | `true` when the number stays on the suppression list after deletion. |

**Example request**

```bash theme={null}
curl -X DELETE https://api.bluereacher.com/functions/v1/delete-contact \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr" }'
```

**Example response**

```json theme={null}
{
  "deleted": true,
  "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
  "phone": "+15551234567",
  "suppression_retained": false
}
```

**Errors**

| Status | Code                | When                                                 |
| ------ | ------------------- | ---------------------------------------------------- |
| 400    | `invalid_request`   | No identifier supplied.                              |
| 404    | `contact_not_found` | Already deleted, or never existed in this workspace. |

Deletes are idempotent from your side in practice: a second call on the same id returns `404`, which you can treat as success.

## Bulk create contacts

Imports up to 1000 contacts in one request and reports exactly which rows were created, which were duplicates, and which failed validation. Nothing is silently dropped.

```
POST https://api.bluereacher.com/functions/v1/bulk-create-contacts
```

**Auth.** Bearer API key in the `Authorization` header.

The request is processed synchronously and returns when the batch is finished, typically under 5 seconds for 1000 rows. Rows are independent: a bad row does not roll back the good ones.

**Request fields**

| Field          | Type             | Required | Default | Description                                                                                                                                                        |
| -------------- | ---------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `contacts`     | array of objects | Yes      | none    | 1 to 1000 contact objects. Each accepts the same fields as `/create-contact`: `phone`, `email`, `first_name`, `last_name`, `tags`, `custom_fields`, `external_id`. |
| `on_duplicate` | string           | No       | `skip`  | `skip` leaves the existing contact untouched. `update` merges the submitted fields into it using the same rules as `/update-contact`.                              |
| `default_tags` | array of strings | No       | none    | Tags added to every contact in the batch, on top of per row `tags`.                                                                                                |

A row counts as a duplicate when its `phone` or `external_id` already exists in the workspace, or when an earlier row in the same payload used the same `phone` or `external_id`. The first occurrence wins and later occurrences are reported as duplicates.

**Response fields**

| Field                | Type             | Description                                                                                                                                                                                                                                                                                   |
| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `summary`            | object           | Counts for the batch.                                                                                                                                                                                                                                                                         |
| `summary.submitted`  | integer          | Rows received.                                                                                                                                                                                                                                                                                |
| `summary.created`    | integer          | New contacts created.                                                                                                                                                                                                                                                                         |
| `summary.duplicates` | integer          | Rows matched to an existing contact.                                                                                                                                                                                                                                                          |
| `summary.failed`     | integer          | Rows rejected by validation.                                                                                                                                                                                                                                                                  |
| `created`            | array of objects | Contact objects for the new records.                                                                                                                                                                                                                                                          |
| `duplicates`         | array of objects | One entry per duplicate row: `index` (zero based position in your payload), `phone`, `contact_id` of the existing record, `matched_on` (`phone`, `external_id`, or `payload`), and `action` (`skipped` or `updated`). When `action` is `updated`, a `contact` object holds the merged record. |
| `errors`             | array of objects | One entry per failed row: `index`, `phone` as submitted, `code`, and `message`.                                                                                                                                                                                                               |

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/bulk-create-contacts \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "on_duplicate": "skip",
    "default_tags": ["july-import"],
    "contacts": [
      { "phone": "+15550001111", "first_name": "Priya", "external_id": "crm_701" },
      { "phone": "+15551234567", "first_name": "Dana" },
      { "phone": "555-12", "first_name": "Broken Row" }
    ]
  }'
```

**Example response**

```json theme={null}
{
  "summary": {
    "submitted": 3,
    "created": 1,
    "duplicates": 1,
    "failed": 1
  },
  "created": [
    {
      "contact_id": "ctc_01k9maa4rv8p2q6yc1t7hzd0nb",
      "phone": "+15550001111",
      "email": null,
      "first_name": "Priya",
      "last_name": null,
      "tags": ["july-import"],
      "custom_fields": {},
      "external_id": "crm_701",
      "opted_out": false,
      "opted_out_at": null,
      "opt_out_source": null,
      "created_at": "2026-07-24T18:30:02Z",
      "updated_at": "2026-07-24T18:30:02Z"
    }
  ],
  "duplicates": [
    {
      "index": 1,
      "phone": "+15551234567",
      "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
      "matched_on": "phone",
      "action": "skipped"
    }
  ],
  "errors": [
    {
      "index": 2,
      "phone": "555-12",
      "code": "invalid_phone",
      "message": "Phone number could not be parsed as E.164."
    }
  ]
}
```

**Importing more than 1000 rows**

Chunk the list and keep the reports.

```python theme={null}
import requests

URL = "https://api.bluereacher.com/functions/v1/bulk-create-contacts"
HEADERS = {
    "Authorization": "Bearer bluereacher_your_api_key",
    "Content-Type": "application/json",
}

def import_contacts(rows, chunk_size=500):
    created = duplicates = failed = 0

    for start in range(0, len(rows), chunk_size):
        chunk = rows[start:start + chunk_size]
        res = requests.post(
            URL,
            headers=HEADERS,
            json={"contacts": chunk, "on_duplicate": "update"},
            timeout=60,
        )
        res.raise_for_status()
        body = res.json()

        created += body["summary"]["created"]
        duplicates += body["summary"]["duplicates"]
        failed += body["summary"]["failed"]

        for err in body["errors"]:
            print(f"row {start + err['index']}: {err['code']} {err['message']}")

    return {"created": created, "duplicates": duplicates, "failed": failed}
```

**Opt-out during import**

Imported rows whose numbers are already on the suppression list are created with `opted_out: true`. Your import cannot clear an opt-out. That is the whole point of the suppression list.

**Errors**

| Status | Code              | When                                                           |
| ------ | ----------------- | -------------------------------------------------------------- |
| 400    | `invalid_request` | `contacts` missing, empty, not an array, or over 1000 entries. |
| 400    | `invalid_request` | `on_duplicate` is not `skip` or `update`.                      |
| 429    | `rate_limited`    | More than 10 bulk requests in a minute.                        |

Row level problems never fail the whole request. A `200` with a non empty `errors` array is the normal way validation failures come back, so check `summary.failed` on every import.

## Set opt-out

Sets or clears the opt-out flag for a contact. This is the only endpoint that writes the flag through the API.

```
POST https://api.bluereacher.com/functions/v1/set-opt-out
```

**Auth.** Bearer API key in the `Authorization` header. The change applies across the whole workspace immediately.

Use it to mirror unsubscribes from your own system, from a landing page, or from an email tool. The effect is instant: sends already queued for that number are cancelled before dispatch.

**Request fields**

| Field         | Type    | Required                | Description                                                                                                                                                      |
| ------------- | ------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_id`  | string  | One identifier required | Contact to change.                                                                                                                                               |
| `phone`       | string  | One identifier required | E.164 number. Works even when no contact record exists, which adds the number to the suppression list directly.                                                  |
| `external_id` | string  | One identifier required | Your identifier.                                                                                                                                                 |
| `opted_out`   | boolean | Yes                     | `true` blocks all sends to this number. `false` clears the block.                                                                                                |
| `reason`      | string  | Conditional             | Free text up to 500 characters. Required when `opted_out` is `false`, describing the consent you collected. Optional but recommended when `opted_out` is `true`. |
| `source`      | string  | No                      | Where the change came from. One of `api`, `import`, `dashboard`. Defaults to `api`.                                                                              |

**Response fields**

| Field            | Type           | Description                                                                      |
| ---------------- | -------------- | -------------------------------------------------------------------------------- |
| `contact_id`     | string or null | Contact id, or `null` when you suppressed a phone number with no contact record. |
| `phone`          | string         | Normalized E.164 number.                                                         |
| `opted_out`      | boolean        | New state.                                                                       |
| `opted_out_at`   | string or null | Timestamp of this opt-out, `null` after clearing.                                |
| `opt_out_source` | string or null | Recorded source.                                                                 |
| `opt_out_reason` | string or null | Recorded reason.                                                                 |
| `updated_at`     | string         | ISO 8601 UTC.                                                                    |

**Example request**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/set-opt-out \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+15551234567",
    "opted_out": true,
    "reason": "Replied STOP to the email newsletter",
    "source": "api"
  }'
```

**Example response**

```json theme={null}
{
  "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
  "phone": "+15551234567",
  "opted_out": true,
  "opted_out_at": "2026-07-24T18:44:51Z",
  "opt_out_source": "api",
  "opt_out_reason": "Replied STOP to the email newsletter",
  "updated_at": "2026-07-24T18:44:51Z"
}
```

**Clearing an opt-out**

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/set-opt-out \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "ctc_01k9m4x2vqe7hb3n8s6t0dwzpr",
    "opted_out": false,
    "reason": "Re-subscribed via pricing page form on 2026-07-24, consent record CR-4471"
  }'
```

**Errors**

| Status | Code                      | When                                                                                                                                   |
| ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`         | No identifier, more than one identifier, or `opted_out` missing.                                                                       |
| 400    | `invalid_phone`           | `phone` is not parseable as E.164.                                                                                                     |
| 404    | `contact_not_found`       | `contact_id` or `external_id` matches nothing. Look up by `phone` instead if you want to suppress a number that has no contact record. |
| 422    | `consent_reason_required` | `opted_out` is `false` and `reason` is missing or empty.                                                                               |

Calling with the state a contact already has is a no-op and returns `200` with the current record. `opted_out_at` is not refreshed when the state is unchanged.

## Errors

Every error uses the same envelope.

```json theme={null}
{
  "error": {
    "code": "contact_not_found",
    "message": "No contact in this workspace matches contact_id ctc_01k9m4x2vqe7hb3n8s6t0dwzpr.",
    "details": {}
  }
}
```

| Status | Code                      | Meaning                                                                                          |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------ |
| 400    | `invalid_request`         | A parameter is missing, malformed, or conflicts with another.                                    |
| 400    | `invalid_phone`           | Phone number could not be normalized to E.164.                                                   |
| 400    | `invalid_cursor`          | Pagination cursor expired or no longer matches the filters.                                      |
| 401    | `unauthorized`            | Missing or malformed `Authorization` header.                                                     |
| 401    | `invalid_api_key`         | Key is unknown or revoked.                                                                       |
| 403    | `contact_opted_out`       | Returned by send endpoints when the recipient is opted out. Contact endpoints never return this. |
| 404    | `contact_not_found`       | No matching contact in this workspace.                                                           |
| 409    | `contact_exists`          | Phone number or `external_id` is already taken. `details.contact_id` holds the existing record.  |
| 422    | `validation_failed`       | A field broke a length, type, or count limit. `details.field` names the offender.                |
| 422    | `consent_reason_required` | Clearing an opt-out without a `reason`.                                                          |
| 429    | `rate_limited`            | Too many requests. `details.retry_after` gives seconds to wait.                                  |
| 500    | `internal_error`          | Something broke on our side. Retry with backoff and contact support if it persists.              |

Treat `4xx` codes as final. Retrying an identical request will produce the identical error. Retry `429` after the stated delay and `500` with exponential backoff.

## Rate limits

| Scope                               | Limit                                             |
| ----------------------------------- | ------------------------------------------------- |
| Contact endpoints, per API key      | 300 requests per minute                           |
| `bulk-create-contacts`, per API key | 10 requests per minute, 1000 contacts per request |

Every response carries the current state of your budget:

```
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1785000060
```

`X-RateLimit-Reset` is a Unix timestamp in seconds. A `429` also returns `details.retry_after` in seconds. Prefer `bulk-create-contacts` over loops of `create-contact`: one bulk call of 1000 rows costs a single request against your budget.

## Next steps

* Send to a contact with the messaging endpoints, using `contact_id` or `to` plus a `channel` of `imessage`, `rcs`, or `sms`.
* Subscribe to the `contact.opted_out` webhook so your CRM learns about stop replies without polling.
* Pull sendable audiences with `list-contacts` filtered on `opted_out=false` before every campaign run.
