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

# Add Opt-Outs

> Suppress up to 10,000 contacts in one call so campaigns and messages skip them 🚫

## Endpoint

```json theme={null}
POST https://api.connectly.ai/v1/businesses/{businessId}/add/opt_outs
```

| Parameter    | Location | Description                 |
| ------------ | -------- | --------------------------- |
| `businessId` | Path     | Your Connectly business ID. |
| `X-API-Key`  | Header   | Your API key.               |

<Tip>
  Safe to repeat. Sending the same list twice suppresses nothing new the second time and reports every entry under `alreadyOptedOutCount`.
</Tip>

***

## Request body

```json theme={null}
{
  "entries": [...]
}
```

### `entries` array (required)

Each object names one WhatsApp contact to suppress. Maximum 10,000 per request — send the rest in further calls.

| Field    | Type   | Required | Description                                                                                                                                                             |
| -------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client` | string | Yes\*    | Contact's phone number in E.164 format (e.g. `+14155558234`), with or without the leading `+`. \*Not required when `userId` is set.                                     |
| `userId` | string | No       | Business-Scoped User ID (e.g. `US.13491208655302741918`), exactly as Connectly sends it in webhooks. Read only when `client` is empty — if both are set, `client` wins. |

<Note>
  The same contact listed twice counts once, so the returned counts can sum to less than the number of entries you sent.
</Note>

***

## Examples

<AccordionGroup>
  <Accordion title="Suppress by phone number">
    The common case for a consent export.

    ```json theme={null}
    {
      "entries": [
        { "client": "+14155558234" },
        { "client": "+14155559345" }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Suppress by Business-Scoped User ID">
    Use `userId` when your records key on the BSUID you received in a webhook rather than a phone number.

    ```json theme={null}
    {
      "entries": [
        { "userId": "US.13491208655302741918" }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Mixed identifiers in one request">
    You can mix phone numbers and BSUIDs freely.

    ```json theme={null}
    {
      "entries": [
        { "client": "+14155558234" },
        { "userId": "US.13491208655302741918" },
        { "client": "+14155559345" }
      ]
    }
    ```
  </Accordion>
</AccordionGroup>

***

## Response

The API returns HTTP `200` with a `data` object. Counts are over the contacts you named, not rows stored.

| Field                  | Type           | Description                                                                                                                                |
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `addedCount`           | integer        | Contacts this request suppressed.                                                                                                          |
| `alreadyOptedOutCount` | integer        | Contacts already suppressed through this API, so nothing changed. Re-sending an unchanged list reports every entry here.                   |
| `rejectedCount`        | integer        | Entries dropped as unreadable.                                                                                                             |
| `rejected`             | array          | The dropped entries with their reasons, up to the first 100. Each carries `client` — the identifier exactly as you sent it — and `reason`. |
| `error`                | object \| null | Summary of why entries were rejected, with a count per reason. Absent when nothing was rejected.                                           |

**Success response**

```json theme={null}
{
  "data": {
    "addedCount": 9870,
    "alreadyOptedOutCount": 128,
    "rejectedCount": 2,
    "rejected": [
      { "client": "not-a-number", "reason": "not a valid identifier for this channel" },
      { "client": "", "reason": "client or userId is required" }
    ],
    "error": {
      "message": "rejected 2 of 10000 entries: not a valid identifier for this channel (1), client or userId is required (1)",
      "type": "ERROR_TYPE_INVALID_ARGUMENT",
      "code": "ERROR_CODE_BAD_REQUEST",
      "userTitle": "Bad request sent",
      "userMessage": "Request or param(s) detected."
    }
  }
}
```

<Warning>
  **Treat the counts as best-effort when reconciling.** Deciding what was already suppressed takes a read that Connectly skips rather than fail your upload for. When it is unavailable every entry is written and duplicates are dropped on storage — your contacts end up suppressed correctly, but an unchanged re-upload can report its whole file under `addedCount` and nothing under `alreadyOptedOutCount`.

  If you reconcile against these numbers, read a full `addedCount` as "unknown" rather than "changed".
</Warning>

***

## Error responses

Unreadable entries are dropped and counted — they do not fail the batch. The request fails outright only when no entry survives.

| HTTP status | Meaning                                                                   |
| ----------- | ------------------------------------------------------------------------- |
| `400`       | Request body is malformed, `entries` is empty, or no entry could be read. |
| `401`       | Missing or invalid API key.                                               |
| `429`       | Rate limit exceeded.                                                      |
| `500`       | Internal server error.                                                    |

Quote `cntTraceId` when reporting a failure — it identifies the request in our logs.

### Rejection reasons

| Reason                                          | Cause                                                                |
| ----------------------------------------------- | -------------------------------------------------------------------- |
| `not a valid identifier for this channel`       | The value is neither a usable phone number nor a BSUID for WhatsApp. |
| `client or userId is required`                  | The entry set neither field.                                         |
| `userId must be a business-scoped user id`      | `userId` was set but is not a BSUID.                                 |
| `client is not a valid business-scoped user id` | `client` looked like a BSUID but is malformed.                       |

<AccordionGroup>
  <Accordion title="No entry could be read">
    Returned as HTTP `400` when every entry was rejected, rather than a `200` with an empty result.

    ```json theme={null}
    {
      "message": "no entry could be read: not a valid identifier for this channel (2)",
      "errorType": "ERROR_TYPE_INVALID_ARGUMENT",
      "errorCode": "XOPTOUT.INVALID_ARGUMENT",
      "userTitle": "Bad request sent",
      "userMessage": "Request or param(s) detected.",
      "cntSsId": "A3304319921049468327",
      "cntTraceId": "5443858048914164853",
      "details": {},
      "type": "ERROR_TYPE_INVALID_ARGUMENT",
      "code": "ERROR_CODE_BAD_REQUEST",
      "extra": []
    }
    ```
  </Accordion>

  <Accordion title="Too many entries">
    Returned when `entries` exceeds 10,000. Split the list across calls.

    ```json theme={null}
    {
      "message": "at most 10000 entries per request",
      "errorType": "ERROR_TYPE_INVALID_ARGUMENT",
      "errorCode": "XOPTOUT.INVALID_ARGUMENT",
      "userTitle": "Bad request sent",
      "userMessage": "Request or param(s) detected.",
      "cntSsId": "A3304319921049468327",
      "cntTraceId": "5443858048914164853",
      "details": {},
      "type": "ERROR_TYPE_INVALID_ARGUMENT",
      "code": "ERROR_CODE_BAD_REQUEST",
      "extra": []
    }
    ```
  </Accordion>
</AccordionGroup>

***

## Rate limiting

This endpoint is limited to **60 requests per minute**. Exceeding this returns HTTP `429 Too Many Requests`. At 10,000 contacts per call that is well above what a daily consent sync needs — use exponential backoff if you expect to approach it.
