Posts

Posts are what people write in a circle: text, with a link or pictures. These endpoints move the posts of the old platform in, with their original dates and authors, and let you correct or remove what you imported. Read Start here first: its rules apply to everything on this page.

The post object

{
  "id": 9001,
  "external_id": "fb-post-5001",
  "circle_ids": [412],
  "author": {"id": "EveABqLyto9L", "name": "Karen Holm"},
  "content": "Who has spare tomato seedlings?\n\nI can swap basil.",
  "link_url": null,
  "images": [{"id": 3307, "filename": "seedlings.jpg"}],
  "created_at": "2023-04-15T10:00:00.000Z",
  "edited_at": null
}
Field Type Meaning
id number The post's id. Use it in /posts/:id.
external_id text or null Your id from the old platform, when the post was created through the API with one. null for every other post.
circle_ids list of numbers The circles it is in. Only this community's circles are listed.
author object Who wrote it (below).
content text The text, as plain text.
link_url text or null The address of its link preview. null until Zen Social has fetched the preview, and stays null if the preview could not be fetched.
images list Its pictures, as {"id", "filename"}. There is no picture address: the pictures belong to the circle's members, and the API does not hand them out.
created_at time When it was written: the original date for an imported post.
edited_at time or null When a person last edited it in the app. Changes through the API never set it.

author is one of two shapes:

  • A member: {"id": "EveABqLyto9L", "name": "Karen Holm"}. The id is the member's id from Members.
  • A name from the old platform, for someone who has no Zen Social account (yet): {"id": null, "name": "Ole Berg", "external_id": "fb-user-1002"}. The external_id is your own id for that person, as you sent it; null if the post was created without an external_id of its own. See Authors and claims.

There are no email addresses, and no picture or attachment addresses: posts are not an export.

List them: GET /posts

curl "$API/posts" -H "Authorization: Bearer $KEY"

Answers 200 OK with {"data": [<post>, …], "next_page": null}, newest first (by created_at), 100 per page (?page=2 for the next).

The list shows exactly what the admin would see in the app, and nothing more:

  • only posts in circles the admin is a member of, including the community's own feed;
  • in a curated circle, a plain member sees only their own posts and those published in an edition, and so does the admin unless they are that circle's owner, admin or editor;
  • posts by people the admin has blocked, or who have blocked the admin, are left out;
  • deleted posts are left out.

One circle only: add ?circle_id=. The community's own feed is a circle here too: its id is the id from GET /organization.

curl "$API/posts?circle_id=412" -H "Authorization: Bearer $KEY"

404 not_found when that circle is not one of this community's; 403 forbidden when the admin is not a member of it.

Read one: GET /posts/:id

curl "$API/posts/9001" -H "Authorization: Bearer $KEY"

Answers 200 OK with the post. 404 not_found when it is not in one of this community's circles, or has been deleted; 403 forbidden when the admin could not see it in the app.

Create one: POST /posts

curl "$API/posts" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-post-5001", "circle_ids": [412], "author_id": "EveABqLyto9L", "created_at": "2023-04-15T10:00:00Z", "content": "Who has spare tomato seedlings?\n\nI can swap basil."}'
Field Rules
circle_ids Required: a list of one or more circle ids of this community. The post appears in each of them. The admin must be able to post in every one (not just read it): 403 forbidden otherwise, and 404 not_found for a circle outside the community.
content Required: plain text (below).
external_id Your id for this post on the old platform, as text or a number, at most 200 characters. Always send it: re-runs depend on it, and so does handing the post back to its author later (see Authors and claims). An empty string counts as none.
author_id A member's id from Members. See "Who wrote it".
author {"external_id": "…", "name": "…"} for someone who has not joined. See "Who wrote it".
created_at The original date and time, ISO 8601 (below). Default: now.
link_url An address for a link preview (below).
image_urls A list of picture addresses for Zen Social to fetch (below).
images Picture files, in a multipart form (below).
notify true to tell people about the post the way the app would. Default false. See "Telling people".

Fields the API does not know are ignored without an error.

