
# Campaigns API

A campaign bundles together everything the AI bot needs to talk to your contacts: its instructions, the channels it runs on, its active hours, and its follow-up behaviour. The Campaigns API lets you list, create, update, duplicate, enable, archive, and fine-tune campaigns from your own code instead of the dashboard.

All endpoints below are relative to the base URL `https://api.youraiconnector.com/v1`. Every request must be authenticated — see [API Access](../integrations/api-access.md) and [Authentication](authentication.md) for how to get and pass your API key. API access is a paid feature; without it, requests are rejected with a `403`.

> **Heads up:** Some examples show the simple `?apiKey=YOUR_API_KEY` query form, others use the `X-API-Key` header. Both work everywhere — use whichever fits your setup.

---

## Campaign types

When you create a campaign you must pick one of these types:

| Type | What it's for |
|---|---|
| `Incoming from Unknown Contacts` | The bot replies to people who message you for the first time. |
| `Outgoing` | The bot starts conversations with contacts you add to the campaign. |
| `Keywords` | **Inert - do not use.** A `Keywords` campaign is inert: it is still accepted for backwards compatibility, but it is invisible to inbound routing on every channel and nothing reads its trigger keywords. Use an Entry Point of type **Keyword** on an AI Agent instead. |
| `Combined` | A mix of incoming and outgoing behaviour. |

**Casing doesn't matter.** `type`, `status`, `booking_provider`, `first_response_mode`, `bot.anthropic_model` and `bot.ai_speed` all accept any casing — `"live"`, `"Live"` and `"LIVE"` are the same thing — and the value is stored in its canonical form, which is what comes back when you read the campaign. The one exception is the pause pair: `"Paused"` and `"paused"` are two genuinely different states, so an ambiguous spelling like `"PAUSED"` is rejected with a `400` telling you to pick one.

### The two pause states

| Status | Who writes it | What it means |
|---|---|---|
| `Paused` | The platform's own safety checks (low engagement, repeated send errors, a limit hit) and the newer Agents and Broadcasts surfaces | The campaign is held. A scheduled sweep can lift a safety pause automatically once the reason clears. |
| `paused` | The dashboard's Pause button, paired with `resumed` on Resume | A person paused it by hand. Scheduled sends are torn down and rebuilt on resume. |

Both stop the campaign: inbound routing only runs while the status is exactly `Live`. **From the API, use `Paused` to pause and `Live` to resume** — the lower-case pair exists for the dashboard button and is kept working for it.

Neither of these is what happens when the AI stops replying inside one conversation. That is a per-contact switch, `is_bot_active` on the contact — set when a human takes over, when the contact opts out, or when the AI concludes the chat. The campaign's own status is untouched, and every other conversation in it keeps running. See [pause or resume the AI for one contact](messages.md#pause-or-resume-the-ai-for-one-contact).

