Back to blog
Chatmaid Developers··12 min read

Send WhatsApp Messages from Google Sheets with Apps Script

Send WhatsApp messages from Google Sheets with a copy-paste Apps Script: one row, every pending row, or on a status change. Test free with a sandbox key.

Chatmaid Team
Send WhatsApp Messages from Google Sheets with Apps Script

To send WhatsApp messages from Google Sheets, add an Apps Script to the sheet that calls a WhatsApp API for each row. Apps Script has no built-in WhatsApp service, but its UrlFetchApp can make HTTP requests, so each message is one POST to https://developers-api.chatmaid.net/v1/messages/send, sent from your own WhatsApp number through Chatmaid Developers. The script below adds a WhatsApp menu to your sheet, writes the result of every send back into the row, and can also send on its own when someone types "Send" in a status cell. You can test every row for free with a sandbox key before anything real goes out.

What will you build?

By the end of this guide your sheet will be able to:

  1. Send to one row. Click a contact's row, pick WhatsApp > Send to selected row, and that person gets the message.
  2. Send to every pending row. One menu click sends to every row whose status is "Pending", with a pause between messages.
  3. Send when a status changes. A teammate types "Send" in a row's status cell and the message goes out.
  4. Show delivery status. A Refresh delivery status menu item pulls WhatsApp's delivered and read receipts into each row.

Everything is copy and paste. You don't need another automation tool or a server.

Can Google Apps Script send WhatsApp messages without an API?

Not on its own. Apps Script's built-in services cover Google products such as Sheets, Gmail and Drive, and none of them talks to WhatsApp. You have two realistic options:

  • Click-to-chat links (wa.me). A formula can build a link per row that opens a chat with the text already typed. The text only lands in the message box (WhatsApp's click to chat guide shows the format), so a person still has to open each link and press send. It is free, but it is manual.
  • An HTTP API. UrlFetchApp calls a WhatsApp API and the message is sent without anyone touching a phone. Chatmaid Developers sends from your own number, which you link by scanning a QR code, with no Meta Business verification. For how that compares with Meta's own platform, see WhatsApp API without Meta Business verification.

If a few clicks per row is fine, the link option needs no code at all. With the phone number in B2 and the message in C2, put this in an empty column on row 2 and fill it down:

=HYPERLINK("https://wa.me/" & REGEXREPLACE(TO_TEXT(B2), "[^0-9]", "") & "?text=" & ENCODEURL(C2), "Open chat")

The rest of this guide uses the API option, which sends without anyone clicking.

What do you need before you start?

  • A Google account with access to Google Sheets.
  • A Chatmaid Developers account. The sandbox is free and needs no credit card.
  • An API key, created on the API Keys page of the dashboard. Keep the default Send Messages and Read Messages scopes. Sandbox keys start with sk_test_ and only simulate sends; live keys start with sk_live_ and send real WhatsApp messages.
  • A sender number registered on the Phone Numbers page. In the sandbox there is no QR code to scan. For live sending, you connect the number by scanning a QR code with WhatsApp on your phone.

Live keys need the $7.99/month plan, which includes one number. You can build and test this whole guide with a sandbox key first, then swap in the live key.

Step 1: How should you set up the sheet?

Create a tab with these five columns. Row 1 holds the headers.

NamePhoneMessageStatusMessage ID
Ana García+15555550101Hi Ana, your appointment is tomorrow at 10amPending
Carlos Ruiz+15555550102Hi Carlos, your order has shippedPending
  • Phone must be in international format: a plus sign, the country code, then the number. Spaces, dashes and brackets are fine; the script removes them. Select column B and choose Format > Number > Plain text before you type numbers, so Sheets keeps the leading plus sign.
  • Message is the text to send. It can be different on every row.
  • Status starts as "Pending". The script changes it to Sending, Sent, Delivered, Read, or an error.
  • Message ID stays empty. The script fills it with the Chatmaid message ID so you can look up delivery later.

The script works on one tab, set by SHEET_NAME. It is Sheet1 below; change it if your tab has another name.

Step 2: Where do you put the script and your API key?

In your Google Sheet, click Extensions > Apps Script. A code editor opens in a new tab. Delete the empty myFunction() it starts with.

Don't paste your API key into the code. Google notes that collaborators with view access to a sheet can view its bound script, and anyone who copies the file gets a copy of the script. Store the key as a script property instead, so it stays out of the code:

  1. In the Apps Script editor, click Project Settings (the gear icon) in the left panel.
  2. Under Script Properties, click Add script property.
  3. Add CHATMAID_API_KEY with your API key as the value.
  4. Click Add script property again and add SENDER_PHONE with your connected number in international format, for example +15551234567.
  5. Click Save script properties.

People with edit access to the sheet can still open the project settings, so use a key created only for this sheet. If it ever leaks, you can revoke it on the API Keys page without breaking anything else.

Step 3: What goes in the script?

Paste this into the editor and save it (Ctrl+S or Cmd+S):

/** @OnlyCurrentDoc */
// Chatmaid WhatsApp sender for Google Sheets
// Columns: A Name, B Phone, C Message, D Status, E Message ID

const SHEET_NAME = 'Sheet1'; // the tab that holds your contacts
const API_BASE = 'https://developers-api.chatmaid.net/v1';

const COL_PHONE = 2;      // B
const COL_MESSAGE = 3;    // C
const COL_STATUS = 4;     // D
const COL_MESSAGE_ID = 5; // E

const PAUSE_MS = 1000;            // about one request per second, under the 100 per minute limit
const MAX_RUN_MS = 5 * 60 * 1000; // stop before Apps Script's 6-minute limit
const DONE = ['Sending', 'Sent', 'Delivered', 'Read'];
const LABELS = { pending: 'Sent', sent: 'Sent', delivered: 'Delivered', read: 'Read', failed: 'Failed' };

// Adds the WhatsApp menu every time the sheet opens.
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('WhatsApp')
    .addItem('Send to selected row', 'sendToSelectedRow')
    .addItem('Send to all Pending', 'sendToAllPending')
    .addItem('Refresh delivery status', 'refreshStatuses')
    .addSeparator()
    .addItem('Reset all to Pending', 'resetAllToPending')
    .addToUi();
}

