mapier docs

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.

event: command
{
  "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

Known issue — does not work on macOS 26.

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

Known issue — does not work on macOS 26.

group.leave takes a group conversation and an empty {} payload. On macOS 26 it is accepted, then settles:

event: command
{
  "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.

On this page