> **Creating a campaign does not decide who answers a channel.** Routing is handled by **Entry Points** on an AI Agent, not by campaigns. Each channel has one channel-default Entry Point naming the Agent that answers new, unknown contacts on it: set it with `PUT /entry-points/channel-defaults`, check whether the ladder is live for the account with `GET /entry-points/routing-status`, clear it with `DELETE /entry-points/channel-defaults`. `POST /channels/campaign` still writes the legacy per-channel campaign routing map, but that map is no longer consulted for inbound routing on any account; it is retained for rollback only. Do not build against it. See [Route a channel to a campaign](channels.md#route-a-channel-to-a-campaign) for both surfaces side by side.

---

## List campaigns

`GET /campaigns`

Returns your campaigns, newest first. Archived campaigns are excluded unless you pass `archived=true`.

**Query parameters**

| Parameter | Required | Description |
|---|---|---|
| `limit` | No | Maximum number of campaigns to return. Default `50`, maximum `100`. |
| `cursor` | No | Pagination cursor. Pass the `next_cursor` value from the previous response to get the next page. |
| `archived` | No | Set to `true` to include archived campaigns. |

**cURL**

```bash
curl "https://api.youraiconnector.com/v1/campaigns?limit=20&apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("https://api.youraiconnector.com/v1/campaigns?limit=20", {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.campaigns, data.next_cursor);
```

**Python**

```python
import requests

res = requests.get(
    "https://api.youraiconnector.com/v1/campaigns",
    params={"limit": 20},
    headers={"X-API-Key": "YOUR_API_KEY"},
)
data = res.json()
print(data["campaigns"], data["next_cursor"])
```

**Response**

```json
{
  "success": true,
  "campaigns": [
    {
      "id": "NBCXrhqGPSFsd6MV7pRo",
      "name": "Inbound WhatsApp Leads",
      "type": "Incoming from Unknown Contacts",
      "status": "Live",
      "enabled": true,
      "archived": false,
      "created_at": 1700000000000,
      "ai_mode": true,
      "language": "en",
      "enabled_channels": ["whatsapp", "instagram"]
    }
  ],
  "next_cursor": "NBCXrhqGPSFsd6MV7pRo"
}
```

When `next_cursor` is `null`, you've reached the last page.

---

## Get a campaign

`GET /campaigns/{campaignId}`

Returns the full campaign document, including the live bot configuration (`bot`), follow-up settings, enabled channels, and any keywords. Timestamps come back as epoch milliseconds.

**cURL**

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
  { headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const { campaign } = await res.json();
```

**Python**

```python
import requests

res = requests.get(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
campaign = res.json()["campaign"]
```

**Response**

```json
{
  "success": true,
  "campaign": {
    "id": "NBCXrhqGPSFsd6MV7pRo",
    "name": "Inbound WhatsApp Leads",
    "type": "Incoming from Unknown Contacts",
    "status": "Live",
    "language": "en",
    "ai_mode": true,
    "enabled": true,
    "archived": false,
    "created_at": 1700000000000,
    "enabled_channels": ["whatsapp", "instagram"],
    "bot": {
      "instructions": "Greet warmly and ask about their goals.",
      "goal": "Book a discovery call.",
      "ai_speed": "balanced",
      "anthropic_model": "standard",
      "max_messages": 20
    }
  }
}
```

::: note
**Note:** A campaign owned by a different account returns `404 Campaign not found` (not `403`), so you can't tell whether an ID exists on another account.
:::


---

## Create a campaign

`POST /campaigns`

Creates a new campaign. `name` and `type` are required; everything else is optional. You can include any other campaign field in the same request — for example `language`, `ai_mode`, or a full `bot` configuration object — and it will be stored with the new campaign. The owner and creation time are set automatically.

**Request fields**

| Field | Required | Description |
|---|---|---|
| `name` | Yes | The campaign name. |
| `type` | Yes | One of the four campaign types above. |
| `language` | No | Language the bot replies in (e.g. `"en"`). |
| `ai_mode` | No | Whether AI mode is on (`true`/`false`). On a campaign answered by an AI Agent, reads return the Agent's **Active** toggle rather than a stored value — see the note under updating below. |
| `bot` | No | The bot configuration object (see [Bot configuration fields](#bot-configuration-fields)). |
| `list_id` | No | ID of the contact list to attach. |
| `event_id` | No | ID of the event type the AI may book. |
| `event_ids` | No | Several event types at once, as an array of event type IDs — the first one is the default. Send either `event_id` or `event_ids`, not both. |

**cURL**

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Spring Promo",
    "type": "Outgoing",
    "language": "en",
    "ai_mode": true,
    "bot": {
      "instructions": "Greet warmly and ask about their goals.",
      "goal": "Book a discovery call."
    }
  }'
```

**JavaScript**

```javascript
const res = await fetch("https://api.youraiconnector.com/v1/campaigns", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Spring Promo",
    type: "Outgoing",
    language: "en",
    ai_mode: true,
    bot: {
      instructions: "Greet warmly and ask about their goals.",
      goal: "Book a discovery call.",
    },
  }),
});
const { campaign_id } = await res.json();
```

**Python**

```python
import requests

res = requests.post(
    "https://api.youraiconnector.com/v1/campaigns",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "name": "Spring Promo",
        "type": "Outgoing",
        "language": "en",
        "ai_mode": True,
        "bot": {
            "instructions": "Greet warmly and ask about their goals.",
            "goal": "Book a discovery call.",
        },
    },
)
campaign_id = res.json()["campaign_id"]
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

---

## Update a campaign

`PUT /campaigns/{campaignId}`

Partially updates a campaign — send only the fields you want to change. This is the only general update verb; there is no `PATCH /campaigns/{campaignId}` (the two `PATCH` routes are the narrow [enable](#enable-or-disable-a-campaign) and [archive](#archive-or-restore-a-campaign) toggles).

**Which fields you can change.** Everything the campaign editor writes, including `name`, `status`, `type`, `language`, `ai_mode`, `enabled_channels`, the trigger and drip settings, the booking and follow-up flags, the Instagram/Facebook monitoring fields, and the whole `bot` configuration. Identity and ownership are locked for the life of the campaign: `user`, `id`, and `created_at` are rejected, and so is any field name the endpoint doesn't recognise. Rejection is per request, not per field — one unknown key returns a `400` and **nothing** in that request is written.

**`ai_mode` on an Agent-backed campaign reflects the Agent.** When a campaign is answered by an AI Agent, reading the campaign returns `ai_mode` derived from that Agent's **Active** toggle — the one switch that actually decides whether the AI replies. Writing `ai_mode` on such a campaign is accepted but won't change what you read back; turn the Agent's Active toggle on or off instead (in the dashboard, or via the Agents API). On classic campaigns with no Agent, `ai_mode` reads and writes the stored value as before.

**Bot fields merge, they don't overwrite.** Send bot settings either as dotted keys (`"bot.instructions": "..."`) or as a nested object (`"bot": { "instructions": "..." }`) — both write leaf by leaf, so the fields you leave out keep their current values. `bot.instructions`, `bot.goal`, `bot.rules`, and `bot.personality` are all editable this way, as is every other bot setting listed under [Bot configuration fields](#bot-configuration-fields). The same applies to `test_bot`, `frequency`, and `follow_up_config`.

To replace a bot configuration wholesale — deleting any field you don't send — use `bot_replace` (or `test_bot_replace`) with the complete object. You can't combine a replace and a merge for the same object in one request; that returns a `400`.

::: note
**Note:** Writing `bot.*` through the API takes effect **immediately** on the live campaign. The dashboard editor works differently: edits there are saved as a draft and only go live when the client clicks Publish. So if a client has unpublished dashboard changes, they sit in `test_bot` and an API read of `bot` correctly shows what the AI is using right now.
:::


A few fields are set through a dedicated key rather than written directly: use `list_id` for the contact list, `event_id` for the event type (or `event_ids`, an ordered array of event type IDs, to let the AI book several — the first is the default; an empty array unlinks them all), and `contact_ids` (an array of contact IDs) for the campaign's contacts. Knowledge-base entries are managed through the [FAQs API](faqs.md), not this endpoint.

**Tags replace, they don't merge.** Send `tags` as the complete array and it becomes the campaign's tag set — see [Campaign tags](#campaign-tags) for the fields and for the endpoints that add or edit a single tag.

**cURL**

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Spring Promo v2", "enabled_channels": ["whatsapp"] }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "Spring Promo v2",
      enabled_channels: ["whatsapp"],
    }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.put(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"name": "Spring Promo v2", "enabled_channels": ["whatsapp"]},
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

---

## Delete a campaign

`DELETE /campaigns/{campaignId}`

Permanently deletes a campaign. This cannot be undone — if you might need the campaign again, [archive it](#archive-or-restore-a-campaign) instead.

**cURL**

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
  { method: "DELETE", headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.delete(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
data = res.json()
```

**Response**

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

---

## Duplicate a campaign

`POST /campaigns/{campaignId}/duplicate`

Creates a copy of the campaign with all of its settings preserved. The copy starts **disabled** and its name gets a `(copy)` suffix, so it never sends messages until you explicitly enable it.

**cURL**

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/duplicate?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/duplicate",
  { method: "POST", headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const { campaign_id } = await res.json();
```

**Python**

```python
import requests

res = requests.post(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/duplicate",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
new_campaign_id = res.json()["campaign_id"]
```

**Response**

```json
{
  "success": true,
  "campaign_id": "aZ9plnewCopyId01234"
}
```

> Duplicate copies **within one account**.

---


## Enable or disable a campaign

`PATCH /campaigns/{campaignId}/enabled`

Turns a campaign on or off. A disabled campaign stops engaging contacts but keeps all of its configuration.

**Request fields**

| Field | Required | Description |
|---|---|---|
| `enabled` | Yes | `true` to enable, `false` to disable. Must be a boolean. |

**cURL**

```bash
curl -X PATCH "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/enabled?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/enabled",
  {
    method: "PATCH",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ enabled: true }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.patch(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/enabled",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"enabled": True},
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "enabled": true
}
```

---

## Archive or restore a campaign

`PATCH /campaigns/{campaignId}/archived`

Archives or restores a campaign. Archived campaigns are hidden from the default campaign list but keep all of their data and can be restored at any time.

**Request fields**

| Field | Required | Description |
|---|---|---|
| `archived` | Yes | `true` to archive, `false` to restore. Must be a boolean. |

**cURL**

```bash
curl -X PATCH "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/archived?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "archived": true }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/archived",
  {
    method: "PATCH",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ archived: true }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.patch(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/archived",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"archived": True},
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "archived": true
}
```

---

## Update the bot configuration

`PUT /campaigns/{campaignId}/bot-config`

This is the safe way to change individual bot settings. Each field you send is **merged** into the existing bot configuration, so any fields you leave out are preserved. Use this instead of the campaign-update endpoint whenever you only want to tweak part of the bot.

Field keys must use letters, numbers, underscores, and hyphens only.

**cURL**

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/bot-config?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "instructions": "Always answer in a friendly, concise tone.",
    "ai_speed": "balanced"
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/bot-config",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      instructions: "Always answer in a friendly, concise tone.",
      ai_speed: "balanced",
    }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.put(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/bot-config",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "instructions": "Always answer in a friendly, concise tone.",
        "ai_speed": "balanced",
    },
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

### Bot configuration fields

All bot fields are optional. Send only the ones you want to set. Any additional bot fields beyond the ones listed here are accepted and stored as-is.

| Field | Type | Description |
|---|---|---|
| `instructions` | string | The primary instructions that steer how the bot talks to contacts. |
| `rules` | string | Hard rules the bot must always follow. |
| `goal` | string | The outcome the bot should work towards in each conversation. |
| `personality` | string | Tone-of-voice and personality description for the bot. |
| `ai_speed` | string | How much reasoning the AI applies before replying. One of `fast`, `fast_thinker`, `balanced`, `thorough`. |
| `anthropic_model` | string | The AI quality tier used for this campaign's replies. One of `standard`, `economy` (deprecated), `max`, `mini`. `max` and `mini` only take effect on accounts eligible for those tiers. |
| `max_messages` | integer | Maximum number of bot messages per conversation. |
| `alert_human_when` | string | Conditions under which the bot should alert a human teammate. |
| `availability` | object | The bot's active-hours schedule. You can set this here, or use the dedicated [active-hours endpoint](#set-the-bot-active-hours). |
| `follow_up_config` | object | Follow-up behaviour configuration, stored as provided. |

---

## Set the bot active hours

`PUT /campaigns/{campaignId}/active-hours`

Sets the bot's availability schedule. Outside the configured windows the bot does not reply automatically. This writes the `availability` field of the bot configuration.

**Request fields**

| Field | Required | Description |
|---|---|---|
| `availability` | Yes | An object keyed by weekday. Allowed keys are `monday` through `sunday`; any other key returns a `400`. Days you leave out are unchanged. |

Each weekday holds either a single time window or an array of windows. A window has a `start_time` and `end_time` in 24-hour `HH:MM` format.

**cURL**

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/active-hours?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "availability": {
      "monday": { "start_time": "09:00", "end_time": "17:00" },
      "tuesday": [
        { "start_time": "09:00", "end_time": "12:00" },
        { "start_time": "13:00", "end_time": "17:00" }
      ]
    }
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/active-hours",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      availability: {
        monday: { start_time: "09:00", end_time: "17:00" },
        tuesday: [
          { start_time: "09:00", end_time: "12:00" },
          { start_time: "13:00", end_time: "17:00" },
        ],
      },
    }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.put(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/active-hours",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "availability": {
            "monday": {"start_time": "09:00", "end_time": "17:00"},
            "tuesday": [
                {"start_time": "09:00", "end_time": "12:00"},
                {"start_time": "13:00", "end_time": "17:00"},
            ],
        }
    },
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

---

## List a campaign's custom functions

`GET /campaigns/{campaignId}/custom-functions`

Returns the custom functions linked to this campaign, resolved into full definitions. Custom functions are external HTTP actions the bot can call during a conversation — for example, checking stock in your store or creating a record in your CRM.

**cURL**

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/custom-functions?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/custom-functions",
  { headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const { custom_functions } = await res.json();
```

**Python**

```python
import requests

res = requests.get(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/custom-functions",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
custom_functions = res.json()["custom_functions"]
```

**Response**

```json
{
  "success": true,
  "custom_functions": [
    {
      "id": "fn_abc123",
      "name": "check_stock",
      "description": "Looks up whether a product is in stock.",
      "url": "https://example.com/api/stock",
      "method": "POST",
      "input": [
        { "name": "sku", "type": "string" }
      ],
      "ai_action": "Tell the customer whether the item is available.",
      "created_at": 1700000000000,
      "updated_at": 1700000500000
    }
  ]
}
```

---

## Link a custom function to a campaign

`POST /campaigns/{campaignId}/custom-functions`

Links an existing [custom function](../ai-automation/custom-functions.md) to this campaign so the bot can call it during a conversation. Linking a function that's already linked is a no-op.

| Field | Required | Description |
|---|---|---|
| `custom_function_id` | Yes | ID of the custom function to link. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/custom-functions?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "custom_function_id": "fn_abc123" }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "custom_function_id": "fn_abc123"
}
```

---

## Unlink a custom function from a campaign

`DELETE /campaigns/{campaignId}/custom-functions/{customFunctionId}`

Unlinking a function that isn't linked is a no-op.

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/custom-functions/fn_abc123?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "custom_function_id": "fn_abc123"
}
```

---

## Link a knowledge-base source to a campaign

`POST /campaigns/{campaignId}/kb-sources`

Links a knowledge-base source (created via the [FAQs API](faqs.md)) to this campaign so the bot can draw on it when answering. Linking a source that's already linked is a no-op.

| Field | Required | Description |
|---|---|---|
| `kb_source_id` | Yes | ID of the knowledge-base source to link. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/kb-sources?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kb_source_id": "kb_abc123" }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "kb_source_id": "kb_abc123"
}
```

---

## Unlink a knowledge-base source from a campaign

`DELETE /campaigns/{campaignId}/kb-sources/{kbSourceId}`

Unlinking a source that isn't linked is a no-op.

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/kb-sources/kb_abc123?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "kb_source_id": "kb_abc123"
}
```

---

## Link an MCP server to a campaign

`POST /campaigns/{campaignId}/mcp-servers`

Links an MCP server to this campaign, giving the bot access to that server's tools during a conversation. Linking a server that's already linked is a no-op.

| Field | Required | Description |
|---|---|---|
| `mcp_server_id` | Yes | ID of the MCP server to link. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/mcp-servers?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mcp_server_id": "mcp_abc123" }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "mcp_server_id": "mcp_abc123"
}
```

---

## Unlink an MCP server from a campaign

`DELETE /campaigns/{campaignId}/mcp-servers/{mcpServerId}`

Unlinking a server that isn't linked is a no-op.

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/mcp-servers/mcp_abc123?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "mcp_server_id": "mcp_abc123"
}
```

---

## Campaign media library

The media library holds images, videos, documents, and voice notes the bot can send during a conversation.

### List a campaign's media library

`GET /campaigns/{campaignId}/media-library`

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/media-library?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "media_items": [
    {
      "id": "media_abc123",
      "item_id": "media_abc123",
      "title": "Pricing sheet",
      "description": "Send when the contact asks about pricing.",
      "media_url": "https://example.com/pricing.pdf",
      "media_content_type": "application/pdf",
      "type": "document",
      "agent_id": "",
      "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
      "media_home": "campaign"
    }
  ]
}
```

`media_url` is a signed URL captured at upload time — it may already be expired by the time you read it back; the dashboard re-signs it on demand.

### Upload a media item

`POST /campaigns/{campaignId}/media-library`

| Field | Required | Description |
|---|---|---|
| `base64Data` | Yes | The file, base64-encoded (no data-URL prefix). |
| `mimeType` | Yes | MIME type of the file (e.g. `image/png`). |
| `title` | Yes | Short label shown in the library and in the AI prompt. |
| `description` | Yes | Instruction telling the bot **when** to send this item. |
| `fileName` | No | Original filename, used to build the storage object name. |
| `sendMessage` | No | Preferred wording the bot should use when it sends this item. |
| `maxSendsPerConversation` | No | Max times the bot may send this item to one contact in a conversation. Defaults to `1`. |
| `sendAsVoiceNote` | No | For an audio upload, transcode it into a WhatsApp voice note. Defaults to `false` (stored as a plain audio file). |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/media-library?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "base64Data": "iVBORw0KGgoAAAANSUhEUgAA...",
    "mimeType": "image/png",
    "title": "Product photo",
    "description": "Send when the contact asks what the product looks like."
  }'
```

