I have a client project coming up shortly where I will be diving back into the Google Sheets API Advanced Service in Google Apps Script. As part of my refamiliarisation with the API, I was thinking about the minimum number of API calls it would take to efficiently create a Google Sheets line archiver based on a set of multi-column parameters.
Here’s the example and the starter sheet if you want to play along.
The Sample Data Google Sheet: The Space Ship Manufacturing Fleet Requests
Stardate 78183.10, it’s surprising spreadsheets have not been improved upon in the almost half millennia since their origin, but they are still everpresent while the inevitable decay of other software products is a little more than the dust of a bygone memory.
We’ve decided to create an archiving automation for our spaceship-building bids for our new space fleet. We want to remove any bids from the sheet that have been declined and are before “2025-04-30”, (Damn it! Timeline inconsistencies already!!!!).
Once, we find these rows to archive, we want to remove the selected rows and append them to our ‘Archive’ sheet.
Check out the sheet:
So, how many API calls?
Well, it ends up being 5 API calls. This is not as great as I had hoped but, perhaps significantly faster using Google Sheets APIs batching capabilities over the standard SpreadsheetApp approach.
What are the API calls?
- Get Basic Sheet data: First, we have a general call to get some general spreadsheet data, including an array of all Sheet tabs by ID and name and the Google Sheet’s timezone offset to ensure consistency in date processing.
- Find Rows: Next, we need to find the target rows of data to remove and archive.
- Copy Rows: Once the rows are found, we will need to copy those rows.
- Append Rows: With the rows copied, we need to archive those rows by appending them to our ‘Archive’ Sheet tab.
- Remove Rows: Finally, we need to remove the rows from our main ‘Bid’ Sheet tab.
Script Structure
As is my experience with most REST APIs, I end up building a service to handle the calls I need and slowly build them out as more services are required. In the end, the script looks like I am reinventing something like the SpreadsheetApp service; a factory data structure to work with Google Sheets easily. The key benefit here is that we can better manage batch API calls rather than relying on SpreadsheetApps service and the flush()
method.
So our main script will end up being a class containing a number of access points to the Sheets API that are much easier to implement over building error-prone objects each time.
Then, we can use a procedural function to step through the stages that we want to complete in our process.
A bonus of this factory approach is that each step can be tested or used in another process on its own.
It’s probably best to first take a look a the main run code.
Before we start
You will need to add the Sheets API advanced service to your Google Apps Script project.
- In your IDE, select Services from the sidebar.
A dialogue will appear.
- Scroll down until you find ‘Google Sheets API’ and select it.
-
Make sure V4 is selected in the version and (4)the identifier is ‘Sheets’.
-
Click ‘Add’.

