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

> ## Agent Instructions
> You are reading OnlyX Help: the OnlyX Help Center and the OnlyX developer documentation. OnlyX is an AI chatting and CRM platform for OnlyFans agencies. Its AI chatter is called Hugo in the app.
> Pages at the site root (for example /inbox/..., /hugo/..., /billing/...) are Help Center articles for agency owners, admins, chatters and creators who use the app at app.onlyx.ai. Words in bold are the exact button, menu and label names the app shows; keep them exactly as written. When these pages do not answer a question, the person can email support at support@onlyx.ai.
> Pages under /developers are the developer documentation. REST API base URL: https://api.onlyx.ai/v1 (authenticate with `Authorization: Bearer <API key>`; keys start with onx_sk_ and are created in app.onlyx.ai under Settings > API & MCP). MCP server: https://mcp.onlyx.ai/mcp (OAuth, or a Bearer API key). In the API the AI chatter is `ai` on the wire. Money is integer US cents in fields ending in Cents; timestamps are UTC ISO-8601.
> Rules for assistants acting on a user's behalf: discover ids with list calls and never invent them; before any call that reaches a real fan or the live OnlyFans account (sending a message, releasing a chat to the AI, turning AI on for a chat, resolving a hand-off, turning review mode off, changing the welcome message, creating a tracking link) show the user the exact content and get explicit confirmation; send every POST with an Idempotency-Key and reuse it on retry; never resend a message whose delivery status is unconfirmed; never ask a creator for her OnlyFans password or codes - she signs in herself through a connect link and the OnlyX Login app.

# Read and send messages

> List and filter conversations, read message history, send text, free vault media and paid messages with an Idempotency-Key, track delivery, and take over or release chats.

This guide covers the inbox end to end: finding conversations, reading messages, sending safely, following delivery, and controlling whether the AI or your team answers a chat.

**Scopes**

| To | You need |
| - | - |
| List and read conversations, messages, counts | `inbox:read` |
| Send messages | `messages:send` |
| Mark read or unread, take over, release, AI on or off per chat | `inbox:write` |

The code samples reuse this setup:

<CodeGroup>
  ```bash cURL theme={"system"}
  export ONLYX_API_KEY="onx_sk_..."
  ```

  ```python Python theme={"system"}
  import os, time, uuid
  import requests

  API = "https://api.onlyx.ai/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['ONLYX_API_KEY']}"}
  ```

  ```javascript JavaScript theme={"system"}
  const API = "https://api.onlyx.ai/v1";
  const headers = { Authorization: `Bearer ${process.env.ONLYX_API_KEY}` };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  ```
</CodeGroup>

## List conversations

`GET /v1/conversations` returns conversations across all creators the key can see, newest activity first.

| Parameter | Values | Default | Meaning |
| - | - | - | - |
| `creatorId` | `cre_...` | all | Only this creator's chats. An id you cannot see returns `404 CREATOR_NOT_FOUND`. |
| `status` | `ai`, `team`, `handoff`, `ai_off` | all | Who is answering the chat. See [Conversations](/developers/concepts/conversations). |
| `unread` | `true`, `false` | all | Only chats with (or without) unread fan messages |
| `q` | text, up to 100 characters | none | Search by fan name (her OnlyFans name or your custom name) |
| `sort` | `recent`, `spend`, `unread`, `name` | `recent` | `spend` puts the biggest spenders first |
| `limit` | 1 to 50 | 25 | Page size |
| `cursor` | from `nextCursor` | none | The next page. See [Pagination](/developers/pagination). |

<CodeGroup>
  ```bash cURL theme={"system"}
  curl "https://api.onlyx.ai/v1/conversations?creatorId=cre_5f2d9a1c7b3e4f60a2d1&unread=true&sort=spend&limit=20" \
    -H "Authorization: Bearer $ONLYX_API_KEY"
  ```

  ```python Python theme={"system"}
  params = {"creatorId": "cre_5f2d9a1c7b3e4f60a2d1", "unread": "true", "sort": "spend", "limit": 20}
  page = requests.get(f"{API}/conversations", headers=HEADERS, params=params, timeout=30).json()
  for c in page["data"]:
      print(c["id"], c["fan"]["displayName"], c["status"], c["unreadCount"], c["totalSpentCents"])
  ```

  ```javascript JavaScript theme={"system"}
  const params = new URLSearchParams({ creatorId: "cre_5f2d9a1c7b3e4f60a2d1", unread: "true", sort: "spend", limit: "20" });
  const page = await (await fetch(`${API}/conversations?${params}`, { headers })).json();
  for (const c of page.data) console.log(c.id, c.fan.displayName, c.status, c.unreadCount, c.totalSpentCents);
  ```