function sendToSelectedRow() {
  const ui = SpreadsheetApp.getUi();
  const range = SpreadsheetApp.getActiveRange();
  const sheet = range.getSheet();
  const row = range.getRow();

  if (sheet.getName() !== SHEET_NAME || row < 2) {
    ui.alert('Click a contact row on the "' + SHEET_NAME + '" tab first.');
    return;
  }
  if (DONE.indexOf(sheet.getRange(row, COL_STATUS).getDisplayValue()) !== -1) {
    ui.alert('Row ' + row + ' was already sent.');
    return;
  }

  ui.alert(sendRow(sheet, row) ? 'Sent.' : 'Not sent. See the Status column.');
}

function sendToAllPending() {
  const ui = SpreadsheetApp.getUi();
  const answer = ui.alert(
    'Send to all Pending?',
    'Every row whose Status is Pending gets a WhatsApp message now.',
    ui.ButtonSet.YES_NO
  );
  if (answer !== ui.Button.YES) return;

  const result = sendPendingRows();
  ui.alert(
    'Sent: ' + result.sent + '. Errors: ' + result.failed + '.' +
    (result.stoppedEarly ? ' Stopped before the time limit. Run it again for the rest.' : '')
  );
}

// No dialogs in here, so a time-driven trigger can run it too.
function sendPendingRows() {
  const sheet = getSheet();
  const started = Date.now();
  const lastRow = sheet.getLastRow();
  const result = { sent: 0, failed: 0, stoppedEarly: false };
  if (lastRow < 2) return result;

  const statuses = sheet.getRange(2, COL_STATUS, lastRow - 1, 1).getDisplayValues();
  for (let i = 0; i < statuses.length; i++) {
    if (statuses[i][0] !== 'Pending') continue;
    if (Date.now() - started > MAX_RUN_MS) {
      result.stoppedEarly = true;
      break;
    }
    if (sendRow(sheet, i + 2)) {
      result.sent++;
    } else {
      result.failed++;
    }
    Utilities.sleep(PAUSE_MS);
  }
  return result;
}

