Getting WhatsApp's Groups API Actually Working

Meta shipped a Groups API for WhatsApp Business — your business number can create groups, invite customers, and chat with up to 8 people in one thread. We spent three days wiring it into our CRM and testing every path against the live Graph API, and the honest summary is: the API works, but the documentation lies to you in at least three places, stays silent in a few more, and several behaviors only reveal themselves when a real payload hits your webhook. This is the guide I wish had existed on day one — just the things that decide whether it works at all.

Before you write any code

Three hard prerequisites, and missing any of them just produces unexplained 4xx errors later:

  1. A Cloud API number. On-Premise numbers and the WhatsApp Business app are out.
  2. The number must be an Official Business Account — the green-checkmark kind. Here's the first trap: eligibility isn't just oba_status == "APPROVED". A number can carry the badge while its oba_status still reads NOT_STARTED (this happens when the badge arrives via Meta Verified instead of the OBA request flow). Check is_official_business_account as well as the status, or you'll tell a perfectly eligible number it isn't.
  3. Know the limits going in, because they shape your UI: 10,000 groups per number, 8 participants including your business number, 3 pinned messages, and billing is per recipient, not per send — one message to a full group costs 7×.

Webhooks are the backbone — set them up first

Half of this API only talks back to you through webhooks, so if your callback URL points somewhere that doesn't understand group events, the feature is half-dead and nothing will error. Subscribe to the four group fields (group_lifecycle_update, group_participants_update, group_settings_update, group_status_update) — and here's the non-obvious part: group messages don't get their own field. They ride the same messages field as ordinary 1:1 chat. You split the traffic yourself:

  • a message with messages[0].group_id → group message, route it away from your 1:1 tables
  • a status with statuses[0].recipient_type == "group" → per-recipient delivery/read tick

Two more things the docs won't warn you about. First, the group_* payload shapes are documented loosely — field names, no full JSON samples — so parse defensively (wa_id ?? user, action ?? event, id ?? group_id) and log unknown shapes instead of throwing; a webhook handler that 500s gets you throttled by Meta's retries. Second, there's one callback URL per WABA: if your production webhook still points at a service that predates your groups work, every group event silently vanishes there. Sort the routing before anything else, or your local build will pass every test and receive nothing in production.

Creating a group: the response you expect isn't coming

POST /{phone_number_id}/groups
{ "messaging_product": "whatsapp", "subject": "...", "join_approval_mode": "auto_approve" }

On paper, this returns the group. In practice, the response frequently arrives without an id — creation is asynchronous, and the official id shows up later via a group_lifecycle_update webhook. We verified this live: the group appears in WhatsApp, the POST response just doesn't tell you who it is. If you can't wait for the webhook (say, in a dev environment where webhooks don't reach you), the workaround that held up is reconciliation: poll GET /{phone_number_id}/groups, and claim the youngest group with a matching subject that you don't already know — with a freshness cutoff (we used ten minutes) so you never adopt some old group that happens to share a name.

Once you have the id, GET /{group_id}/invite_link gets you the invite link.

Participants: you invite, you never add

A business cannot force-add anyone to a group. The only doors in are the invite link and its QR code. Plan your whole UI around that fact.

With auto_approve, someone taps the link and you get a group_participants_update webhook with action: "add". With approval_required, you instead receive a join request carrying a join_request_id — and that id is the only currency the approval endpoint accepts:

POST   /{group_id}/join_requests   { "join_requests": ["<real id from the webhook>"] }   // approve
DELETE /{group_id}/join_requests   { ... }                                               // reject

A fabricated id gets you (#100) Invalid parameter, full stop — which also means you cannot fake this flow end-to-end in testing; you can only simulate its outcome (more on testing at the end). One more thing to plan for: participant payloads carry phone numbers, essentially never names. If your UI shows a member list, build your own name enrichment (we match against contacts who've chatted 1:1 with the business, and fall back to showing the number).

The conversation window rules everything you send

A new group has no service window at all. Your first message into it must be an approved template. When a member sends a message, a 24-hour window opens, and inside it you can send free-form text and media through the ordinary messages endpoint — just put the group id in to. Window expires, you're back to templates.

Templates into groups have their own minefield, and Meta's error for all of it is the same useless string: (#132000) Number of parameters does not match the expected number of params, with no hint of which component mismatched. What it actually means, from testing each case:

  • a template with an image/video/document header needs a media parameter you may not be supplying,
  • a template with {{n}} placeholders needs exactly that many body parameters,
  • and AUTHENTICATION-category templates aren't supported in groups at all.

The fix that saved our users: validate against the template's own stored definition before calling Meta — count the unique {{n}} placeholders, detect media headers — and return a human-readable error yourself. Never let #132000 reach a person.

Media: two directions, two very different jobs

Inbound is the involved one. The webhook gives you only a media id. You call GET /{media_id} (with your bearer token) to receive a JSON envelope containing a lookaside URL, then download that URL — also authenticated. Two rules learned the hard way: those lookaside URLs expire, so re-host the bytes into your own storage immediately or the message's attachment is gone forever; and put strict timeouts on both calls (we use 30s for the metadata, 60s for the download, plus connect timeouts) because a hung media download will strand a worker indefinitely.

Outbound is easy — send by public link, or upload via POST /{phone_number_id}/media and send by id. We re-host outbound images to our own storage first anyway, since the CRM needs a stable URL to render the bubble.

The group photo deserves its own paragraph, because this is where the docs actively lie. The upload is a multipart POST to /{group_id}, and the parameter table says the field is profile_picture_file. Meta's own cURL example on the same page uses -F 'file=...'. The example is wrong. Send file and you get a 400 (#131009) At least one property must be specified — we confirmed against the live API that profile_picture_file is the name that works. And once uploaded: there is no way to read a group's photo back. No field on Get Group returns it. If your UI needs to display the photo, keep your own copy at upload time — it's the only copy you'll ever have.

Counting groups: there's no API for that

The 10,000-groups-per-number limit is real (it's in the overview doc). What doesn't exist is any endpoint that tells you how many you've used. GET /{phone_number_id}/groups is cursor-paginated at up to 1,024 per page and returns no total — a full page-through is the only source of truth. Three consequences for anyone keeping a local mirror:

  • deleted groups simply vanish from the list, they're never flagged — so only mark local rows as deleted after a complete, error-free page-through, or one flaky page mass-deletes your mirror;
  • a group deleted before your first-ever sync never existed as far as you can know — it's invisible by design;
  • a number that isn't Groups-capable fails this call permanently (nonexisting field (groups)) — treat that as "skip this number", not as a sync error, or every sync run reports failures forever.

Testing locally when webhooks can't reach you

Production owns the webhook URL, your laptop doesn't — but the whole inbound half of this API is webhooks. The recipe that carried us: simulate the webhooks yourself, by POSTing hand-built payloads at your local webhook endpoint, using real identifiers from your own database (the WABA id for tenant resolution, the phone_number_id, the group's Meta id). Joins, join requests, text messages, delivery ticks — all of it works this way.

Two refinements. For inbound images, upload a file through POST /{phone_number_id}/media first so the media id in your fake payload is real — then your simulation exercises the actual metadata-fetch-and-download path instead of skipping it. And for join-request approval, remember the #100 rule above: you can't approve a fabricated request against the real API, so simulate the result instead — fire the participants webhook with action: "add" and let your handler resolve the pending request the same way a real approval webhook would.

That's the load-bearing set. Everything else we built on top — audit trails, unread counters, live UI updates — was ordinary product work. These are the parts where the API itself, or its documentation, will fight you.

Subscribe to Building software. Writing what I learn.

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
[email protected]
Subscribe