# Set who a conversation is private to **PATCH /threads/{thread}** Makes a thread a **private room**, moves it between rooms, or opens it back up. Needs `content: write`, and `audience: read` as well whenever the body names a segment, and is a write. `audience_tag_ids` replaces whatever the thread was private to. Each id has to be a live tag of this newsletter with `is_room` set; anything else is refused `422` naming the ids that are not rooms, and nothing is written. `null` or `[]` clears the audience. **Why two permissions.** The thread is `content` and the segments are `audience`, and this call is both acts at once: it writes the thread's row and it reads a segment to name it. The contract prices "which segments does this newsletter keep" at `audience: read` wherever a credential can ask (`GET /newsletters/{newsletter}/tags`, `GET /tags/{tag}`), so a credential that cannot list the rooms has no legitimate way to hold a room's id, and a request that named one anyway would be told whether its guess was a real segment. Refusing on the family instead answers the guess with `403` and names what is missing. `read` rather than `write`, because this moves nobody between segments and creates no tag: it reads one and writes a thread. `audience: read` is not in the OAuth scopes below for the reason `?expand=subscriber` on `GET /newsletters/{newsletter}/insights` is not in that operation's either: the scopes say what the operation always spends, an authorization request built from a wider list would ask a person to grant access most callers of this never use, and clearing an audience spends nothing beyond `content: write`. A credential that sends segments without it is refused `403` `insufficient_scope` with `param` set to `audience_tag_ids`, and nothing is written. **Widening asks first.** Clearing the audience, or adding a segment that was not already there, lets more people read every reply that was written while the room was smaller. That is refused `422` with `param` set to `confirm_widening` the first time, and applies when you retry with `confirm_widening: true`. Narrowing (scoping an open thread, or dropping one segment of several) applies straight away and needs nothing. Commune's own chat puts a confirmation dialog on the same rule and from the same code, so the two cannot disagree about which direction a change moves. **Scoping a `public` thread pulls it off the global feed** in the same write, because a private conversation on a public feed is the one combination this feature exists to make impossible. The response carries the thread with `visibility` already `subscribers`. The opposite order is refused instead: `POST /threads/{thread}/publish` answers `422` for a thread that has an audience, because there the caller asked for the unsafe direction outright. Replies have no audience of their own. A reply is exactly as readable as the conversation it is in, so an id that names one answers `422` rather than writing a column no reader consults. Publishes no event. There is no topic for a conversation changing hands between rooms, and `thread.created` already reports whether a thread was born private; adding one would change the delivery service's own topic list, which is a deployment rather than a code change. ## Servers - Production. There is no separate sandbox host. : https://api.usecommune.com (Production. There is no separate sandbox host. ) ## Authentication methods - Api key - Oauth2 ## Parameters ### Headers - **Idempotency-Key** (string) A value of your choosing naming the change this request is making. Send the same value again to retry the same request. Commune replays the answer the first attempt gave instead of making the change twice, and marks the replay with an `Idempotent-Replay: true` response header. Send a different value for a different change: a key reused for a request that differs in any way answers `409`, because replaying an answer to a question you did not ask is a wrong answer you could not detect. A UUID per change is the usual choice. Remembered for 24 hours, per credential, so two credentials choosing the same value never see each other's answers. Required, not optional. - **Commune-Version** (string) The contract version this request is written against. Every version published so far is a release date (`YYYY-MM-DD`), which is why the examples look like one, but the value is an opaque identifier: match it against the versions this API publishes rather than parsing it, because a future one may not be only a date. An unknown value answers `400` with `invalid_version`. Omitting the header pins the request to the version that was current when the API key was issued, so an integration keeps working when a newer version ships. ### Path parameters - **thread** (string) The thread's `id` (a UUID) or its `short_id`. A thread that Commune opened under an article has no `short_id`, because it is addressed on the web through the article's own permalink, so use its `id`. ### Body: application/json (object) - **audience_tag_ids** (array[string(uuid)] | null) - **confirm_widening** (boolean) Say `true` when the change lets **more** people read the conversation than could before: clearing the audience, or adding a segment that was not already there. Without it such a change is refused `422` with `param` set to `confirm_widening`, and nothing is written. Narrowing needs nothing. Scoping an open thread to a room, or dropping a segment from a thread that had two, applies straight away: removing a reader is the safe direction and the one somebody reaching for privacy takes. The reason the widening is not simply allowed is that the replies already in the thread were written for the audience the thread had when they were written, and clearing it publishes all of them at once to a wider room. Commune's own chat raises a confirmation dialog on the same rule, from the same code. Only the literal `true` counts: the one flag here that must not be easy to send by accident. ## Responses ### 200 The thread, as it now stands. `audience_scoped` and `audience_tag_ids` carry the audience that was just set, and `visibility` is `subscribers` if scoping the thread pulled it off the feed. #### Body: application/json (object | null) - **object** (string) Always `thread`. - **id** (string(uuid)) Stable identifier. - **short_id** (string | null) Eight character base62 identifier used by the thread's own URL at `/n/{handle}/chat/{short_id}`. Null for a thread Commune opened under an article, which is reached through the article instead. - **newsletter** (object | null) The community this thread lives in. A `Ref` unless `newsletter` is named in `?expand=`. - **author** (object | null) Who opened the thread. A `Ref` unless `author` is named in `?expand=`. - **content** (string) The opening message. HTML, since people format what they write. Treat it as untrusted markup and render it in a sandboxed context. - **media** (array[object]) Attachments on the opening message. - **visibility** (string) Where a thread is placed. `public` puts it on the global Commune feed and makes it readable by anyone. `subscribers` keeps it inside the newsletter. Set and changed by the newsletter's team. A third value, `paid`, was in this enum until 2026-10-10. It was never produced by anything and no thread ever carried it, so it was removed rather than left as a state consumers had to handle. See Removals under Versioning. - **audience_scoped** (boolean) `true` when this thread is a **private room**: it was scoped to one or more of the newsletter's subscriber segments, and only the people holding one of those segments and the newsletter's team can read it or its replies. Evaluated live, so losing the segment takes the conversation away on the next request. A private thread is never on the global feed, so `visibility` is `subscribers` whenever this is `true`. Scoping a `public` thread to a room moves it to `subscribers` in the same write. Always present. It says *that* the conversation is private without saying *to whom*, which is why every credential that can read the thread gets it while `audience_tag_ids` is narrower. - **audience_tag_ids** (array[string(uuid)] | null) Which segments the thread is private to, by tag `id`, or `null` when it is not private. The semantics are OR: holding any one of them is enough. **Present only for a credential holding `audience: read` on this newsletter.** A thread is `content`, and the words in it come back to any credential the thread's own gate admits; which segments it was addressed to is the `audience` family's fact, priced at `audience: read` everywhere else a credential can ask for it (`GET /newsletters/{newsletter}/tags`, `GET /tags/{tag}`). So a credential without that family sees `audience_scoped` and not this property, and cannot read a creator's segmentation off a conversation. Absent and `null` are different answers: absent is "this credential may not read it", `null` is "there is none". Every id here is a tag with `is_room` set, because that is the only kind a thread can be scoped to. A tag that was since retired or un-roomed stays in the list and goes on deciding who may read the thread, exactly as it goes on deciding the audience of an article it was addressed to. - **is_article_thread** (boolean) `true` when Commune opened this thread under an article rather than a person starting it. These are kept off the global feed, because the article card already represents the conversation there. - **article** (object | null) The article that opened this thread, when `is_article_thread` is `true`. `null` otherwise. A `Ref` unless `article` is named in `?expand=`. - **reply_count** (integer) Undeleted replies in the thread, at any depth. - **view_count** (integer) How many times the thread was opened. - **created_at** (string(date-time)) When the thread was opened. - **updated_at** (string(date-time)) When the thread row last changed for any reason. - **edited_at** (string(date-time) | null) When the author last edited the opening message. Null when it was never edited, which is what drives the edited marker in the product. - **last_activity_at** (string(date-time)) When the thread last received a reply, or when it was opened if it never did. This is the sort key for the thread list. ### 400 The request was malformed, and the same request will fail the same way until it is changed. `param` names the parameter or header at fault when there is exactly one, and `allowed_values` lists what it accepts when that is a finite set. The code is `bad_request` for every case below except the last. * **A query parameter**: one the operation does not have, a value outside its set, range or format (an unparseable cursor, an unknown `expand` path or `fields` name, an identifier that is not a UUID), or a required one left out, such as `q` on a search or `newsletter` when the credential reaches more than one. * **The request body**: not JSON, not the shape the operation reads, a property it does not write, or a value of the wrong type, length or format. `param` is absent here, since the body is not a parameter, and the message names the property. * **The `Idempotency-Key` header**, on an operation that changes something: missing, or a value this API will not store. * **An unrecognised `Commune-Version`**, which answers with its own code, `invalid_version`, because it is never fixed by changing the body. #### Body: application/json (object) - **error** (object) ### 401 No credential was presented, or it is malformed, unknown, revoked or expired, or it is an access token minted for a different audience. Every one of these answers identically, down to the wording and the headers, so a refusal never confirms that a string was once real. #### Headers - **WWW-Authenticate** (string) The authentication scheme this API accepts, and where to find out how to get a credential for it. Always `Bearer realm="Commune API", resource_metadata="https://api.usecommune.com/.well-known/oauth-protected-resource"`. `resource_metadata` is the RFC 9728 pointer to this API's protected resource metadata, which names the authorization server an OAuth client should send its user to. A client holding an API key can ignore it. The header carries no `error` parameter, not even `error="invalid_token"`, because it describes what this API accepts rather than what was wrong with the credential sent, and the reasons above are deliberately indistinguishable. There is no second scheme and no query-parameter fallback, because a credential that can travel in a URL ends up in access logs and referer headers. #### Body: application/json (object) - **error** (object) ### 402 The credential is allowed to do this but the newsletter's plan does not include it. Two surfaces can answer it: **insights**, the engagement and metrics operations, which are the only reads Commune reserves the right to meter, and **writing**, every operation that changes something. Every other read stays free on every plan, so a credential refused at one of these can still read everything else. The body names the plan the newsletter is on and the plans that would work. One refusal is a feature of the paid plan rather than a charge for API access, and it answers whatever is metered: a newsletter whose plan does not include it may have **one automation active at a time**, so `POST /automations/{automation}/activate` is refused while another is running. That one is also the only `402` a client can clear on its own, by pausing the automation that is running. **This status is predictable and should not be how you discover it.** `GET /newsletters/{newsletter}/entitlements` answers the same question in advance, carrying the same plan list this puts in `allowed_values` and the same sentence it puts in `message`. Read it once at the start of a run rather than finding out in the middle of one. #### Body: application/json (object) - **error** (object) ### 403 The credential is valid but is not allowed to do this. Two codes answer with this status, and `error.code` says which. **`insufficient_scope`: it does not hold the permission.** The operation needs, say, `audience: read` on the newsletter addressed, and this credential holds less than that there. `allowed_values` carries the permission that was needed, and the message says what the credential does hold on that newsletter, because a credential granted the wrong family and a credential belonging to somebody whose standing on the team has narrowed look identical without it. The answer can differ per newsletter: the same credential may be allowed here and refused on the next one it reaches. The same code answers an operation that needs the **account permission** from a credential that does not carry it. That permission is about the person a credential belongs to rather than about any newsletter, so nothing granted on a newsletter adds up to it. It is granted on the credential itself, when a key is minted or when an authorization asks for `account:read`. And it answers a parameter the credential may send, but not with the value it sent: a filter a credential holding only `read` permissions may not use, or an `expand` path whose rows need a permission the operation does not. `param` names the parameter, and `allowed_values` carries what this credential may send instead, or is absent when it may send nothing there at all. **`forbidden`: it may not act here at all.** Either the credential does not reach the newsletter addressed, because it was never granted it or because the person it belongs to can no longer act on it, or it reaches no newsletter at all; `param` is `newsletter`, and `GET /newsletters` lists the ones it does reach. Or, on `DELETE /api-keys/{key}`, the credential named belongs to somebody else. Neither carries `allowed_values`, because there is no value to send instead. #### Body: application/json (object) - **error** (object) ### 404 No such resource, or the key is not allowed to know that it exists. Commune answers `404` rather than `403` where distinguishing the two would leak the existence of private content. #### Body: application/json (object) - **error** (object) ### 409 The request collided with something. On a write this is always the `Idempotency-Key`, in one of two ways, and the message says which. Either the key was already used for a **different** request, which is refused rather than answered with the earlier request's result. Or an earlier request using the same key has not finished, or never reported an outcome, in which case this one was not run and the key becomes usable again shortly. Nothing was changed by a request that answers this. #### Headers - **Retry-After** (integer) Seconds to wait before retrying, on the second case only. #### Body: application/json (object) - **error** (object) ### 422 The request is well formed and every value in it is legal, and the state of what it addresses refuses it anyway. The message says what about that state is in the way. #### Body: application/json (object) - **error** (object) ### 429 Too many requests. Back off and retry after the interval named by the `Retry-After` response header. One of the budgets in `RateLimit-Policy` ran out, and the `RateLimit-*` headers on this response say which and when it resets. #### Headers - **Retry-After** (integer) Seconds to wait before retrying. #### Body: application/json (object) - **error** (object) ### 500 Something failed inside Commune. The request may be retried. #### Body: application/json (object) - **error** (object) [Powered by Bump.sh](https://bump.sh)