# Create an article
**POST /newsletters/{newsletter}/articles**
Writes a new article and returns it. Needs `content: write`, and is a
write.
The article's text goes in `content_markdown`, as Markdown: the same
rendition `GET /articles/{article}` returns as `content_markdown` under
`?expand=content`, so what you read back is what you send. HTML is not
accepted. Commune parses it strictly: text it cannot read answers `400`
naming the line, and nothing is written, rather than guessing at a
document you did not write and sending it to your list.
**What you get is always a draft.** `status`, `posted_at` and
`scheduled_for` are not properties of the request. A draft is invisible
on every reader surface, so nothing this operation does reaches anybody.
It goes out through Schedule an article
(`POST /articles/{article}/schedule`) or Send an article to the list (`POST /articles/{article}/send`),
each of which runs the six gates described on the second before its
email leaves Commune.
**Send no body at all and Commune seeds one.** `{}` creates an empty
untitled draft: two blank lines and an editable unsubscribe line. Send a body and it is stored exactly as sent,
with nothing appended. The send operations refuse an article whose body
carries no unsubscribe mechanism and no postal address, so a body you
intend to send should carry `{{ unsubscribe_url }}` and `{{ address }}`.
**Only a newsletter Commune publishes.** A newsletter whose `esp` is
anything but `commune` has its articles written elsewhere and mirrored
into Commune afterwards, so there is nothing here to create. That
answers `422` with the code `not_commune_newsletter`, whose `docs_url` is
`https://usecommune.dev/errors/not_commune_newsletter`, the page on
what the refusal means and how to move a newsletter onto Commune.
Publishes no event. A draft has neither gone out nor been queued, and
`status` is what says so until one of those happens.
## The Markdown `content_markdown` takes
Headings, paragraphs, bold, italic, strikethrough, inline code, links,
images, blockquotes, bullet and ordered lists, fenced code blocks with
a language, tables and thematic breaks. A line ending in two spaces or
a backslash is a line break; a code fence without a language is stored
without one rather than guessed at.
Merge tags survive exactly as written. `{{ subscriber.first_name }}`
and `{% if %}` are personalization rather than Markdown, so nothing
inside a Liquid construct is escaped or read as formatting.
Raw HTML is refused rather than passed through or dropped. Write a
literal `<` as `\<`.
## The five components
Five things the editor can hold have no Markdown spelling, so they get
an MDX-shaped syntax. The set is closed: any other tag name is a `400`.
* `` ... `` wraps blocks in a styled band. Optional
`backgroundColor`, `textColor` (hex, with the `#`), `fontFamily`
(`sans`, `serif`, `mono`), `fontSize` (a number, in px) and
`textAlign` (`left`, `center`, `right`).
* `` ... `` wraps blocks that belong in the
inbox and not on the web. This is where the unsubscribe line and the
mailing address go: on the website there is no subscriber, so the
link is dead and the address is noise. Commune seeds exactly this
into a draft created with no body.
* `` is a call to action. `href` is
required; `alignment` is optional.
* `` is a row of linked platform icons. `items`
is required and is a JSON array of
`{"platform": "...", "url": "...", "imageUrl": null}`. Optional
`align`, `iconColor` and `iconBgColor`.
* `` is a video. The URL has to be one Commune can
read a video id out of (`youtube.com/watch?v=`, `youtu.be/`,
`youtube.com/shorts/` or `youtube.com/embed/`), because the email
shows a thumbnail built from that id rather than an iframe, which
every major email client strips.
Attributes are written `name="value"` or `name={json}`. A component
that holds nothing is written self-closing; one that holds content is
opened and closed on their own lines.
## 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
- **newsletter** (string)
The newsletter's `id` (a UUID) or its `handle`. A handle is unique
across Commune and is the identifier its public web profile uses, so it
is the one to hardcode in an integration.
### Body: application/json (object)
- **title** (string | null)
The subject line. Null or absent leaves the article untitled, which
is legal: a draft is often started before it is named.
- **preview_text** (string | null)
The short line email clients show after the subject.
- **image_url** (string(uri) | null)
The cover image. Leave it out and Commune stamps the first image in
the body when the article is sent.
- **slug** (string)
The article's URL segment, unique within the newsletter. Lowercase
letters, digits and single hyphens.
Leave it out and Commune derives one from the title, adding a
numeric suffix if that one is taken. An untitled article falls back to
its `short_id`. A slug you choose yourself is never renamed for you:
one that is already taken answers `422` rather than quietly becoming
something else, because a permalink you did not choose is worse than
an error you can act on.
- **content_markdown** (string)
An article's text, as Markdown. Create an article documents the
Markdown Commune accepts.
## Responses
### 201
The article, as created: a draft, with the `id`, `short_id` and `slug`
every other operation addresses it by. Read it back with
`GET /articles/{article}` to see the stored body.
#### Body: application/json (object | null)
- **object** (string)
Always `article`.
- **id** (string(uuid))
Stable identifier.
- **short_id** (string)
Eight character base62 identifier, unique across Commune. Safe in a
URL and accepted anywhere `{article}` is.
- **slug** (string)
URL segment under the newsletter, unique within it but not across
Commune. The permalink is `/n/{handle}/a/{slug}`. Falls back to the
`short_id` for an untitled article.
- **newsletter** (object | null)
The newsletter this article belongs to. A `Ref` unless `newsletter` is
named in `?expand=`.
- **title** (string | null)
Subject line of the article. Null for an untitled draft.
- **preview_text** (string | null)
The short line email clients show after the subject, and what
Commune uses as the excerpt on a card.
- **image_url** (string(uri) | null)
Cover image. When the creator set none, Commune stamps the first
image in the body at send time, so this is usually populated for a
sent article.
- **external_url** (string(uri) | null)
The article's canonical URL on the newsletter's own provider, for an
imported article. Null for one written in Commune.
- **status** (string)
Where an article is in its life. A credential holding only `read`
permissions ever sees `sent` and nothing else. An imported article is
always `sent`, since Commune sees it after the provider delivered it.
- **is_imported** (boolean)
`true` when the article came in from the newsletter's provider,
`false` when it was written and sent in Commune.
- **posted_at** (string(date-time) | null)
When the article went out. An article dated in the future is not
returned by any read operation until that moment passes, so this is
never ahead of now in a response.
- **scheduled_for** (string(date-time) | null)
When a queued article may go out. Set while `status` is `scheduled`
and null otherwise.
This is not `posted_at` and the difference matters: a queued article
has no publication date yet, which is why it stays invisible on
every reader surface until it really goes out. Commune dispatches
in passes, so this is the moment from which the article may go rather
than the moment it will.
- **authors** (array[object])
The byline, in order. Each entry is a `Ref` unless `authors` is
named in `?expand=`. Empty when no Commune account is credited.
- **thread** (object | null)
The chat thread this article opened, where its discussion lives.
`null` when the newsletter does not open a thread per article. A `Ref`
unless `thread` is named in `?expand=`.
- **stats** (object)
Engagement counts for an article, computed at read time. These are
Commune side counts, not provider side email metrics: opens, clicks and
deliveries are not here.
- **created_at** (string(date-time))
When the row was created in Commune.
- **updated_at** (string(date-time))
When the article was last edited.
### 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.
**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)