Create WhatsApp Groups from Google Sheets

A step-by-step Apps Script build: read a sheet of customers, create one group per row, publish the invite link, and write the results back.

By Wapito teamPublished

  • tutorial
  • groups
  • no-code

A spreadsheet is where most small operations already live: the list of customers for this month's cohort, the clients who each get a project group, the parents of every class. Turning each row into a WhatsApp group by hand — create it on the phone, add the people, copy the invite link back into the sheet — is the kind of task that eats an afternoon a week. This tutorial replaces it with about eighty lines of Google Apps Script and a handful of Wapito endpoints: create a group, add participants and read the member list back, read the invite link and send the first message. It ends with a variant that adds new rows to one existing group instead, and an n8n version for teams that would rather not write code at all.

There is no Cloud API version of this walkthrough, because Meta's official API has no group endpoints. That is the whole reason a linked-number API exists, and it comes with a responsibility we will keep coming back to: only put people in a group who expect to be in it.

What you will build

A sheet with one row per group to create:

A: Group nameB: DescriptionC: ParticipantsD: Group idE: Invite linkF: StatusG: Not added
Acme Launch TeamQ3 launch coordination+15551234567, +15559876543
Cohort 12 — MondaysClass chat for cohort 12+15550001111, +15550002222, +15550003333

Columns A–C are yours. The script fills D–G: the group's id, its chat.whatsapp.com link, either created or an error message, and the numbers from column C that WhatsApp did not let into the group — the people you will send the link to instead. It skips any row that already has a group id so you can run it again safely. Each new group gets a welcome message so the first thing members see is context rather than an empty chat.

Before you start

  • A Wapito channel with a number that is warmed up. Group creation is the highest ban-risk operation on the API — WhatsApp watches it closely — and a number linked yesterday should not create twenty groups today. The anti-ban guide explains the warm-up ladder; give the number a week.
  • The channel token from the dashboard's API tab. In Apps Script it goes into Project Settings → Script Properties as WAPITO_TOKEN, never into the code.
  • Participants in international format, + and country code, separated by commas. The API also accepts bare digits and @s.whatsapp.net ids, but a sheet is friendlier with one format.
  • Consent. A row in a spreadsheet is not consent. If the people in column C did not agree to be added to a WhatsApp group, use the invite-link variant below instead of adding them.

Step 1 — the API calls, in isolation

Before the script, the requests it makes, so you can run them from curl first and know what each returns. The group creation call takes a subject, an optional description and the initial participants:

curl -X POST https://api.wapito.com/v1/groups \
  -H "Authorization: Bearer $WAPITO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Acme Launch Team", "description": "Q3 launch coordination", "participants": ["+15551234567", "+15559876543"]}'
{
  "id": "120363041234567890@g.us",
  "subject": "Acme Launch Team",
  "invite_code": "HkQ2ZpL9vRtAeYm1",
  "participants_count": 3,
  "settings": { "member_add_mode": "admin_add", "membership_approval": false, "messages_admin_only": false, "info_admin_only": false }
}

The id is the group's address for every later call. Then the invite link, the member list, the participants you want to add later, and the first message, each addressed to that id:

curl https://api.wapito.com/v1/groups/120363041234567890@g.us/invite \
  -H "Authorization: Bearer $WAPITO_TOKEN"
# {"code": "HkQ2ZpL9vRtAeYm1", "link": "https://chat.whatsapp.com/HkQ2ZpL9vRtAeYm1"}

curl https://api.wapito.com/v1/groups/120363041234567890@g.us/participants \
  -H "Authorization: Bearer $WAPITO_TOKEN"
# {"participants": [{"id": "15551234567@s.whatsapp.net", "phone": "15551234567", "lid": null, "name": null, "role": "member", "joined_at": "2026-09-15T09:14:00.000Z"}, …], "count": 3}

curl -X POST https://api.wapito.com/v1/groups/120363041234567890@g.us/participants \
  -H "Authorization: Bearer $WAPITO_TOKEN" -H "Content-Type: application/json" \
  -d '{"participants": ["+15550004444"]}'
# {"results": [{"id": "15550004444@s.whatsapp.net", "status": "added"}]}