// Sends one row and writes the outcome to Status and Message ID.
function sendRow(sheet, row) {
  const config = getConfig();
  const lastCol = sheet.getLastColumn();
  const headers = sheet.getRange(1, 1, 1, lastCol).getDisplayValues()[0];
  const values = sheet.getRange(row, 1, 1, lastCol).getDisplayValues()[0];
  const statusCell = sheet.getRange(row, COL_STATUS);

  const raw = values[COL_PHONE - 1].trim();
  const isGroup = /@g\.us$/.test(raw); // WhatsApp group IDs end in @g.us
  const phone = isGroup ? raw : raw.replace(/[\s().-]/g, '');
  if (!isGroup && !/^\+[1-9]\d{1,14}$/.test(phone)) {
    return markError(statusCell, 'phone must start with + and the country code');
  }

  const message = fillTemplate(values[COL_MESSAGE - 1], headers, values);
  if (message.missing) {
    return markError(statusCell, 'no column named ' + message.missing);
  }
  if (!message.text) {
    return markError(statusCell, 'empty message');
  }

  // Claim the row so two runs (menu, edit trigger, timer) can't both send it.
  const lock = LockService.getScriptLock();
  lock.waitLock(30000);
  if (DONE.indexOf(statusCell.getDisplayValue()) !== -1) {
    lock.releaseLock();
    return false; // another run already took this row
  }
  statusCell.setValue('Sending').setBackground(null);
  SpreadsheetApp.flush();
  lock.releaseLock();

  const result = callChatmaid('post', '/messages/send', {
    fromPhoneId: config.sender,
    to: phone,
    content: message.text
  });
  if (!result.ok) {
    return markError(statusCell, result.error);
  }

  statusCell.setValue('Sent').setBackground('#d4edda');
  sheet.getRange(row, COL_MESSAGE_ID).setValue(result.data.id);
  SpreadsheetApp.flush();
  return true;
}

// Updates rows to Delivered, Read or Failed using the stored message IDs.
function refreshStatuses() {
  const sheet = getSheet();
  const started = Date.now();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const rows = sheet.getRange(2, COL_STATUS, lastRow - 1, 2).getDisplayValues();
  for (let i = 0; i < rows.length; i++) {
    const status = rows[i][0];
    const messageId = rows[i][1];
    if (!messageId || status === 'Read' || status === 'Failed') continue;
    if (Date.now() - started > MAX_RUN_MS) break;

    const result = callChatmaid('get', '/messages/' + encodeURIComponent(messageId));
    if (result.ok) {
      const label = LABELS[result.data.status] || result.data.status;
      sheet.getRange(i + 2, COL_STATUS)
        .setValue(label)
        .setBackground(label === 'Failed' ? '#f8d7da' : '#d4edda');
    }
    Utilities.sleep(PAUSE_MS);
  }
}

// Sets every row back to Pending so you can send the same list again.
function resetAllToPending() {
  const ui = SpreadsheetApp.getUi();
  const sheet = getSheet();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;
  if (ui.alert('Reset every row to Pending?', ui.ButtonSet.YES_NO) !== ui.Button.YES) return;

  sheet.getRange(2, COL_STATUS, lastRow - 1, 1).setValue('Pending').setBackground(null);
  sheet.getRange(2, COL_MESSAGE_ID, lastRow - 1, 1).clearContent();
}

// Replaces {Column name} with that column's value in the same row.
function fillTemplate(template, headers, values) {
  let missing = null;
  const text = template.replace(/\{([^}]+)\}/g, function (match, name) {
    const col = headers.indexOf(name.trim());
    if (col === -1) {
      missing = name;
      return match;
    }
    return values[col];
  });
  return { text: text.trim(), missing: missing };
}

// Calls the Chatmaid API and returns { ok, data } or { ok, error }.
function callChatmaid(method, path, body) {
  const options = {
    method: method,
    headers: { Authorization: 'Bearer ' + getConfig().apiKey },
    muteHttpExceptions: true
  };
  if (body) {
    options.contentType = 'application/json';
    options.payload = JSON.stringify(body);
  }

  let response;
  try {
    response = UrlFetchApp.fetch(API_BASE + path, options);
  } catch (err) {
    return { ok: false, error: 'no response from Chatmaid, check before resending' };
  }

  const code = response.getResponseCode();
  let json = {};
  try {
    json = JSON.parse(response.getContentText());
  } catch (err) {
    // Not JSON; report the HTTP code below.
  }
  if (code >= 200 && code < 300 && json.success) {
    return { ok: true, data: json.data };
  }
  const detail = Array.isArray(json.message) ? json.message.join('; ') : json.message;
  return { ok: false, error: (json.error || 'HTTP ' + code) + (detail ? ' - ' + detail : '') };
}