</CodeGroup>

```json Response 200 theme={"system"}
{
  "data": [
    {
      "id": "cnv_9f2c41d7a8b35e06c1f4",
      "creatorId": "cre_5f2d9a1c7b3e4f60a2d1",
      "fan": { "id": "fan_3b7e9d2a1c5f48e06b92", "displayName": "Jake", "username": "jake_91", "avatarUrl": "https://..." },
      "status": "ai",
      "unreadCount": 2,
      "lastMessage": { "text": "are you online rn?", "direction": "in", "at": "2026-09-26T13:58:12.000Z" },
      "lastMessageAt": "2026-09-26T13:58:12.000Z",
      "takenOverUntil": null,
      "totalSpentCents": 48500
    }
  ],
  "hasMore": true,
  "nextCursor": "eyJrIjoiMjAyNi0wOS0yNlQxMzo1ODoxMloifQ"
}
```

For a quick overview use `GET /v1/conversations/counts` (optionally with `creatorId`). It scans the whole inbox, so it has its own limit of 30 calls per 60 seconds per key:

```json Response 200 theme={"system"}
{ "total": 1912, "ai": 1733, "team": 141, "handoff": 3, "aiOff": 35, "unread": 14 }
```

## Get one conversation

`GET /v1/conversations/{conversationId}` returns the conversation plus the full `fan` (subscription, spend, notes, lists, `salesOptOut`), the open `handoff` if any, and `aiCanReply`: whether the AI would answer this chat right now.

Read it before you send: it tells you whether the fan opted out of sales and whether a hand-off is open.

## Read messages

`GET /v1/conversations/{conversationId}/messages` returns one page of messages, **oldest first**. Without `before` you get the newest page. To go further back, pass the id of the **oldest** message you have as `before`; `hasMore: false` means you reached the start of the chat.

| Parameter | Values | Default |
| - | - | - |
| `before` | a message id (`msg_...`) from this conversation; any other id is `404 MESSAGE_NOT_FOUND` | none (newest page) |
| `limit` | 1 to 100 | 25 |

<CodeGroup>
  ```bash cURL theme={"system"}
  # newest page
  curl "https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/messages?limit=50" \
    -H "Authorization: Bearer $ONLYX_API_KEY"

  # the page before it
  curl "https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/messages?limit=50&before=msg_2c8e4a6f1b3d5079e2a4" \
    -H "Authorization: Bearer $ONLYX_API_KEY"
  ```

  ```python Python theme={"system"}
  def full_history(conversation_id):
      """Every message in a conversation, oldest first."""
      messages, before = [], None
      while True:
          params = {"limit": 100, **({"before": before} if before else {})}
          page = requests.get(
              f"{API}/conversations/{conversation_id}/messages",
              headers=HEADERS, params=params, timeout=30,
          ).json()
          messages = page["data"] + messages  # each page is oldest-first
          if not page["hasMore"] or not page["data"]:
              return messages
          before = page["data"][0]["id"]
  ```

  ```javascript JavaScript theme={"system"}
  async function fullHistory(conversationId) {
    let messages = [];
    let before;
    for (;;) {
      const q = new URLSearchParams({ limit: "100", ...(before ? { before } : {}) });
      const page = await (await fetch(`${API}/conversations/${conversationId}/messages?${q}`, { headers })).json();
      messages = [...page.data, ...messages]; // each page is oldest-first
      if (!page.hasMore || page.data.length === 0) return messages;
      before = page.data[0].id;
    }
  }
  ```
</CodeGroup>