**Response**

```json
{
  "success": true,
  "itemId": "media_abc123",
  "mediaUrl": "https://example.com/product.png",
  "storagePath": "ai_media/campaigns/NBCXrhqGPSFsd6MV7pRo/media_abc123.png",
  "mediaContentType": "image/png",
  "type": "image",
  "isVoiceNote": false
}
```

### Update a media item

`PATCH /campaigns/{campaignId}/media-library/{itemId}`

Edits the item's metadata only — to replace the file itself, delete the item and upload a new one.

| Field | Description |
|---|---|
| `title` | Short label. |
| `description` | When-to-send instruction. |
| `send_message` | Preferred wording for the bot to use. |
| `max_sends_per_conversation` | Non-negative integer, or `null` to clear the cap. |

```bash
curl -X PATCH "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/media-library/media_abc123?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Updated pricing sheet" }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "item_id": "media_abc123"
}
```

### Delete a media item

`DELETE /campaigns/{campaignId}/media-library/{itemId}`

Deleting an item that's already gone is a no-op.

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/media-library/media_abc123?apiKey=YOUR_API_KEY"
```

**Response**

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

---

## Campaign tags

A campaign tag is a label you teach the bot to apply to a contact during a conversation — `hot-lead`, `not-interested`, `booked-a-call`. Each tag has three parts:

| Field | Type | Description |
|---|---|---|
| `name` | string, required | The label itself. This is what the bot applies to the contact and what you match on later, so keep it short and stable. |
| `description` | string | The instruction telling the bot **when** to apply this tag. This is the part that does the work — "the person confirms they joined the community" gets used, "hot lead" does not. |
| `webhook` | string | A URL that receives a `POST` the moment the tag lands on a contact. Leave it out if you don't need one. |
| `tag_id` | string | Optional. Links this entry to an existing tag in your account instead of a fresh one. Supply it if you want to address this specific tag later with the single-tag endpoints below. |

Tag names must be unique within a campaign. The bot applies tags **by name**, so two entries sharing one name have no defined winner.

### Set all of a campaign's tags

`PUT /campaigns/{campaignId}` with a `tags` array.

This replaces the campaign's tags with exactly what you send, which is the same thing the dashboard's Tags tab does when you save it. **Send the complete array every time** — a tag you leave out is a tag you deleted. Sending `[]` clears them all.

**cURL**

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": [
      {
        "name": "hot-lead",
        "description": "The person confirms they want to buy, or asks how to get started right away.",
        "webhook": "https://example.com/hooks/campaign-events"
      },
      {
        "name": "not-interested",
        "description": "The person declines the offer or says they are not a fit."
      }
    ]
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      tags: [
        {
          name: "hot-lead",
          description:
            "The person confirms they want to buy, or asks how to get started right away.",
          webhook: "https://example.com/hooks/campaign-events",
        },
        {
          name: "not-interested",
          description: "The person declines the offer or says they are not a fit.",
        },
      ],
    }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.put(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "tags": [
            {
                "name": "hot-lead",
                "description": "The person confirms they want to buy, or asks how to get started right away.",
                "webhook": "https://example.com/hooks/campaign-events",
            },
            {
                "name": "not-interested",
                "description": "The person declines the offer or says they are not a fit.",
            },
        ]
    },
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

Read the tags back with [`GET /campaigns/{campaignId}`](#get-a-campaign).

### Add one tag

`POST /campaigns/{campaignId}/tags`

Appends a single tag without resending the rest. Use this when you are adding to a set you did not build in this request.

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/tags?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tag": { "name": "booked-a-call", "description": "The person confirms a booked time." } }'
```