curl -X POST https://api.wapito.com/v1/messages/text \
  -H "Authorization: Bearer $WAPITO_TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "120363041234567890@g.us", "body": "Welcome! This is the launch coordination group."}'

One detail matters for the script. The add-participants call is the only one that answers per person: someone whose privacy settings do not allow strangers to add them comes back with status: "invite_required" instead of added. That is not an error and must not be retried; it is WhatsApp telling you to send that person the link instead. The create call gives no such verdict. WhatsApp adds whom it can, the rest are simply not in the group, and the response carries a participants_count (your own number included), not a list of who was left out. So after creating each group the script reads the member list back — GET /groups/{id}/participants — and writes the numbers from column C that are not in it to column G, which is the list Step 3 works from.

Step 2 — the Apps Script

Open the sheet, choose Extensions → Apps Script, replace the contents of Code.gs with the following, and save. Set WAPITO_TOKEN in Script Properties before the first run.

// Create one WhatsApp group per row, via the Wapito API. Columns:
// A subject · B description · C participants (comma-separated, +E.164) · D group id · E invite link · F status · G not added
const BASE_URL = 'https://api.wapito.com/v1';
const SHEET_NAME = 'Groups';
const FIRST_ROW = 2; // row 1 is the header

function wapito_(method, path, payload) {
  const token = PropertiesService.getScriptProperties().getProperty('WAPITO_TOKEN');
  const res = UrlFetchApp.fetch(BASE_URL + path, {
    method,
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + token },
    payload: payload ? JSON.stringify(payload) : undefined,
    muteHttpExceptions: true, // we branch on the error code ourselves
  });
  const body = JSON.parse(res.getContentText() || '{}');
  if (res.getResponseCode() >= 400) {
    const err = body.error || {};
    throw new Error((err.code || res.getResponseCode()) + (err.details ? ' ' + JSON.stringify(err.details) : ''));
  }
  return body;
}

function numbersIn_(cell) {
  return String(cell).split(',').map((p) => p.trim()).filter(Boolean);
}

// The numbers from column C that did not make it into the group. WhatsApp adds
// whom it can and leaves the rest out without saying so; the create response
// only carries a count, so the member list is read back and compared.
function notAdded_(groupPath, requested) {
  const list = wapito_('get', groupPath + '/participants');
  if (list.count >= requested.length + 1) return []; // everyone, plus your own number
  const present = new Set(list.participants.map((p) => p.phone).filter(Boolean));
  return requested.filter((number) => !present.has(number.replace(/\D/g, '')));
}

function createGroupsFromSheet() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const rows = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow() - FIRST_ROW + 1, 7).getValues();

  rows.forEach((row, i) => {
    const [subject, description, participantsCell, groupId] = row;
    const rowNumber = FIRST_ROW + i;
    if (!subject || groupId) return; // blank row, or already created on a previous run

    const participants = numbersIn_(participantsCell);

    let group;
    try {
      group = wapito_('post', '/groups', { subject, description: description || undefined, participants });
    } catch (e) {
      sheet.getRange(rowNumber, 6).setValue('error: ' + e.message);
      Utilities.sleep(15000);
      return;
    }
    // Write the id before anything else can fail: a re-run must never create this group twice.
    sheet.getRange(rowNumber, 4).setValue(group.id);

    try {
      const groupPath = '/groups/' + encodeURIComponent(group.id);
      const invite = wapito_('get', groupPath + '/invite');
      sheet.getRange(rowNumber, 5).setValue(invite.link);
      sheet.getRange(rowNumber, 7).setValue(notAdded_(groupPath, participants).join(', '));
      wapito_('post', '/messages/text', {
        to: group.id,
        body: 'Welcome to ' + subject + '. ' + (description || 'Say hello!'),
      });
      sheet.getRange(rowNumber, 6).setValue('created');
    } catch (e) {
      sheet.getRange(rowNumber, 6).setValue('created, then: ' + e.message);
    }

    // The channel's send queue paces messages on its own; the pause here keeps
    // group creation itself unhurried, which is the part WhatsApp watches.
    Utilities.sleep(15000);
  });
}

Run createGroupsFromSheet from the editor toolbar. The first run asks you to authorise the script to call external services and edit the sheet — that is UrlFetchApp and SpreadsheetApp, nothing else. Within a minute the D–G columns fill in, and the groups appear on the linked phone.