```json Response 200 theme={"system"}
{
  "data": [
    {
      "id": "msg_1f3a5c7e9b2d4f60a8c1",
      "conversationId": "cnv_9f2c41d7a8b35e06c1f4",
      "direction": "out",
      "sender": "ai",
      "text": "since you asked so nicely",
      "media": [
        { "id": "4012345678", "type": "photo", "preview": true },
        { "id": "4012345679", "type": "photo", "preview": false }
      ],
      "paid": { "priceCents": 1500, "purchased": true, "purchasedAt": "2026-09-25T22:41:09.000Z" },
      "tipCents": null,
      "createdAt": "2026-09-25T22:30:02.000Z",
      "delivery": { "status": "sent", "reason": null }
    },
    {
      "id": "msg_2c8e4a6f1b3d5079e2a4",
      "conversationId": "cnv_9f2c41d7a8b35e06c1f4",
      "direction": "in",
      "sender": "fan",
      "text": "are you online rn?",
      "media": [],
      "paid": null,
      "tipCents": 500,
      "createdAt": "2026-09-26T13:58:12.000Z",
      "delivery": null
    }
  ],
  "hasMore": true
}
```

| Field | Meaning |
| - | - |
| `direction` | `in` (from the fan) or `out` (to the fan) |
| `sender` | `fan`, `ai` (the AI chatter) or `team` (your people or your integration) |
| `text` | The message text (may be empty for a media-only message) |
| `media[]` | Attached vault media: `id`, `type` (`photo`, `video`, `gif`, `audio`), and `preview` (`true` = the fan sees it free, `false` = locked behind the price) |
| `paid` | For a paid message: `priceCents`, whether the fan `purchased` it, and when. `null` for free messages. |
| `tipCents` | A tip the fan attached, or `null` |
| `delivery` | Outgoing messages only (`null` for incoming): `status` and a human-readable `reason` when it did not go out |

`GET /v1/conversations/{conversationId}/messages/{messageId}` returns a single message. Use it to follow a send.

## Send a message

`POST /v1/conversations/{conversationId}/messages` sends a message to the fan as the creator. It needs the `messages:send` scope and an **`Idempotency-Key` header**, which is required on this endpoint.

<Warning>
  Every send reaches a real fan on OnlyFans and cannot be taken back through the API. If an AI assistant or an automation composes the message, show a person the exact text, media and price, and send only after they confirm.
</Warning>

### The body

| Field | Type | Rules |
| - | - | - |
| `text` | string | Up to 4,000 characters. Optional when media is attached. |
| `previewMediaIds` | string\[] | Up to 20 vault media ids the fan receives **free**, unlocked. On a paid message these are the teaser. |
| `mediaIds` | string\[] | Up to 20 vault media ids **locked behind the price**. Requires a `priceCents` of at least 300. |
| `priceCents` | integer | `0` (free, the default), or `300` to `500000` ($3.00 to $5,000.00). A price above 0 requires `mediaIds`. |

