Help & Troubleshooting
Copy and Paste the entire Apps Script code in your Google Sheets App Script. No need to change anything in the code. Make sure that you copy and paste the entire code as it is.
Full Tutorial here: https://docs.pabbly.com/pabbly/doc/google-sheets-1-minute-trigger-send-new-rows-in-every-1-minute.12954
Apps Script:
var MARKER_HEADER = "_pabbly_sent"; // header of the hidden bookkeeping column
var TIME_BUDGET_MS = 30000; // stop starting new sends after 30s (trigger fires every 60s)
var MAX_ROWS_PER_RUN = 100; // max webhook calls per execution
var MAX_SPAN_ROWS = 5000; // max rows read in one execution
var FETCH_RETRIES = 3;
function props_() {
return PropertiesService.getDocumentProperties();
}
/* ------------------------------------------------------------------ menu */
function onOpen(e) {
SpreadsheetApp.getUi()
.createMenu("Pabbly Webhooks")
.addItem("Send rows every minute", "setup")
.addItem("Status", "status")
.addItem("Help", "help")
.addToUi();
}
function help() {
var htmlOutput = HtmlService.createHtmlOutput(
'<p style="font:13px Roboto,Arial,sans-serif">Refer detailed guide <a href="https://docs.pabbly.com/pabbly/doc/google-sheets-1-minute-trigger.12927" target="_blank">here</a>.</p>',
)
.setWidth(300)
.setHeight(100);
SpreadsheetApp.getUi().showModelessDialog(htmlOutput, "Pabbly Webhooks Help");
}
/**
* Menu entry point - opens the setup form.
* A dialog cannot be resized once it is open, so the height is estimated here
* from what the form will actually render (status card, error line).
*/
function setup() {
var p = props_();
var h = 470; // form fields + buttons only
if (p.getProperty("sheetId") && p.getProperty("setup")) {
h += 120; // status card
} else if (
ScriptApp.getProjectTriggers().some(function (t) {
return t.getHandlerFunction() == "onSchedule";
})
) {
h += 90; // status card without a saved configuration
}
if (p.getProperty("lastError")) {
h += 40;
}
var html = HtmlService.createHtmlOutput(setupHtml_())
.setWidth(580)
.setHeight(h);
SpreadsheetApp.getUi().showModalDialog(
html,
"Send rows to Pabbly Connect every minute",
);
}
/* --------------------------------------------------- dialog: server side */
/* NOTE: functions called by google.script.run must NOT end with "_" */
function pabblyGetState() {
var p = props_();
var ss = SpreadsheetApp.getActive();
var activeId = String(ss.getActiveSheet().getSheetId());
var tabs = ss.getSheets().map(function (sh) {
return {
id: String(sh.getSheetId()),
name: sh.getName(),
active: String(sh.getSheetId()) === activeId,
};
});
var cfgSheetId = p.getProperty("sheetId");
var known = tabs.some(function (t) {
return t.id == cfgSheetId;
});
var selectedId = cfgSheetId && known ? String(cfgSheetId) : activeId;
var setupStr = p.getProperty("setup") || "";
var url = "";
if (setupStr) {
var li = setupStr.lastIndexOf(",");
url = li == -1 ? setupStr.trim() : setupStr.substring(0, li).trim();
}
var mine = ScriptApp.getProjectTriggers().filter(function (t) {
return t.getHandlerFunction() == "onSchedule";
}).length;
return {
ssName: ss.getName(),
configured: !!(cfgSheetId && setupStr),
missingTab: !!(cfgSheetId && !known),
paused: !!p.getProperty("paused"),
trigOwner: p.getProperty("trigOwner") || "",
me: Session.getEffectiveUser().getEmail(),
triggerCount: mine,
lastRun: p.getProperty("lastRun") || "",
lastError: p.getProperty("lastError") || "",
url: url,
triggerCol: p.getProperty("triggerCol") || "",
tabs: tabs,
selectedId: selectedId,
cfgSheetId: cfgSheetId ? String(cfgSheetId) : "",
sheet: pabblyGetSheetInfo(selectedId, p.getProperty("triggerCol") || ""),
};
}
/**
* @param {string} sheetId
* @param {string=} colLetter trigger column; when given, "ready" is counted too
*/
function pabblyGetSheetInfo(sheetId, colLetter) {
var sh = sheetById_(sheetId);
if (!sh) {
return null;
}
var lastCol = sh.getLastColumn();
var lastRow = sh.getLastRow();
var head = lastCol ? sh.getRange(1, 1, 1, lastCol).getDisplayValues()[0] : [];
var cols = [],
markerCol = 0;
for (var i = 0; i < head.length; i++) {
var t = String(head[i]).trim();
if (t === MARKER_HEADER) {
markerCol = i + 1;
continue;
}
var letter = colLetter_(i + 1);
cols.push({
letter: letter,
label: letter + " - " + (t ? t : "(no header)"),
});
}
var pending = 0;
var ready = null; // rows that are pending AND already have data in the trigger column
if (markerCol && lastRow > 1) {
var vals = sh.getRange(2, markerCol, lastRow - 1, 1).getDisplayValues();
var trig = null;
if (colLetter && /^[A-Za-z]{1,3}$/.test(String(colLetter))) {
var ci = letterToIndex_(String(colLetter).toUpperCase());
if (ci >= 1 && ci <= sh.getMaxColumns() && ci !== markerCol) {
trig = sh.getRange(2, ci, lastRow - 1, 1).getDisplayValues();
ready = 0;
}
}
for (var v = 0; v < vals.length; v++) {
if (String(vals[v][0]).trim() === "") {
pending++;
if (trig && String(trig[v][0]).trim() !== "") {
ready++;
}
}
}
}
return {
id: String(sh.getSheetId()),
name: sh.getName(),
cols: cols,
dataRows: Math.max(0, lastRow - 1),
markerCol: markerCol ? colLetter_(markerCol) : "",
pending: pending,
ready: ready,
};
}
/**
* @param {{sheetId:string, url:string, col:string, existing:string}} form
*/
function pabblySave(form) {
var p = props_();
var sheet = sheetById_(form.sheetId);
if (!sheet) {
return {
ok: false,
error:
"That sheet/tab no longer exists. Close this window and try again.",
};
}
var cfg = buildConfig_(form.url, form.col, sheet);
if (cfg.error) {
return { ok: false, error: cfg.error };
}
var marker = findMarkerCol_(sheet, cfg.colIndex);
if (!marker.col) {
marker = createMarkerCol_(sheet, cfg.colIndex);
}
if (marker.error) {
return { ok: false, error: marker.error };
}
// "all" -> clear every marker so all rows are sent again
// "new" -> mark every row as sent (drops anything still pending)
// "keep" -> do not touch the markers at all; pending rows stay queued
var existing = Math.max(0, sheet.getLastRow() - 1);
var mode =
form.existing === "all" ? "all" : form.existing === "new" ? "new" : "keep";
var stillQueued = 0;
if (existing > 0) {
if (mode === "all") {
sheet.getRange(2, marker.col, existing, 1).clearContent();
stillQueued = existing;
} else if (mode === "new") {
sheet.getRange(2, marker.col, existing, 1).setValue("baseline");
} else {
var mv = sheet.getRange(2, marker.col, existing, 1).getDisplayValues();
for (var q = 0; q < mv.length; q++) {
if (String(mv[q][0]).trim() === "") {
stillQueued++;
}
}
}
}
SpreadsheetApp.flush();
var oldSheetId = p.getProperty("sheetId");
var oldOwner = p.getProperty("trigOwner");
var trig = ensureSingleTrigger_();
if (trig.created || !oldOwner) {
p.setProperty("trigOwner", Session.getEffectiveUser().getEmail());
}
p.setProperty("sheetId", String(sheet.getSheetId()));
p.setProperty("setup", cfg.url + ", " + cfg.colLetter); // kept for backward compatibility / Status
p.setProperty("triggerCol", cfg.colLetter);
p.deleteProperty("paused");
p.deleteProperty("lastSentRow"); // v1 pointer is no longer used
p.deleteProperty("lastError");
var notes = [];
notes.push("Sheet/tab: " + sheet.getName());
notes.push(
"Trigger column: " +
cfg.colLetter +
(cfg.autoDetected ? " (auto-detected)" : ""),
);
if (mode === "all") {
notes.push(existing + " row(s) will be sent over the next few minutes.");
} else if (mode === "new") {
notes.push(
existing +
" existing row(s) marked as already sent. Only new rows will be sent.",
);
} else if (stillQueued > 0) {
notes.push(
stillQueued +
" row(s) were already waiting and are still queued - they go out within a minute.",
);
} else {
notes.push("Only new rows will be sent from now on.");
}
if (trig.removedExtra) {
notes.push(
"Removed " + trig.removedExtra + " duplicate 1-minute trigger(s).",
);
}
if (oldSheetId && oldSheetId != String(sheet.getSheetId())) {
notes.push("The trigger no longer runs on the previously configured tab.");
}
notes.push(
'Hidden column "' +
MARKER_HEADER +
'" (column ' +
colLetter_(marker.col) +
") remembers which rows were sent - please do not delete or edit it.",
);
return { ok: true, title: "Trigger set successfully", notes: notes };
}
/**
* Sends ONE row to the webhook right now, using whatever is currently typed in
* the form. It does not save the configuration, does not create the marker
* column and does not mark the row as sent - so the live trigger will still
* send that row normally.
*
* @param {{sheetId:string, url:string, col:string}} form
*/
function pabblyTest(form) {
var sheet = sheetById_(form.sheetId);
if (!sheet) {
return {
ok: false,
error: "That tab no longer exists. Click Refresh and try again.",
};
}
var cfg = buildConfig_(form.url, form.col, sheet);
if (cfg.error) {
return { ok: false, error: cfg.error };
}
var lastRow = sheet.getLastRow();
if (lastRow < 2) {
return {
ok: false,
error:
'The tab "' +
sheet.getName() +
'" has no data rows yet. Add one row below the header row and try again.',
};
}
// Prefer the last row whose trigger cell has data (that is what the live
// trigger would pick); fall back to the very last row.
var from = Math.max(2, lastRow - 49);
var block = sheet
.getRange(from, 1, lastRow - from + 1, cfg.colIndex)
.getDisplayValues();
var pick = -1;
for (var i = block.length - 1; i >= 0; i--) {
if (String(block[i][cfg.colIndex - 1]).trim().length) {
pick = i;
break;
}
}
var emptyTrigger = false;
if (pick === -1) {
pick = block.length - 1;
emptyTrigger = true;
}
var ss = SpreadsheetApp.getActive();
var rowNum = from + pick;
var headers = sheet.getRange(1, 1, 1, cfg.colIndex).getDisplayValues()[0];
var payload = convertToJson_(
block[pick],
headers,
ss.getName(),
ss.getId(),
sheet.getName(),
String(sheet.getSheetId()),
cfg.colLetter,
rowNum,
);
var res = postWithRetry_(cfg.url, payload);
return {
ok: res.ok,
error: res.ok
? ""
: "The webhook did not accept the test row: " + res.detail,
rowIndex: rowNum,
emptyTrigger: emptyTrigger,
triggerCol: cfg.colLetter,
payload: JSON.stringify(payload, null, 2),
};
}
function pabblySetPaused(paused) {
var p = props_();
if (paused) {
p.setProperty("paused", "1");
} else {
p.deleteProperty("paused");
}
return {
ok: true,
title: paused ? "Sending paused" : "Sending resumed",
notes: [
paused
? "No rows will be sent until you resume."
: "New rows will be sent again from the next minute.",
],
};
}
function pabblyRemove() {
var p = props_();
var me = Session.getEffectiveUser().getEmail();
var trigOwner = p.getProperty("trigOwner");
var removed = 0;
var trigs = ScriptApp.getProjectTriggers();
for (var i = 0; i < trigs.length; i++) {
if (trigs[i].getHandlerFunction() == "onSchedule") {
ScriptApp.deleteTrigger(trigs[i]);
removed++;
}
}
if (removed > 0 || !trigOwner || me == trigOwner) {
p.deleteAllProperties();
return {
ok: true,
title: "Trigger removed",
notes: [
"Removed " + removed + " trigger(s) and cleared the configuration.",
'The hidden "' +
MARKER_HEADER +
'" column is left as it is - delete it manually if you no longer need it.',
],
};
}
// We cannot see or delete another user's trigger, so pause sending instead of
// leaving an orphan trigger running against a cleared configuration.
p.setProperty("paused", "1");
return {
ok: true,
title: "Sending paused (trigger not removed)",
notes: [
"The 1-minute trigger belongs to " +
trigOwner +
", so this account cannot delete it.",
"Sending is paused, so no rows will go out.",
"To remove it completely, open this sheet as " +
trigOwner +
" and click Remove trigger again.",
],
};
}
/* --------------------------------------------------- dialog: client side */
function setupHtml_() {
return (
'<!DOCTYPE html><html><head><base target="_top"><meta charset="utf-8">' +
"<style>" +
"body{font:13px/1.5 Roboto,Arial,sans-serif;color:#202124;margin:0;padding:14px 18px 12px}" +
"h1{font-size:15px;font-weight:500;margin:0 0 4px}" +
".sub{color:#5f6368;margin:0 0 12px}" +
"label.f{display:block;font-weight:500;margin:12px 0 4px}" +
"input[type=text],select{width:100%;box-sizing:border-box;padding:7px 8px;border:1px solid #dadce0;border-radius:4px;font:13px Roboto,Arial,sans-serif;background:#fff}" +
"input[type=text]:focus,select:focus{outline:none;border-color:#1a73e8}" +
"input.bad{border-color:#d93025;background:#fce8e6}" +
"select:disabled,input:disabled{background:#f1f3f4;color:#80868b}" +
".hint{color:#5f6368;font-size:11.5px;margin-top:4px}" +
".err{color:#d93025;font-size:11.5px;margin-top:4px;display:none}" +
".card{border:1px solid #dadce0;border-radius:6px;padding:10px 12px;margin-bottom:14px;background:#f8f9fa;font-size:12px}" +
".card b{font-weight:500}" +
".row{display:flex;justify-content:space-between;gap:10px;padding:1px 0}" +
".row span:first-child{color:#5f6368}" +
".warn{background:#fef7e0;border-color:#f9ab00}" +
".bad2{background:#fce8e6;border-color:#d93025}" +
"fieldset{border:1px solid #dadce0;border-radius:4px;margin:12px 0 0;padding:6px 12px 8px}" +
"legend{font-weight:500;padding:0 4px;color:#202124}" +
"fieldset label{display:block;margin:5px 0;font-weight:400}" +
".acts{display:flex;gap:8px;align-items:center;margin-top:18px;flex-wrap:wrap}" +
"button{font:13px Roboto,Arial,sans-serif;padding:8px 16px;border-radius:4px;border:1px solid #dadce0;background:#fff;color:#1a73e8;cursor:pointer}" +
"button:hover{background:#f1f3f4}" +
"button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}" +
"button.primary:hover{background:#1765cc}" +
"button.quiet{color:#d93025;border-color:#dadce0}" +
"button.quiet:hover{background:#fce8e6}" +
"button.danger{color:#d93025;border-color:#d93025}" +
"button.danger:hover{background:#fce8e6}" +
"button:disabled{opacity:.5;cursor:default}" +
".spacer{flex:1}" +
"#done{display:none}" +
"#done ul{margin:8px 0 0;padding-left:20px;color:#3c4043}" +
/* loading bits */
".spin{width:22px;height:22px;border:2.5px solid #dadce0;border-top-color:#1a73e8;border-radius:50%;" +
" animation:sp .8s linear infinite;flex:none}" +
".spin.sm{width:12px;height:12px;border-width:2px}" +
"@keyframes sp{to{transform:rotate(360deg)}}" +
"#overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#fff;z-index:9;" +
" display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;" +
" color:#5f6368;text-align:center;padding:24px}" +
"#overlayErr{display:none;color:#d93025;max-width:420px}" +
"#retry{display:none}" +
".inline{display:none;align-items:center;gap:6px;color:#5f6368;font-size:11.5px;margin-top:4px}" +
".inline.on{display:flex}" +
"#busy{display:none;align-items:center;gap:8px;color:#5f6368;margin-top:14px}" +
"#busy.on{display:flex}" +
".skel{display:inline-block;height:11px;width:34px;border-radius:3px;background:#e8eaed;" +
" animation:pulse 1.2s ease-in-out infinite;vertical-align:middle}" +
"@keyframes pulse{50%{opacity:.45}}" +
".ctx{display:flex;align-items:baseline;gap:8px;margin:0 0 14px;color:#5f6368;font-size:12px}" +
".ctx b{color:#202124;font-weight:500}" +
".ctx .grow{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" +
"button.link{border:none;background:none;color:#1a73e8;padding:2px 4px;font-size:12px;flex:none}" +
"button.link:hover{background:#f1f3f4;border-radius:4px}" +
".ok{background:#e6f4ea;border-color:#34a853}" +
"#testOut{margin:14px 0 0}" +
"pre{margin:8px 0 0;padding:8px;background:#fff;border:1px solid #dadce0;border-radius:4px;" +
" max-height:150px;overflow:auto;font:11.5px/1.45 Consolas,Menlo,monospace;" +
" white-space:pre-wrap;word-break:break-word}" +
"</style></head><body>" +
'<div id="overlay">' +
' <div class="spin" id="overlaySpin"></div>' +
' <div id="overlayText">Loading your sheet details...</div>' +
' <div id="overlayErr"></div>' +
' <button id="retry" title="Tries to read the tabs, columns and status from the sheet again.">Try again</button>' +
"</div>" +
'<div id="form">' +
' <p class="sub">Runs every minute and sends each new row once, after its trigger column is filled.</p>' +
' <div class="ctx"><span class="grow">Spreadsheet: <b id="ssName"><span class="skel"></span></b></span>' +
' <button class="link" id="refresh" disabled title="Reloads the tabs, columns and status from the sheet.">Refresh</button></div>' +
' <div id="state" class="card" style="display:none"></div>' +
' <label class="f" for="tab">Tab to watch</label>' +
' <select id="tab" disabled><option>Loading...</option></select>' +
' <label class="f" for="url">Pabbly Connect webhook URL</label>' +
' <input id="url" type="text" disabled placeholder="https://connect.pabbly.com/workflow/sendwebhookdata/...">' +
' <div class="err" id="urlErr"></div>' +
' <label class="f" for="col">Trigger column</label>' +
' <select id="col" disabled><option>Loading columns...</option></select>' +
' <div class="inline" id="colBusy"><span class="spin sm"></span><span>Reading the header row...</span></div>' +
' <div class="hint" id="colHint"> </div>' +
' <fieldset id="exBox"><legend>Rows already in the sheet</legend>' +
' <label><span class="skel" style="width:190px"></span></label>' +
' <label><span class="skel" style="width:230px"></span></label>' +
" </fieldset>" +
' <div class="acts">' +
' <button id="save" class="primary" disabled>Save & start</button>' +
' <button id="test" disabled title="Sends one row to your webhook right now so Pabbly Connect can capture the fields. The row is not marked as sent, so the trigger will still send it normally.">Send test row</button>' +
' <button id="pause" style="display:none">Pause</button>' +
' <button id="remove" class="danger" style="display:none" title="Deletes the every-minute trigger and clears this configuration. Nothing more is sent. The hidden _pabbly_sent column stays in the sheet.">Remove trigger</button>' +
' <span class="spacer"></span>' +
' <button id="cancel" class="quiet" title="Closes this window. Nothing is saved and nothing is sent.">Cancel</button>' +
" </div>" +
' <div id="busy"><span class="spin sm"></span><span id="busyText">Working, please wait...</span></div>' +
' <div id="testOut" class="card" style="display:none"></div>' +
"</div>" +
'<div id="done"><h1 id="doneTitle"></h1><ul id="doneList"></ul>' +
' <div class="acts"><button class="primary" onclick="google.script.host.close()">Close</button></div>' +
"</div>" +
"<script>" +
"var S=null,KEEPURL=null;" +
"function $(i){return document.getElementById(i)}" +
'function esc(t){var d=document.createElement("div");d.textContent=t==null?"":String(t);return d.innerHTML}' +
'function row(k,v){return "<div class=\\"row\\"><span>"+esc(k)+"</span><span><b>"+esc(v)+"</b></span></div>"}' +
'function fields(){return ["save","test","pause","remove","cancel","tab","col","url","refresh"]}' +
"function badUrl(u){if(/^https:\\/\\/\\S+$/.test(u)){return false}" +
' $("urlErr").textContent="Enter a valid webhook URL starting with https:// and without spaces.";' +
' $("urlErr").style.display="block";$("url").classList.add("bad");$("url").focus();return true}' +
'function syncColHint(){var v=$("col").value;' +
' $("colHint").textContent=v?("Columns A-"+v+" are sent, once "+v+" has data.")' +
' :"Columns A to the last one with data are sent."}' +
/* full-dialog loader, used while the initial state is fetched */
'function overlay(show,text){var o=$("overlay");' +
' if(!show){o.style.display="none";return}' +
' o.style.display="flex";$("overlaySpin").style.display="block";' +
' $("overlayText").style.display="block";$("overlayText").textContent=text||"Loading...";' +
' $("overlayErr").style.display="none";$("retry").style.display="none"}' +
'function overlayFail(msg){var o=$("overlay");o.style.display="flex";' +
' $("overlaySpin").style.display="none";$("overlayText").style.display="none";' +
' $("overlayErr").style.display="block";' +
' $("overlayErr").innerHTML="Could not load the sheet details.<br><br>"+esc(msg);' +
' $("retry").style.display="inline-block"}' +
/* inline loader, used while an action runs */
'function busy(b,label){var x=$("busy");' +
' if(b){$("busyText").textContent=label||"Working, please wait...";x.classList.add("on")}' +
' else{x.classList.remove("on")}' +
" fields().forEach(function(i){$(i).disabled=b});" +
' Array.prototype.forEach.call(document.querySelectorAll("input[name=ex]"),function(r){r.disabled=b})}' +
"function paintState(){" +
' var box=$("state"),h=[],cls="card";' +
' if(!S.configured&&!S.triggerCount){box.style.display="none";' +
' $("pause").style.display="none";$("remove").style.display="none";return}' +
' box.style.display="block";' +
' h.push(row("Status",S.paused?"PAUSED":(S.configured?"Active":"Not configured")));' +
' h.push(row("Trigger owner",S.trigOwner||"-"));' +
' h.push(row("1-minute triggers (this account)",S.triggerCount));' +
' if(S.lastRun)h.push(row("Last activity",S.lastRun));' +
" if(S.sheet&&S.sheet.markerCol){var pv=String(S.sheet.pending);" +
" if(S.sheet.pending>0&&S.sheet.ready!==null&&S.sheet.ready!==undefined){" +
' pv=S.sheet.pending+" ("+S.sheet.ready+" ready, "' +
' +(S.sheet.pending-S.sheet.ready)+" waiting for "+(S.triggerCol||"the trigger column")+")"}' +
' h.push(row("Rows not sent yet",pv))}' +
' if(S.paused)cls="card warn";' +
' if(S.triggerCount>1){cls="card warn";' +
' h.push("<div style=\\"margin-top:6px\\">More than one 1-minute trigger found. Click <b>Save & start</b> to keep only one.</div>")}' +
' if(S.missingTab){cls="card warn";' +
' h.push("<div style=\\"margin-top:6px\\">The previously configured tab no longer exists. Pick a tab below and save again.</div>")}' +
' if(S.trigOwner&&S.trigOwner!==S.me){cls="card warn";' +
' h.push("<div style=\\"margin-top:6px\\">The trigger was set up by <b>"+esc(S.trigOwner)+"</b>. Saving from this account ("+esc(S.me)+") may add a second trigger. Rows still will not be duplicated, but it is cleaner to use the original account.</div>")}' +
' if(S.lastError){cls="card bad2";' +
' h.push("<div style=\\"margin-top:6px\\">Last error: "+esc(S.lastError)+"</div>")}' +
' box.className=cls;box.innerHTML=h.join("");' +
' var sv=$("save"),pz=$("pause");' +
' pz.style.display=S.configured?"inline-block":"none";' +
" if(S.paused){" +
/* one button only: Resume saves whatever is in the form and un-pauses */
' pz.textContent="Resume";pz.className="primary";pz.style.order="-1";' +
' pz.title="Saves the settings above and starts sending again. Rows that are waiting go out within a minute.";' +
' sv.style.display="none"}' +
" else{" +
' sv.style.display="inline-block";' +
' pz.textContent="Pause";pz.className="";pz.style.order="0";' +
' pz.title="Stops sending. New rows keep piling up and go out when you resume.";' +
' sv.textContent=S.triggerCount?"Save changes":"Save & start";sv.className="primary";' +
' sv.title=S.triggerCount?"Saves the settings below. The every-minute trigger keeps running."' +
' :"Saves the settings below and starts the every-minute trigger."}' +
' $("remove").style.display=(S.configured||S.triggerCount)?"inline-block":"none"}' +
"function paintSheet(info,prefer){" +
' var sel=$("col");sel.innerHTML="";' +
' var o=document.createElement("option");o.value="";' +
' o.textContent="Auto - last column that has data (not recommended)";sel.appendChild(o);' +
" if(info&&info.cols){info.cols.forEach(function(c){" +
' var x=document.createElement("option");x.value=c.letter;x.textContent=c.label;sel.appendChild(x)})}' +
" if(prefer){sel.value=prefer}" +
" if(!sel.value&&info&&info.cols&&info.cols.length){sel.value=info.cols[info.cols.length-1].letter}" +
" syncColHint()}" +
'function att(t){return esc(t).replace(/"/g,""")}' +
"function opt(v,on,label,tip){" +
' return "<label title=\\""+att(tip)+"\\"><input type=\\"radio\\" name=\\"ex\\" value=\\""+v+"\\""' +
' +(on?" checked":"")+"> "+esc(label)+"</label>"}' +
/* what to do with rows that are already in the sheet - depends on whether
this tab is already configured and whether anything is still pending */
"function paintExisting(){" +
' var info=S.sheet,n=info?info.dataRows:0,h="";' +
' var same=!!(S.cfgSheetId&&String($("tab").value)===String(S.cfgSheetId));' +
" var pend=(info&&info.markerCol)?info.pending:0;" +
' var resend="Clears the sent history of every row, so all "+n+" row(s) are sent again. Uses "+n+" task(s) in Pabbly Connect.";' +
" if(same&&S.configured&&pend>0){" +
' h="<legend>"+pend+" row(s) not sent yet"' +
' +((info.ready!==null&&info.ready!==undefined)?" ("+info.ready+" ready now)":"")+"</legend>"' +
' +opt("keep",1,"Send them - nothing is skipped",' +
' "Keeps these rows queued. Each one is sent as soon as its trigger column has data.")' +
' +opt("new",0,"Drop them - mark as already sent",' +
' "Marks these rows as sent WITHOUT sending them. Use this for old or test rows you do not want in Pabbly Connect.")' +
' +opt("all",0,"Send all "+n+" row(s) again",resend)}' +
" else if(same&&S.configured){" +
' h="<legend>Rows already in the sheet</legend>"' +
' +opt("keep",1,"Leave them as they are",' +
' "Nothing already in the sheet is re-sent. Only new rows are sent from now on.")' +
' +opt("all",0,"Send all "+n+" row(s) again",resend)}' +
" else{" +
' h="<legend>Rows already in the sheet</legend>"' +
' +opt("new",1,"Only new rows from now on",' +
' "The "+n+" row(s) already in this tab are marked as sent, so only rows added later go to Pabbly Connect.")' +
' +opt("all",0,"Send the "+n+" existing row(s) now",' +
' "All "+n+" row(s) are sent over the next few minutes. Uses "+n+" task(s) in Pabbly Connect.")}' +
' $("exBox").innerHTML=h}' +
/* tab changed -> reload just the column list, with its own spinner */
"function loadSheet(id){" +
' var sel=$("col");sel.disabled=true;' +
' sel.innerHTML="<option>Loading columns...</option>";' +
' $("colBusy").classList.add("on");' +
' $("exBox").innerHTML="<legend>Rows already in the sheet</legend>"' +
' +"<label><span class=\\"skel\\" style=\\"width:190px\\"></span></label>"' +
' +"<label><span class=\\"skel\\" style=\\"width:230px\\"></span></label>";' +
' $("save").disabled=true;$("test").disabled=true;$("testOut").style.display="none";' +
" google.script.run.withSuccessHandler(function(info){" +
" S.sheet=info;" +
' paintSheet(info,(S.cfgSheetId&&String(id)===String(S.cfgSheetId))?S.triggerCol:"");' +
" paintExisting();" +
' sel.disabled=false;$("save").disabled=false;$("test").disabled=false;' +
' $("colBusy").classList.remove("on")})' +
" .withFailureHandler(function(e){" +
' $("colBusy").classList.remove("on");sel.disabled=false;$("save").disabled=false;$("test").disabled=false;' +
' sel.innerHTML="<option value=\\"\\">Could not read the columns - try again</option>";' +
" alert(e.message)}).pabblyGetSheetInfo(id)}" +
'function loadState(){overlay(true,"Loading your sheet details...");' +
" google.script.run.withSuccessHandler(function(st){S=st;" +
' $("ssName").textContent=S.ssName||"-";' +
' var t=$("tab");t.innerHTML="";' +
' S.tabs.forEach(function(x){var o=document.createElement("option");' +
' o.value=x.id;o.textContent=x.name+(x.active?" (currently open tab)":"");t.appendChild(o)});' +
" t.value=S.selectedId;" +
' $("url").value=(KEEPURL!==null?KEEPURL:(S.url||""));KEEPURL=null;' +
" paintState();" +
' paintSheet(S.sheet,(S.cfgSheetId&&String(S.selectedId)===String(S.cfgSheetId))?S.triggerCol:"");' +
" paintExisting();" +
" fields().forEach(function(i){$(i).disabled=false});" +
' Array.prototype.forEach.call(document.querySelectorAll("input[name=ex]"),function(r){r.disabled=false});' +
" overlay(false)})" +
" .withFailureHandler(function(e){overlayFail(e.message)}).pabblyGetState()}" +
'function showTest(r){var b=$("testOut");b.style.display="block";' +
' if(!r.ok){b.className="card bad2";' +
' b.innerHTML="<b>Test failed</b><br>"+esc(r.error);return}' +
' b.className="card ok";' +
' var h="<b>Row "+esc(r.rowIndex)+" sent to your webhook.</b> It is not marked as sent, so the trigger will still send it normally.";' +
" if(r.emptyTrigger){" +
' h+="<div style=\\"margin-top:6px\\">Column "+esc(r.triggerCol)+" is empty in this row, so the live trigger would NOT send it yet.</div>"}' +
' h+="<pre>"+esc(r.payload)+"</pre>";b.innerHTML=h;' +
' b.scrollIntoView({block:"end"})}' +
"function finish(r){busy(false);" +
' if(!r.ok){$("urlErr").textContent=r.error;$("urlErr").style.display="block";' +
' $("url").classList.add("bad");return}' +
' $("doneTitle").textContent=r.title;' +
' $("doneList").innerHTML=r.notes.map(function(n){return "<li>"+esc(n)+"</li>"}).join("");' +
' $("form").style.display="none";$("done").style.display="block"}' +
'$("retry").addEventListener("click",loadState);' +
'$("refresh").addEventListener("click",function(){' +
' KEEPURL=$("url").value;loadState()});' +
'$("tab").addEventListener("change",function(){loadSheet(this.value)});' +
'$("col").addEventListener("change",syncColHint);' +
'$("url").addEventListener("input",function(){this.classList.remove("bad");$("urlErr").style.display="none"});' +
'$("cancel").addEventListener("click",function(){google.script.host.close()});' +
'$("test").addEventListener("click",function(){' +
' var u=$("url").value.trim();if(badUrl(u)){return}' +
' $("testOut").style.display="none";' +
' busy(true,"Sending one row to your webhook...");' +
" google.script.run.withSuccessHandler(function(r){busy(false);showTest(r)})" +
" .withFailureHandler(function(e){busy(false);alert(e.message)})" +
' .pabblyTest({sheetId:$("tab").value,url:u,col:$("col").value})});' +
"function doSave(resuming){" +
' var u=$("url").value.trim();if(badUrl(u)){return}' +
' var ex=document.querySelector("input[name=ex]:checked").value;' +
" var n=S.sheet?S.sheet.dataRows:0;" +
" var pend=(S.sheet&&S.sheet.markerCol)?S.sheet.pending:0;" +
' if(ex==="all"&&n>0&&!confirm("This sends all "+n+" row(s) to Pabbly Connect again and consumes "+n+" task(s). Continue?"))return;' +
' if(ex==="new"&&pend>0&&!confirm(pend+" row(s) were never sent. They will be marked as sent and NEVER go to Pabbly Connect. Continue?"))return;' +
' busy(true,resuming?"Resuming...":(ex==="all"&&n>0?"Saving and queueing "+n+" row(s)...":"Saving..."));' +
" google.script.run.withSuccessHandler(finish).withFailureHandler(function(e){busy(false);alert(e.message)})" +
' .pabblySave({sheetId:$("tab").value,url:u,col:$("col").value,existing:ex})}' +
'$("save").addEventListener("click",function(){doSave(false)});' +
'$("pause").addEventListener("click",function(){' +
" if(S.paused){doSave(true);return}" +
' busy(true,"Pausing...");' +
" google.script.run.withSuccessHandler(finish).withFailureHandler(function(e){busy(false);alert(e.message)})" +
" .pabblySetPaused(true)});" +
'$("remove").addEventListener("click",function(){' +
' if(!confirm("Stop sending rows and remove the 1-minute trigger?"))return;' +
' busy(true,"Removing the trigger...");' +
" google.script.run.withSuccessHandler(finish).withFailureHandler(function(e){busy(false);alert(e.message)})" +
" .pabblyRemove()});" +
"loadState();" +
"</script></body></html>"
);
}
/* ----------------------------------------------------------- validation */
function buildConfig_(url, colLetter, sheet) {
url = String(url == null ? "" : url).trim();
if (!/^https:\/\/\S+$/.test(url)) {
return {
error:
"Enter a valid webhook URL starting with https:// and without spaces.",
};
}
var maxCols = sheet.getMaxColumns();
var colIndex,
autoDetected = false;
colLetter = String(colLetter == null ? "" : colLetter)
.trim()
.toUpperCase();
if (colLetter) {
if (!/^[A-Z]{1,3}$/.test(colLetter)) {
return { error: 'Invalid trigger column "' + colLetter + '".' };
}
colIndex = letterToIndex_(colLetter);
if (colIndex > maxCols) {
return {
error:
"Column " +
colLetter +
' does not exist in "' +
sheet.getName() +
'" (it has ' +
maxCols +
" columns).",
};
}
} else {
colIndex = sheet.getLastColumn();
autoDetected = true;
var m = findMarkerCol_(sheet, 0);
if (m.col && m.col == colIndex) {
colIndex--;
} // never pick the bookkeeping column
if (colIndex < 1) {
return {
error:
'The tab "' +
sheet.getName() +
'" is empty. Add your header row first, then set up the trigger.',
};
}
colLetter = colLetter_(colIndex);
}
return {
url: url,
colIndex: colIndex,
colLetter: colLetter,
autoDetected: autoDetected,
};
}
function ensureSingleTrigger_() {
var trigs = ScriptApp.getProjectTriggers();
var keep = null,
extra = 0;
for (var i = 0; i < trigs.length; i++) {
if (trigs[i].getHandlerFunction() != "onSchedule") {
continue;
}
if (!keep) {
keep = trigs[i];
} else {
ScriptApp.deleteTrigger(trigs[i]); // duplicate every-minute trigger
extra++;
}
}
if (!keep) {
ScriptApp.newTrigger("onSchedule").timeBased().everyMinutes(1).create();
return { created: true, removedExtra: extra };
}
return { created: false, removedExtra: extra };
}
/* -------------------------------------------------------------- the loop */
function onSchedule() {
var start = Date.now();
var lock = LockService.getDocumentLock();
if (!lock.tryLock(0)) {
// Previous execution is still running - skip this minute instead of
// sending the same rows twice.
return;
}
try {
runOnce_(start);
} catch (err) {
logError_(err && err.message ? err.message : String(err));
console.error(err);
} finally {
lock.releaseLock();
}
}
function runOnce_(start) {
var p = props_();
if (p.getProperty("paused")) {
return;
}
var sheetId = p.getProperty("sheetId");
var setupStr = p.getProperty("setup");
if (!sheetId || !setupStr) {
return;
}
var ss = SpreadsheetApp.getActive();
var sheet = sheetById_(sheetId);
if (!sheet) {
logError_(
"The configured sheet/tab no longer exists. Open Pabbly Webhooks > Send rows every minute and pick a tab again.",
);
return;
}
var cfg = getConfig_(sheet, setupStr);
if (cfg.error) {
logError_(cfg.error);
return;
}
var lastRow = sheet.getLastRow();
if (lastRow < 2) {
return;
}
// Bookkeeping column (created + back-filled on the first run after upgrading)
var marker = findMarkerCol_(sheet, cfg.colIndex);
if (!marker.col) {
var oldPointer = parseInt(p.getProperty("lastSentRow"), 10);
var baseline = oldPointer > 1 ? Math.min(oldPointer, lastRow) : lastRow;
marker = createMarkerCol_(sheet, cfg.colIndex);
if (marker.error) {
logError_(marker.error);
return;
}
if (baseline > 1) {
sheet.getRange(2, marker.col, baseline - 1, 1).setValue("baseline");
}
p.deleteProperty("lastSentRow");
SpreadsheetApp.flush();
logRun_(
'created "' +
MARKER_HEADER +
'" column, rows 2-' +
baseline +
" marked as already sent",
);
}
if (marker.error) {
logError_(marker.error);
return;
}
// Which rows are still unsent?
var markerVals = sheet
.getRange(2, marker.col, lastRow - 1, 1)
.getDisplayValues();
var pending = [];
for (var i = 0; i < markerVals.length; i++) {
if (String(markerVals[i][0]).trim() === "") {
pending.push(i + 2);
}
}
if (!pending.length) {
return;
}
var lo = pending[0];
var hi = pending[pending.length - 1];
var deferred = 0;
if (hi - lo + 1 > MAX_SPAN_ROWS) {
hi = lo + MAX_SPAN_ROWS - 1;
for (var d = 0; d < pending.length; d++) {
if (pending[d] > hi) {
deferred++;
}
}
}
var headers = sheet.getRange(1, 1, 1, cfg.colIndex).getDisplayValues()[0];
var data = sheet
.getRange(lo, 1, hi - lo + 1, cfg.colIndex)
.getDisplayValues();
var sent = 0,
waiting = 0,
stopped = "";
for (var k = 0; k < pending.length; k++) {
var rowNum = pending[k];
if (rowNum > hi) {
break;
}
if (sent >= MAX_ROWS_PER_RUN) {
stopped = "row limit reached";
break;
}
if (timeUp_(start)) {
stopped = "time limit reached";
break;
}
var row = data[rowNum - lo];
if (!String(row[cfg.colIndex - 1]).trim().length) {
waiting++; // trigger column still empty -> check again next minute
continue;
}
var payload = convertToJson_(
row,
headers,
ss.getName(),
ss.getId(),
sheet.getName(),
sheetId,
cfg.colLetter,
rowNum,
);
var res = postWithRetry_(cfg.url, payload);
if (!res.ok) {
logError_(
"Row " +
rowNum +
" could not be sent (" +
res.detail +
"). It will be retried next minute.",
);
stopped = "webhook error";
break;
}
// Commit immediately: this row can never be sent twice, even if the
// execution dies on the very next line.
sheet.getRange(rowNum, marker.col).setValue(stamp_());
SpreadsheetApp.flush();
sent++;
}
var note = "sent " + sent;
if (waiting) {
note += ", " + waiting + " row(s) waiting for column " + cfg.colLetter;
}
if (deferred) {
note += ", " + deferred + " row(s) deferred to next run";
}
if (stopped) {
note += " (" + stopped + ")";
}
logRun_(note);
if (sent > 0 && !stopped) {
p.deleteProperty("lastError");
}
}
function getConfig_(sheet, setupStr) {
var p = props_();
var url,
colLetter = p.getProperty("triggerCol");
var li = setupStr.lastIndexOf(",");
if (li == -1) {
url = setupStr.trim();
} else {
url = setupStr.substring(0, li).trim();
if (!colLetter) {
colLetter = setupStr
.substring(li + 1)
.trim()
.toUpperCase();
}
}
if (!url) {
return { error: "Webhook URL is missing from the configuration." };
}
if (!colLetter) {
// v1 configuration without a stored column: detect once, then freeze it.
var last = sheet.getLastColumn();
var m = findMarkerCol_(sheet, 0);
if (m.col && m.col == last) {
last--;
}
if (last < 1) {
return { error: "Could not determine the last column of the sheet." };
}
colLetter = colLetter_(last);
p.setProperty("triggerCol", colLetter);
}
if (!/^[A-Z]{1,3}$/.test(colLetter)) {
return {
error:
'Configured trigger column "' +
colLetter +
'" is not a valid column letter. Open Pabbly Webhooks > Send rows every minute and save again.',
};
}
var colIndex = letterToIndex_(colLetter);
if (colIndex > sheet.getMaxColumns()) {
return {
error:
"Configured trigger column " +
colLetter +
" does not exist in this sheet any more.",
};
}
return { url: url, colIndex: colIndex, colLetter: colLetter };
}
/* -------------------------------------------------------- marker column */
function findMarkerCol_(sheet, endColIndex) {
var lastCol = sheet.getLastColumn();
if (lastCol < 1) {
return { col: 0 };
}
var head = sheet.getRange(1, 1, 1, lastCol).getDisplayValues()[0];
for (var i = 0; i < head.length; i++) {
if (String(head[i]).trim() === MARKER_HEADER) {
var col = i + 1;
if (endColIndex && col <= endColIndex) {
return {
col: col,
error:
'The "' +
MARKER_HEADER +
'" column sits inside the range that is sent to Pabbly (column ' +
colLetter_(col) +
"). Move it to the right of column " +
colLetter_(endColIndex) +
", or pick an earlier trigger column.",
};
}
return { col: col };
}
}
return { col: 0 };
}
function createMarkerCol_(sheet, endColIndex) {
var col = Math.max(sheet.getLastColumn(), endColIndex) + 1;
if (col > sheet.getMaxColumns()) {
sheet.insertColumnsAfter(
sheet.getMaxColumns(),
col - sheet.getMaxColumns(),
);
}
sheet.getRange(1, col).setValue(MARKER_HEADER);
try {
sheet.hideColumns(col);
} catch (e) {
/* hiding is cosmetic */
}
return { col: col };
}
/* --------------------------------------------------------------- status */
function status() {
var p = props_();
var ui = SpreadsheetApp.getUi();
var sheetId = p.getProperty("sheetId");
var setupStr = p.getProperty("setup");
var lines = [];
lines.push("Trigger owner : " + (p.getProperty("trigOwner") || "-"));
lines.push("Paused : " + (p.getProperty("paused") ? "YES" : "no"));
var trigs = ScriptApp.getProjectTriggers().filter(function (t) {
return t.getHandlerFunction() == "onSchedule";
});
lines.push(
"My triggers : " +
trigs.length +
(trigs.length > 1 ? " <-- DUPLICATE, re-run setup" : ""),
);
var sheet = !sheetId ? null : sheetById_(sheetId);
lines.push(
"Sheet/tab : " +
(sheet ? sheet.getName() : sheetId ? sheetId + " (NOT FOUND)" : "-"),
);
lines.push("Configuration : " + (setupStr || "-"));
if (sheet && setupStr) {
var cfg = getConfig_(sheet, setupStr);
if (cfg.error) {
lines.push("Config error : " + cfg.error);
} else {
lines.push(
"Trigger column: " +
cfg.colLetter +
' (header: "' +
String(sheet.getRange(1, cfg.colIndex).getDisplayValue()) +
'")',
);
var marker = findMarkerCol_(sheet, cfg.colIndex);
lines.push(
"Marker column : " +
(marker.col ? colLetter_(marker.col) : "not created yet"),
);
if (marker.error) {
lines.push("Marker error : " + marker.error);
}
var lastRow = sheet.getLastRow();
if (marker.col && lastRow > 1) {
var mv = sheet
.getRange(2, marker.col, lastRow - 1, 1)
.getDisplayValues();
var dv = sheet
.getRange(2, cfg.colIndex, lastRow - 1, 1)
.getDisplayValues();
var pend = 0,
ready = 0;
for (var i = 0; i < mv.length; i++) {
if (String(mv[i][0]).trim() === "") {
pend++;
if (String(dv[i][0]).trim() !== "") {
ready++;
}
}
}
lines.push("Rows total : " + (lastRow - 1));
lines.push(
"Not sent yet : " +
pend +
" (ready: " +
ready +
", waiting for column " +
cfg.colLetter +
": " +
(pend - ready) +
")",
);
}
}
}
lines.push("Last activity : " + (p.getProperty("lastRun") || "-"));
lines.push("Last error : " + (p.getProperty("lastError") || "none"));
ui.alert("Pabbly Webhooks - Status", lines.join("\n"), ui.ButtonSet.OK);
}
/* -------------------------------------------------------------- helpers */
function sheetById_(sheetId) {
return (
SpreadsheetApp.getActive()
.getSheets()
.find(function (sh) {
return String(sh.getSheetId()) == String(sheetId);
}) || null
);
}
function timeUp_(start) {
return Date.now() - start > TIME_BUDGET_MS;
}
function postWithRetry_(url, data) {
// NOTE: options are kept identical to v1 (no contentType) so the payload
// reaches Pabbly Connect exactly as before. Only error handling is added.
var options = {
method: "post",
payload: JSON.stringify(data),
muteHttpExceptions: true,
};
var detail = "unknown error";
for (var attempt = 1; attempt <= FETCH_RETRIES; attempt++) {
try {
var resp = UrlFetchApp.fetch(url, options);
var code = resp.getResponseCode();
if (code >= 200 && code < 300) {
return { ok: true };
}
detail = "HTTP " + code;
if (code >= 400 && code < 500 && code != 408 && code != 429) {
// bad URL / disabled workflow - retrying will not help
return {
ok: false,
detail:
detail + " " + String(resp.getContentText()).substring(0, 200),
};
}
} catch (e) {
detail = String(e).substring(0, 200);
}
if (attempt < FETCH_RETRIES) {
Utilities.sleep(1000 * attempt);
}
}
return { ok: false, detail: detail };
}
function convertToJson_(
values,
headers,
spreadsheetName,
spreadsheetID,
sheetName,
sheetID,
triggerColumn,
rowIndex,
) {
var data = {};
for (var i = 0; i < values.length; i++) {
var key = String(headers[i] == null ? "" : headers[i]).trim();
if (!key) {
key = colLetter_(i + 1);
} // blank header -> use the column letter
data[key] = values[i];
}
data.SpreadsheetName = spreadsheetName;
data.SpreadsheetID = spreadsheetID;
data.SheetName = sheetName;
data.SheetID = sheetID;
data.TriggerColumn = triggerColumn;
data.RowIndex = rowIndex;
return data;
}
function colLetter_(index) {
var i = index,
l = "",
c;
while (i > 0) {
c = (i - 1) % 26;
l = String.fromCharCode(c + 65) + l;
i = (i - c - 1) / 26;
}
return l;
}
function letterToIndex_(letter) {
var n = 0;
for (var i = 0; i < letter.length; i++) {
n = n * 26 + (letter.charCodeAt(i) - 64);
}
return n;
}
function stamp_() {
return Utilities.formatDate(
new Date(),
Session.getScriptTimeZone(),
"yyyy-MM-dd HH:mm:ss",
);
}
function logRun_(note) {
props_().setProperty("lastRun", stamp_() + " - " + note);
}
function logError_(msg) {
var text = stamp_() + " - " + String(msg).substring(0, 400);
props_().setProperty("lastError", text);
console.error(text);
}