Posting the exact same tag twice does nothing the second time. Posting the same `tag_id` with a different name or description appends a **second** entry rather than editing the first — use the endpoint below to edit in place.

### Update or remove one tag

`PUT /campaigns/{campaignId}/tags/{tagId}`
`DELETE /campaigns/{campaignId}/tags/{tagId}`

These address one entry by its `tag_id`, so they only work on tags that were created with one. If a tag has no `tag_id`, change it with the whole-array `PUT /campaigns/{campaignId}` above.

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/tags/tag_abc123?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tag": { "name": "hot-lead", "description": "Updated instruction." } }'
```

A `tagId` that is not on the campaign returns `404` with `"Tag not found in campaign tags"`.

---

## Toggle a campaign's channels

`POST /campaigns/{campaignId}/channels`

Adds or removes channels from the campaign's `enabled_channels` array without resending the whole array — safer than [`PUT /campaigns/{campaignId}`](#update-a-campaign) when something else might be editing the campaign at the same time.

Send either a single toggle or a batch — not both in the same request:

```json
{ "channel": "whatsapp", "action": "add" }
```

```json
{ "add": ["whatsapp", "instagram"], "remove": ["sms"] }
```

| Field | Description |
|---|---|
| `channel` | One channel to toggle. Pair with `action`. |
| `action` | `"add"` or `"remove"`. Pair with `channel`. |
| `add` | Array of channels to add. Batch form — use instead of `channel`/`action`. |
| `remove` | Array of channels to remove. Batch form. |

Valid channels: `whatsapp`, `whatsapp_web`, `sms`, `instagram`, `messenger`, `facebook`, `chat_widget`, `custom_channel`, `imessage`, `telegram`, `instagram_private`, `line`, `viber`, `tiktok`, `email`, `linkedin`, `skool`.

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/channels?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "whatsapp", "action": "add" }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "added": ["whatsapp"],
  "removed": []
}
```

