Direct messages

Direct messages are the private conversations between members. These endpoints move the conversations of the old platform in, with their original dates and authors, so members keep their history. Read Start here first: its rules apply to everything on this page. Direct messages work like Comments in some ways, and this page says where they differ.

The API only puts conversations in. It never reads them. Nothing on this page lets you, or the admin, see anyone's conversations through Zen Social.

Import the posts, events and comments first, and the direct messages after them (the rota, when it comes, is last). A conversation can only be moved when every person in it has already joined Zen Social, so a move runs in this order: members join, you match them with GET /members, and then the conversations come in.

What the API has, and what it does not

  • Has: moving a conversation in, in one request or in batches of messages, with its original authors and dates, and photos. And undoing the move of a conversation, while nobody has touched it.
  • Not in the API: reading, listing, searching or exporting conversations or messages. There is no GET. An answer holds a status and four counts, and never the text, the photo addresses, a Zen Social id or anything else about a conversation. It does not even say whether two people already talked in Zen Social.
  • Not moved: people who are not members of the community (a conversation with someone who has not joined is not moved at all), reactions, read receipts, edit histories, voice messages and files other than photos. A conversation has no name.
  • Not sending: an import is not sending. Nobody is told, and nothing you move in looks new (see "Nobody is told").
  • Not a check on what is true: the API can't tell an export from an invented conversation. Any key with the permission can put in a conversation between any members, with any text and any dates, and it looks like the real thing. The safeguards are the rules on this page and in Start here, and the line Zen Social keeps of every request: the key, the admin who made it, the community, your external_id for the conversation and the counts (see "What Zen Social keeps of a request").

Before the first import: the members must be told

The community tells its members before the move that their old private conversations are moving to Zen Social. That is a condition for using this part of the API, and Zen Social does not ask each member again. Before the first real import, ask the admin to confirm that the members have been told. If the admin has not told them yet, stop and tell the admin so. Do not start with "just a test" on real people's conversations either: see "Rules for assistants" in Start here.

Match every person with the admin first

A wrong match puts a private conversation in front of the wrong person, and an undo cannot take back that it was seen. So before any direct-message import, show the admin the full mapping: each person in the old conversations, and the Zen Social member (name and id) you will use for them. Wait for an explicit yes. Never guess between similar names. A person you cannot match with certainty means that conversation is not imported: leave it out and tell the admin.

Move conversations only as they are in the old platform's export, between the people who were in them: never compose, reword, add or leave out a message, and never make a conversation that did not exist.

The key needs a permission

A key can move direct messages only if it was made with the permission May import direct messages. An owner or admin ticks it when making the key, on the tab Import & AI access. It cannot be added to a key later: the admin makes a new key. The key list shows which keys have it. See Keys and access.

A key without the permission answers 403 {"error":"forbidden"} on both endpoints below, and nothing is written. The admin who made the key must be an owner or admin of the community, as for every key.

Import one: POST /conversations

One request moves one conversation, or the next batch of its messages. The conversation is named by your own external_id for it. The people are the members' ids from Members.

curl "$API/conversations" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-thread-3001", "participant_ids": ["EveABqLyto9L", "Qm3TzK8pWn2D"], "messages": [{"external_id": "fb-msg-1", "author_id": "EveABqLyto9L", "created_at": "2023-04-15T12:30:00+02:00", "body": "Are you coming to the seed swap on Saturday?"}, {"external_id": "fb-msg-2", "author_id": "Qm3TzK8pWn2D", "created_at": "2023-04-15T12:41:00+02:00", "body": "Yes! I will bring the tomato seedlings."}]}'
Field Rules
external_id Required: your id for the conversation on the old platform, as text or a number, at most 200 characters, and not starting with fb:.
participant_ids Required: the members' ids from Members. At least 2, all different.
messages Required: 1 to 100 messages (below).

Each message in messages:

Field Rules
external_id Required: your id for the message, as text or a number, at most 200 characters, without a /, and different for every message in the request.
author_id Required: a member's id. The author need not be one of the participant_ids: see "Authors who left".
body The text, as plain text, at most 4000 characters. May be empty only when the message has photos.
created_at Required: when it was written, in ISO 8601 with an offset (2023-04-15T12:30:00+02:00 or …Z), from 1990 and not in the future. A date alone, or a time without an offset, is refused.
image_urls Optional: a list of picture addresses for Zen Social to fetch.
images Optional: picture files in a multipart form (below).