A few things the script does on purpose:

  • It is re-runnable. The group id is written the moment the group exists, and a row with an id is skipped, so a failure on row 12 is fixed by correcting row 12 and running again; rows 2–11 are not created twice, and neither is row 12 if only its welcome message failed.
  • It reads the group back instead of trusting the request. notAdded_ fetches the member list once per new group and keeps every number from column C that is not in it. The comparison is by phone number, and a member the newer WhatsApp versions list by LID with no phone (see the LID explainer) cannot be matched, so when the count is short a number can land in column G that is in fact in the group; when the count is full, nothing is compared and column G stays empty. Glance at column G before Step 3.
  • It writes the error code, not a stack trace. invalid_recipient {"to":"+1555"} in column F tells you which number is malformed; created, then: warmup_limit tells you the group exists but the welcome was paced; channel_not_connected means the phone is unlinked. Every code is documented under error codes.
  • It waits between groups. Fifteen seconds is not a magic number. It is a reminder that a human creating groups takes minutes, not milliseconds, and that the group-creation call is the one to keep slow. The welcome message goes through the channel's send queue regardless.

Step 3 — the people who could not be added

Column G holds the people the group does not know about: the numbers WhatsApp declined to add when the group was created, typically because their privacy settings only let contacts add them — the same people the add-participants call would have reported as invite_required. For those, the honest path is to send them the link and let them join. This second function reads column G, messages one number at a time, and takes each number off the cell as soon as its message has gone out, so a run can stop anywhere and the next one picks up what is left:

function inviteTheRest() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const rows = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow() - FIRST_ROW + 1, 7).getValues();

  for (let i = 0; i < rows.length; i += 1) {
    const [subject, , , , inviteLink, , notAddedCell] = rows[i];
    const pending = numbersIn_(notAddedCell);
    if (!inviteLink || pending.length === 0) continue;

    while (pending.length > 0) {
      try {
        wapito_('post', '/messages/text', {
          to: pending[0],
          body: 'You were invited to "' + subject + '" on WhatsApp. Join here if you would like to: ' + inviteLink,
        });
      } catch (e) {
        // Usually cold_send_limit: the guard has spoken for today. Whoever is
        // left stays in column G, and the next run starts with them.
        sheet.getRange(FIRST_ROW + i, 6).setValue('inviting: ' + e.message);
        return;
      }
      pending.shift();
      sheet.getRange(FIRST_ROW + i, 7).setValue(pending.join(', '));
      Utilities.sleep(3000);
    }
    sheet.getRange(FIRST_ROW + i, 6).setValue('invited');
  }
}

Run it after createGroupsFromSheet, once you have looked at column G; column F reads invited once a row's list is empty. Each of those messages is a cold send if the person has never written to your number, and the cold-send guard caps how many you can start an hour (ten on a number's first two days). If the list is long, the guard will answer 429 cold_send_limit; that is the platform doing its job. The function then writes inviting: cold_send_limit to column F and stops, with the unsent numbers still in column G, and the fix is to run it again tomorrow, not to remove the pause. Better still, put the link where the people already are — the confirmation e-mail, the receipt, the class portal — and let them come to the group.

Variant: add new rows to one existing group

The other common shape is a single group — this month's cohort, the volunteers, the delivery drivers — that new sign-ups should be added to as rows appear. Same helper, different loop:

const GROUP_ID = '120363041234567890@g.us'; // from column D, or the group's info in the dashboard

function addNewRowsToGroup() {
  const sheet = SpreadsheetApp.getActive().getSheetByName('Signups'); // A phone · B added
  const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
  const pending = rows.map((r, i) => ({ phone: String(r[0]).trim(), row: i + 2, done: r[1] })).filter((r) => r.phone && !r.done);

  // Up to fifty per call; keep batches small anyway.
  for (let start = 0; start < pending.length; start += 10) {
    const batch = pending.slice(start, start + 10);
    const { results } = wapito_('post', '/groups/' + encodeURIComponent(GROUP_ID) + '/participants', {
      participants: batch.map((r) => r.phone),
    });
    results.forEach((result, i) => sheet.getRange(batch[i].row, 2).setValue(result.status));
    Utilities.sleep(30000);
  }
}