A message needs text, media, or both. Media ids are strings of digits, and the same media id cannot appear twice across `mediaIds` and `previewMediaIds`. Any other body field is refused with `400 VALIDATION_ERROR`. Media ids come from [the creator's vault](/developers/guides/vault-media) (`GET /v1/creators/{creatorId}/media`).

### The Idempotency-Key

* Use a new key for every new message: a UUID is ideal (8 to 64 characters, letters, digits, `-`, `_`).
* If the request fails with a network error or a `5xx`, retry with the **same** key. OnlyX returns the original result instead of sending twice; the replayed response carries `Idempotent-Replayed: true`.
* Reusing a key with a different body returns `409 IDEMPOTENCY_KEY_REUSED`. More in [Idempotency](/developers/idempotency).

### Send text

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/messages \
    -H "Authorization: Bearer $ONLYX_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 3c5e7a9b-1d2f-4a6c-8e0b-2d4f6a8c0e1b" \
    -d '{"text": "haha you are trouble. how was your day?"}'
  ```

  ```python Python theme={"system"}
  r = requests.post(
      f"{API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages",
      headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
      json={"text": "haha you are trouble. how was your day?"},
      timeout=30,
  )
  r.raise_for_status()
  message = r.json()  # 202, delivery.status == "queued"
  ```

  ```javascript JavaScript theme={"system"}
  const res = await fetch(`${API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ text: "haha you are trouble. how was your day?" }),
  });
  const message = await res.json(); // 202, delivery.status === "queued"
  ```
</CodeGroup>

### Send free vault media

Put the media in `previewMediaIds` and leave out the price. The fan receives it unlocked.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/messages \
    -H "Authorization: Bearer $ONLYX_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 8a0c2e4f-6b8d-4f1a-9c3e-5b7d9f1a3c5e" \
    -d '{"text": "a little good morning for you", "previewMediaIds": ["4012345680"]}'
  ```

  ```python Python theme={"system"}
  r = requests.post(
      f"{API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages",
      headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
      json={"text": "a little good morning for you", "previewMediaIds": ["4012345680"]},
      timeout=30,
  )
  ```

  ```javascript JavaScript theme={"system"}
  await fetch(`${API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ text: "a little good morning for you", previewMediaIds: ["4012345680"] }),
  });
  ```
</CodeGroup>

### Send a paid message

A paid message (PPV) has locked `mediaIds`, a `priceCents`, optional free `previewMediaIds` to tease, and optional `text` shown with it.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/messages \
    -H "Authorization: Bearer $ONLYX_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: e1f3a5c7-9b0d-4e2f-8a4c-6e8a0c2e4f6a" \
    -d '{
          "text": "the red lace set you asked about... 8 pics",
          "previewMediaIds": ["4012345678"],
          "mediaIds": ["4012345679", "4012345681", "4012345682"],
          "priceCents": 1500
        }'
  ```

  ```python Python theme={"system"}
  r = requests.post(
      f"{API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages",
      headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
      json={
          "text": "the red lace set you asked about... 8 pics",
          "previewMediaIds": ["4012345678"],
          "mediaIds": ["4012345679", "4012345681", "4012345682"],
          "priceCents": 1500,  # $15.00
      },
      timeout=30,
  )
  if r.status_code == 409 and r.json()["error"]["code"] == "SALES_OPTED_OUT":
      print("This fan asked not to be sold to. Send text only.")
  ```

  ```javascript JavaScript theme={"system"}
  const res = await fetch(`${API}/conversations/cnv_9f2c41d7a8b35e06c1f4/messages`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({
      text: "the red lace set you asked about... 8 pics",
      previewMediaIds: ["4012345678"],
      mediaIds: ["4012345679", "4012345681", "4012345682"],
      priceCents: 1500, // $15.00
    }),
  });
  ```
</CodeGroup>

```json Response 202 theme={"system"}
{
  "id": "msg_7a1d3f9c2e5b40d8a6c3",
  "conversationId": "cnv_9f2c41d7a8b35e06c1f4",
  "direction": "out",
  "sender": "team",
  "text": "the red lace set you asked about... 8 pics",
  "media": [
    { "id": "4012345678", "type": "photo", "preview": true },
    { "id": "4012345679", "type": "photo", "preview": false },
    { "id": "4012345681", "type": "photo", "preview": false },
    { "id": "4012345682", "type": "photo", "preview": false }
  ],
  "paid": { "priceCents": 1500, "purchased": false, "purchasedAt": null },
  "tipCents": null,
  "createdAt": "2026-09-26T14:02:31.000Z",
  "delivery": { "status": "queued", "reason": null }
}
```

### What sending also does

* **The chat moves to `team` for 12 hours** (if it was `ai` or `handoff`), so the AI does not talk over you. `takenOverUntil` shows when it returns to the AI.
* **The AI's pending follow-ups in that chat are cancelled**, and a draft reply the AI had waiting for review in that chat is withdrawn.
* **Unread state is not changed.** Call `POST /v1/conversations/{conversationId}/read` if you want `unreadCount` back to 0.

## Track delivery

`202` means OnlyX accepted and queued the message. OnlyX then sends it on OnlyFans at a natural pace, so `queued` can last from a few seconds to several minutes. Follow it with `GET /v1/conversations/{conversationId}/messages/{messageId}`.

```mermaid theme={"system"}
stateDiagram-v2
  [*] --> queued: 202 Accepted
  queued --> sent: OnlyFans confirmed it
  queued --> failed: refused or could not be sent
  queued --> unconfirmed: sent, but no receipt came back
  queued --> not_sent: sending is off, or withdrawn
```

| `delivery.status` | Meaning | What to do |
| - | - | - |
| `queued` | Waiting to be sent | Keep polling every 5 to 10 seconds |
| `sent` | OnlyFans confirmed it. The fan has it. | Done |
| `failed` | It did not go out. `reason` says why, in plain words (for example the fan unsubscribed). | Fix the cause. To try again, send a **new** message with a **new** `Idempotency-Key`. |
| `unconfirmed` | It was sent, but OnlyFans gave no receipt. **The fan usually received it.** | **Never resend automatically.** Check the chat in OnlyFans or with a person before sending anything similar. |
| `not_sent` | Sending is turned off for this workspace, or the message was withdrawn before it went out. | Nothing reached the fan. Ask a workspace admin if sending should be on. |

<CodeGroup>
  ```python Python theme={"system"}
  def wait_for_delivery(conversation_id, message_id, timeout_s=900):
      deadline = time.time() + timeout_s
      while time.time() < deadline:
          m = requests.get(
              f"{API}/conversations/{conversation_id}/messages/{message_id}",
              headers=HEADERS, timeout=30,
          ).json()
          status = m["delivery"]["status"]
          if status != "queued":
              return status, m["delivery"]["reason"]
          time.sleep(10)
      return "queued", "still queued; check again later"

  status, reason = wait_for_delivery(message["conversationId"], message["id"])
  if status == "unconfirmed":
      print("Probably delivered. Do NOT resend.")
  elif status == "failed":
      print("Not delivered:", reason)
  ```

  ```javascript JavaScript theme={"system"}
  async function waitForDelivery(conversationId, messageId, timeoutMs = 900_000) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const m = await (await fetch(`${API}/conversations/${conversationId}/messages/${messageId}`, { headers })).json();
      if (m.delivery.status !== "queued") return m.delivery;
      await sleep(10_000);
    }
    return { status: "queued", reason: "still queued; check again later" };
  }

  const delivery = await waitForDelivery(message.conversationId, message.id);
  if (delivery.status === "unconfirmed") console.log("Probably delivered. Do NOT resend.");
  if (delivery.status === "failed") console.log("Not delivered:", delivery.reason);
  ```
</CodeGroup>

## Send errors

| Status | Code | Meaning | What to do |
| - | - | - | - |
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | No `Idempotency-Key` header | Add one |
| 400 | `VALIDATION_ERROR` | The body breaks a rule (no text or media, a price without `mediaIds`, `mediaIds` without a price, a price outside 300 to 500000, a duplicate id, an unknown field) | Fix the body |
| 400 | `SEND_REFUSED` | OnlyFans or OnlyX refused this send; `message` explains, for example the fan is no longer subscribed or the price is outside what OnlyFans allows for this account | Do not retry the same send. Change it or skip this fan. |
| 404 | `CONVERSATION_NOT_FOUND` | The conversation does not exist or the key cannot see it | Check the id and the key's creator restriction |
| 409 | `SALES_OPTED_OUT` | The fan asked not to be sold to, and the message has a price | Send without a price, or not at all |
| 409 | `CREATOR_NOT_CONNECTED` | The creator's OnlyFans account is not connected | Reconnect her. See [Add a creator](/developers/guides/add-a-creator). |
| 409 | `IDEMPOTENCY_KEY_REUSED` | The key was used before with a different body | Use a new key for a new message |
| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running | Wait a second, then retry with the same key |
| 429 | `RATE_LIMITED` | More than 30 sends per minute for this key, or 300 per hour for this creator | Wait `Retry-After` seconds |
| 503 | `SEND_UNAVAILABLE` | Sending is briefly unavailable for this creator; nothing was sent | Retry later with the same key |
| 503 | `SENDING_DISABLED` | Sending through the API is switched off right now | Try again later; nothing was sent |

Every error body has the same shape, for example `{"error": {"code": "SALES_OPTED_OUT", "message": "…", "requestId": "req-3f9a1c2b7d4e5f60a1b2c3d4"}}`. See [Errors](/developers/errors).

Media sends also fail while the creator's `contentConsentRequired` is `true` (OnlyFans is waiting for her to accept its consent prompt). Text still works.

## Mark read and unread

`POST /v1/conversations/{conversationId}/read` sets `unreadCount` to 0; `/unread` flags the chat as unread again so your team comes back to it. Both take no body and return the updated Conversation. They change OnlyX only: OnlyFans shows the fan no read receipt.

These state changes (read, unread, take over, release, AI on or off) accept an optional `Idempotency-Key` header, which makes a retry return the stored response instead of acting twice.

```bash cURL theme={"system"}
curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/read \
  -H "Authorization: Bearer $ONLYX_API_KEY"