> This only changes which channels the campaign advertises — it doesn't decide who answers a channel. See [Campaign types](#campaign-types) above and [Route a campaign to incoming channels](#route-a-campaign-to-incoming-channels) below for that.

---

## Comment-to-DM (Instagram and Facebook)

Comment-to-DM turns a comment on one of your posts into a private conversation: someone comments, the bot sends them a DM, and the campaign takes the conversation from there. It is configured entirely through the campaign object, so there is nothing UI-only about it.

Connect the Facebook Page first — see [Channel Connection](channels.md#instagram--messenger-meta). Then set the fields below with [`PUT /campaigns/{campaignId}`](#update-a-campaign).

> **The campaign must be `Live`.** Comment monitoring only picks up campaigns whose `status` is `Live` (any casing — see [Campaign types](#campaign-types)). Any other status silently disables it, and an invented one like `"Active"` is now rejected with a `400` rather than stored. Valid statuses include `Draft`, `Pending Approval`, `Scheduled`, `Live`, `Paused`, `Completed`, `Sent` and `Failed`.

**Fields**

| Field | Type | Description |
|---|---|---|
| `monitor_instagram_posts` | boolean | Watch every Instagram post on the connected page. |
| `instagram_post_ids` | string[] | Watch only these Instagram posts. Leave unset when `monitor_instagram_posts` is on. |
| `instagram_comment_delay_minutes` | number | Wait this many minutes after a comment before sending the DM. |
| `monitor_facebook_posts` | boolean | Watch every Facebook post on the connected page. |
| `facebook_post_ids` | string[] | Watch only these Facebook posts. |
| `facebook_comment_delay_minutes` | number | Delay before the DM, in minutes. |
| `public_comment_reply_instructions` | string | Guidance for the visible reply left on the comment itself. Overrides the default "check your DMs" wording. |
| `first_response_mode` | string | `"ai"` (default) generates the first DM and the public reply. `"exact_text"` sends your wording verbatim, with no AI generation and no credit charge. |
| `first_response_exact_text` | string | The verbatim first DM, used when `first_response_mode` is `"exact_text"`. Required for that mode to take effect. |
| `first_response_exact_text_variants` | string[] | Extra wordings for the first DM. One is picked at random per send, so repeated DMs are not byte-identical. |
| `public_comment_reply_exact_text` | string | The verbatim public reply in `"exact_text"` mode. Leave blank to skip the public reply and send only the DM. |
| `public_comment_reply_exact_text_variants` | string[] | Extra wordings for the public reply. |
| `monitor_instagram_followers` | boolean | Treat a new follower as a trigger and send an opening DM (Instagram personal accounts). |
| `follower_outreach_instructions` | string | Guidance for that new-follower opening DM. |
| `respond_to_instagram_story_replies` | boolean | Whether the AI answers replies to your Instagram Stories. Default `true`. Set `false` to have Story replies land in the chat (with the Story attached) without an AI reply. Live setting — not part of the draft, so it does not need publishing. |

**Clearing a field**

These fields are removed rather than set to `null` when you send `null`, so the bot falls back to its defaults: `instagram_post_ids`, `facebook_post_ids`, `instagram_comment_delay_minutes`, `facebook_comment_delay_minutes`, `public_comment_reply_instructions`, `follower_outreach_instructions`, `first_response_exact_text`, `first_response_exact_text_variants`, `public_comment_reply_exact_text`, `public_comment_reply_exact_text_variants`.

> **One unknown key rejects the whole request.** `PUT /campaigns/{campaignId}` validates the entire body against an allow-list. A key that isn't recognised returns `400` for the request as a whole — it is not silently ignored, and none of the other fields in that body are written.

**cURL**

```bash
curl -X PUT "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "Live",
    "monitor_instagram_posts": true,
    "instagram_comment_delay_minutes": 2,
    "first_response_mode": "exact_text",
    "first_response_exact_text": "Hey! Sending the details over now.",
    "first_response_exact_text_variants": [
      "Hi there, here are the details you asked for.",
      "Thanks for commenting, here is what you need."
    ],
    "public_comment_reply_exact_text": "Just sent you a DM."
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      status: "Live",
      monitor_instagram_posts: true,
      instagram_comment_delay_minutes: 2,
      first_response_mode: "ai",
      public_comment_reply_instructions:
        "Tell them to check their message requests folder too.",
    }),
  }
);
const data = await res.json();
```

**Python**

```python
import requests

res = requests.put(
    "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "status": "Live",
        "monitor_facebook_posts": True,
        "facebook_post_ids": None,
        "facebook_comment_delay_minutes": 5,
    },
)
data = res.json()
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

> The visible reply left on the comment requires the comment-reply feature on your plan. Without it the DM still sends and the public reply is skipped.

---

## Optimize a campaign with AI

`POST /campaigns/{campaignId}/optimize`

Runs the same AI rewrite as the dashboard's Optimize and thumbs-down feedback flows: takes your feedback, rewrites the bot's instructions, and stages the result as a new draft revision for you to review.

| Field | Required | Description |
|---|---|---|
| `user_feedback` | One of these two is required | Freeform feedback describing what to improve. |
| `thumbs_down_feedback` | One of these two is required | Feedback captured from a thumbs-down on a specific bot reply. |
| `thumbs_down_message` | No | The bot message the thumbs-down feedback refers to. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/optimize?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_feedback": "Make the tone more casual and mention the free trial earlier." }'
```

**Response** (`202` — the rewrite runs in the background)

```json
{ "success": true, "campaign_id": "NBCXrhqGPSFsd6MV7pRo" }
```

Poll [`GET /campaigns/{campaignId}`](#get-a-campaign) and watch `test_bot.status`: it flips to `"Optimizing"` right away, then back to `"Draft"` once the rewrite lands in `test_bot`. From there it behaves like any dashboard draft — review it, then publish it in the dashboard to make it live. A `409` means an optimization is already running for this campaign.

> Optimizing costs credits, the same as any other AI operation on your account.

---

## Assign a contact to a campaign

`POST /campaigns/{campaignId}/contacts/{contactId}/assign`

Puts an existing contact into a campaign and, if you ask for it, sends the campaign's opening message right away. This is the way to send a campaign's approved WhatsApp template to one contact: the template that a campaign was approved with belongs to that campaign, so it does not appear in the [Templates API](templates.md) library and cannot be sent through `/whatsapp-templates/send`.

| Field | Required | Description |
|---|---|---|
| `sendOpeningMessage` | No | `true` sends the campaign's opening message (the approved WhatsApp template on a WhatsApp campaign) as soon as the contact is assigned. Defaults to `false`. |
| `triggerAIResponse` | No | `true` lets the AI write its own first message instead. Defaults to `false`. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/contacts/contact_abc123/assign?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "sendOpeningMessage": true }'
```

**Response**

```json
{
  "success": true,
  "data": { "contactId": "contact_abc123", "campaignId": "NBCXrhqGPSFsd6MV7pRo" }
}
```

> **Credits:** Sending the opening message on a WhatsApp campaign is charged like any template send, priced by the recipient's country and the template's category. On other channels the opening message is a normal outgoing message.

---

## Route a campaign to incoming channels

These endpoints manage which campaign answers new, unknown contacts on a channel. **Prefer Entry Points** for new integrations (see the note under [Campaign types](#campaign-types)) — these stay useful for working with campaigns that route the older way, and for resolving a channel-ownership conflict between two incoming campaigns.

### Assign a campaign to incoming channels

`POST /campaigns/{campaignId}/incoming-routing`

| Field | Required | Description |
|---|---|---|
| `channels` | Yes | Array of channels this campaign should answer for new, unknown contacts. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/incoming-routing?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channels": ["whatsapp", "instagram"] }'
```

**Response**

```json
{
  "success": true,
  "uid": "abc123",
  "campaignId": "NBCXrhqGPSFsd6MV7pRo",
  "channels": ["whatsapp", "instagram"],
  "failed": []
}
```

`channels` lists only the channels that were actually routed to this campaign; `failed` lists any that weren't. If every requested channel fails, the request itself fails.

### Clear a campaign's incoming routing

`DELETE /campaigns/{campaignId}/incoming-routing`

| Field | Required | Description |
|---|---|---|
| `channelToUnassign` | No | Clear routing for just this one channel. Omit to clear every channel this campaign currently answers. |

```bash
curl -X DELETE "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/incoming-routing?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channelToUnassign": "instagram" }'
```

**Response**

```json
{
  "success": true,
  "uid": "abc123",
  "campaignId": "NBCXrhqGPSFsd6MV7pRo",
  "channelsRemoved": ["instagram"]
}
```

### Reactivate a dormant campaign

`POST /campaigns/{campaignId}/reactivate`

Brings a campaign back from `Ended`, `Completed`, `Paused`, or `Draft` and re-claims its channels. Only works on `Incoming from Unknown Contacts` or `Combined` campaigns — a campaign that's already `Live` is treated as success with nothing to do.

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/reactivate?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "data": {
    "success": true,
    "channelsReactivated": ["whatsapp"],
    "channelsBlockedByConflict": [],
    "campaignType": "Incoming from Unknown Contacts"
  }
}
```

A channel already claimed by a different campaign's agent shows up in `channelsBlockedByConflict` rather than failing the whole call — use [stop a conflicting incoming campaign](#stop-a-conflicting-incoming-campaign) below to free it up first if you want this campaign to take it over. A `400` is returned for a campaign type that doesn't support reactivation, or a status that isn't one of the dormant ones above.

### Stop a conflicting incoming campaign

`POST /campaigns/{campaignId}/stop-incoming`

Frees this campaign's channels from whichever OTHER campaign currently holds them, so this campaign can claim them next. This is the REST version of what the dashboard does automatically when you launch an incoming campaign into a channel someone else is already answering.

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/stop-incoming?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "ended_campaign_ids": [],
  "released_channels": ["whatsapp"],
  "cleared_entire_field": false
}
```

`released_channels` comes back empty when this campaign already owns every channel it advertises — there's nothing to take over.

---

## Cost estimates

Estimate what launching a campaign will cost before you send it.

### WhatsApp template cost estimate

`GET /campaigns/{campaignId}/template-cost-estimate`

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/template-cost-estimate?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "billing_mode": "credits",
  "data": {
    "countries": [
      {
        "countryCode": "1",
        "name": "United States",
        "iso": "US",
        "flag": "🇺🇸",
        "contactCount": 120,
        "costPerContact": 2,
        "subtotal": 240
      }
    ],
    "totalContacts": 120,
    "totalTemplateCost": 240,
    "templateCategory": "marketing",
    "billing_mode": "credits",
    "service_messages_billable_soon": false
  }
}
```

`billing_mode` is `"credits"` on the managed WhatsApp lane. On a lane where Meta bills your own WhatsApp Business Account directly, `costPerContact`, `subtotal`, and `totalTemplateCost` come back `null` — never `0`, which would read as free — since there's no credit figure to report.

### SMS cost estimate

`GET /campaigns/{campaignId}/sms-cost-estimate`

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/sms-cost-estimate?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "billing_mode": "twilio_direct",
  "data": {
    "totalContacts": 120,
    "messageLength": 87,
    "segmentsPerMessage": 1,
    "totalSegments": 120,
    "estimatedCostUsd": 0.96,
    "priceUnit": "USD per segment",
    "billedByTwilio": true
  }
}
```

SMS is always sent through your own Twilio account (see [SMS provider](../settings/sms-provider.md)), so this is always billed by Twilio directly — `estimatedCostUsd` is an estimate of that Twilio bill, not a credit charge.

---

## Limit checks

Check a limit before you launch, instead of finding out from a failed send.

### Campaign-scoped checks

`GET /campaigns/{campaignId}/limits/ai-credit-messaging` — whether launching or scheduling this campaign would exceed your account's AI-credit messaging limit.

`GET /campaigns/{campaignId}/limits/messaging` — whether it would exceed your account's daily messaging limit.

```bash
curl "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/limits/messaging?apiKey=YOUR_API_KEY"
```

**Response** (limit not exceeded)

```json
{
  "success": true,
  "data": "Campaign is within the daily messaging limit."
}
```

A `400` is returned instead when the limit would be exceeded, with the reason in `error`.

### Account-scoped checks

`GET /campaigns/limits/campaigns` — whether you've hit your subscription's monthly campaign-creation limit.

`GET /campaigns/limits/contacts` — whether you've hit your subscription's contact limit.

```bash
curl "https://api.youraiconnector.com/v1/campaigns/limits/campaigns?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "data": "You can create 3 more campaigns this month."
}
```

---

## Campaign stat totals

`GET /campaigns/stats/totals`

Sent and replied totals for every campaign AND every AI agent on your account, over a trailing window — the same numbers the campaign list page shows next to each row, in one call instead of one request per campaign.

| Query parameter | Description |
|---|---|
| `days` | Size of the trailing window, 1-365. Defaults to 90. |

```bash
curl "https://api.youraiconnector.com/v1/campaigns/stats/totals?days=30&apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "byCampaign": {
    "NBCXrhqGPSFsd6MV7pRo": { "sent": 1204, "replied": 318 }
  },
  "byAgent": {
    "agent_abc123": { "sent": 1204, "replied": 318 }
  },
  "windowDays": 30
}
```

`byAgent` is its own roll-up, not a sum of `byCampaign` — an AI-Agent-native account's traffic can carry no campaign at all, so it would otherwise be invisible here.

---

## Test a campaign in the playground

The playground lets you hold a conversation with a campaign's bot without touching a real channel or a real contact. It is the same sandbox as the dashboard's try-out panel, and it is fully available over the API.

The flow is: create a hidden test contact, send a message, then poll the campaign for the bot's reply. Replies are generated asynchronously, so they arrive in `test_messages` on the campaign rather than in the response body.

> **Playground runs over the API cost credits.** A test conversation started with an API key is charged at the normal AI-message rate, the same as a real reply, and appears in your usage history as a regular entry. Testing from the dashboard stays free. The difference is deliberate: a test run does the same AI work as a live one, so an unmetered API playground would be a way to run unlimited AI on someone else's tab.

### Step 1 - Create the test contact

`POST /campaigns/{campaignId}/try-out/contact`

Creates the hidden test contact and links it to the campaign. All body fields are optional; anything you leave out falls back to a built-in sample identity (John Doe).

| Field | Required | Description |
|---|---|---|
| `first_name` | No | Test contact's first name. |
| `last_name` | No | Test contact's last name. |
| `email` | No | Test contact's email. |
| `phone` | No | Test contact's phone number. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/try-out/contact?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "first_name": "Maria", "last_name": "Lopez" }'
```

**Response**

```json
{
  "success": true,
  "contactId": "8kQx1vNbA2fLpR7d"
}
```

### Step 2 - Record the incoming message

`POST /campaigns/{campaignId}/try-out/messages`

Appends messages to the test thread. Send the visitor's message here first, so it appears in the conversation history the bot reads.

| Field | Required | Description |
|---|---|---|
| `messages` | Yes | Array of message objects, max 200 per request. |
| `messages[].body` | Yes | The message text. |
| `messages[].direction` | Yes | `"inbound"` for the visitor, `"outbound"` for the bot. |
| `messages[].timestamp` | No | ISO-8601 string or epoch milliseconds. |
| `messages[].role` | No | Optional role label. |
| `messages[].name` | No | Optional display name. |
| `ignoreCounter` | No | Integer. Resets the campaign's ignore counter in the same write. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/try-out/messages?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {
        "body": "Do you ship to Belgium?",
        "direction": "inbound",
        "timestamp": "2026-07-22T09:30:00Z"
      }
    ]
  }'
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo",
  "appended": 1
}
```

### Step 3 - Ask the bot to reply

`POST /campaigns/{campaignId}/try-out/test-message`

Dispatches the message to the AI pipeline. This is the call that actually produces a bot response.

| Field | Required | Description |
|---|---|---|
| `message` | Yes | The visitor's latest message text. |

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/try-out/test-message?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Do you ship to Belgium?" }'
```

**Response**

```json
{
  "success": true,
  "data": "Published"
}
```

`"Published"` means the message went to the AI pipeline. `"Ignored"` means a newer test message superseded this one — the playground folds a rapid burst into a single reply, roughly four seconds after the last message, the same way a real conversation waits for someone to finish typing. Because of that fold window, this call takes a few seconds to return.

### Step 4 - Read the reply

`GET /campaigns/{campaignId}`

The bot's reply is appended to the campaign's `test_messages` array. Poll the campaign until a new `outbound` entry appears.

```json
{
  "success": true,
  "campaign": {
    "id": "NBCXrhqGPSFsd6MV7pRo",
    "test_messages": [
      { "body": "Do you ship to Belgium?", "direction": "inbound" },
      { "body": "Yes, we ship across the EU.", "direction": "outbound" }
    ]
  }
}
```

### Reset the playground

`POST /campaigns/{campaignId}/try-out/reset`

Clears the whole sandbox: deletes the test contact, wipes `test_messages`, and releases the bot's response locks. Use this between test runs.

```bash
curl -X POST "https://api.youraiconnector.com/v1/campaigns/NBCXrhqGPSFsd6MV7pRo/try-out/reset?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "campaign_id": "NBCXrhqGPSFsd6MV7pRo"
}
```

### Other playground endpoints

| Endpoint | What it does |
|---|---|
| `DELETE /campaigns/{campaignId}/try-out/contact` | Deletes just the current test contact and unlinks it, leaving `test_messages` intact. Succeeds even when no contact is linked. |
| `POST /campaigns/{campaignId}/try-out/transfer` | Starts a fresh playground seeded with an existing conversation, in one request: replaces the test contact and overwrites `test_messages`. Body takes `first_name`, `last_name`, `messages` (may be empty) and `ignoreCounter`. Prefer this over delete-then-create-then-append, which triples your rate-limit spend. |
| `POST /campaigns/{campaignId}/try-out/messages/replace` | Overwrites `test_messages` wholesale instead of appending. Use for truncating or rewinding a thread. |
| `POST /campaigns/{campaignId}/try-out/contact/reset-ignore-counter` | Resets only the test contact's ignore counter, for redo and repeat flows after a send. |

---

## Campaigns API errors

Campaign endpoints return the standard error envelope:

```json
{
  "success": false,
  "error": "Campaign not found"
}
```

| Status | When it happens on a campaign endpoint |
|---|---|
| `400` | A required field is missing or invalid (for example a bad `type`, a non-boolean `enabled`, or an unknown weekday key). Also returned by a [limit check](#limit-checks) endpoint when the limit would be exceeded, and by [reactivate](#reactivate-a-dormant-campaign) for a campaign type or status that doesn't support it. |
| `404` | The campaign was not found — either it doesn't exist or it belongs to another account. |
| `409` | An [optimization](#optimize-a-campaign-with-ai) is already running for this campaign. |

The shared codes every endpoint can return — `401`, `403` (your plan does not include API access), `429` (rate limit) and `500` — are listed with retry guidance in [Errors & Pagination](errors-and-pagination.md).

---

## Related

- [Route a channel to a campaign](channels.md#route-a-channel-to-a-campaign) — point Instagram, WhatsApp, or any other channel at the AI Agent that should answer it, using Entry Points.
- [Generate follow-up templates with AI](templates.md#generate-follow-up-templates-with-ai) — start a background job that writes a campaign's WhatsApp follow-up templates.
- [FAQs API](faqs.md) — manage the question-and-answer entries your campaigns use.
- [API Access](../integrations/api-access.md) — generate your API key.
- [Authentication](authentication.md) — all the ways to pass your key.