Fields the API does not know are ignored.

Everyone must be a member

Every person in participant_ids and every author_id must be a member of the community. Someone who is in one of its circles is a member of the community too. If anyone is not, the whole conversation is refused: nothing is moved, and the answer names the ids you sent that did not match:

{"error": "participants_not_found", "details": {"participant_ids": ["fb-user-77"], "author_ids": ["fb-user-81"]}}

A list with nothing unmatched is left out of details. The answer is the same for an id that does not exist and for a member of another community, and names only ids you sent. Do not guess: tell the admin who is missing. When those people have joined, send the conversation again.

Authors who left

A person who wrote in the old thread but is no longer in it may be the author_id of their messages without being in participant_ids. Their messages keep their name, but they are not added to the conversation, so they cannot see it. They must still be members.

What the conversation becomes

  • Two people who have no direct conversation in Zen Social yet get their direct conversation.
  • Two people who already talk in Zen Social get the old thread as a separate conversation. Their live conversation is not touched, and the old messages are not mixed into it. In the app it is listed under the other person's name, with the date of its own last message.
  • Three or more people get a group conversation. A group may hold more than 8 people, as moved conversations are allowed to. Nobody can be added to such a group in the app afterwards.
  • Conversations have no names. Every moved conversation opens with a small line saying "Moved from the earlier system · 27 Oct 2026" ("Flyttet fra det tidligere system · 27. okt 2026" in Danish), dated the day it was moved, by the UTC calendar (near midnight in Denmark that can be the day before).

Blocks

A block between people in the conversation does not stop the move: the conversation comes in as if the block came after it. Zen Social's usual block rules then apply, as for a conversation people had in the app before one of them blocked the other:

  • The person who blocked does not see a conversation of just the two of them in their inbox.
  • Nobody can write to someone who blocked them, so a conversation of just the two of them is read-only for both.
  • In a larger group, the person who blocked sees the other's messages folded away.

The answer is the same as without a block, so it never tells you, or the admin, who has blocked whom.

Photos

image_urls and images work as for posts (see Pictures), with these limits: at most 10 photos on a message and 20 in a request, counted before any is fetched. A photo file (multipart) takes at most 10 MB. A photo address is fetched while you wait, at most 5 MB each.

Photos are JPEG, PNG or AVIF only, judged from the file's contents, not its name. Each is saved again, turned upright and without its metadata: the camera's details and the place it was taken are not kept. An AVIF is stored as a JPEG, or as a PNG when it is transparent.

  • Left out and counted in skipped_files, for files and for fetched addresses alike: a photo of another type (a GIF, WebP or HEIC, say), one whose start cannot be read as a picture, one too large in pixels, or a file over 10 MB. The message goes in without it.
  • A message left with no text and no photo is skipped and counted in skipped_messages. A message sent with an empty body and no photos at all is not that: it is refused (422 invalid).
  • Refused with 422 invalid, and nothing is moved: an address that cannot be fetched, is over 5 MB, or does not give an image at all (a web page, say) (under messages[2].image_urls for the third message), and a photo with a good start but a damaged picture (under messages[2].images). Try again later, or leave the photo out and tell the admin.
  • On a re-run (below) no photo is downloaded again for a message that is already moved.

Photos as files go in a multipart form. A list is participant_ids[], and each message's fields are messages[][external_id], messages[][author_id], messages[][body], messages[][created_at] and messages[][images][]. Start every message with its external_id: that is how the form is told where one message ends and the next begins.

curl "$API/conversations" \
  -H "Authorization: Bearer $KEY" \
  -F "external_id=fb-thread-3002" -F "participant_ids[]=EveABqLyto9L" -F "participant_ids[]=Qm3TzK8pWn2D" \
  -F "messages[][external_id]=fb-msg-1" -F "messages[][author_id]=EveABqLyto9L" \
  -F "messages[][created_at]=2023-05-02T18:30:00+02:00" -F "messages[][body]=Photos from Saturday" \
  -F "messages[][images][]=@saturday-1.jpg" -F "messages[][images][]=@saturday-2.jpg"

The photos are private to the people in the conversation, as photos sent in the app are.

The answers

A request is either moved whole or not at all. One that is refused writes nothing.

201 Created, when something was moved, and 200 OK, when there was nothing new, answer with the same four counts:

