When to Use
- HR — employee record sync: An HRIS integration runs hourly to sync employee status changes. Using
appendOrUpdate with EmployeeId as the lookup key ensures that existing records are updated (e.g. status changed from "Active" to "On Leave") while genuinely new employees are added as new rows — without duplicates regardless of how many times the sync runs.
- Marketing — campaign performance sync: A daily workflow pulls campaign metrics from the ad platform API and calls
appendOrUpdate keyed on CampaignId + Date. If today's row already exists (e.g. the workflow ran twice), it updates impressions, clicks, and spend. If it's a new day, a new row is appended.
- Operations — order status tracking: Order status updates arrive via webhook throughout the day.
appendOrUpdate keyed on OrderId updates the status, last-updated timestamp, and shipping details in the "Orders" sheet — no duplicate rows, no lost updates.
- Finance — invoice reconciliation: A payment processor webhook fires on each payment received. The workflow calls
appendOrUpdate keyed on InvoiceNumber to mark invoices as Paid and record the payment date, idempotently handling retry webhook deliveries.
- Inventory — stock level update: A POS integration updates product quantities in the "Inventory" sheet.
appendOrUpdate keyed on SKU ensures each product has exactly one row — updated on every stock change, inserted on first encounter of a new SKU.
Recommended pattern: sheet/appendOrUpdate is the preferred write operation for any workflow that may run more than once with the same data. It is inherently idempotent: running it 10 times with the same input produces the same sheet state as running it once.
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. Controls how input data fields are mapped to sheet columns. See sheet/append for detailed explanation of each mode. |
ColumnMappings | Optional | Required when DataMode = DefineBelow. List of ColumnMapping objects: Column (sheet header) and Value (expression or literal). |
LookupFilters | Required | List of SheetFilterInfo objects that identify the existing row to update. Uses the same filter structure as sheet/get: Column, Operator, Value. The node scans the sheet for rows matching ALL lookup filters, then updates the first match. If no match is found, a new row is appended. |
CombineFilters | Optional | Enum: And | Or. Default And. How multiple LookupFilters are combined when searching for an existing row. |
CellFormat | Optional | Enum: UserEntered | Raw. Default UserEntered. |
HeaderRow | Optional | Integer. Default 1. Row number containing column headers. |
FirstDataRow | Optional | Integer. Default 2. First row containing data. |
HandlingExtraData | Optional | Enum: IgnoreIt | InsertInNewColumn | Error. Default IgnoreIt. Behavior when input has fields with no matching column header. |
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_LOOKUP_FILTERS | LookupFilters is null or empty. At least one lookup filter is required to identify the row to update. |
VAL_MISSING_COLUMN_MAPPINGS | DataMode = DefineBelow but ColumnMappings is null or empty. |
SHEET_NOT_FOUND | The specified SheetName does not exist in the spreadsheet. |
Output Fields
| Field | Type | Description |
action | string | "append" if no matching row was found and a new row was created; "update" if an existing row was matched and updated. Use this field in a downstream IF node to branch on what happened. |
updatedRange | string | A1 notation of the affected cells. |
updatedRows | integer | Number of rows affected (always 1). |
spreadsheetId | string | Echoed spreadsheet ID. |
sheetName | string | Echoed sheet/tab name. |
status | string | "success". |
resource | string | Always "sheet". |
operation | string | Always "appendOrUpdate". |
Sample Configuration
{
"resource": "sheet",
"operation": "appendOrUpdate",
"CredentialId": 12,
"SpreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
"SheetName": "Employees",
"LookupFilters": [
{ "Column": "EmployeeId", "Operator": "Equals", "Value": "{{ $json.employeeId }}" }
],
"CombineFilters": "And",
"DataMode": "DefineBelow",
"ColumnMappings": [
{ "Column": "EmployeeId", "Value": "{{ $json.employeeId }}" },
{ "Column": "FirstName", "Value": "{{ $json.firstName }}" },
{ "Column": "LastName", "Value": "{{ $json.lastName }}" },
{ "Column": "Department", "Value": "{{ $json.department }}" },
{ "Column": "Title", "Value": "{{ $json.jobTitle }}" },
{ "Column": "Salary", "Value": "{{ $json.salary }}" },
{ "Column": "Status", "Value": "{{ $json.status }}" },
{ "Column": "LastSyncedAt", "Value": "{{ $now | date('YYYY-MM-DD HH:mm:ss') }}" }
],
"CellFormat": "UserEntered",
"HeaderRow": 1,
"FirstDataRow": 2,
"HandlingExtraData": "IgnoreIt"
}
Sample Output
{
"action": "update",
"updatedRange": "Employees!A23:H23",
"updatedRows": 1,
"spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
"sheetName": "Employees",
"status": "success",
"resource": "sheet",
"operation": "appendOrUpdate"
}
Expression Reference
Assuming this node is named syncEmployee in the workflow:
| Expression | Returns | Description |
{{ $output.syncEmployee.action }} | string | "append" (new row created) or "update" (existing row modified). |
{{ $output.syncEmployee.updatedRange }} | string | A1 notation of modified cells. |
{{ $output.syncEmployee.updatedRows }} | integer | Rows affected (always 1). |
{{ $output.syncEmployee.spreadsheetId }} | string | Spreadsheet ID for chaining to next node. |
{{ $output.syncEmployee.action == "append" }} | boolean | Use in an IF node: true if a new employee was added. |
Node Policies & GuardRails
- Lookup is a full sheet scan: The node reads all rows in the sheet to find the matching row before writing. For sheets with thousands of rows, this read adds latency and consumes read quota. Consider using a dedicated lookup column with a short, indexed value (e.g. a numeric ID) rather than a free-text string.
- Multiple matches: If more than one row matches the
LookupFilters, only the first match (by row order, top to bottom) is updated. All others are left unchanged. Ensure your lookup key is truly unique to avoid silently updating the wrong row.
- Concurrent safety: Do not run two parallel branches calling
appendOrUpdate on the same sheet simultaneously. The read-then-write pattern is not atomic — concurrent executions can result in both branches appending (creating duplicates) instead of one updating.
- Formula injection: Sanitize any user-supplied text before it enters
ColumnMappings values. Text starting with =, +, -, or @ is treated as a formula in UserEntered mode.
- Credential scope: The Google credential must have Edit (not just View) permission on the spreadsheet. Read-only credentials will cause a
403 Forbidden error.
- action field usage: Always inspect
{{ $output.nodeName.action }} in downstream nodes when your workflow cares whether a record was new or existing — for example, to send a "new employee" welcome email only on append, not on update.
Workflow Examples
Example 1 — HRIS Employee Sync (Hourly)
// Trigger: Scheduled every hour
// Node 1 — HttpRequest: GET /hris/employees/modified?since={{ $lastRun }}
// Node 2 — Loop over modified employees
// Node 3 — sheet/appendOrUpdate (syncEmployee)
{
"resource": "sheet", "operation": "appendOrUpdate",
"CredentialId": 12,
"SpreadsheetId": "{{ $env.HR_SHEET_ID }}",
"SheetName": "Employees",
"LookupFilters": [
{ "Column": "EmployeeId", "Operator": "Equals", "Value": "{{ $item.id }}" }
],
"DataMode": "AutoMap"
}
// Node 4 — IF: action == "append"
// True branch: send welcome email to new employee
// False branch: continue (no action needed for updates)
Example 2 — Campaign Metrics Daily Upsert
// Trigger: Scheduled daily at 6 AM
// Node 1 — HttpRequest: GET /ads/campaigns/performance?date={{ $today }}
// Node 2 — Loop over campaigns
// Node 3 — sheet/appendOrUpdate (upsertCampaign)
{
"resource": "sheet", "operation": "appendOrUpdate",
"CredentialId": 15,
"SpreadsheetId": "{{ $env.MARKETING_SHEET_ID }}",
"SheetName": "Campaign Performance",
"LookupFilters": [
{ "Column": "CampaignId", "Operator": "Equals", "Value": "{{ $item.campaignId }}" },
{ "Column": "Date", "Operator": "Equals", "Value": "{{ $today | date('YYYY-MM-DD') }}" }
],
"CombineFilters": "And",
"DataMode": "DefineBelow",
"ColumnMappings": [
{ "Column": "CampaignId", "Value": "{{ $item.campaignId }}" },
{ "Column": "CampaignName", "Value": "{{ $item.name }}" },
{ "Column": "Date", "Value": "{{ $today | date('YYYY-MM-DD') }}" },
{ "Column": "Impressions", "Value": "{{ $item.impressions }}" },
{ "Column": "Clicks", "Value": "{{ $item.clicks }}" },
{ "Column": "Spend", "Value": "{{ $item.spend }}" },
{ "Column": "Conversions", "Value": "{{ $item.conversions }}" }
]
}