Groups
Creating groups, changing membership, and the staleness rule that catches everyone.
Group chats work like any other conversation once they exist — you send into them with a conversation target, and inbound messages arrive on the stream the same way. What is different is creating one, and what happens to your conversation id when the membership changes.
Group commands are not retry-safe
group.create, group.update and group.leave take the admin routing path, where an
Idempotency-Key header is accepted and then ignored — the server mints a fresh identity for
every call. Retrying a group.create that appeared to time out creates a second group. Wait
for the settlement on the response stream before you decide
whether anything needs retrying.
Creating a group
iMessage has no concept of an empty group, so
group.create creates the chat and sends its opening
message in one operation. firstMessage is required and runs from 1 to 65,536
bytes; there is no way to make a group and then decide what to say.
The target is a new_group with between 2 and 32 participants, each an E.164
phone number or a lowercase email address. Do not include yourself — you are
implied — and order does not matter.
curl $MAPIER_BASE_URL/v1/commands/group.create \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target": {
"kind": "new_group",
"participantExternalIds": ["+14155550100", "+14155550111"]
},
"payload": { "firstMessage": "Kicking off the thread here." }
}'If you are not sure every participant is on iMessage, check the addresses first — see Checking reachability. A participant who cannot receive iMessages does not fail the request in a way you can see at the boundary.
Getting the new conversation id
A successful group.create settlement carries an extra field the other
commands do not have: the conversationId of the group it created.
{
"commandId": "cmd-1",
"logicalActionId": "cmd-1",
"status": "succeeded",
"errorCode": null,
"conversationId": "a0000000-0000-4000-8000-000000000002"
}That id is what you address the group with afterwards — replies, renames, membership changes.
The id is best-effort
conversationId is resolved after the group is created, and only appears on a successful
settlement where the lookup finished in time. It is omitted otherwise. Its absence does not
mean the group failed — the group exists, you do not have its id yet. The id also arrives with the
first inbound message in that group, so treat the field as optional and fall back to the stream.
There is a third settlement to handle. group.create checks its own work
before claiming success: it looks for the new chat, compares its participants
against the set you asked for, and looks for your firstMessage in its
history. When it cannot prove all three, it settles ambiguous with
group_content_unverified — meaning the group may well exist, but nothing
confirmed it. That is the case where a blind retry does real damage, because a
second group.create makes a second group.
In practice that means writing the handler so all three paths are covered:
if (event.status === "succeeded" && event.conversationId) {
await recordGroup(commandId, event.conversationId);
} else if (event.status === "succeeded") {
// Created, but unresolved. The id will arrive with the first inbound
// message in the group; match it up then.
await markGroupAwaitingId(commandId);
} else if (event.status === "ambiguous") {
// May or may not exist. Do not retry automatically — check first.
await markGroupNeedsReview(commandId);
}Renaming a group
group.update is discriminated by action. Renaming
takes a name of 1 to 128 characters with no control characters.
curl $MAPIER_BASE_URL/v1/commands/group.update \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000002"
},
"payload": { "action": "rename", "name": "Trip planning" }
}'The target must be a group conversation. Pointing group.update at a direct
message is rejected at the boundary with a 400, code
group_command_requires_group_conversation, before anything reaches the Mac.
Adding a participant
Same command, different action:
{ "action": "add_participant", "participantExternalId": "+14155550122" }After adding someone, wait for a message
A group conversation id is resolved against the set of participants it was
expected to have. Add someone and that expectation no longer matches reality,
so the next command you send to the same id fails closed with
stale_target instead of running.
The fix is not a retry. Wait for a new message to arrive in the group, and
use the conversationId that comes with it.
This is worth understanding rather than working around, because it is a safety
property. Resolving a group means finding the chat on the Mac that matches both
the id and the participant set. When those disagree — because membership
changed, because two chats match, because the directory cannot be read — the
command refuses to fire rather than guessing which chat you meant. A wrong
guess would send your message to the wrong group of people. stale_target is
that guess being declined.
The same rule is why you should not cache a group id indefinitely and assume it stays addressable. Anything that changes who is in the chat invalidates it until a fresh message re-establishes it.
Removing a participant
The remove_participant action exists and is accepted with a 202, but on
hosts running macOS 26 the underlying call stalls and the command settles as a
failure. The fault is in the operating system's own path, not in the request
you sent, so there is nothing to correct on your side.
Do not build a flow that depends on removing someone from a group. If you need that today, the practical alternative is to create a new group with the participant set you want.
Leaving a group
group.leave takes a group conversation and an empty
{} payload. On macOS 26 it is accepted, then settles:
{
"commandId": "cmd-1",
"logicalActionId": "cmd-1",
"status": "failed",
"errorCode": "group_leave_unverified"
}It is documented for completeness. Treat it as unavailable until the host issue is resolved.
Group photos
Setting a group photo needs a transferId referring to staged media, and the
/v1 API has no endpoint that produces one. There is no upload path today, so
set_photo cannot be used from the HTTP API — the same limitation that applies
to attachments.
Capabilities
Group support is advertised per Mac. command.group_create.v1 covers
creation; command.group_update.v1 declares supportsRename,
supportsPhoto and supportsParticipants independently, so a host can support
renaming without supporting membership changes. There is no endpoint that lists
what a Mac advertises — see Capabilities for what
to do about that.