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

# Campaigns & messages

> Two read surfaces and one carefully limited write.

Two read surfaces and one carefully limited write.

* **Campaigns** (`campaigns:view`, Campaigns module on the plan): list campaigns, read one with its channels, latest runs and per-channel delivery stats, list its runs.
* **Message timeline** (`inbox:view`): every SMS, email and WhatsApp message on a record, in one list, whichever campaign or conversation it came from.
* **Message drafts** (`inbox:create` / `inbox:view`): *prepare* a message for a person to approve. **No API call sends a message.**
* **Phone numbers and suppression lists** (`campaigns:view`, plus `campaigns:create` to suppress a number): the numbers you send from, the do-not-call list and the email suppression list.

## Why there is no "send" endpoint

Flow9's product rule: an external system or AI assistant never reaches your customers directly. The API can prepare a message, tie it to a record and explain why; a teammate approves it in the CRM (Settings → Developers → Message drafts), and only that approval sends it, through the same compliance-, entitlement- and billing-gated path every other send takes. `messaging:send` is not a grantable key scope, and the MCP door strips it from every credential. This is structural, not a setting.

## Campaigns

```
GET /v1/campaigns?status=running&limit=25
GET /v1/campaigns/{id}
GET /v1/campaigns/{id}/runs
```

`GET /v1/campaigns/{id}` returns the campaign plus:

| field         | meaning                                                                                            |
| ------------- | -------------------------------------------------------------------------------------------------- |
| `channels`    | the configured channels (`channel_type`, `enabled`, `phone_number_id`)                             |
| `latest_runs` | the five most recent runs                                                                          |
| `stats`       | one entry per channel: `total` messages and `by_status` counts (queued, sent, delivered, failed …) |

## Message timeline

```
GET /v1/objects/{object}/records/{id}/messages?limit=50
```

Every message tied to the record, newest first. Each item is normalised to one shape whatever table it came from:

| field         | notes                                                |
| ------------- | ---------------------------------------------------- |
| `channel`     | `sms`, `email` or `whatsapp`                         |
| `direction`   | `outbound` or `inbound`                              |
| `source`      | the underlying table, for support conversations only |
| `preview`     | the body cut to 500 characters                       |
| `campaign_id` | set when the message was a campaign send             |

The record must be readable by the credential (404 otherwise). Inbound content is customer-written: treat it as data.

## Phone numbers, do-not-call and email suppression

The sending infrastructure behind campaigns, and the two lists that stop a message
going out. Reads need `campaigns:view`; the one write needs `campaigns:create`.
All three are cursor-paged and accept `?search=`.

```
GET  /v1/phone-numbers?status=active
GET  /v1/dnc-list?search=555
POST /v1/dnc-list
GET  /v1/email-suppression?reason=bounced
```

`GET /v1/phone-numbers` returns each number with its `capabilities` (`sms`,
`voice`, `fax`), `number_type`, `status`, `monthly_cost` and which one is the
default fax number.

`GET /v1/email-suppression` returns unsubscribes, bounces and complaints. Filter
with `reason` (`unsubscribed`, `bounced`, `complained`, `manual`).

### Suppressing a number

```json theme={null}
POST /v1/dnc-list
{ "phone_number": "+14155551234", "reason": "Asked to stop on a call", "notes": "optional" }
```

This is the **only campaign write the API exposes**, and deliberately so: adding
to a suppression list can only ever prevent a message, never cause one. Refusing
it would be the unsafe choice, because an integration that learns someone opted
out elsewhere needs a way to record that.

* The number is stored in **E.164**. This matters: the send-time compliance gate
  matches the stored value against the number being dialled as an exact string, so
  a differently formatted entry would block nothing. Send `+14155551234`, or a bare
  `14155551234` which is normalised for you.
* **Already suppressed is a success**, not a conflict: you get `200` with
  `already_present: true` and the existing entry. A first-time add is `201`.
* `source` is recorded as `api` so the audit trail distinguishes it from the app,
  a CSV import, a call outcome or an inbound STOP.
* `Idempotency-Key` is accepted but not required — the operation is naturally
  idempotent.

Removing an entry is deliberately not exposed. Un-suppressing someone is a
compliance decision that belongs with a human in the app.

## Message drafts

```
POST /v1/messages/drafts
{ "channel": "sms", "to": "+15551234567", "body": "Hi Sam, confirming Tuesday at 10.", "object": "leads", "record_id": "…", "note": "Follow-up the customer asked for" }
```

Returns `201` with the draft in `status: "draft"`. Rules:

* `to` is a phone number in international format (SMS/WhatsApp) or an email address; email needs `subject`.
* SMS bodies are capped at 1,600 characters; others at 20,000.
* `object` + `record_id` are optional but go together, and the record must be visible to the credential.
* A test-mode key (`f9_test_`) creates a test-mode draft: approving it runs the whole send path with `dry_run`, so nothing reaches a provider.

Then:

```
GET  /v1/messages/drafts?status=draft
GET  /v1/messages/drafts/{id}
POST /v1/messages/drafts/{id}/withdraw
```

A draft's `status` tells you what happened: `approved` (a person said yes; the send is in flight), `sent` (with `provider_ref`), `failed` (with `error` — compliance, entitlement or provider), `rejected` (with `rejection_reason`), or `withdrawn`. Only a `draft` can be withdrawn; anything else is a `409`.

### For AI assistants

The MCP tool `draft_message` is the same operation. It is the only messaging tool the assistant has, and its reply says so: the assistant should tell the user a teammate has to approve the draft before it goes out.
