Managing WhatsApp groups
Everything the Groups page does, twelve operations under /v1/whatsapp/groups do too, so a group's whole life fits in code: create it, wait for WhatsApp to confirm it, hand out its invite link, decide who gets in, keep its details and its pins current, and delete it when the conversation is over.
Two things shape every call on this page. Nobody can be added to a group, so the invite link is the only way in, and inviting people happens outside the API. Most changes settle at WhatsApp rather than in the response, so the call that accepts a change is not the call that tells you it worked; How a change settles covers that once for every operation it applies to.
WhatsApp groups covers what a group is for and the same lifecycle in the dashboard. Sending to a WhatsApp group covers messaging one.
Prerequisites
You need an API key with whatsapp_management access: read level for the reads on this page, write level for everything else. Users, teams, and roles covers granting it.
You also need a business number that holds Official Business Account status, which WhatsApp requires of a group's administrator and Before you create a group covers earning. GET /v1/whatsapp/numbers reports it per number as is_official_business_account, so read it there rather than discovering it from a 412. The flag is as of the number's meta_synced_at, so a number connected minutes ago may not have been read back yet.
Initialize the client for your language using the TypeScript, Python, Go, or PHP SDK guide. For CLI examples, install and authenticate the CLI. The cURL examples name the US host; if your key starts with bk_eu1_, call https://eu1.platform.bird.com instead. The SDKs read the region from your key, so they set no host.
1. Create the group
A create names the administering number and a subject, and nobody else. Two choices are fixed for the group's whole life and cannot be changed afterwards: the number, and join_approval_mode, which decides whether opening the invite link admits someone outright (auto_approve, the default) or raises a request you decide (approval_required).
const group = await bird.whatsapp.groups.create({
whatsapp_number_id: "wan_01krdgeqcxet5s7t44vh8rt9mg",
subject: "Norwood Fleet — Tuesday route",
join_approval_mode: "approval_required",
});
console.log(group.id, group.status); // pending; read it back for the invite linkgroup = client.whatsapp.groups.create(
whatsapp_number_id="wan_01krdgeqcxet5s7t44vh8rt9mg",
subject="Norwood Fleet — Tuesday route",
join_approval_mode="approval_required",
)
print(group.id, group.status) # pending; read it back for the invite linkgroup, err := client.Whatsapp.Groups.Create(context.Background(), bird.WhatsappGroupsCreateParams{
WhatsappNumberID: "wan_01krdgeqcxet5s7t44vh8rt9mg",
Subject: "Norwood Fleet — Tuesday route",
JoinApprovalMode: bird.Ptr(bird.WhatsAppGroupJoinApprovalMode("approval_required")),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(*group.Id, *group.Status) // pending; read it back for the invite link$group = $bird->whatsapp->groups->create(
(new WhatsAppGroupCreate())
->setWhatsappNumberId('wan_01krdgeqcxet5s7t44vh8rt9mg')
->setSubject('Norwood Fleet — Tuesday route')
->setJoinApprovalMode('approval_required'),
);
echo $group->getId(), ' ', $group->getStatus(); // pendingbird whatsapp groups create \
--whatsapp-number-id wan_01krdgeqcxet5s7t44vh8rt9mg \
--subject 'Norwood Fleet — Tuesday route' \
--join-approval-mode approval_requiredcurl -X POST "https://us1.platform.bird.com/v1/whatsapp/groups" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"whatsapp_number_id": "wan_01krdgeqcxet5s7t44vh8rt9mg",
"subject": "Norwood Fleet — Tuesday route",
"join_approval_mode": "approval_required"
}'The API returns 202 with the group at status pending and no invite_link: WhatsApp issues the link when it confirms the group. The subject runs to 128 characters and an optional description to 2,048, and both are shown to anyone who opens the link, so name the group for its purpose rather than for an internal reference.
2. Wait for WhatsApp to confirm it
Read the group back until its status leaves pending. The read answers 200 whatever the status is, so branch on status rather than on the call succeeding.
const group = await bird.whatsapp.groups.get("wag_01krdgeqcxet5s7t44vh8rt9mg");
console.log(group.status, group.invite_link);group = client.whatsapp.groups.get("wag_01krdgeqcxet5s7t44vh8rt9mg")
print(group.status, group.invite_link)group, err := client.Whatsapp.Groups.Get(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg")
if err != nil {
log.Fatal(err)
}
fmt.Println(*group.Status)
if group.InviteLink != nil {
fmt.Println(*group.InviteLink)
}$group = $bird->whatsapp->groups->get('wag_01krdgeqcxet5s7t44vh8rt9mg');
echo $group->getStatus(), ' ', $group->getInviteLink();bird whatsapp groups get wag_01krdgeqcxet5s7t44vh8rt9mgcurl "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg" \
-H "Authorization: Bearer $BIRD_API_KEY"Five statuses can come back, and only one of them is a group you can work with:
| Status | What it means |
|---|---|
| pending | WhatsApp has not confirmed the group. invite_link is null and nothing here is shareable |
| active | The group exists, its invite link works, and it takes messages and changes |
| suspended | WhatsApp stopped activity in the group. Only WhatsApp lifts this |
| deleted | The group is gone, and stays readable so a stored reference to it still resolves |
| failed | WhatsApp refused the create, which is terminal |
failed is the other way out of pending, and last_operation.last_error.description carries WhatsApp's own reason. Never retry a failed create against that group: create a new one instead, changing what the refusal objected to.
3. Let people into the group
Send the group's invite_link to the people you want in it, over whatever channel already reaches them. No operation adds a participant, so the link is the only way in and joining is their decision. It admits whoever holds it, so handle it as the credential it is: WhatsApp groups covers how far to spread one.
On an auto_approve group, anyone holding the link is in and there is nothing to decide. On an approval_required group, opening the link raises a join request. List the ones waiting, then decide them in batches of up to 50:
const waiting = [];
for await (const request of bird.whatsapp.groups.joinRequests.list(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
)) {
waiting.push(request.id);
}
const result = await bird.whatsapp.groups.joinRequests.approve("wag_01krdgeqcxet5s7t44vh8rt9mg", {
join_request_ids: waiting.slice(0, 50),
});
console.log(result.decided.length, result.failed.length);waiting = [r.id for r in client.whatsapp.groups.join_requests.list("wag_01krdgeqcxet5s7t44vh8rt9mg")]
result = client.whatsapp.groups.join_requests.approve(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
join_request_ids=waiting[:50],
)
print(len(result.decided), len(result.failed))result, err := client.Whatsapp.Groups.JoinRequests.Approve(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg", bird.WhatsappGroupsJoinRequestsApproveParams{
JoinRequestIDs: []string{"wgj_01krdgeqcxet5s7t44vh8rt9mg"},
})
if err != nil {
log.Fatal(err)
}
for _, failure := range *result.Failed {
fmt.Println(*failure.JoinRequestId, *failure.Error.Description)
}$result = $bird->whatsapp->groups->joinRequests->approve(
'wag_01krdgeqcxet5s7t44vh8rt9mg',
(new WhatsAppGroupJoinRequestDecision())->setJoinRequestIds(['wgj_01krdgeqcxet5s7t44vh8rt9mg']),
);
echo count($result->getDecided() ?? []), ' ', count($result->getFailed() ?? []);bird whatsapp groups join-requests list wag_01krdgeqcxet5s7t44vh8rt9mg
bird whatsapp groups join-requests approve wag_01krdgeqcxet5s7t44vh8rt9mg \
--join-request-ids wgj_01krdgeqcxet5s7t44vh8rt9mgcurl -X POST "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg/join-requests/batch-approve" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"join_request_ids": ["wgj_01krdgeqcxet5s7t44vh8rt9mg"]}'WhatsApp decides each request on its own, so a batch is part-appliable: the response splits decided from failed, and each failure carries the reason WhatsApp gave in error.description. Read the response rather than the status code. A refusal is often a person who has not accepted WhatsApp's current terms, which no retry fixes.
Approving more people than the group has room for does not refuse the call: the requests that fit are decided and the rest come back in failed, each saying the approval would take the group past its participant limit. Rejecting works the same way, through POST /v1/whatsapp/groups/{group_id}/join-requests/batch-reject. A request also leaves the list when the person cancels it themselves, so an ID that was valid a moment ago can return a 422 E15051 by the time you use it.
The 50 is Bird's own bound on one request, not a WhatsApp one, so a rejection sweep is not held to how many people the group can hold.
Rotating the invite link
Rotating issues a new link and stops every link the group had before, so anyone still holding one cannot join. Rotate when a link has spread further than you intended, or after removing someone you do not want back. The new link comes back on the call itself, which answers 200:
const link = await bird.whatsapp.groups.inviteLink.rotate("wag_01krdgeqcxet5s7t44vh8rt9mg");
console.log(link.invite_link); // every earlier link has stopped workinglink = client.whatsapp.groups.invite_link.rotate("wag_01krdgeqcxet5s7t44vh8rt9mg")
print(link.invite_link) # every earlier link has stopped workinglink, err := client.Whatsapp.Groups.InviteLink.Rotate(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg")
if err != nil {
log.Fatal(err)
}
fmt.Println(*link.InviteLink) // every earlier link has stopped working$link = $bird->whatsapp->groups->inviteLink->rotate('wag_01krdgeqcxet5s7t44vh8rt9mg');
echo $link->getInviteLink(); // every earlier link has stopped workingbird whatsapp groups invite-link rotate wag_01krdgeqcxet5s7t44vh8rt9mg --yescurl -X POST "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg/invite-link/rotate" \
-H "Authorization: Bearer $BIRD_API_KEY"Rotating is not how you read the current link. The group carries its invite_link, so reading the group is the read.
The CLI never prompts, so its destructive commands take --yes instead: rotating a link, removing a participant, deleting a group, unpinning a message, and rejecting join requests all refuse with confirmation_required without it.
How a change settles
WhatsApp confirms some changes in its reply and others on a webhook, and the status code says which kind you are looking at:
| Operation | Answers | Where the outcome lands |
|---|---|---|
| Create, update, delete | 202 | The group's last_operation, once WhatsApp reports back |
| Remove a participant | 202 | That participant's own last_operation in participants |
| Approve or reject join requests | 202 | decided and failed on the response itself |
| Rotate, pin, unpin | 200 | The response, which is the change already applied |
For the first two rows, the 202 is the request accepted, not applied. last_operation.status is pending until WhatsApp reports back, usually within seconds, then settles to success or failed, with last_error.description carrying the reason on a failure. Re-read the group to see what took effect.
While an operation is pending, another change to the same thing returns a 409 E15054. Group-level changes share the group's last_operation, so an outstanding rename blocks a delete. Each participant carries their own, so removing several people at once gives each their own state and their own failure, and only that person's pending removal blocks another attempt at them.
Two 409s guard the writes and status is checked first, so a group that is not active returns E15047 whatever its last_operation says. That matters for a group still being created, which is pending on both counts at once.
Every write also takes an Idempotency-Key header. Idempotency covers retrying safely.
Change the group's subject, description, or picture
An update changes what participants see at the top of the group. Fields you omit are left alone; send null to clear the description or the picture, since an empty string is a 422 rather than a second way to clear.
const group = await bird.whatsapp.groups.update("wag_01krdgeqcxet5s7t44vh8rt9mg", {
subject: "Norwood Fleet — Wednesday route",
});
console.log(group.last_operation?.status); // pending until WhatsApp reports backgroup = client.whatsapp.groups.update(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
subject="Norwood Fleet — Wednesday route",
)
print(group.last_operation) # pending until WhatsApp reports backgroup, err := client.Whatsapp.Groups.Update(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg", bird.WhatsappGroupsUpdateParams{
Subject: bird.Ptr("Norwood Fleet — Wednesday route"),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(*group.LastOperation.Status) // pending until WhatsApp reports back$group = $bird->whatsapp->groups->update(
'wag_01krdgeqcxet5s7t44vh8rt9mg',
(new WhatsAppGroupUpdate())->setSubject('Norwood Fleet — Wednesday route'),
);
echo $group->getLastOperation()?->getStatus(); // pending until WhatsApp reports backbird whatsapp groups update wag_01krdgeqcxet5s7t44vh8rt9mg \
--subject 'Norwood Fleet — Wednesday route'curl -X PATCH "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"subject": "Norwood Fleet — Wednesday route"}'WhatsApp applies each field separately, so an update can be part-applied: one field refused while the others take effect. When the operation settles, last_operation.results carries one entry per field the update sent, which is how you put a refusal next to the input it came from.
profile_picture_url names a file in your workspace's media library rather than any address on the internet, and WhatsApp takes a square JPEG of at least 192 by 192 pixels and up to 5 MB. Clearing it clears the picture Bird stores, but WhatsApp has no operation for taking a group's photo down, so participants keep seeing the current one until another picture replaces it. The administering number and join_approval_mode are fixed at create time and cannot be changed here.
Remove a participant
Removal cannot be undone. WhatsApp blocks a removed person from joining the group by invite link, and the block follows the person rather than the link, so rotating the link does not let them back in. With no add operation to pair with this one, nothing returns someone to this group once they are out, and reaching them again means creating another group and inviting them to it.
Name the person by either identifier the group's participants list them under: their bsuid, which everyone has, or their phone_number in E.164 format, which is there only when WhatsApp shares it. In a URL, percent-encode the leading + as %2B unless your client does that for you.
const group = await bird.whatsapp.groups.participants.remove(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
"+15550002222",
);
console.log(group.participants?.length);group = client.whatsapp.groups.participants.remove(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
"+15550002222",
)
print(len(group.participants or []))group, err := client.Whatsapp.Groups.Participants.Remove(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg", "+15550002222")
if err != nil {
log.Fatal(err)
}
if group.Participants != nil {
fmt.Println(len(*group.Participants))
}$group = $bird->whatsapp->groups->participants->remove(
'wag_01krdgeqcxet5s7t44vh8rt9mg',
'+15550002222',
);
echo count($group->getParticipants() ?? []);bird whatsapp groups participants remove wag_01krdgeqcxet5s7t44vh8rt9mg +15550002222 --yescurl -X DELETE "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg/participants/%2B15550002222" \
-H "Authorization: Bearer $BIRD_API_KEY"A participant's removal is pending on their own entry until WhatsApp confirms it, and never reaches success: the entry disappearing from participants is what says it worked. A value naming someone who is not in the group returns a 422 E15050.
Pin a message
Pinning holds one of the group's own messages at the top of its chat, so someone joining later does not have to scroll for the thing the conversation keeps coming back to. A pin lasts duration_days, from 1 to 30, and defaults to 7.
const pin = await bird.whatsapp.groups.pins.create("wag_01krdgeqcxet5s7t44vh8rt9mg", {
message_id: "wam_01kya19eknftrs2s6p82asmvnh",
duration_days: 14,
});
console.log(pin.pinned_until);pin = client.whatsapp.groups.pins.create(
"wag_01krdgeqcxet5s7t44vh8rt9mg",
message_id="wam_01kya19eknftrs2s6p82asmvnh",
duration_days=14,
)
print(pin.pinned_until)pin, err := client.Whatsapp.Groups.Pins.Create(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg", bird.WhatsappGroupsPinsCreateParams{
MessageID: "wam_01kya19eknftrs2s6p82asmvnh",
DurationDays: bird.Ptr(14),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(*pin.PinnedUntil)$pin = $bird->whatsapp->groups->pins->create(
'wag_01krdgeqcxet5s7t44vh8rt9mg',
(new WhatsAppGroupPinnedMessageCreate())
->setMessageId('wam_01kya19eknftrs2s6p82asmvnh')
->setDurationDays(14),
);
echo $pin->getPinnedUntil()?->format(DATE_ATOM);bird whatsapp groups pins create wag_01krdgeqcxet5s7t44vh8rt9mg \
--message-id wam_01kya19eknftrs2s6p82asmvnh \
--duration-days 14curl -X POST "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg/pinned-messages" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message_id": "wam_01kya19eknftrs2s6p82asmvnh", "duration_days": 14}'The message has to be one this group carries: a message in another group, or a one-to-one message, returns a 422 E15053. A group holds only so many pins at once, and pinning past that unpins the oldest rather than failing; pinning an already-pinned message replaces its expiry. What the group currently pins is on the group itself, as pinned_messages.
WhatsApp unpins a message on its own once its days have passed, so unpin only to take one down early. DELETE /v1/whatsapp/groups/{group_id}/pinned-messages/{message_id} answers 200, and repeating it is safe: a message this group carries that is not pinned changes nothing and still answers 200.
Delete the group
Deleting removes the group at WhatsApp. Every participant loses access, your business included, and the invite link stops working. This cannot be undone, and running the conversation again means creating a new group and sending its link.
const group = await bird.whatsapp.groups.delete("wag_01krdgeqcxet5s7t44vh8rt9mg");
console.log(group.last_operation?.status); // pending until WhatsApp confirms itgroup = client.whatsapp.groups.delete("wag_01krdgeqcxet5s7t44vh8rt9mg")
if group.last_operation:
print(group.last_operation.status) # pending until WhatsApp confirms itgroup, err := client.Whatsapp.Groups.Delete(context.Background(), "wag_01krdgeqcxet5s7t44vh8rt9mg")
if err != nil {
log.Fatal(err)
}
fmt.Println(*group.LastOperation.Status) // pending until WhatsApp confirms it$group = $bird->whatsapp->groups->delete('wag_01krdgeqcxet5s7t44vh8rt9mg');
echo $group->getLastOperation()?->getStatus(); // pending until WhatsApp confirms itbird whatsapp groups delete wag_01krdgeqcxet5s7t44vh8rt9mg --yescurl -X DELETE "https://us1.platform.bird.com/v1/whatsapp/groups/wag_01krdgeqcxet5s7t44vh8rt9mg" \
-H "Authorization: Bearer $BIRD_API_KEY"The group stays readable afterwards at status deleted, so a stored reference to it still resolves rather than turning into a 404. A failed group can be deleted too, and that one reaches no external system: the create never produced a group at WhatsApp, so clearing the row is Bird's own bookkeeping. Every other status refuses with a 409 E15047.
List your groups
The list returns the groups your workspace created, newest first, as a cursor page. Filter by the administering number (number, as an E.164 number or a wan_ ID), by the WhatsApp Business Account it sends under (waba), or by status, repeating the parameter to match any of several. q searches the subject and the description for a substring, case-insensitively.
for await (const group of bird.whatsapp.groups.list({ status: ["active"] })) {
console.log(group.id, group.subject, group.participant_count);
}for group in client.whatsapp.groups.list(status=["active"]):
print(group.id, group.subject, group.participant_count)for group, err := range client.Whatsapp.Groups.List(context.Background(), bird.WhatsappGroupsListParams{
Status: []bird.WhatsAppGroupStatus{"active"},
}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(*group.Id, group.Subject, *group.ParticipantCount)
}foreach ($bird->whatsapp->groups->list(['status' => ['active']]) as $group) {
echo $group->getId(), ' ', $group->getSubject(), PHP_EOL;
}bird whatsapp groups list --status activecurl "https://us1.platform.bird.com/v1/whatsapp/groups?status=active" \
-H "Authorization: Bearer $BIRD_API_KEY"A deleted group stays in the list with status deleted, so filter by status to see only the groups you can still message.
Troubleshooting
- 412 (E15045): The number does not hold Official Business Account status, so WhatsApp will not let it create a group. The decision is WhatsApp's: request the status in WhatsApp Manager on the number's profile, or create the group on a number that already holds it.
- 404 (E15046): The ID names no group this workspace holds. A group belongs to the workspace that created it, so an ID from another workspace is not found here.
- 409 (E15047): The group is pending, suspended, deleted, or failed. Only an active group takes a change. Read the group to see where it stands.
- 409 (E15049): The number already administers the 10,000 groups WhatsApp allows. Delete groups whose conversation is over, or create this one on another number.
- 409 (E15054): A change to the same group or the same participant is still outstanding. Read last_operation and wait for it to settle.
- 422 (E15050): The path names nobody in the group. Read the group's participants and use a bsuid or phone_number as it reports them.
- 422 (E15051): One of the join request IDs is no longer waiting for a decision. List the requests again and use the IDs that come back.
- 422 (E15053): The message belongs to another group or to a one-to-one conversation. A message's to.group_id says which group can pin it.
- A pending group that never becomes active: Bird gives up waiting rather than leaving it pending, and moves the group to failed with the reason on last_operation.last_error. Create a replacement rather than retrying.
Reference
The full request and response shapes are in the API reference: create, list, get, update, delete, rotate the invite link, remove a participant, list, approve and reject join requests, and pin and unpin a message.
Next steps
- WhatsApp groups: what a group is for, and the same lifecycle in the dashboard
- Sending to a WhatsApp group: addressing the group, what it takes, and its per-participant receipts
- Receiving WhatsApp group messages: which participant wrote one, and replying to the group
- Business-scoped user IDs: the identifier a participant is named by when you have no phone number
- Idempotency: retrying a write that may already have been accepted
Ressources associées
Poursuivez avec la documentation, les guides et les exemples sur ce sujet. Les ressources sont en anglais.
Regarder le guideConnecting WhatsApp to Bird: from buying a number to a live channelComprendre le conceptWhat is the 24-hour customer service window on WhatsApp?Utiliser l'outilWhatsApp message builderExplorer la fonctionnalitéWhatsApp
Essayez la pratique et obtenez un guide d'implémentation