{"status": "created", "imported": 2, "already_imported": 0, "skipped_messages": 0, "skipped_files": 0}
Field Meaning
status created, or already_imported when the request held nothing new.
imported How many messages this request moved in.
already_imported How many messages of the request were moved in by an earlier request.
skipped_messages How many messages had no text and no photo Zen Social could take.
skipped_files How many photo files were left out (above).

The refusals:

Status error Meaning
403 forbidden The key does not have the permission.
422 participants_not_found Someone is not a member of the community (above).
422 not_allowed Rare: someone in the conversation, or one of its authors, was banned from the community while the request ran (a ban ends the membership, so otherwise the answer is participants_not_found). Nothing was moved. Tell the admin. A block is never a refusal (see "Blocks").
422 older_than_imported A later batch that starts before the newest message already moved (below).
422 invalid A value is not allowed. details names the field, and for a message its place in the list, counting from 0, for example messages[3].created_at. It never repeats the text. A first import in which not one message has text or a photo Zen Social can take is invalid too, under details.messages.
409 cannot_continue The conversation was moved, but can take no more messages (below).
409 conflict The same conversation was sent twice at the same moment. Send it again, once.
429 Too many messages or requests (below).

Batches, and re-runs

A long conversation can come in several requests of up to 100 messages. Send the same external_id and the same participant_ids every time, and send the batches in date order: the oldest messages first.

  • Inside one request the order does not matter: the messages go in sorted by created_at, and messages with the same time in the order you sent them.
  • A batch whose first message is older than the newest message already moved is refused with 422 older_than_imported. Nothing is moved.
  • A batch can continue the conversation only while nobody has done anything in it. Once anyone has written in it, reacted to, edited, deleted or reported a message, or left, a batch with new messages is refused with 409 cannot_continue. The same answer comes for different participant_ids than the first time, and for a conversation that has been removed. Skip the rest of that conversation and tell the admin.
  • While a batch that continues a conversation is being written, photos included, the members' own messages to that conversation wait until it has finished. A batch with many photos can take a while, so send continuations with photos in small batches.
  • A request with nothing new answers 200 already_imported, whatever has happened since, even when the conversation has been touched. When every message in it was moved before, that holds even if someone in it has left the community since. That makes it safe to run a whole script again after a crash: what is moved is not moved twice, and no old conversation meets a refusal. The messages are recognised by their external_id.

Undo one: DELETE /conversations/:external_id

If a conversation was moved in wrongly, the admin can have it taken out again, without anyone reading it. The address holds your external_id for the conversation, percent-encoded (a / in it as %2F):

curl -X DELETE "$API/conversations/fb-thread-3001" -H "Authorization: Bearer $KEY"

Answers 204 No Content. The conversation and the messages you moved in are removed from the people's inboxes at once, photos included. Then the same conversation can be moved in again.

  • 404 not_found when no conversation was moved in with that external_id, and for a second undo of the same one.
  • 409 cannot_undo when anyone has touched the conversation since (the same list as for continuing: written, reacted, edited, deleted, reported, left). It stays as it is. Tell the admin: what people have done in a conversation is theirs.
  • 422 invalid when the id starts with fb: or is longer than 200 characters.
  • Any key with the permission in the same community may undo, not only the key that moved it in.

Undo is for one conversation you moved yourself. Confirm with the admin before you undo: say which conversation it is by your own id, and wait for their yes. There is nothing that undoes many at once, and there is no other way to delete a conversation through the API.

Limits

  • At most 100 messages and 20 photos in a request.
  • At most 1,000 messages a minute per key. Every message in a request that passes the checks on its shape and the permission counts, whether or not the import is then accepted, and whether or not the message was already moved. A request refused earlier (403, or 422 invalid for its shape) counts nothing. Above that the answer is 429 with Retry-After, as for too many requests (see Keys and access). Wait, then send the same request again.
  • The usual limits on requests also apply: 300 a minute per key, and 600 from one network address.

Send one request at a time. A request with photo addresses is slow, as it fetches them.

What Zen Social keeps of a request

Every request leaves one line in Zen Social's log: the key, the admin who made it, the community, that the key has the permission, your external_id for the conversation, how many people it names and how many messages it holds. Never the text, the photos or their names. Do not put your own secrets in an external_id.

Nobody is told

Moving a conversation in, continuing it and undoing it tell no member anything: no bell, no push message, no email, no live update on a screen that is open. The conversation does not show as unread to anyone. It is read by everyone in it, and "Seen by" shows them all. It sits in the inbox by the date of its last message, so a conversation from 2023 is far down the list. People see it when they open their inbox.