The Main Run Code
As mentioned above, this is a procedural function that steps through each stage of the process of archiving our data.
Let’s take a look:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/** * Archives bids from the bid sheet that have been declined and * are less than or equal to the target date. * These bids are removed from the 'Bids' sheet and appended to * the 'Archive' sheet. */ function archiveBids() { /** @type {FindQueries[]} */ const queries = [ { column: "J", query: "Declined", type: SsReq.QueryType.EQUALS }, { column: "F", query: "2025-04-30", type: SsReq.QueryType.LESS_THAN_OR_EQUAL_TO, isDate: true } ] const sheetName = "Bids" const rowStart = 2 const archiveSheetName = "Archive" const ssID = "1sReaoJmec<<Your SpreadSheetID >>aoHbCmrHL448" const ssReq = new SsReq(ssID) // Find the target rows. const rows = ssReq.findRows(sheetName, queries, rowStart) console.log(rows) if(rows.length === 0) return // Copy the found rows. const rowVals = ssReq.copyRows(sheetName, rows) // Append the rows to the to the selected sheet and range. ssReq.appendRows(archiveSheetName, rowVals) // Removes the targe range to archive. ssReq.removeRows("Bids", rows) } |
At a glance, you can probably already see that this function could easily be modified into another process or indeed into a UI for the spreadsheet for users to archive their own data how they would prefer using a dialogue or sidebar or indeed using another Google Sheet tab and a button image.
Building a Data Log in Google Sheets with Google Apps Script
🐐You can support me for free by using this Amazon affiliate link in your next tech purchase :Computers & Stuff! 🐐
Code Breakdown
Queries
Lines 8-22 – First, we set the queries for our archiving. As a part of our Class build for our Sheets API factory, we will create some JSDoc type definitions, to help us maintain our code structure and provide code completion hints.
The query consists of an array of FindQueries
objects. Each query object requires a column letter, query string or number a type of query (For example, equals, less than, not equal to greater than, etc) and an optional boolean if the query is a date.
Our first query ensures that we check column ‘J’ for any cell that has been marked “Declined”.
The next, query, checks column ‘F’ for any cell that has a Bid Submission Date that is less than or equal to 30 May 2025.
These queries will occur sequentially from the first query set in the array. So first we will find the declined rows and then find any of those rows that are below or equal to the target date.
Variables
Lines 23-24: Next, we set our variables identifying the source sheet name, ‘Bids’ and the destination sheet name ‘Archive’. We also set the start row for our query search so that we are not wasting processing time or confusing the query with any potential summary or header data that we may have.
Line 28: Now, we add the ID for the spreadsheet.
Line 29: Finally, we create a new instance of our Sheets API factory class that we have named, ‘SsReq’. This class requires our spreadsheet ID as a parameter.
Query the Bid Sheet
Now we can send our query to the Sheets API via our ssReq
instance.
Lines 31-34: Here, we create a ‘rows’ variable that will store an array of row numbers for any met queries.
To do this, we call the findRows
method and pass in our ‘Bids’ sheet name, our queries array and the row start.
Before we move on, we check to see if we did not find any rows matching our query. If so, we just return the function. We don’t need to do anything else.
Copy the Target Rows
With our new list of found target rows, we can now copy them to add them to our archive sheet.
Lines 36-37: Calling the copyRows
method providing the ‘Bids’ sheet name and our found rows, we can now store the data of those rows in a 2d array in our variable rowVals
.
Appending the old rows to our Archive Sheet
Next, we can append our array of old row data to the ‘Archive’ sheet.
Lines 39-40: Here we use the appendRows
method providing the ‘Archive’ sheet name and the 2d array of row values. This will append the array to the bottom of this sheet.
Remove the Old Rows from the Bids Sheet
Finally, we remove the old data from our working ‘Bids’ sheet.
Lines 42-23: We call the removeRows()
method passing in the ‘Bids’ sheet name and the array of found row numbers. This will delete these target rows from the sheet.
Some things to consider
If you have a very active sheet with multiple editors, it may be important for you to prevent users from editing the target and destination sheets or running the script simultaneously. Here you may need to consider implementing:
- LockService: This prevents concurrent access to sections of the code based on certain permission types. More examples here.
- Protection Class: Here you can temporarily (or more permanently) protect ranges and sheets using the SpreadsheetApp Protection Class. Note that this may significantly slow down the processing of your script.
The Sheets API Advance Service factory class SsReq
Let’s now take a look at the SsReq class that we built to interface with the Sheets API advanced service.
The first thing you will probably notice is that it is far more verbose than using SpreadSheetApp. Probably, the best way to consider this class is that it is similar to what SpreadsheetApp would look like behind the scenes.
The code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 |
/** * @typedef {Object} FindQueries * @property {String} column - column to search. * @property {String|Number} query - The query as a number or a string. Dates should be a string value. * @property {SsReq.QueryType} [type] - Use SsReq.QueryType enum. If `query` is a string, only `EQUALS` and `NOT_EQUAL_TO` will be used. Default is `EQUALS` * @property {Boolean} [isDate] - `true` if the query is a date. `false` by default. */ /** * @typedef {Object} Sheets * @property {String} title * @property {Number} sheetId */ /** * Builder class to work with Sheets API advanced serivce * for the Ship building Google Sheet */ class SsReq { /** * @param {String} spreadsheetID */ constructor(spreadhseetID) { this.ssID = spreadhseetID this.calledSpreadsheet = false this.ssTimezone = null /** @type {Sheets[]} */ this.ssSheetsList = [] // If the Spreadsheet has not been called then retrieve it // once. // Things like timezones will need to be used in other methods // and we don't want to unnecessarily repeat calls. if (this.calledSpreadsheet === false) { const payload = { fields: "sheets(properties.title,properties.sheetId),properties.timeZone" } try { const result = Sheets.Spreadsheets.get(this.ssID, payload) this.ssTimezone = result.properties.timeZone this.ssSheetsList = result.sheets.map(item => item.properties) } catch (e) { throw new Error(`Failed retrieving spreadsheet time zone, and sheets list when calling Spreadsheet.get with: ${e}`, { cause: e }) } return this.calledSpreadsheet = true } } /** * Converts a JS date (Apps Script) to Google (Lotus 123) Time Serial & * considers any timezone difference between the Apps Script Environment & * the Google Sheets timezone. * * param {Date} date - A JavaScript Date * returns {Number} Google Serial Number (Lotus 123) format */ dateJStoSheetsDateVal_(date) { // Milliseconds for minutes & days const MS_PER_MINUTE = 60000; // Get UTC times. const utcLocal = date.getTime() const utcSheet = new Date(date.toLocaleString('en-US', { timeZone: this.ssTimezone })).getTime() // Get the difference of between the Apps Script/JS project timezone and the Google Sheets // timezone. // Add the project timezone offset to the UTC local minus the UTC Sheet const dif = Math.round( (date.getTimezoneOffset() * MS_PER_MINUTE) + (utcLocal - utcSheet) ) // Convert to days to time const lotusSerialNumber = 25569.0 + ((utcLocal - dif) / (1000 * 60 * 60 * 24)) return lotusSerialNumber } /** * Retrieves the selected range values. * @param {String[]} targetRanges - e.g. ["Bids!A2:A", ...] * @returns {*[][][]} Arrays of selected columns. */ getRangeVals(targetRanges) { const payload = { ranges: targetRanges, fields: "valueRanges(values)", valueRenderOption: "UNFORMATTED_VALUE", dateTimeRenderOption: "SERIAL_NUMBER" } try { const result = Sheets.Spreadsheets.Values.batchGet(this.ssID, payload) const valsForEachRange = result .valueRanges .map(range => range.values.flat()) return valsForEachRange } catch (e) { console.error("Failed with error: ", e) } } /** * Finds rows based on any matches from the search query. * - The query can take multiple columns and query types. * @param {String} sheetName - The sheet tab name * @param {FindQueries[]} queries - The queries array by column. * @param {Number} rowStart - Starting row of the query * @param {Number} [rowEnd] - Optional: End row number. * @returns {Number[]} found rows */ findRows(sheetName, queries, rowStart, rowEnd = "") { // Get target Ranges. const targetRanges = queries.map(q => { return `${sheetName}!${q.column}${rowStart}:${q.column}${rowEnd}` }) const rangeValues = this.getRangeVals(targetRanges) console.log(rangeValues) // Validate query type // If it is a string then ensure Equals or !Equals queries.forEach(query => { // Handle for date. if (query.isDate) { const dt = new Date(query.query) if (dt instanceof Date === false || isNaN(dt)) { throw new Error( `A date query can't be tranformed into a date: QUERY: ${JSON.stringify(query, null, " ")}` ) } query.query = this.dateJStoSheetsDateVal_(dt) } if (typeof query.query === String) { if ( query.type !== SsReq.QueryType.EQUALS || query.type !== SsReq.QueryType.NOT_EQUAL_TO ) { query.type = SsReq.QueryType.EQUALS } } }) // Find matches by row. let rows = [] for (let i = 0; i < rangeValues[0].length; i++) { const allMatches = [] let match = false // Check each column against each condition queries.forEach((query, j) => { const val = rangeValues[j][i] // TODO carry out query. switch (query.type) { case SsReq.QueryType.GREATER_THAN: match = val > query.query allMatches.push(match) break; case SsReq.QueryType.GREATER_THAN_OR_EQUAL_TO: match = val >= query.query allMatches.push(match) break; case SsReq.QueryType.LESS_THAN: match = val < query.query allMatches.push(match) break; case SsReq.QueryType.LESS_THAN_OR_EQUAL_TO: match = val <= query.query allMatches.push(match) break; case SsReq.QueryType.NOT_EQUAL_TO: match = val != query.query allMatches.push(match) break; case SsReq.QueryType.EQUALS: match = val === query.query allMatches.push(match) break; } }) if (allMatches.every(match => match)) { rows.push(i + rowStart) } } return rows } /** * Copys the selected rows from the target sheet. * @param {String} sheetName * @param {String[]} rows * @returns {String[]} rows as A1 Notation (Maybe another step here.) */ copyRows(sheetName, rows) { // Combine sheet names with rows bundling adjacent rows. let ranges = [] let startRow = rows[0] for (let i = 0; i < rows.length; i++) { const thisRowVal = rows[i] const nextRowVal = rows[i + 1] if (thisRowVal + 1 !== nextRowVal) { ranges.push(`'${sheetName}'!${startRow}:${thisRowVal}`) startRow = nextRowVal } } const payload = { ranges, fields: "valueRanges(values)", valueRenderOption: "FORMULA", dateTimeRenderOption: "FORMATTED_STRING" } // Retrieve the ranges. let resp try { resp = Sheets.Spreadsheets.Values.batchGet(this.ssID, payload) }catch(e){ throw new Error(`Failed copy selected rows from target sheet ${sheetName} with error: ${e}`, { cause: e }) } // Merge values const valsCombined = resp.valueRanges.reduce((acc, range) => { return acc.concat(range.values) }, []) return valsCombined } /** * Append rows to selected range. * @param {String} sheetName * @parma {*[][]} rows - a 2d array of rows to append to the sheet. * @returns {} */ appendRows(sheetName, rows) { const lastRowRange = `'${sheetName}'!A:A` let result try{ result = Sheets.Spreadsheets.Values.append( { values: rows }, this.ssID, lastRowRange, { valueInputOption: "USER_ENTERED" } ) }catch(e){ throw new Error(`Failed append rows to sheet, ${sheetName}, with error: ${e}`, { cause: e }) } return result; } /** * Append rows to selected range. * @param {String} sheetName * @parma {*[][]} rows - a 2d array of rows to append to the sheet. * @returns {Object} */ removeRows(sheetName, rows) { const sheets = this.ssSheetsList.find(sheet => { return sheet.title === sheetName }) const sheetId = sheets.sheetId // Generates start and end range rows to delete for efficiency. let rowRanges = [] let startRow = rows[0] for (let i = 0; i < rows.length; i++) { const thisRowVal = rows[i] const nextRowVal = rows[i + 1] console.log(thisRowVal, nextRowVal) if (thisRowVal + 1 !== nextRowVal) { rowRanges.push( { startIndex: startRow - 1, endIndex: thisRowVal } ) startRow = nextRowVal } } // Iterate over the delete requests. const requests = rowRanges.map(range => { return { deleteDimension: { range: { sheetId, dimension: 'ROWS', startIndex: range.startIndex, // zero based index endIndex: range.endIndex // Stops at the one before. } } } }) const payload = { requests } let resp try{ resp = Sheets.Spreadsheets.batchUpdate(payload, this.ssID) }catch(e){ throw new Error(`Failed to remove target rows from, ${sheetName}, with error: ${e}`, { cause: e }) } return resp } // ==== Enumerators ==== /** * Enum for QueryType * @readonly * @enum {String} */ static get QueryType() { return { EQUALS: "EQUALS", NOT_EQUAL_TO: "NOT_EQUAL_TO", GREATER_THAN: "GREATER_THAN", LESS_THAN: "LESS_THAN", GREATER_THAN_OR_EQUAL_TO: "GREATER_THAN_OR_EQUAL_TO", LESS_THAN_OR_EQUAL_TO: "LESS_THAN_OR_EQUAL_TO", } } } |
As you can see, there is a lot of clicky-clacky here that we have neatly abstracted away from the main archiveBids()
function.
In the following sections, I will briefly cover what is going on in each method, constructor and pseudo-enumerator. However, I intend to go into detail on each method as its own process in future tutorials, so stay tuned.
The main Constructor
Lines 21-50
When an instance of SsReq is created (new SsReq(ssID)
), we store the spreadsheet ID for other other calls to Google Sheets API.
Reducing API Calls
Calls to the API take time and we are all about speeding up our script processing when moving from SpreadsheetApp to Google Sheets API Advanced Service.
As a part of our initial process, we will want to retrieve and store some basic spreadsheet data locally to apply to other services.
First, we store the spreadsheet’s timezone. This is particularly important when working with international teams that use Google Sheets in different timezones as your Google Apps Script project. We will need to convert timezones to make any comparisons among dates for our queries without making timezone errors because we did not check this correctly.
Secondly, we will retrieve a list of all Google Sheet tabs by their name and their id. Weirdly, the Google Sheets API uses either the Sheet Names OR the Sheet ID depending on the type of request. So instead of having to make an additional request for the Sheet ID before completing another API request, we will store all of these sheet name-id values in an array at the beginning.
More on getting Sheet Names and IDs with Sheets API Advanced Service:
Get Google Sheet Tab Name and ID with Google Sheets API Advanced Service with Apps Script
Converting dates between JS date and Google Sheets date-types dateJStoSheetsDateVal_(date)
Lines 52-79
In Google Sheets, dates are stored as floating point numbers where the whole number is a day since 1899-12-30 (based on the old Lotus 123 convention). We can choose to retrieve the dates as displayed in the sheet tab or as this date-number.
Display dates can be unreliable based on how the user formats the date. Indeed, a classic example here is the U.S. preference for month, day year format over everywhere else’s day, month and year (Let’s just all go year-month-day – much easier for sorting).
Anyway, we need to convert the Google Sheets date-number to a JS date time number and we do this with the method function dateJStoSheetsDateVal_(date)
. This method is called from findRows
method any time a date query is detected.
Retrieving range values with Google Sheets API getRangeVals()
Lines 83-104
We don’t directly, call the getRangeVals()
method in our archiveBids()
function. However, this is a useful access point to have and is well worth making it available should we wish to reuse our class in other tasks or projects.
Instead, we will call this method from the findRows()
method to get our target range to search.
The getRangeVals()
method takes a target range in A1 notation and returns a 3d array of values from the range. That is sets of 2d ranges by row and column. If a value is a date, then that value will be returned as a date-number.
To retrieve values from Sheets API we use the Values.batchGet method. This takes a spreadsheet ID and a payload of options.
Finding Rows based on a query array findRows()
Lines 107-195
Find rows does not directly access Sheets API, rather it calls the getRangeVals
method of the SsReq class.
The method takes the following parameters:
sheetName
(string): The name of the string.queries
(array of queries): The array of queries used to find the rows.rowStart
(number): The starting row of the range of the target sheet.rowEnd
(number): An optional row end to limit the search range and perhaps improve performance.
The method first iterates over the queries and retrieves as a batch of ranges all of the query columns.
Next, the method iterates over each query. First, we check if the query column is a date and try and validate a date from the query input before converting the JS date to a Google Sheets date-value.
We then validate the query type. If it is a string then we want to ensure the query type is either ‘equal’ or ‘not equal to’. Throughout the query, we use an enumerator to set the query type. This helps with auto-completion and reduces errors.
Now, we iterate over each row in the range, carrying out matching checks based on the query type that was assigned to the query. If the current query matches the current row values, then it is stored and then the next query is checked. If all queries are matched then the current row number is pushed to the rows
variable.
Copying the Target Rows copyRows()
The copyRows()
method copies the values of each item in a row in a target Google Sheet and an array of row numbers.
Typically, my best practice in Google Sheets management, means that I remove any extraneous columns in a Google Sheet. This means that when copying rows I will have no cells where empty columns might have resided.
This method copies all the cell values in each of the target rows and then combines them together into a 2d array that is returned from the method.
Again, we use the Values.batchGet method of the Sheet API class to retrieve these rows.
To save on batch calls, and improve efficiency, we combine any adjacent row number into a single range. So for example, if we wanted to collect rows, 1,2,3,6, 10, and 11, we would have only 3 requests in our batch request (“1:3”, “6” and “10:11”).
Appending the Archive Sheet with the old data appendRows()
You can learn more about the append method in this tutorial:
Lines 246-269
While there are a number of issues with the Google Sheets API examples and application of the Values.append() method (Docs issues, Method issues), once you understand how it works, it has some awesome advantages over the SpreadSheetApp.appendRows()
method:
- You can customise where to start your last-row search from.
- You can append multiple ranges.
For us, the appendRows
method takes a sheet name and the row numbers we found in findRows()
.
The method, once it has updated the sheet also conveniently returns an object with some data about the range it applied.
1 2 3 4 5 6 7 8 |
{ updates: { spreadsheetId: '1sReaoJmec<<Your SpreadSheetID >>aoHbCmrHL448', updatedColumns: 11, updatedRows: 3, updatedRange: 'Archive!A12:K14', updatedCells: 33 }, tableRange: 'Archive!A1:K11', spreadsheetId: '1sReaoJmec<<Your SpreadSheetID >>aoHbCmrHL448' } |
From this object, we can see:
updatedColumns
: The number of columns that were updated. Just the count and not the end row.updatedRows
: The number of rows updated.updatedRange
: The actual range that was updated. This is really the most hand property. From here we can update the formatting of the target values or use it in some other API request.updatedCells
: A weird one, but this is the total number of cells in the rows and columns appended.tableRange
: This was the range from the start row all the way down to the last value.
Removing the archived rows removeRows()
Lines 272-330
Removing rows again requires our ‘Bid’ or target sheet and a list of rows.
Just like in our copyRows()
method, we will combine any adjacent rows into a row range before making a batch request.
The Sheets API request uses the batchUpdate method with the deleteDimension request as its request payload.
Confusingly, the term dimension is used to explain what to delete. The dimension is either a ‘ROW’ or a ‘COLUMN’. For us, as the name of the method suggests, we will be setting this to ‘ROW’.
Further confusing things, the deleteDimension request requires the sheet identifier to be the sheet ID rather than the sheet name.
This request can also return an array of dimension ranges that it deleted.
1 2 3 4 5 6 |
{ "sheetId": integer, "dimension": enum (Dimension), "startIndex": integer, "endIndex": integer } |
The QueryType Enumerator
Line 355-352
I really do love enumerators as a way to efficiently autocomplete and avoid errors. While JavaScript and Google Apps Script do not have ‘real’ enumerators, we can simulate what we need from these by providing a frozen object of values.
Because there is more than one query type for our query object we can use the static get QueryType
to provide options for this type for our developers.
Conclusion
This was quite a fun exercise for me to get my head back into using the Google Sheets API advanced service. I hope you can see that the real power of using the API over Spreadsheets is the control you have over making batch requests to do a whole bunch of things at once.
Having said that, don’t for one minute discount the SpreadsheetApp service. For 99% of cases, it is perfectly fine to use. Plus SpreadsheetApp does have the ability to carry out a number of batch methods, most notably getRangeList(). Further, SpreadsheetApp will try and do some ‘magic’ batching for you. And if you don’t want some processes batched together you can always use the flush() method.
Have fun playing with this. I would really love to see what you do with it and why you decided to go with the Sheets API approach over the Spreadsheet App approach. Feel free to add your thoughts in the comments below.
~Yagi
Have I ever told you that you are the best ? with sunshine from Austria – Maximillian
Thanks, mate. You too are quite the legend!