Attach it to a time-driven trigger (Triggers → Add trigger → every hour) and the sheet becomes a queue. The status column fills with added or invite_required, and the group's participant list — GET /groups/{id}/participants — is the source of truth if the two ever disagree. Newer WhatsApp versions may list a member by LID with no phone number; the LID explainer covers what to do with those.

The same flow in n8n

If code is not the team's language, the calls map onto HTTP Request nodes:

  1. A Google Sheets trigger or Read rows node, filtered to rows with an empty Group id.
  2. HTTP Request — POST https://api.wapito.com/v1/groups, JSON body from the row's columns, Header Auth credential holding Authorization: Bearer wpt_….
  3. HTTP Request — GET https://api.wapito.com/v1/groups/{{ $json.id }}/invite.
  4. HTTP Request — POST https://api.wapito.com/v1/messages/text with to set to the group id and the welcome text.
  5. A Google Sheets Update row node writing id, link and status back.

To fill a Not added column as the script does, add a fourth HTTP Request — GET https://api.wapito.com/v1/groups/{{ $json.id }}/participants — after step 3 and a small Code node that keeps the row's numbers whose digits are not among the returned phone values.

Put a Wait node of fifteen to thirty seconds between iterations, and turn on Retry on fail only for the rate_limited status: every other 4xx on these calls is a fact about the data, not a transient. The n8n integration page has the credential setup and a receiving workflow for the group events.

When a row says "error"

Column F is where the API tells you what it did not like. The codes you will actually see, and what each one means for the row:

Column FWhat happenedWhat to do
invalid_recipient {"to":"+1555"}A participant is not a dialable number.Fix the number in column C; the API checks the format before anything is sent.
channel_not_connectedThe phone is unlinked or the session is restarting.Open the Connect tab, re-link if needed, run again. Nothing was created.
created, then: warmup_limitThe welcome message tripped a pacing control.The group exists — column D is filled — but the message was refused. Send it by hand from the phone or with curl and the id in column D; the script skips every row that has an id, so it will not.
inviting: cold_send_limitinviteTheRest hit the cold-send guard.Nothing is wrong. Column G still lists who has not been messaged; run inviteTheRest again tomorrow. Any other code here names a number in column G the API would not accept — fix or remove it and run again.
rate_limitedMore than sixty requests in a minute on Sandbox.Raise the sleep, or run fewer rows per execution.
forbiddenThe linked number is not an admin of the group.Only on the add-participants variant: someone demoted the number. Promote it from the phone.
not_foundThe group id in column D no longer exists.The group was deleted from the phone, or the number left it. Clear D and E to recreate it.

Two of these are worth a second look. created, then: warmup_limit means the messages are the problem, not the groups: the number is on its first days and the daily rung is spent. The groups exist, the members are in, and the welcome can wait until tomorrow — do not loop on the error. And rate_limited is the one code where a retry is correct; the Retry-After header on the response says how many seconds to wait, and the rate-limits guide shows the pattern. Apps Script's six-minute execution limit is the other clock to watch: a sheet with more than about twenty rows should be run in slices, which the "skip rows that have a group id" rule already makes safe.

Keeping it out of trouble

Groups are where WhatsApp's judgement is harshest, and a script makes it very easy to do the wrong thing quickly. The rules that keep this integration alive:

  • Consent before addition. People who did not agree to be added report groups, and reports ban numbers. When in doubt, send the invite link and let them choose.
  • Small batches, real gaps. A few groups an hour from a warmed-up number; a few dozen additions an hour at most. The sleeps in the script are the floor, not the ceiling.
  • Membership approval for public links. If the invite link goes anywhere public, turn on membership_approval in the group settings so a leaked link does not fill the group with strangers.
  • Subscribe to the group events. groups.participants tells you who joined, left or was removed, and channel tells you the moment the number is timelocked or banned; the script cannot see either, your webhook can.
  • Keep a human admin. The linked number is the group's owner. Promote a person as well, so the group survives a number change.

Everything above is a linked-number API doing what the official one cannot, and the price of that is the responsibility to use it the way a person would: create the groups people asked for, invite the ones who did not ask, and never faster than you could by hand.

Related

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.