Answers:

  • 201 Created with the new post.
  • 200 OK, 409 record_deleted, 409 changed_since_import, 409 in_edition, 409 conflict or 403 forbidden when the external_id was imported before: see "Re-runs" below.
  • 403 forbidden: the admin cannot post in one of the circles.
  • 404 not_found: one of the circles is not this community's.
  • 422 invalid: a value is not allowed; details names each field and why, for example {"author_id": ["must be the id of a member of every circle the post goes into"]}. Nothing was made.
  • 422 notify_needs_member_author: "notify": true with an author name. Nothing was made.

The text: content

Plain text, not HTML and not Markdown: what you send is shown exactly as written.

  • A blank line starts a new paragraph. A single line break stays a line break. In JSON, write them as \n\n and \n.
  • Addresses starting with http:// or https:// become links by themselves.
  • <b>, **bold** and the like are shown as typed, not as formatting.

It cannot be empty. The answer gives it back as plain text.

A circle can have a word limit for posts. Imports do not have to keep to it, since they were written elsewhere; a post sent with "notify": true does, as in the app (422, with the reason under details.content).

Who wrote it: author_id or author

Send one of these, or neither:

  • author_id: the person is already a member. Use their id from GET /members. They must be a member of every circle in circle_ids, or the answer is 422 with details.author_id. The post is theirs, as if they had written it in the app.
  • author: the person has not joined (yet). Send {"external_id": "fb-user-1002", "name": "Ole Berg"}:
    • external_id is your own id for that person on the old platform, as text or a number, 1 to 200 characters. A number comes back as text (1002 as "1002"), and that text is the id in a claim's address. Use the same one for every post by the same person: it is how all their posts are handed to them later, in one go.
    • name is the name the post shows, 1 to 100 characters, after spaces at either end are trimmed.

The post shows that name, with no profile and no link. When the person joins, you can hand the post to them: see Authors and claims. - Neither: the admin who made the key is the author.

Both at once is 422 with details.author.

The date: created_at

The post's original date and time, in ISO 8601, for example 2023-04-15T10:00:00Z or 2023-04-15T12:00:00+02:00. Send the offset (or Z for UTC): a time without one is read as UTC. It must be in the past, and no earlier than 1 January 1990; otherwise the answer is 422 with details.created_at.

The post keeps that date everywhere: it sorts by it in the feeds, it does not count as new on anyone's Home page, and its circle does not look "active just now". Without created_at the post is dated now.

An absolute http or https address, at most 2000 characters. The post shows a preview of it (title, picture, summary), as when a member pastes a link in the app. Zen Social fetches the preview shortly after the post is saved: until then, and when it cannot be fetched, the answer shows "link_url": null.

Pictures: images and image_urls

At most 10 pictures a post, counted together. JPEG, PNG or AVIF only, judged by the file's contents, not by its name or the type a server declares for it.

  • image_urls, in a JSON body: a list of public http or https addresses. Zen Social fetches each one while you wait, at most 5 MB each. That is slow: send posts with many pictures one at a time.
  curl "$API/posts" \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d '{"external_id": "fb-post-5003", "circle_ids": [412], "author": {"external_id": "fb-user-1002", "name": "Ole Berg"}, "created_at": "2023-05-02T18:30:00Z", "content": "The swap table is up!", "image_urls": ["https://example.org/swap-table.jpg"]}'
  • images, files in a multipart form, at most 10 MB each. The other fields go in the same form: a list as circle_ids[], the author name as author[external_id] and author[name].
  curl "$API/posts" \
    -H "Authorization: Bearer $KEY" \
    -F "external_id=fb-post-5004" -F "circle_ids[]=412" -F "author_id=EveABqLyto9L" \
    -F "created_at=2023-05-03T09:00:00Z" -F "content=Photos from Saturday" \
    -F "images[]=@saturday-1.jpg" -F "images[]=@saturday-2.jpg"

A picture that cannot be used answers 422 and nothing is saved: an address that cannot be fetched or is not a picture under details.image_urls, a file of the wrong kind or too large under details.attachments, more than 10 under details.images.

The pictures are private to the members of the post's circles, as pictures posted in the app are.

Telling people: notify

By default nothing tells anyone about a post you create (see Silent by default).

"notify": true makes the create announce the post exactly as the app does when a member posts: notifications, push messages and lines in daily summaries, for the circle's members, each according to their own notification settings. Send it only when the admin has asked for people to be told, and never for imported history.

