iMessage Group Chat API: Create, Send, Read and Manage Groups
Short answer: every ZeroSettle number can start an iMessage group with up to 31 people, send into it, read it, rename it, add or remove members, and leave it. You start a group with POST /api/imessage/groups, which sends the first message and returns the group's chat_id. From then on, a group is just a conversation keyed by chat_id instead of handle: the same send, typing, reaction, reply and poll calls work, and group messages reach your webhook as the usual events with a chat_id attached.
Key takeaways
- On by default: group chats are on for every number;
GET /api/imessage/lineshows"group_chats": true. - One id to store: the
chat_id(such asiMessage;+;chat123456789) never changes, even when the group is renamed or its members change. - Branch on
chat_id: when a message or conversation carries it, it's a group, and an inbound message'shandleis the member who wrote it. - Membership changes are their own event: renames, adds, removes and leaves arrive as
group.event, nevermessage.received. - Limits still apply: each member new to the number counts as a new contact, and a STOP from any member blocks sends into every group they're in.
- Broadcast is not a group:
POST /api/imessage/broadcastsends separate 1:1 messages.
A lot of real coordination happens in group threads: a homeowner, a project manager and a crew lead; friends planning a trip; a team sorting out Saturday's game. This is a reference walkthrough of the group endpoints in the ZeroSettle group chats docs, with the request shapes as the docs give them. For the product side (when an assistant should speak, how to keep it quiet), read How to build an AI assistant for iMessage group chats alongside it.
The examples assume two shell variables: BASE=https://portal.zerosettle.io and TOKEN, your API token from the console.
Does my number support group chats?
Yes: group chats are on for every ZeroSettle number, current and new, and there is nothing to request or switch on. You can confirm it on the line itself:
curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/imessage/line
# { ..., "group_chats": true, ... } also on each entry in "lines" when you have several numbers
ZeroSettle can turn group chats off for an individual number. Such a number has no group_chats key at all, every group call on it is refused with 403 group_chats_not_enabled, and messages people send in a group that includes it carry no chat_id. So the safe check is "the key is present and true", not "the key isn't false".
A number that needs a software update before it can use group chats answers 409 client_update_required and sends nothing. Retry it later; it isn't permanent.
What is a chat_id?
A chat_id is the group's permanent name in the API: a string such as iMessage;+;chat123456789, at most 128 characters. Store it exactly as you receive it. It stays the same for the life of the group, through renames and membership changes, so it is the right primary key for anything you attach to a group in your own database (a job, a trip, a team).
Everywhere a 1:1 conversation is keyed by handle, a group is keyed by chat_id. A conversation on a line is either one contact or one group. In a URL, encode it: iMessage%3B%2B%3Bchat123456789.
A group lives on the line that is in it. Calls that name a chat_id act on that line, so you don't pass line or line_id even with several numbers. A chat_id that none of your lines has answers 404 unknown_chat.
How do I start a group?
Send POST /api/imessage/groups with the other members and a first message; the group is created by sending that message.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"to": ["+18055559876", "+13105550000", "jane@example.com"],
"text": "Welcome to the launch group!", "name": "Launch crew"}' \
$BASE/api/imessage/groups
to: 2 to 31 handles (phone numbers or emails, each as in/send). These are the other members. Your number is always in the group; don't list it.text(required): the first message, 1 to 4,000 characters after trimming.name(optional): up to 60 characters. Without one, Messages shows the members' names.line_idorline(optional, with more than one number): the number that creates the group. It must have group chats on.
Before anything is sent, every member is checked as if you were texting them 1:1. If any check fails, nothing is sent:
- STOP: a member who texted STOP to any of your numbers gives
403 recipient_opted_out, witherror.handlenaming them. - New contacts: each member the line has never exchanged a message with (1:1 or in another of its groups) counts as one new contact toward the line's day. If they don't all fit, you get
429 new_contact_limit. - On iMessage: each new member must be on iMessage, or the call answers
422 not_on_imessagewitherror.handle.
A successful response is the shape of a send plus the group:
{
"ok": true, "status": "message_sent", "message": "…", "id": 70,
"record": {"id": 70, "kind": "message", "direction": "out", "handle": "iMessage;+;chat123456789",
"text": "Welcome to the launch group!", "status": "sent", "guid": "7B1D…",
"ts_ms": 1757400200000, "chat_id": "iMessage;+;chat123456789"},
"chat": {"chat_id": "iMessage;+;chat123456789", "name": "Launch crew",
"participants": ["+18055559876", "+13105550000", "jane@example.com"]}
}
participants are always the other members, never your own number. With several numbers, chat also carries line and line_id.
Don't retry a group_unconfirmed
Rarely, the first message goes out to everyone before the group's id is known. The answer is still 200 with "ok": true, but "status": "group_unconfirmed" and "chat": null. The message is stored and counted (it's in record), and each member already counts as a contact, so a retry would start a second group. The group appears with its real chat_id in GET /api/imessage/groups (and as group.updated on the live stream) once someone in it replies. If your code treats a missing chat as a failure and retries, it will double-send; check ok and status instead.
How do I send into a group?
Use the normal POST /api/imessage/send with chat_id instead of to; pass exactly one of the two.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"chat_id":"iMessage;+;chat123456789","text":"Doors open at 7"}' $BASE/api/imessage/send
Everything a 1:1 send takes works in a group: text, image_b64 or audio_b64, video_id or video_b64, poll, reply_to and effect. The response is a normal send response; its record carries chat_id and its handle is the chat id. A video into a group uses credits (1 per 3 MB) once per send, however many members the group has.
Two rules differ from 1:1 sends, and both are worth encoding in your client:
- STOP blocks the whole group. If any member has texted STOP, the send is refused with
403 recipient_opted_outanderror.handlesays who. Nothing is sent. Remove that member, or leave the group. - Some 1:1 rules are skipped. The unanswered-message rule (
awaiting_reply), the on-iMessage check and the automatic name-and-photo share are 1:1 rules, and a group send skips them.
The usual line rules still hold: a line sends one message at a time, so concurrent sends queue and can surface as 429 line_busy (retry after 2 seconds). The send a message docs and Send an iMessage from code cover the retry pattern.
How do I read groups and their messages?
GET /api/imessage/groups lists every group on your numbers that have group chats on:
curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/imessage/groups
# {"groups": [{"chat_id": "iMessage;+;chat123456789", "name": "Launch crew",
# "participants": ["+18055559876", "+13105550000", "jane@example.com"],
# "created_at_ms": 1757400200000, "updated_at_ms": 1757400200000}]}
name is null when the group has none, and groups your number has left are not listed. With several numbers, each entry carries line and line_id.
Messages. A group message has the same shape as any other message, plus chat_id. A 1:1 message never has the key. For an inbound group message, handle is the member who sent it; for an outbound one, handle is the chat id.
# one group's messages (chat_id and handle can't be combined)
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/api/imessage/messages?chat_id=iMessage%3B%2B%3Bchat123456789&after_id=0&limit=100"
# {"messages": [{"id": 71, "kind": "message", "direction": "in", "handle": "+13105550000",
# "text": "See you there", "status": "received", "guid": "8C2E…", "ts_ms": 1757400230000,
# "chat_id": "iMessage;+;chat123456789"}], "next_after_id": 71}
Be careful with filters: ?handle= returns only the 1:1 conversation with that contact, never their group messages. Without either filter you get everything, groups included, so a sync job that pages through all messages will see group rows and must branch on chat_id. Paging works as in the read messages docs: pass next_after_id back as after_id.
Conversations. GET /api/imessage/conversations lists each group as one entry next to the 1:1 ones, with chat_id, name, participants, count and last. When chat_id is present, the entry is a group and its handle is the chat id. A group your number has left, or was removed from, is still listed with its messages and "left": true; sending into it answers 404 unknown_chat.
Deleting. DELETE /api/imessage/conversations/{handle} with an encoded chat id deletes that group's stored messages. As with a 1:1 delete, nothing is unsent and nobody in the group is told.
How do I rename, add, remove or leave?
Renames and membership changes go through POST /api/imessage/groups/update, and leaving goes through POST /api/imessage/groups/leave.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"chat_id": "iMessage;+;chat123456789", "name": "Launch crew 2026",
"add": ["+16465550199"], "remove": ["jane@example.com"]}' \
$BASE/api/imessage/groups/update
# {"ok": true, "chat": {"chat_id": …, "name": "Launch crew 2026", "participants": […]}}
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"chat_id": "iMessage;+;chat123456789"}' $BASE/api/imessage/groups/leave
# {"ok": true}
name,addandremoveare all optional; send only what changes. The response is the group after the change.- Adding someone who has texted STOP is refused with
403 recipient_opted_out. Someone new to the line counts as a new contact on the day you add them, exactly as when you start a group. - After
/groups/leave, your number stops receiving the group's messages and the group drops out ofGET /api/imessage/groups. The stored messages stay readable.
When a group send comes back recipient_opted_out, remove that member with /groups/update before your next send, and don't add them back unless they text START.
Do typing, reactions, replies and polls work in groups?
Yes, all four work in groups. Typing and polls take chat_id where a 1:1 call takes to, while reactions and replies point at a message that is already in the group:
| Feature | Outbound | Inbound |
|---|---|---|
| Typing | POST /api/imessage/typing with chat_id instead of to shows your "…" in the group | typing.started / typing.stopped carry chat_id; handle is the member typing (always on the live stream, on the webhook only with "typing_events": true) |
| Reactions | POST /api/imessage/reactions with the message_id of a group message; the stored reaction carries chat_id | Members' tapbacks arrive as reaction.received with chat_id and their own handle |
| Replies | reply_to on a group send must name a message in that same group (422 invalid_reply_to otherwise) | Replies carry reply_to as in 1:1 |
| Polls | poll with chat_id sends a poll every member can vote in | Votes arrive as poll.voted; voters lists each member's handle |
A poll is the most useful of these in a group, because it turns "what night works for everyone?" from twenty bubbles into one tap each:
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"chat_id": "iMessage;+;chat123456789",
"poll": {"question": "Which night works?", "options": ["Thu", "Fri", "Sat"]}}' \
$BASE/api/imessage/send
Members on iOS 26 or later vote in the thread; older iPhones show "Sent a poll" and can't vote, so don't wait forever on a missing vote. What can an iMessage API send? and the typing indicators docs have the full shapes.
What do group webhooks and stream events look like?
Group messages arrive as the same events as 1:1 messages (message.received, message.sent, message.failed, reaction.*, poll.voted), each message carrying chat_id. Two things are new: group.event on the webhook, and group.updated on the live stream only.
- Thu 4:02 PM
- Business: Welcome to the launch group! Doors open at 7 on Friday at Hollis Hall.
- Priya: Can I bring a plus one?
- Priya
- Marcus: Same question, also is there parking
- Marcus
- Business: Yes to a plus one each. Parking is the lot on 4th, free after 6.
- Jen: Perfect, see you all there👍
- Jen
- Marcus named the conversation “Launch crew 2026”
message.received with the group's chat_id and that member's handle. The business's sends come back as message.sent, the tapback as reaction.sent, and Marcus's rename as group.event with group_event.type renamed.group.event. A rename, a member added or removed, a member leaving and a new group photo are stored as a message of kind: "group_event" and delivered as their own event, with the header X-ZeroSettle-Event: group.event. They are never message.received, so an auto-responder that acts only on message.received never answers one. handle is who did it, text says what happened, and group_event holds the details:
{"id": 72, "kind": "group_event", "direction": "in", "handle": "+18055559876",
"text": "Renamed the group “Launch crew 2026”", "status": "received", "guid": "9D3F…",
"ts_ms": 1757400300000, "chat_id": "iMessage;+;chat123456789",
"group_event": {"type": "renamed", "name": "Launch crew 2026"}}
group_event.type is renamed (with name), member_added or member_removed (with handle), member_left, or photo_changed. A member_removed without handle means your number was removed, and the group is then left, as with /groups/leave.
group.updated. Whenever a group's name or members change (you start or update it, or a group event changes it), the live stream gets an event: group message whose data is group.updated with the whole group as it is now. It is not sent to your webhook. A dashboard on the stream should keep the copy with the newest updated_at_ms; a backend on the webhook should re-read GET /api/imessage/groups or follow group.event messages.
Here is the routing a receiver needs, after it has verified the signature and answered 200 (the webhooks docs and How to receive iMessages with a webhook cover signing, retries and dedupe):
type ZsMessage = {
id: number; kind: string; direction: "in" | "out"; handle: string; text?: string;
chat_id?: string; group_event?: { type: string; name?: string; handle?: string };
};
export function route(ev: { event: string; message: ZsMessage }) {
const m = ev.message;
if (ev.event === "group.event") return onGroupEvent(m);
if (ev.event !== "message.received") return; // your own sends echo back as message.sent
if (m.chat_id) return onGroupMessage(m.chat_id, m); // m.handle is the member who wrote it
return onDirectMessage(m.handle, m);
}
function onGroupEvent(m: ZsMessage) {
const e = m.group_event!;
if (e.type === "member_removed" && !e.handle) return markLeft(m.chat_id!); // your number was removed
return refreshGroup(m.chat_id!); // re-read GET /api/imessage/groups
}
Queue work per conversation key (chat_id for groups, handle for 1:1) so a group's messages are handled in order, and never key group work on the member's handle: the same person can be in a 1:1 thread and three groups with you at once.
Which MCP tools handle groups?
When at least one of your numbers has group chats on, the ZeroSettle MCP connector adds group support to its tools, so Claude, Claude Code or Cursor can work with groups directly:
| Tool | Arguments | Does |
|---|---|---|
send_imessage | chat_id? (instead of to) | Sends into a group |
list_messages | chat_id? | One group's messages, each labeled with its sender |
show_typing | chat_id? (instead of to) | "…" in a group |
create_group_chat | to, text, name?, line? | Same as POST /api/imessage/groups |
list_group_chats | None | Same as GET /api/imessage/groups |
update_group_chat | chat_id, name?, add?, remove?, leave? | Same as /groups/update, or /groups/leave with leave: true |
list_conversations shows each group by its name, or by its members when it has none. The hosted iMessage MCP server covers setup for each client.
Does the hosted AI agent answer in groups?
Yes: with no server at all, a number managed by ZeroSettle's hosted AI agent answers inside its groups too. It reads the group's recent messages (each labeled with who wrote it) and knows the group's name and members. It replies into the group, never in a 1:1 chat, and only when a message is meant for it or asks something it can help with; it stays silent when people are talking to each other.
- No triggers in groups: its follow-ups and daily texts never fire into a group.
- Not woken by everything: tapbacks, poll votes and the group's own events (a rename, someone joining or leaving) don't wake it.
- Never another ZeroSettle number: it never answers one of ZeroSettle's own iMessage numbers, so two agents can't keep texting each other. When several of your numbers with the agent on share a group, only one of them answers.
- Credits: each text it sends uses one credit, as anywhere else.
To run your own code instead, set the number to managed_by: "api" so inbound texts reach your webhook (see the AI agent docs), and read When should an AI speak in a group chat? before your agent says anything.
Should I use a group or a broadcast?
Use a group when the members should see each other and talk to each other; use a broadcast when each person should get the same message privately. POST /api/imessage/broadcast never creates or uses a group chat.
| Question | Group chat | Broadcast |
|---|---|---|
| Endpoint | POST /api/imessage/groups, then /send with chat_id | POST /api/imessage/broadcast |
| Recipients per call | 2 to 31 other members | 1 to 100 handles |
| Do recipients see each other? | Yes, one shared thread | No, each gets their own 1:1 message |
| Replies | Everyone sees every reply; handle is the member | Each reply lands in that person's 1:1 thread |
| STOP | One member's STOP blocks every send into the group | That recipient is skipped; the others still get it |
| Unanswered-message rule | Skipped (a 1:1 rule) | Applies to each recipient |
| New contacts | Each member new to the line, counted when you start the group or add them | Each new recipient, counted per send |
| Video credits | Once per send | Per recipient |
| Result | One send response with chat | Per-recipient results with sent, failed, skipped, deferred |
A quick test: if a recipient's reply might hold something the others shouldn't see (a price, an address, a complaint), use a broadcast, not a group.
Which errors should my code handle?
Group calls use the same JSON error shape as the rest of the API: branch on error.code and show error.message to a person. These are the group-specific ones:
| Response | Meaning | What to do |
|---|---|---|
403 group_chats_not_enabled | A group call on a number without group chats. Nothing was sent. | Contact ZeroSettle support, which may have turned them off for this number. |
404 unknown_chat | No line on your account has that chat_id, or the number has left the group. | Use a chat_id from GET /api/imessage/groups or a message. |
409 client_update_required | The number needs a software update before it can use group chats. Nothing was sent. | Try again after the update. |
403 recipient_opted_out | A member, or a member you are adding, texted STOP. error.handle says who. | Remove them from the group, or leave it. |
429 new_contact_limit | Starting a group, or adding members, would go over the line's new contacts. | Wait retry_after_s, or start with fewer new people. |
422 not_on_imessage | A new member isn't on iMessage. | Leave them out. |
422 invalid_request | Both or neither of to and chat_id, fewer than 2 or more than 31 members, a name over 60 characters. error.fields lists them. | Fix the input. |
422 invalid_reply_to | reply_to names a message outside this group. | Reply to a message in the same group. |
The full list, including line_busy and the 503 codes to retry with backoff, is in the errors, limits and compliance docs.
What limits and consent rules apply?
The same per-number limits as 1:1 messaging, applied to every member. Three rules matter most in groups:
- Each new person is a new contact. Starting a group, or adding someone to one, counts every member the line has never exchanged a message with as a new contact: 5 a day on Pro, 50 a day per number on Growth (and 3 per 15 minutes). After that, members are known contacts, and a 1:1 text to one of them is not new. People who text your number first are never limited.
- STOP from any member blocks the group. A STOP blocks that person on every number of the account, as always, and sends into any group they are in are refused until they are removed or text START. How STOP and opt-outs work has the details.
- Consent covers everyone. A group message reaches every member, and each of them must have agreed to hear from you. Adding someone to a group is texting them.
Each number allows 10,000 messages a day, sent and received, with known contacts (people who texted it first or it already has a conversation with). In practice, the new-contact cap is what limits how many unfamiliar people you can put in a group at once. A group of 20 people the line has never messaged can't be started in one call on Pro or Growth: Pro allows 5 new contacts a day, and Growth allows 50 a day per number but 3 per 15 minutes, so the call answers 429 new_contact_limit and nothing is sent. Start with people who have already texted the number, or start small and add the rest over time with /groups/update. For high volume and compliance needs, Enterprise has custom pricing through sales (book a demo).
Only group people who asked to hear from you
Create groups only from people who gave you their number for this purpose: customers on a job who agreed to texts, attendees who signed up, team members who opted in, or people who texted the number first. Never build a group from a purchased list, scraped numbers or cold outreach. The TCPA consent guide covers what consent looks like in practice; this is not legal advice.
What isn't covered?
Four things are outside the group API today, and it's better to know them before you design around them:
- Broadcast is not a group.
POST /api/imessage/broadcaststill sends each recipient their own 1:1 message. - Only messages your number sends through ZeroSettle are recorded from your side: the API, MCP and the console.
- CRMs: group messages are not logged in Close or HubSpot. If your team works from a CRM, log group activity yourself from the webhook; the CRM integrations docs describe what is logged for 1:1 threads.
- Chat backgrounds set in a group are not reported.
For what people actually build with this, see one iMessage group per job for homeowners, project managers and crews, a group trip planner, and a coordinator for teams, clubs and communities.
Frequently asked questions
Can I create an iMessage group chat with an API?
Yes. On ZeroSettle, one POST to /api/imessage/groups with the members' handles and a first message creates the group by sending that message. Every later send into it uses the group's chat_id. Groups someone else adds your number to work the same way.
How many people can be in a group?
A group holds up to 31 people. When you start one, the to list names 2 to 31 other members; your own number is always in the group and is never listed. Members you add later are checked for STOP and counted as new contacts, as when you start a group.
Do group members count toward my new-contact limit?
Yes, but only members the number has never exchanged a message with. Each one counts as one new contact on the day you start the group or add them: 5 a day on Pro, and 50 a day per number (3 per 15 minutes) on Growth. After that they are known contacts, so a 1:1 text to one of them is not new. People who text the number first are never limited.
What is the difference between a group chat and a broadcast?
A group is one shared thread where every member sees every message and each other. A broadcast sends the same message to up to 100 people, each in their own 1:1 conversation, and nobody sees anyone else. Use a group only when the members should see each other.
Will the hosted AI agent reply in my group chats?
On a number managed by the hosted AI agent, yes: it reads the group's recent messages and replies into the group only when a message is meant for it or asks something it can help with, and stays silent when people are talking to each other. Its triggers (follow-ups, daily texts) never fire into a group, and it never answers another ZeroSettle number.
Do group chats cost extra?
No. Group chats are on for every number on every plan. Pro is $49 a month for one dedicated number. Growth is $199 a month per number when you upgrade in the console, or $599 a month for 3 numbers from a Growth link plus $199 per extra number. Enterprise has custom pricing through sales for high volume and compliance needs. The subscription has a 30-day money-back guarantee. Credits pay only for the hosted agent's texts (1 per text) and for videos; a video into a group is charged once per send, however many members it has.
Sources
- ZeroSettle iMessage API docs: “Group chats” (availability, the chat id, start, send, read, rename/add/remove/leave, typing/reactions/replies/polls, webhooks and the live stream, MCP, errors, limits and compliance, not covered). Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Send a message” (broadcast, polls, replies, reactions). Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Read messages”. Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Typing indicators”. Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Webhooks, step by step” (events, typing events). Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Live stream (SSE)”. Checked November 2, 2026.
- ZeroSettle iMessage API docs: “AI agent” (in group chats, credits). Checked November 2, 2026.
- ZeroSettle iMessage API docs: “Errors, limits, and compliance” (limits, consent, STOP). Checked November 2, 2026.
- ZeroSettle iMessage API Terms of Service.