function markError(cell, text) {
  cell.setValue('Error: ' + text).setBackground('#f8d7da');
  SpreadsheetApp.flush();
  return false;
}

function getSheet() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  if (!sheet) {
    throw new Error('No tab named "' + SHEET_NAME + '". Change SHEET_NAME at the top of the script.');
  }
  return sheet;
}

function getConfig() {
  const props = PropertiesService.getScriptProperties();
  const apiKey = props.getProperty('CHATMAID_API_KEY');
  const sender = props.getProperty('SENDER_PHONE');
  if (!apiKey || !sender) {
    throw new Error('Add CHATMAID_API_KEY and SENDER_PHONE under Project Settings > Script Properties.');
  }
  return { apiKey: apiKey, sender: sender };
}

A few details worth knowing:

  • The @OnlyCurrentDoc line limits the script to this one spreadsheet instead of all your Sheets (Google's authorization guide).
  • The script reads what each cell displays, so dates and numbers go into messages the way you see them in the sheet.
  • A row is claimed under a script lock and marked "Sending" before the request goes out, so two runs at once can't both send it. If a run is ever cut off in the middle of a request, that row stays "Sending" instead of going back to "Pending", so the next run doesn't send it twice. Check those rows by hand.
  • "Sent" means Chatmaid accepted the message and passed it to WhatsApp. It is not a delivery receipt; Refresh delivery status fetches that later.
  • The request matches the Messages API reference: fromPhoneId is your sender number, to is the recipient in international format, and content is the text. Successful responses come back as { "success": true, "data": { "id": "msg_..." } }, which is where the Message ID column comes from.

Step 4: How do you authorize the script?

The first time you run any function, Google asks for permission to:

  • See and edit the spreadsheet the script is attached to.
  • Connect to an external service (the Chatmaid API).

The easiest way to trigger this is to select getSheet in the function list at the top of the editor and click Run. It only checks that your contacts tab exists, so nothing is sent. Google shows a warning that it hasn't verified the app, because it's your own script. Click Advanced, then Go to followed by your project name. Approve every permission listed (tick Select all if Google shows checkboxes), then continue. If you leave the external-service permission unticked, every send fails.

You only do this once. Google asks again if you later add code that needs a new permission.

Step 5: How do you send to one person or to every pending row?

Reload the Google Sheet. A new WhatsApp menu appears in the menu bar.

To send to one person:

  1. Click any cell in their row.
  2. Choose WhatsApp > Send to selected row.
  3. The Status cell turns green with "Sent", or red with the error, and the Message ID column fills in.

To send to every pending row:

  1. Choose WhatsApp > Send to all Pending.
  2. Confirm the dialog.
  3. Each row updates as it goes. When the run ends, a dialog shows how many were sent and how many failed.

To send the same list again later, use WhatsApp > Reset all to Pending. It sets every Status back to "Pending" and clears the old message IDs.

With a sandbox key, every send is simulated: the rows turn "Sent" and nothing reaches WhatsApp. Refresh delivery status leaves sandbox rows at Sent, because Delivered and Read only come from real WhatsApp receipts. That's the moment to check your phone formats and message text across the whole sheet. When it all looks right, replace CHATMAID_API_KEY in Script Properties with your live key and make sure SENDER_PHONE is the number you connected for live sending. Then choose WhatsApp > Reset all to Pending, because the sandbox run marked every row Sent.

Step 6: How do you personalize each message with row data?

You don't need to change the code. Write the message as a template and put any column header in curly braces. The script swaps in that row's value before sending.

For appointment reminders, add a sixth column called Appointment:

NamePhoneMessageStatusMessage IDAppointment
Ana García+15555550101Hi {Name}, this is a reminder of your appointment on {Appointment}. Reply YES to confirm.PendingOct 21, 10:00 AM

Ana receives "Hi Ana García, this is a reminder of your appointment on Oct 21, 10:00 AM. Reply YES to confirm."

You can also type one template in C2 and fill it down the column, so every row shares the same wording with its own values. If a placeholder doesn't match any header, the row gets "Error: no column named ..." instead of a message with a stray {Placeholder} in it.

Replies arrive on your WhatsApp like any other message. To mark a row confirmed from the sheet itself when someone answers YES, add a time-driven trigger that calls callChatmaid('get', '/messages/inbound?limit=100') and matches each message's from and content against column B. Inbound messages exist for live keys only (the sandbox returns an empty list), and the default Read Messages scope covers it. For instant, signed delivery to a server of your own, subscribe to the message.received webhook; the webhook guide walks through it.

Step 7: How do you send automatically when the status changes?

The idea is simple: a teammate types "Send" in a row's Status cell and the message goes out. The catch is that a plain onEdit(e) function won't work. Google's rules for simple triggers say they can't access services that require authorization, and UrlFetchApp is one of them. You need an installable trigger instead, attached to a function with a different name.

Add these two functions to the bottom of the script:

// Runs from an installable "On edit" trigger, not as a simple trigger.
function onStatusEdit(e) {
  const sheet = e.range.getSheet();
  if (sheet.getName() !== SHEET_NAME) return;
  if (e.range.getColumn() !== COL_STATUS || e.range.getRow() < 2) return;
  if (e.value !== 'Send') return;

  sendRow(sheet, e.range.getRow());
}

// Run this once from the editor to install the trigger.
function createEditTrigger() {
  const exists = ScriptApp.getProjectTriggers().some(function (trigger) {
    return trigger.getHandlerFunction() === 'onStatusEdit';
  });
  if (!exists) {
    ScriptApp.newTrigger('onStatusEdit')
      .forSpreadsheet(SpreadsheetApp.getActive())
      .onEdit()
      .create();
  }
}

Save, select createEditTrigger in the function list and click Run. Google asks you to approve the new permission once. The check at the top stops a second run from adding a duplicate trigger, which would send every message twice.

If you'd rather click than code, open Triggers (the alarm clock icon) in the editor, click Add Trigger, choose onStatusEdit, set the event source to From spreadsheet and the event type to On edit, then save.

How it behaves:

  • It only reacts when a single cell in the Status column of your contacts tab is changed to exactly "Send". Google only fills in e.value when the edited range is a single cell, so pasting over many cells at once does nothing.
  • The trigger runs under the Google account of whoever created it, even when a teammate makes the edit. Messages always go out from the SENDER_PHONE in Script Properties.
  • The script's own writes to the Status cell ("Sending", "Sent") don't fire the trigger again. Google's documentation notes that script executions don't cause triggers to run.

What can you use it for?

  • Appointment reminders. One row per client with the appointment time in its own column. Run Send to all Pending the day before. The Python appointment reminder system shows a 48, 24 and 2-hour reminder schedule you can copy into separate sheet runs.
  • Order updates. Paste your order export, use a template like "Hi {Name}, order {Order} has shipped", and send.
  • Lead follow-up. Sales reps change a row's status to "Send" when a lead is ready for a message.
  • Payment reminders. Filter overdue invoices into the tab, adjust the wording, and send.
  • Event updates. Keep the guest list in the sheet, write the message once, and send to everyone who signed up.

Only message people who expect to hear from you; the FAQ below covers ban risk.

How many rows can you send in one run?

Three limits matter:

  • Chatmaid: as of October 2026, the API's default limit is 100 requests per minute. The script waits one second between sends, which keeps it well under that.
  • Apps Script runtime: one execution can run for 6 minutes. The script stops itself after 5 minutes and tells you to run it again; the rows it didn't reach stay "Pending". Counting the pause and the time each request takes, one run covers about 300 rows at most, usually fewer.
  • Apps Script daily quotas: a personal Google account gets 20,000 URL fetch calls a day, and a Google Workspace account gets 100,000. Triggers also share a daily runtime budget of 90 minutes on personal accounts and 6 hours on Workspace.

For a few hundred rows at a time, the sheet works well. For thousands of rows on a schedule, move the job to an automation platform such as Make (see how to connect Make to WhatsApp with Chatmaid) or to your own server.

If you don't want to maintain a script at all, Chatmaid Web gives you contact lists, personalized messages and campaigns that space out bulk sends, all from the browser.

What do the errors in the Status column mean?

When a send fails, the Status cell shows the error code and message the API returned. The errors reference has the full list. These are the ones you are most likely to see:

Status showsWhat it meansWhat to do
Error: Unauthorized - Invalid API keyThe key in Script Properties is wrong, revoked or missing a characterCopy the key again from the API Keys page
Error: ForbiddenThe key lacks the Send Messages scope, or it is a live key without an active subscriptionCreate a key with the right scope, or check your plan
Error: Not Found - Phone number not found or does not belong to your accountSENDER_PHONE doesn't match a number on your account for this key typeUse the number exactly as registered; sandbox keys only see sandbox numbers
Error: Bad Request - Phone number is not connected to WhatsApp, Error: NOT_CONNECTED or Error: NOT_AUTHORIZEDThe live sender number has been disconnectedReconnect it by scanning the QR code again
Error: INVALID_RECIPIENTThe number isn't a WhatsApp account, or isn't a valid WhatsApp addressCheck the number with the person; remove the row if they don't use WhatsApp
Error: RECIPIENT_NOT_REACHABLEWhatsApp refused delivery to this personThey may need to message you first, or may not be reachable on WhatsApp
Error: TIMEOUTWhatsApp didn't answer in time, so the message may or may not have gone outCheck the chat on your phone before you send that row again
Error: Rate limit exceededToo many requests in one minuteWait a minute, set the rows back to Pending and send again; if it keeps happening, raise PAUSE_MS
Error: phone must start with + and the country codeThe script's own format check failedFormat column B as plain text and retype the number with +

Fix the cause, set the row back to "Pending" (or select it and use Send to selected row), and send again.

Frequently asked questions

Is it free to send WhatsApp messages from Google Sheets?

Apps Script itself costs nothing extra; it runs within your Google account's daily quotas. The Chatmaid sandbox is free, but it only simulates sends. As of October 2026, real WhatsApp messages need a Chatmaid Developers plan at $7.99/month flat, which includes one number. See pricing for the current details. Click-to-chat links are free, but someone has to press send on every message.

Do I need the WhatsApp Business API or Meta verification?

No. Chatmaid sends from your own WhatsApp number, linked by scanning a QR code, the same way WhatsApp Web links a device. There is no Meta Business verification or template approval step.

Can I send to a WhatsApp group from the sheet?

Yes. The API accepts a group ID (it ends in @g.us) in the to field, and the script passes values ending in @g.us through unchanged. To find the IDs, add this function, run it once from the editor and copy the id values from the execution log into the Phone column:

function logGroups() {
  const result = callChatmaid('get', '/groups?fromPhoneId=' + encodeURIComponent(getConfig().sender));
  Logger.log(result.ok ? JSON.stringify(result.data, null, 2) : result.error);
}

Your number must be a member of the group and allowed to post in it. The Groups reference has the details.

How do I avoid sending the same message twice?

The script only sends rows marked "Pending", rows typed as "Send", or a row you pick yourself with Send to selected row (which refuses rows already Sending, Sent, Delivered or Read). It claims each row under a script lock and marks it "Sending" before the request, so two runs can't send the same row, and it never retries on its own. If you build your own retries, add an idempotencyKey (up to 64 characters) to the request body. Chatmaid returns the original message for a repeated key instead of sending again, so a retry can't create a duplicate.

Can I send on a schedule instead of right away?

Yes. In the Apps Script editor, open Triggers, click Add Trigger, choose sendPendingRows, set the event source to Time-driven, then pick a type. Day timer runs once a day, but Google picks the minute inside the hour you choose: a 9 AM trigger runs between 9 and 10 AM (Google's trigger guide). Specific date and time runs once at the moment you set. The function has no confirmation dialog, so it runs unattended.

Will WhatsApp ban my number for sending from a spreadsheet?

It's very unlikely with normal use. Restrictions happen when an account behaves like spam, such as unsolicited bulk messages to people who don't have you saved. Send to customers and contacts who expect your messages, keep the pause between sends, and test with the sandbox before you go live.