A post with notify needs a real member as its author (author_id, or neither field): a name from the old platform cannot speak to anyone. With author the answer is 422 {"error":"notify_needs_member_author"}. A post with notify also keeps to the circle's word limit, and its author needs a complete profile, as in the app (422 invalid otherwise).

Re-runs

Send a create again with the same external_id, and the post is not made twice. The answers are those of Re-runs and external_id:

Answer Meaning
200 OK The post was updated and keeps its id.
409 record_deleted The post was deleted (in the app or through the API), or turned into an event in the app. It is never brought back. Skip it and report it.
409 changed_since_import Someone edited the post in the app since your last write. It was not overwritten. Skip it and report it. A comment on the post, or a claim, does not count as an edit.
409 in_edition The post is in a newsletter edition, so its text can no longer change. Nothing was changed. Skip it and report it.
409 conflict The same create arrived twice at once. Send it again, once.
403 forbidden The admin can no longer post in one of the post's circles. Skip it and report it.
422 invalid The content or link_url sent is not allowed (see "Create one"); details says why. Nothing was changed.

A re-run changes only content and link_url, and only when they are sent. Everything else about the post stays as it was first imported, and a re-run does not even check it: circle_ids, the author, created_at, pictures and notify are ignored. A re-run never moves a post, never changes its date or author, never adds pictures and never tells anyone. To change any of those, delete the post (with the admin's yes) and create it under a new external_id.

Change one: PATCH /posts/:id

Send content, link_url or both, with the same rules as for a create. "link_url": null removes the link preview. Nothing else can be changed: other fields are ignored.

curl -X PATCH "$API/posts/9001" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"content": "Who has spare tomato seedlings?\n\nI can swap basil or chives."}'

Before a PATCH of a post whose author is now a member (its author has an id), or whose edited_at is not null, show the admin the change (the post as it is and as it will be) and wait for their yes. Those may be a person's own words, written or corrected by them.

Answers 200 OK with the post, 422 invalid with the reasons, 403 forbidden when the admin may not change this post (see "Who may change which post"), 409 in_edition when the post is in a newsletter edition, or 404 not_found.

A change through the API is not an edit by a person: the post shows no "edited" mark, keeps its date, and nobody is told. A later re-run of its create still updates it. A post someone edited in the app is different: it keeps that mark after your PATCH, so a later re-run still answers 409 changed_since_import.

A post created through the API with an external_id need not keep to its circles' word limit when you change it; any other post must, as in the app.

Delete one: DELETE /posts/:id

Warning: this deletes the post for everyone. Its comments go with it and its pictures are removed. There is no undo, and a re-run never brings it back.

Never send a DELETE without the admin's clear yes. Tell them which post it is (its author, date and the start of its text) and wait for their answer.

curl -X DELETE "$API/posts/9001" -H "Authorization: Bearer $KEY"

Answers 204 No Content. 403 forbidden when the admin may not delete it, 404 not_found when it is not this community's or is already deleted. A later create with its external_id answers 409 record_deleted.

Who may change which post

Posts you imported: a post created through this API with an external_id may be re-run, changed, deleted and handed to its author through the API, whoever its author is now, as long as the admin can still post in every one of its circles. That holds for any admin's key in this community, and after a claim has made a member its author. This is the steward right: whoever moves the content in can still correct it.

A post in a newsletter edition can no longer be changed, not even under the steward right: the edition shows it as published, and in the app not even its author can edit it. A PATCH or a re-run answers 409 {"error":"in_edition"} and changes nothing. It can still be deleted.

Every other post follows the app's rules, as if the admin did it in the app: only its author can change it (the admin's own posts, and posts created through the API without an external_id and without an author), and not even its author once it is in a newsletter edition (403 forbidden); its author or an owner or admin of one of its circles can delete it. A post created without an external_id under someone else's name or member id cannot be changed through the API afterwards: another reason to always send one.

The steward right exists only in this API. In the app, an imported post follows the usual rules: a member author can edit it like any post of theirs; a post under a name from the old platform cannot be edited by anyone in the app, and an owner or admin of its circle can delete it.

Nobody is told

Creating, changing and deleting posts tells no member anything, unless a create sends "notify": true.

Two things to know about communities whose circles share posts with other servers on the open social web (federation): deleting a post through the API also asks those servers to remove their copy, and changing a post through the API does not update the copies they already have.