```

## Take over and release

**Take over** a chat before your team starts typing, so the AI does not answer in the meantime:

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/takeover \
    -H "Authorization: Bearer $ONLYX_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"holdMinutes": 120}'
  ```

  ```python Python theme={"system"}
  conv = requests.post(
      f"{API}/conversations/cnv_9f2c41d7a8b35e06c1f4/takeover",
      headers=HEADERS, json={"holdMinutes": 120}, timeout=30,
  ).json()
  print(conv["status"], conv["takenOverUntil"])  # team, two hours from now
  ```

  ```javascript JavaScript theme={"system"}
  const conv = await (await fetch(`${API}/conversations/cnv_9f2c41d7a8b35e06c1f4/takeover`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ holdMinutes: 120 }),
  })).json();
  console.log(conv.status, conv.takenOverUntil); // team, two hours from now
  ```
</CodeGroup>

`holdMinutes` is 15 to 1440 (default 720, twelve hours); the body is optional. The chat's status becomes `team`, and the AI withdraws any reply it had waiting in it. Taking over a chat your team already holds restarts the hold. When the hold ends (`takenOverUntil`), the chat returns to the AI on its own. Nothing is sent to the fan.

**Release** gives the chat back to the AI now:

```bash cURL theme={"system"}
curl -X POST https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/release \
  -H "Authorization: Bearer $ONLYX_API_KEY"
```

