# Add a subscriber **POST /newsletters/{newsletter}/subscribers** Puts an address on this newsletter's list. Needs `audience: write`, and is a write. **This is not an import, and it is the call a welcome sequence listens for.** It publishes `subscriber.created`, which is the topic automations trigger on, so every address added here enters whatever welcome sequence the newsletter has running. An import and the daily provider sync deliberately publish no such event, so a list migrated in never gets welcomed; calling this in a loop over a list you already have is how you undo that, one email at a time. Move a list with an import. **An address already on the list is not an error.** A signup form cannot know whether the person in front of it subscribed last year, so: * Already `subscribed`: nothing changes, nothing is published, and the answer is `200` with the subscriber as it stands. * `unsubscribed` or `bounced`: the subscription is reactivated and the answer is `200`. This publishes `subscriber.created` with `resubscribed: true`, which automations treat as **not** a new subscriber, so nobody is welcomed twice. * `complained`: refused with `422`. The reader told a mailbox provider this newsletter is spam, and only the reader can undo that by subscribing again themselves. A new subscriber answers `201`. So the status says whether a subscription was created, and the body says what it now is either way. The same person is one subscriber however their row is shaped. An address that belongs to a Commune account is attached to that account, so the subscription follows the address on it, and an address Commune has never seen is stored on the subscription itself, lowercased. Two calls differing only in case are therefore one subscriber rather than two copies of one person receiving every article twice. `source` comes back `commune`: the subscription was made on Commune and was not mirrored off a provider's list. `tags` is empty, because a tag decides what somebody can read and is applied by its own call. **On a newsletter connected to an outside provider this writes Commune's record and not the provider's.** Nothing is added to Kit, Mailchimp or anywhere else, so add the contact there too if that list is your source of truth. The daily sync only ever adds, so a subscriber written here is not removed by it. ## 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) - **email** (string(email)) Where the newsletter should reach this person. Matched case-insensitively against the list and stored lowercased, so two calls differing only in case are one subscriber. ## Responses ### 200 The address was already on the list. Reactivated if it was inactive, left alone if it was already subscribed. #### Body: application/json (object) - **object** (string) Always `subscriber`. - **id** (string(uuid)) Stable identifier for this membership. - **newsletter** (object | null) The newsletter subscribed to. A `Ref` unless `newsletter` is named in `?expand=`. - **user** (object | null) The Commune account behind the subscription, or `null` for someone who joined by email without one. A `Ref` unless `user` is named in `?expand=`. - **email** (string(email)) Where the newsletter reaches this person. Returned on every subscriber, including one with a Commune account behind them. For a subscriber with no account it is the address they subscribed with. For one with an account it is the address on that account, which is where the article is actually delivered. Never null. **This is the one place in the API an address appears.** It is a property of the subscription rather than of the person, so it is never on a public profile, never on an expanded author, and never reachable from a credential granted another newsletter. Reading it needs `audience: read`, counts against the tighter `audience` rate limit budget, and is recorded in Commune's audit log with the credential and how many subscribers it read. - **status** (string) Where a subscription stands. Source of truth for a `commune` newsletter. For a newsletter connected to an outside provider it reflects what Commune last saw of the provider's state. - **source** (string) How the subscription was made. `commune` when the subscription was made on Commune: the person pressed subscribe, finished signup or accepted an invitation, or the newsletter's own integration added them with `POST /newsletters/{newsletter}/subscribers`. `imported` when they arrived in an import of the newsletter's provider list or a file. **Not the same question as whether Commune grew the list.** Somebody who subscribes through Commune to a newsletter whose provider already held their address is `commune` here, even though the creator's list did not get longer. `unknown` for a subscription recorded before Commune kept this. It is stated rather than left out, so "Commune does not know" cannot be mistaken for `imported`. - **tags** (array[object]) The audience tags this subscriber holds, which is what decides which tag scoped articles reach them. Each entry is a `Ref` unless `tags` is named in `?expand=`. - **created_at** (string(date-time)) When Commune first recorded the subscription. - **synced_at** (string(date-time) | null) When this row was last reconciled with the newsletter's provider. Null for a `commune` newsletter, which has no provider to reconcile with. ### 201 A subscription that did not exist before. #### Body: application/json (object) - **object** (string) Always `subscriber`. - **id** (string(uuid)) Stable identifier for this membership. - **newsletter** (object | null) The newsletter subscribed to. A `Ref` unless `newsletter` is named in `?expand=`. - **user** (object | null) The Commune account behind the subscription, or `null` for someone who joined by email without one. A `Ref` unless `user` is named in `?expand=`. - **email** (string(email)) Where the newsletter reaches this person. Returned on every subscriber, including one with a Commune account behind them. For a subscriber with no account it is the address they subscribed with. For one with an account it is the address on that account, which is where the article is actually delivered. Never null. **This is the one place in the API an address appears.** It is a property of the subscription rather than of the person, so it is never on a public profile, never on an expanded author, and never reachable from a credential granted another newsletter. Reading it needs `audience: read`, counts against the tighter `audience` rate limit budget, and is recorded in Commune's audit log with the credential and how many subscribers it read. - **status** (string) Where a subscription stands. Source of truth for a `commune` newsletter. For a newsletter connected to an outside provider it reflects what Commune last saw of the provider's state. - **source** (string) How the subscription was made. `commune` when the subscription was made on Commune: the person pressed subscribe, finished signup or accepted an invitation, or the newsletter's own integration added them with `POST /newsletters/{newsletter}/subscribers`. `imported` when they arrived in an import of the newsletter's provider list or a file. **Not the same question as whether Commune grew the list.** Somebody who subscribes through Commune to a newsletter whose provider already held their address is `commune` here, even though the creator's list did not get longer. `unknown` for a subscription recorded before Commune kept this. It is stated rather than left out, so "Commune does not know" cannot be mistaken for `imported`. - **tags** (array[object]) The audience tags this subscriber holds, which is what decides which tag scoped articles reach them. Each entry is a `Ref` unless `tags` is named in `?expand=`. - **created_at** (string(date-time)) When Commune first recorded the subscription. - **synced_at** (string(date-time) | null) When this row was last reconciled with the newsletter's provider. Null for a `commune` newsletter, which has no provider to reconcile with. ### 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)