How do I connect WhatsApp to a Google Sheet with WAIA Connect?
You paste an Apps Script into your sheet, publish it as a web app and register that URL as a webhook in Connect. Every message you get lands in a row, and if you want, the script replies on its own. The full code is below, along with the three odd things about Apps Script worth knowing first.
Before you start
- A WAIA Connect account with a connected number.
- An API key (
wc_live_…): in the panel, API → Create API key. It is shown only once: copy it. - A Google account and a new spreadsheet (or the one you already use).
1. Paste the script into your sheet
- In the sheet: Extensions → Apps Script.
- Delete whatever is in
Code.gsand paste the whole script below. - Save (the floppy-disk icon).
/**
* WAIA Connect → Google Sheets (Apps Script web app)
* Guide: https://waiaconnect.com/guias/google-sheets-webhook-apps-script
*
* What it does, for every event Connect sends to your webhook:
* 1. checks the secret token in the URL (Apps Script cannot read HTTP headers, so the
* X-Connect-Signature header is not available here — see the guide);
* 2. ignores an event it already handled (Connect retries if your script is slow);
* 3. saves it as a row in the "Messages" sheet;
* 4. optionally answers a text message through the Connect API.
*
* Script properties (Project Settings → Script properties):
* CONNECT_API_KEY your wc_live_… key (Connect panel → API keys). Never paste it in the code.
* CONNECT_WEBHOOK_TOKEN a long random secret. The SAME value goes at the end of the
* webhook URL you register in Connect: …/exec?token=<value>
* AUTO_REPLY_TEXT optional. If set, every incoming text message gets this answer.
* Leave it empty to only save messages.
*
* ⚠ After ANY change to this code: Deploy → Manage deployments → edit → Version: "New version".
* Otherwise the /exec URL keeps running the old code.
*/
var CONNECT_API_URL = 'https://api.waiaconnect.com/v1/messages';
var MESSAGES_SHEET = 'Messages';
var ERRORS_SHEET = 'Errors';
var MESSAGE_COLUMNS = ['Received at', 'Event', 'From', 'Name', 'Text', 'Event id', 'Number (connection)'];
var SEEN_SECONDS = 21600; // 6 h: the longest CacheService keeps a value
function doPost(e) {
try {
var props = PropertiesService.getScriptProperties();
// 1. The token. A request without it is not from Connect: answer and do nothing.
var expected = props.getProperty('CONNECT_WEBHOOK_TOKEN');
var given = e && e.parameter ? String(e.parameter.token || '') : '';
if (!expected || !sameText_(given, expected)) {
return answer_('ignored');
}
var evt = JSON.parse(e.postData.contents);
if (!evt || !evt.id || !evt.type) {
return answer_('ignored');
}
// 2. Once per event. Connect retries with the SAME event id if your script took
// longer than 10 seconds; the lock stops two copies running at the same time.
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
var cache = CacheService.getScriptCache();
if (cache.get('evt:' + evt.id)) {
return answer_('duplicate');
}
cache.put('evt:' + evt.id, '1', SEEN_SECONDS);
} finally {
lock.releaseLock();
}
// 3. Save it.
var data = evt.data || {};
var msg = data.message || {};
var contact = (data.contacts && data.contacts[0]) || {};
var name = (contact.profile && contact.profile.name) || '';
var text = msg.type === 'text' && msg.text ? String(msg.text.body || '') : '[' + (msg.type || evt.type) + ']';
sheet_(MESSAGES_SHEET, MESSAGE_COLUMNS).appendRow([
new Date(),
evt.type,
cell_(msg.from || ''),
cell_(name),
cell_(text),
evt.id,
(evt.connection && evt.connection.id) || ''
]);
// 4. Answer — only a text a person sent you. Never an echo (message.echo is what YOU
// sent): answering it would make the bot talk to itself.
if (evt.type === 'message.received' && msg.type === 'text' && msg.from) {
var reply = buildReply(text, name);
if (reply) {
sendText_(evt.connection.id, msg.from, reply, evt.id);
}
}
return answer_('ok');
} catch (err) {
logError_(err);
return answer_('error');
}
}
/**
* The bot's answer. This is the ONE function to change (or to ask ChatGPT to change):
* return the text to send, or null to send nothing.
*/
function buildReply(text, name) {
var fixed = PropertiesService.getScriptProperties().getProperty('AUTO_REPLY_TEXT');
return fixed ? fixed : null;
}
function sendText_(connectionId, to, body, eventId) {
var key = PropertiesService.getScriptProperties().getProperty('CONNECT_API_KEY');
if (!key) {
throw new Error('CONNECT_API_KEY is not set in Script properties');
}
var res = UrlFetchApp.fetch(CONNECT_API_URL, {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + key,
// Same event → same key: if this runs twice, Connect sends the answer only once.
'Idempotency-Key': 'sheets-reply-' + eventId
},
payload: JSON.stringify({ connectionId: connectionId, to: to, type: 'text', text: { body: body } }),
muteHttpExceptions: true
});
var code = res.getResponseCode();
if (code !== 200 && code !== 202) {
throw new Error('Connect API ' + code + ': ' + res.getContentText().slice(0, 300));
}
}
// Apps Script always answers 200 (it cannot send another status), so what we return
// here is only for you to read in a test.
function answer_(text) {
return ContentService.createTextOutput(text).setMimeType(ContentService.MimeType.TEXT);
}
function sheet_(name, columns) {
var book = SpreadsheetApp.getActiveSpreadsheet();
var sh = book.getSheetByName(name);
if (!sh) {
sh = book.insertSheet(name);
sh.appendRow(columns);
}
return sh;
}
// A cell that starts with = + - @ would be run as a FORMULA by Sheets. Someone could
// send you "=IMPORTXML(...)" over WhatsApp: the leading apostrophe keeps it as text.
function cell_(value) {
var s = String(value);
return /^[=+\-@]/.test(s) ? "'" + s : s;
}
function sameText_(a, b) {
if (a.length !== b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
function logError_(err) {
try {
sheet_(ERRORS_SHEET, ['When', 'Error']).appendRow([new Date(), cell_(String(err && err.stack ? err.stack : err))]);
} catch (ignored) {
// Even the error sheet failed: it still shows in Apps Script → Executions.
}
console.error(err);
}
The script creates two tabs by itself: Messages (one row per message) and Errors (if something fails, it is written there).
If you ask ChatGPT for changes, ask it to touch only the buildReply function: that one decides what to answer. The rest (the token, the duplicates, the formula guard) is there to protect you.
2. Put your data in the script properties (not in the code)
- In Apps Script: Project Settings (the gear) → Script Properties → Add script property.
CONNECT_API_KEY= yourwc_live_…key.CONNECT_WEBHOOK_TOKEN= a long password you make up (30+ characters, letters and digits). It proves the message comes from Connect.AUTO_REPLY_TEXT(optional) = the text you want to answer each message with. Leave it empty and the script only saves.
3. Publish it as a web app
- Deploy → New deployment. In the «Select type» gear, choose Web app.
- Execute as: Me. Who has access: Anyone. ⚠ «Anyone with a Google account» does NOT work: Connect has no Google account and would get the sign-in page.
- Deploy, accept the permissions Google asks for (it is your own script) and copy the Web app URL, the one ending in
/exec.
4. Register the webhook in Connect
- In the panel: Webhooks → Add endpoint.
- URL: the
/execone with your token at the end:https://script.google.com/macros/s/…/exec?token=YOUR_TOKEN(the same value asCONNECT_WEBHOOK_TOKEN). - Events: message.received. Add message.echo only if you also want to save what you send from the phone (the script never answers an echo).
- Press Test: it must say 200, and a
webhook.testrow appears in Messages. Then send yourself a WhatsApp from another phone and look at the Messages tab.
The odd parts of Apps Script (read this even if everything works)
- The 302. Google runs your
doPostand only then answers with a redirect (302) toscript.googleusercontent.com, where it leaves what your script returned. Connect follows that redirect with a GET and counts the final 200 as delivered. If you test withcurl, use-Land do not add-X POST. - It always answers 200, even when your script fails. Apps Script does not let you choose the response code, so an error inside
doPostreaches Connect as «delivered». If the bot does not answer, Connect will not see it: check the Errors tab and, in Apps Script, Executions (every call shows up there with its error). - It cannot read headers. The
doPostevent does not carry the HTTP headers, so theX-Connect-Signaturesignature cannot be verified in Apps Script. That is why the token goes in the URL. Anyone who sees your Connect panel sees that URL: if it leaks, change the token on both sides (script property and webhook URL). - Every code change needs a new version. Saving is not enough: Deploy → Manage deployments → edit (pencil) → Version: New version → Deploy. Otherwise the
/execURL keeps running the old code. This way the URL does not change and you do not have to touch Connect. - 10 seconds. Connect waits 10 s for the answer. If your script takes longer (for instance, because it asks ChatGPT), Connect retries the same event: the script recognises it and does not save it twice, and the
Idempotency-Keymakes Connect send the reply only once. Google stops any execution at 6 minutes.
Google's limits
- Calls to other URLs (each reply the script sends is one): 20,000 per day with a gmail.com account, 100,000 with Google Workspace.
- 30 simultaneous executions per user. If many people write at the same moment, the extra ones fail and Connect retries them.
- The «already processed» memory lasts 6 hours (the maximum of the Apps Script cache). A later retry would be saved again, but the reply is not sent twice: the
Idempotency-Keystops it. - A sheet is not a database: with tens of thousands of rows it gets slow. Archive the Messages tab from time to time.
If something does not work
- Test says 200 but no row appears: the token in the URL does not match
CONNECT_WEBHOOK_TOKEN(the script ignores the request and still answers 200), or you published without New version. - The row appears but there is no reply: look at Errors.
Connect API 401= the key is wrong;CONNECT_API_KEY is not set= the property is missing; no error =AUTO_REPLY_TEXTis empty. - Test does not give 200: check that access is Anyone and that the URL ends in
/exec(not/dev). - Want the exact shape of each event? It is in Receive the webhook.
Test it without WhatsApp (optional, from a terminal)
This sends a made-up message straight to your script, the way Connect would. A new row must appear in Messages.
curl -L -H 'Content-Type: application/json' \
-d '{"id":"evt_prueba_1","type":"message.received","connection":{"id":"conn_…"},"data":{"message":{"from":"5493511234567","type":"text","text":{"body":"hello"}},"contacts":[{"profile":{"name":"Test"}}]}}' \
'https://script.google.com/macros/s/<…>/exec?token=<CONNECT_WEBHOOK_TOKEN>'
Sources (checked on 2026-09-29)
Connect your first number today
Meta-verified technology provider. Coexistence in one click.
Get started