When to Use
- Finance — expense report logging: Each time an employee submits an expense claim via a web form, the claim details (amount, category, vendor, date, submitter) are appended to the "Expense Log" sheet. Finance teams review and approve directly in Sheets without needing system access.
- HR — new hire onboarding log: When a new hire record is created in the HRIS, a workflow appends a row to the "Onboarding Tracker" sheet with name, start date, department, assigned buddy, and checklist status — giving HR visibility into all in-progress onboarding cases.
- Operations — shipment tracking: A webhook from the shipping platform triggers a row append to the "Shipment Log" sheet for every dispatch event, recording tracking number, destination, carrier, weight, and estimated delivery. Operations managers see a live rolling log.
- Marketing — lead capture: Web form submissions are appended to the "Leads" sheet in real time, enabling the sales team to work from a live pipeline without waiting for CRM sync cycles.
- Reporting — daily KPI batch: A nightly scheduled workflow computes daily KPIs (revenue, orders, active users) and appends a single summary row to the "KPI History" sheet, building a historical time series for trend analysis in connected charts.
Configuration
| Field | Required | Description |
CredentialId | Required | Integer ID referencing a stored Google credential in the BizFirst Credentials Manager. |
SpreadsheetId | Required | The Google Sheets spreadsheet ID from the URL. |
SheetName | Required | Exact name of the target tab. Case-sensitive. |
DataMode | Optional | Enum: AutoMap | DefineBelow | Nothing. Default AutoMap. AutoMap: the node reads the header row from the sheet and maps incoming data fields by matching property names to column headers — no manual mapping needed. DefineBelow: use the ColumnMappings list to explicitly specify which input field maps to which column. Nothing: the node writes whatever raw array structure is provided without any header-based mapping. |
ColumnMappings | Optional | List of ColumnMapping objects, required when DataMode = DefineBelow. Each entry has: Column (sheet header name) and Value (expression or literal value to write). Supports workflow expressions: {{ $json.fieldName }}. |
CellFormat | Optional | Enum: UserEntered | Raw. Default UserEntered. UserEntered: Google Sheets interprets the value as if the user typed it — formulas starting with = are evaluated, numbers are stored as numbers. Raw: the value is stored exactly as provided, no interpretation. |
HeaderRow | Optional | Integer. Default 1. Row number containing column headers (used by AutoMap mode to discover column names). |
FirstDataRow | Optional | Integer. Default 2. First row containing data. New rows are appended after the last populated row at or below this index. |
HandlingExtraData | Optional | Enum: IgnoreIt | InsertInNewColumn | Error. Default IgnoreIt. Controls what happens when the input data contains fields that have no matching column header. IgnoreIt: extra fields are silently dropped. InsertInNewColumn: a new column is added to the sheet for each unrecognized field. Error: the node routes to the error port. |
UseAppend | Optional | Boolean. Default true. When true, uses the Google Sheets Append API which automatically finds the next empty row after the last row with data. When false, uses the batchUpdate Values API and the node computes the target range manually. Leave as true in almost all cases. |
AutoMap mode tip: In AutoMap mode, the node reads the header row from the sheet at execution time (one additional API read call). Ensure the header row is populated before the first append. If you are creating the sheet programmatically with sheet/create, write the header row separately using sheet/update before using sheet/append.
Validation Errors
| Error Code | Cause |
VAL_MISSING_SPREADSHEET_ID | SpreadsheetId is empty or whitespace. |
VAL_MISSING_SHEET_NAME | SheetName is empty or whitespace. |
VAL_MISSING_COLUMN_MAPPINGS | DataMode is DefineBelow but ColumnMappings is null or empty. |
VAL_EXTRA_DATA_ERROR | HandlingExtraData is Error and input contains fields with no matching header. |
SHEET_NOT_FOUND | The specified SheetName does not exist in the spreadsheet. |
VAL_FORMULA_INJECTION | A cell value begins with =, +, -, or @ and CellFormat is UserEntered — potential formula injection. Sanitize user-supplied input before passing to the node. |
Output Fields
| Field | Type | Description |
updatedRange | string | A1 notation of the cells that were written, e.g. "Employees!A15:I15". |
updatedRows | integer | Number of rows appended. |
updatedColumns | integer | Number of columns written per row. |
updatedCells | integer | Total number of cells written (updatedRows * updatedColumns). |
spreadsheetId | string | Echoed spreadsheet ID. |
sheetName | string | Echoed sheet/tab name. |
status | string | "success". |
resource | string | Always "sheet". |
operation | string | Always "append". |
Sample Configuration
{
"resource": "sheet",
"operation": "append",
"CredentialId": 12,
"SpreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
"SheetName": "Expense Log",
"DataMode": "DefineBelow",
"ColumnMappings": [
{ "Column": "SubmittedAt", "Value": "{{ $now | date('YYYY-MM-DD HH:mm:ss') }}" },
{ "Column": "EmployeeId", "Value": "{{ $json.employeeId }}" },
{ "Column": "EmployeeName", "Value": "{{ $json.fullName }}" },
{ "Column": "Amount", "Value": "{{ $json.amount }}" },
{ "Column": "Currency", "Value": "{{ $json.currency }}" },
{ "Column": "Category", "Value": "{{ $json.category }}" },
{ "Column": "Vendor", "Value": "{{ $json.vendor }}" },
{ "Column": "Description", "Value": "{{ $json.description }}" },
{ "Column": "Status", "Value": "Pending" }
],
"CellFormat": "UserEntered",
"HeaderRow": 1,
"FirstDataRow": 2,
"HandlingExtraData": "IgnoreIt",
"UseAppend": true
}
Sample Output
{
"updatedRange": "Expense Log!A47:I47",
"updatedRows": 1,
"updatedColumns": 9,
"updatedCells": 9,
"spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
"sheetName": "Expense Log",
"status": "success",
"resource": "sheet",
"operation": "append"
}
Expression Reference
Assuming this node is named logExpense in the workflow:
| Expression | Returns | Description |
{{ $output.logExpense.updatedRange }} | string | A1 notation of written cells, e.g. "Expense Log!A47:I47". |
{{ $output.logExpense.updatedRows }} | integer | Number of rows appended (typically 1 for single-record workflows). |
{{ $output.logExpense.updatedColumns }} | integer | Number of columns populated. |
{{ $output.logExpense.updatedCells }} | integer | Total cell count written. |
{{ $output.logExpense.spreadsheetId }} | string | Spreadsheet ID (for passing to a subsequent read node). |
{{ $output.logExpense.status }} | string | "success" on success port. |
Node Policies & GuardRails
- Formula injection prevention: User-supplied text that begins with
=, +, -, or @ is interpreted as a formula by Google Sheets when CellFormat = UserEntered. Always sanitize free-text input from external sources before writing. Prepend a single quote ' to force text treatment, or set CellFormat = Raw for untrusted input.
- Idempotency caution:
sheet/append always adds new rows — it never checks for duplicates. For idempotent writes (where re-running the workflow should not create duplicate rows), use sheet/appendOrUpdate with a unique key lookup filter instead.
- Concurrent writes: Avoid two parallel workflow branches writing to the same sheet simultaneously. Google Sheets does not support row-level locking — concurrent appends can collide and one write may overwrite the other. Serialize writes using a sequential node chain.
- Quota: Each append is one write request. With 100 writes/min quota, avoid firing this node more than 100 times per minute across all concurrent workflow executions.
- Sheet name case sensitivity: "expense log" will fail if the tab is named "Expense Log".
- Bulk appends: If upstream data is an array (e.g. 50 lead records from a batch webhook), pass the entire array to a single
sheet/append call rather than looping. One API call for 50 rows is far more efficient than 50 individual calls.
Workflow Examples
Example 1 — Real-Time Lead Capture from Web Form
// Trigger: Webhook (form submission)
// Node 1 — sheet/append (logLead)
{
"resource": "sheet", "operation": "append",
"CredentialId": 12,
"SpreadsheetId": "{{ $env.LEADS_SHEET_ID }}",
"SheetName": "Leads",
"DataMode": "DefineBelow",
"ColumnMappings": [
{ "Column": "Timestamp", "Value": "{{ $now | date('YYYY-MM-DD HH:mm') }}" },
{ "Column": "FirstName", "Value": "{{ $json.firstName }}" },
{ "Column": "LastName", "Value": "{{ $json.lastName }}" },
{ "Column": "Email", "Value": "{{ $json.email }}" },
{ "Column": "Company", "Value": "{{ $json.company }}" },
{ "Column": "Source", "Value": "{{ $json.utmSource }}" },
{ "Column": "Status", "Value": "New" }
],
"CellFormat": "Raw"
}
// Node 2 — Slack notification to #sales-leads channel
Example 2 — Daily KPI Summary Append
// Trigger: Scheduled — daily at 11:55 PM
// Node 1 — HttpRequest: GET /api/metrics/daily for today's KPIs
// Node 2 — sheet/append (appendKPI)
{
"resource": "sheet", "operation": "append",
"CredentialId": 14,
"SpreadsheetId": "{{ $env.KPI_SHEET_ID }}",
"SheetName": "KPI History",
"DataMode": "DefineBelow",
"ColumnMappings": [
{ "Column": "Date", "Value": "{{ $now | date('YYYY-MM-DD') }}" },
{ "Column": "Revenue", "Value": "{{ $output.getMetrics.revenue }}" },
{ "Column": "Orders", "Value": "{{ $output.getMetrics.orderCount }}" },
{ "Column": "ActiveUsers", "Value": "{{ $output.getMetrics.activeUsers }}" },
{ "Column": "ConversionPct", "Value": "{{ $output.getMetrics.conversionRate }}" }
]
}