Set who a conversation is private to Run in API Explorer
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.
Headers
-
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: trueresponse header. Send a different value for a different change: a key reused for a request that differs in any way answers409, 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.
Minimum length is
1, maximum length is255. -
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 answers400withinvalid_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.
Minimum length is
1.
Path parameters
-
The thread's
id(a UUID) or itsshort_id. A thread that Commune opened under an article has noshort_id, because it is addressed on the web through the article's own permalink, so use itsid.
Body
Required
-
The segments the thread becomes private to, replacing whatever it was private to before.
nullor[]clears the audience, which opens the conversation to everybody in the newsletter's space and needsconfirm_widening.Required, because this operation exists to set it and a body that omitted it would be a request to change nothing. Send the current list back to leave it alone.
Not more than
20elements. -
Say
truewhen 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 refused422withparamset toconfirm_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
truecounts: the one flag here that must not be easy to send by accident.Default value is
false.
Responses
-
The thread, as it now stands.
audience_scopedandaudience_tag_idscarry the audience that was just set, andvisibilityissubscribersif scoping the thread pulled it off the feed. -
The request was malformed, and the same request will fail the same way until it is changed.
paramnames the parameter or header at fault when there is exactly one, andallowed_valueslists what it accepts when that is a finite set. The code isbad_requestfor 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
expandpath orfieldsname, an identifier that is not a UUID), or a required one left out, such asqon a search ornewsletterwhen 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.
paramis absent here, since the body is not a parameter, and the message names the property. - The
Idempotency-Keyheader, 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.
- A query parameter: one the operation does not have, a value
outside its set, range or format (an unparseable cursor, an unknown
-
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.
-
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}/activateis refused while another is running. That one is also the only402a 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}/entitlementsanswers the same question in advance, carrying the same plan list this puts inallowed_valuesand the same sentence it puts inmessage. Read it once at the start of a run rather than finding out in the middle of one. -
The credential is valid but is not allowed to do this. Two codes answer with this status, and
error.codesays which.insufficient_scope: it does not hold the permission. The operation needs, say,audience: readon the newsletter addressed, and this credential holds less than that there.allowed_valuescarries 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
readpermissions may not use, or anexpandpath whose rows need a permission the operation does not.paramnames the parameter, andallowed_valuescarries 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;paramisnewsletter, andGET /newsletterslists the ones it does reach. Or, onDELETE /api-keys/{key}, the credential named belongs to somebody else. Neither carriesallowed_values, because there is no value to send instead. -
No such resource, or the key is not allowed to know that it exists. Commune answers
404rather than403where distinguishing the two would leak the existence of private content. -
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.
-
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.
-
Too many requests. Back off and retry after the interval named by the
Retry-Afterresponse header.One of the budgets in
RateLimit-Policyran out, and theRateLimit-*headers on this response say which and when it resets. -
Something failed inside Commune. The request may be retried.
curl \
--request PATCH 'https://api.usecommune.com/threads/b3Xn8kTw' \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: 3f7c1a26-9b0e-4f5a-9a2c-2c8f1d6b4e77" \
--header "Commune-Version: 2026-08-26" \
--data '{
"audience_tag_ids": [
"9f1c7a43-2b65-4d18-9f0e-71a3c5d9b240"
]
}'
{
"audience_tag_ids": [
"9f1c7a43-2b65-4d18-9f0e-71a3c5d9b240"
]
}
{
"audience_tag_ids": null,
"confirm_widening": true
}
{
"object": "thread",
"id": "string",
"short_id": "string",
"newsletter": {
"object": "newsletter",
"id": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
},
"author": {
"object": "newsletter",
"id": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
},
"content": "string",
"media": [
{
"url": "https://example.com",
"type": "string",
"thumbnail": "https://example.com"
}
],
"visibility": "public",
"audience_scoped": true,
"audience_tag_ids": [
"string"
],
"is_article_thread": true,
"article": {
"object": "newsletter",
"id": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
},
"reply_count": 42,
"view_count": 42,
"created_at": "2026-05-04T09:42:00Z",
"updated_at": "2026-05-04T09:42:00Z",
"edited_at": "2026-05-04T09:42:00Z",
"last_activity_at": "2026-05-04T09:42:00Z"
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
# Headers
WWW-Authenticate: string
# Payload
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
# Headers
Retry-After: 42
# Payload
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
# Headers
Retry-After: 42
# Payload
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}
{
"error": {
"code": "bad_request",
"message": "Newsletter not found.",
"param": "cursor",
"allowed_values": [
"subscribed",
"unsubscribed",
"bounced",
"complained",
"pending"
],
"request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0",
"docs_url": "https://usecommune.dev/errors/not_found"
}
}