<Warning>
  If the fan's last message is unanswered, **the AI may reply to it immediately after a release**. Confirm before releasing on someone's behalf. If the creator's AI is off, the chat stays with your team.
</Warning>

Because a release can reach the fan, it is refused with `503 SENDING_DISABLED` while sending through the API is switched off.

## Turn the AI on or off for one chat

`PUT /v1/conversations/{conversationId}/ai` with `{"enabled": false}` sets the chat to `ai_off`: the AI never answers it until someone turns it back on. `{"enabled": true}` gives it back to the AI, which may answer the waiting message right away, so confirm with a person first. The creator's own AI switch still applies on top of this. Switching it on is refused with `503 SENDING_DISABLED` while sending through the API is switched off.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X PUT https://api.onlyx.ai/v1/conversations/cnv_9f2c41d7a8b35e06c1f4/ai \
    -H "Authorization: Bearer $ONLYX_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"enabled": false}'
  ```

  ```python Python theme={"system"}
  conv = requests.put(
      f"{API}/conversations/cnv_9f2c41d7a8b35e06c1f4/ai",
      headers=HEADERS, json={"enabled": False}, timeout=30,
  ).json()
  assert conv["status"] == "ai_off"
  ```

  ```javascript JavaScript theme={"system"}
  const conv = await (await fetch(`${API}/conversations/cnv_9f2c41d7a8b35e06c1f4/ai`, {
    method: "PUT",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ enabled: false }),
  })).json();
  ```
</CodeGroup>

## Fans who opted out of sales

When a fan asks not to be sold to, OnlyX records it and the fan's `salesOptOut` becomes `true`. From then on:

* the AI stops offering paid content to that fan;
* any API send with a `priceCents` above 0 returns `409 SALES_OPTED_OUT`;
* free messages (text and free media) still work.

Check `fan.salesOptOut` in `GET /v1/conversations/{conversationId}` before composing a paid message.

## A safe sending pattern

For automations and AI assistants, follow this order every time:

1. Read the conversation (`GET /v1/conversations/{conversationId}`) and its latest messages.
2. Check `fan.salesOptOut` before adding a price, and the creator's `contentConsentRequired` before adding media.
3. Show a person the exact text, media and price. Send only on an explicit yes.
4. Send with a fresh `Idempotency-Key`, and keep the key with the draft until you get a response.
5. On a timeout or `5xx`, retry with the **same** key. Never with a new one.
6. Poll delivery. On `unconfirmed`, stop: do not resend.

## Related

* [Conversations](/developers/concepts/conversations): statuses and hand-back rules.
* [Vault media](/developers/guides/vault-media): find media ids and thumbnails.
* [Hand-offs](/developers/guides/handoffs): chats the AI passed to your team.
* [Idempotency](/developers/idempotency) and [Rate limits](/developers/rate-limits).
