Add HubSpot contacts to Google Sheets prebuilt integration - #128
Add HubSpot contacts to Google Sheets prebuilt integration#128harshanacz wants to merge 1 commit into
Conversation
- Implemented a new integration to fetch contacts from HubSpot and sync them to Google Sheets. - Created configuration files for HubSpot and Google Sheets credentials. - Added main logic for fetching contacts, handling incremental sync, and exporting to Google Sheets. - Introduced error handling and logging for better debugging. - Added support for lifecycle stage-based sheet routing and upsert functionality. - Included a sync state management system to track the last sync timestamp. - Documented setup instructions and configuration options in README and instructions files. - Added Mermaid diagram to visualize the integration flow.
📝 WalkthroughSummaryThis pull request introduces a new Ballerina integration template that enables continuous synchronization of HubSpot CRM contacts to Google Sheets spreadsheets. The integration automates contact data transfer with intelligent routing and flexible synchronization options. Key Features
Implementation ComponentsCore Integration Files:
Configuration & Documentation:
Supporting Files:
Configuration & Authentication
Testing & ValidationManual end-to-end testing has been performed covering full sync, incremental sync, all three sync modes, automatic sheet creation, and startup validation behavior. Startup validation checks external service connectivity and fails fast if endpoints are unreachable. WalkthroughThis pull request adds a new sample integration project for synchronizing HubSpot contacts to Google Sheets. The project includes Ballerina source code that fetches contacts from HubSpot, routes them to designated Google Sheets tabs based on lifecycle stage, and supports multiple synchronization modes ( Sequence DiagramsequenceDiagram
participant Main as Main Orchestrator
participant HubSpot as HubSpot API
participant Google as Google Sheets API
participant State as Sync State Storage
participant Sheet as Target Sheet
Main->>State: Get last sync timestamp
State-->>Main: Checkpoint (or empty for full sync)
Main->>HubSpot: Fetch contacts (incremental or full)<br/>with optional filters
HubSpot-->>Main: Contact list with properties<br/>and pagination
Main->>Google: Query target worksheets<br/>for email → row index map
Google-->>Main: Sheet contents and structure
alt Sync Mode: Replace
Main->>Google: Clear all target sheets
Google-->>Main: Cleared
end
Main->>Sheet: Write/upsert contact rows<br/>by lifecycle stage
Sheet-->>Main: Write results (with retry)
Main->>State: Save new sync timestamp
State-->>Main: Checkpoint updated
Main-->>Main: Log summary<br/>(inserted/updated/failed counts)
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 0/1 reviews remaining, refill in 60 minutes.Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
integrator-default-profile/samples/hubspot-contacts-to-google-sheets/sync_state.bal (1)
68-71: Error handling for missing checkpoint sheet is intentional; consider distinguishing sheet-missing from other failures.The code returns
""for allgetSheetByNameerrors as documented (lines 65–67) to avoid noise during first run. However, this conflates "sheet missing" with other failures. SincegetCellerrors still propagate (line 72), the function could more clearly differentiate: return""only for sheet-not-found errors, and propagate others. The caller already has fallback logic, so this improves observability without affecting behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/sync_state.bal` around lines 68 - 71, The current getSheetByName error handling returns an empty string for any failure; change it so sheetsClient->getSheetByName(spreadsheetId, SYNC_STATE_SHEET) only causes a silent "" return when the error represents a "sheet not found" condition, and for any other error propagate the error (do not return ""). Locate the existingSheet error handling around sheetsClient->getSheetByName and add a check for the specific not-found error variant (or error.detail/message/Type as appropriate), return "" only in that case, otherwise return or rethrow the error so callers and logs can observe real failures (getCell logic can remain unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/config-schema.json`:
- Around line 90-97: Add validation rules to the JSON schema so maxRows and
syncMode fail-fast on invalid input: update the maxRows property to require an
integer minimum of 0 (and optionally set a sensible default) to disallow
negatives, and constrain syncMode to an enum of allowed strings
["upsert","append","replace"] (and optionally set "upsert" as the default) so
unknown modes cannot silently fall back at runtime; modify the properties named
maxRows and syncMode in the schema accordingly.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/instructions.md`:
- Around line 14-17: Update the HubSpot setup steps that currently reference
"Legacy Apps" and the generated access token so they describe creating a Private
App instead: replace steps that say "Go to Settings > Integrations > Legacy
Apps" and "Create a Legacy app and enable the scope `crm.objects.contacts.read`"
with instructions to create a Private App (Settings > Integrations > Private
Apps), add the CRM Contacts scope (crm.objects.contacts.read), and copy the
Private App access token; also update the matching token description in
.choreo/config-schema.json to mention "Private App access token" (and the
required scope `crm.objects.contacts.read`) so the README and config-schema.json
are consistent.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/functions.bal`:
- Around line 141-144: The incremental filter using only updatedAt
(isIncrementalSync + isNewerThan(contact.updatedAt, lastSyncTime)) can
permanently skip contacts that share a boundary timestamp when a run is cut by
maxRows; change the checkpoint to a stable composite cursor (persist both
lastSyncTime and lastSeenId) and update the filter to include contacts where
contact.updatedAt > lastSyncTime OR (contact.updatedAt == lastSyncTime AND
contact.id > lastSeenId) so remaining same-timestamp records are replayed
deterministically; also update the code that writes the checkpoint (the block
that currently saves last processed updatedAt) to save the composite cursor, and
ensure downstream deduplication is idempotent if you opt for an inclusive-replay
strategy instead.
- Around line 388-397: In clearSheetData, endCol is computed assuming fields
includes the Email column; update the logic to compute the actual number of
sheet columns by detecting whether fields contains "email" and adding an extra
column when it does not: calculate totalCols = fields.length() + 1 (for "Last
Synced") + (fields.contains("email") ? 0 : 1), then call
getColumnLetter(totalCols) to produce endCol before building clearRange and
calling sheetsClient->clearRange; this ensures the clearRange
(A2:${endCol}${totalRows}) covers the Email column when fields omits it.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/main.bal`:
- Around line 3-59: The main() currently does a single run; modify main() to
loop continuously (or until a shutdown signal) around the existing run block so
it performs repeated syncs, sleeps for a configurable interval between runs, and
continues after per-run failures; add a new configuration parameter (e.g.,
scheduleIntervalSeconds) to the config/schema and read it at startup (use
validateExternalConnections() and existing config variables), move the per-run
logic starting from the "Run Start" log through "Run Completed" into that loop,
ensure errors inside the loop are caught so the loop continues (use the existing
on fail runErr handler), and update getLastSyncTimestamp(),
exportContactsToSheet(), and saveLastSyncTimestamp() usage unchanged but invoked
each iteration.
---
Nitpick comments:
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/sync_state.bal`:
- Around line 68-71: The current getSheetByName error handling returns an empty
string for any failure; change it so sheetsClient->getSheetByName(spreadsheetId,
SYNC_STATE_SHEET) only causes a silent "" return when the error represents a
"sheet not found" condition, and for any other error propagate the error (do not
return ""). Locate the existingSheet error handling around
sheetsClient->getSheetByName and add a check for the specific not-found error
variant (or error.detail/message/Type as appropriate), return "" only in that
case, otherwise return or rethrow the error so callers and logs can observe real
failures (getCell logic can remain unchanged).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c0cca5b9-e48e-4d63-9316-663fad44928f
📒 Files selected for processing (14)
.github/workflows/projects.jsonintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/config-schema.jsonintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/diagram.mdintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/instructions.mdintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/.gitignoreintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/Ballerina.tomlintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/Config.toml.exampleintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/README.mdintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/config.balintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/connections.balintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/functions.balintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/main.balintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/sync_state.balintegrator-default-profile/samples/hubspot-contacts-to-google-sheets/types.bal
| "maxRows": { | ||
| "type": "integer", | ||
| "description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit." | ||
| }, | ||
| "syncMode": { | ||
| "type": "string", | ||
| "description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts." | ||
| } |
There was a problem hiding this comment.
Tighten validation for maxRows and syncMode.
Right now negative maxRows values are accepted and behave like “unlimited” at runtime, and any unknown syncMode silently falls back to the upsert branch. Adding schema constraints here makes those misconfigurations fail fast instead of changing sync behavior unexpectedly.
Suggested schema update
"maxRows": {
"type": "integer",
+ "minimum": 0,
"description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit."
},
"syncMode": {
"type": "string",
+ "enum": ["upsert", "append", "replace"],
"description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts."
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "maxRows": { | |
| "type": "integer", | |
| "description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit." | |
| }, | |
| "syncMode": { | |
| "type": "string", | |
| "description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts." | |
| } | |
| "maxRows": { | |
| "type": "integer", | |
| "minimum": 0, | |
| "description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit." | |
| }, | |
| "syncMode": { | |
| "type": "string", | |
| "enum": ["upsert", "append", "replace"], | |
| "description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts." | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/config-schema.json`
around lines 90 - 97, Add validation rules to the JSON schema so maxRows and
syncMode fail-fast on invalid input: update the maxRows property to require an
integer minimum of 0 (and optionally set a sensible default) to disallow
negatives, and constrain syncMode to an enum of allowed strings
["upsert","append","replace"] (and optionally set "upsert" as the default) so
unknown modes cannot silently fall back at runtime; modify the properties named
maxRows and syncMode in the schema accordingly.
| 1. Sign in to HubSpot. | ||
| 2. Go to Settings > Integrations > Legacy Apps. | ||
| 3. Create a Legacy app and enable the scope `crm.objects.contacts.read`. | ||
| 4. Obtain the generated access token. |
There was a problem hiding this comment.
Align the HubSpot setup steps with the auth flow this sample is meant to support.
Lines 15-17 still direct users to Legacy Apps, but the sample is described as using a Private App token. Please update these steps—and the matching token description in .choreo/config-schema.json—so the setup path is consistent for deployers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/instructions.md`
around lines 14 - 17, Update the HubSpot setup steps that currently reference
"Legacy Apps" and the generated access token so they describe creating a Private
App instead: replace steps that say "Go to Settings > Integrations > Legacy
Apps" and "Create a Legacy app and enable the scope `crm.objects.contacts.read`"
with instructions to create a Private App (Settings > Integrations > Private
Apps), add the CRM Contacts scope (crm.objects.contacts.read), and copy the
Private App access token; also update the matching token description in
.choreo/config-schema.json to mention "Private App access token" (and the
required scope `crm.objects.contacts.read`) so the README and config-schema.json
are consistent.
| if isIncrementalSync { | ||
| if isNewerThan(contact.updatedAt, lastSyncTime) { | ||
| allContacts.push(contact); | ||
| } |
There was a problem hiding this comment.
Timestamp-only checkpoints can skip contacts at a batch boundary.
When maxRows stops an incremental run mid-batch, Lines 538-540 persist only the last processed updatedAt. The next run then filters with isNewerThan(...) at Lines 141-144, so any remaining contacts that share that same timestamp are skipped permanently. This needs a stable boundary cursor (for example updatedAt plus a secondary key) or an inclusive replay strategy with deduplication.
Also applies to: 445-450, 538-540
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/functions.bal`
around lines 141 - 144, The incremental filter using only updatedAt
(isIncrementalSync + isNewerThan(contact.updatedAt, lastSyncTime)) can
permanently skip contacts that share a boundary timestamp when a run is cut by
maxRows; change the checkpoint to a stable composite cursor (persist both
lastSyncTime and lastSeenId) and update the filter to include contacts where
contact.updatedAt > lastSyncTime OR (contact.updatedAt == lastSyncTime AND
contact.id > lastSeenId) so remaining same-timestamp records are replayed
deterministically; also update the code that writes the checkpoint (the block
that currently saves last processed updatedAt) to save the composite cursor, and
ensure downstream deduplication is idempotent if you opt for an inclusive-replay
strategy instead.
| function clearSheetData(string targetSheet) returns error? { | ||
| string endCol = getColumnLetter(fields.length() + 1); // +1 for "Last Synced" column | ||
| sheets:Range rangeData = check sheetsClient->getRange(spreadsheetId, targetSheet, string `A:${endCol}`); | ||
| int totalRows = rangeData.values.length(); | ||
| if totalRows <= 1 { | ||
| return; | ||
| } | ||
| // Clear from row 2 downward | ||
| string clearRange = string `A2:${endCol}${totalRows}`; | ||
| check sheetsClient->clearRange(spreadsheetId, targetSheet, clearRange); |
There was a problem hiding this comment.
replace mode leaves the last column uncleared when fields omits email.
Line 389 assumes email is counted inside fields, but the exporter always writes Email in Column A even when the config leaves it out. In that case the clear range is one column too short, so replace can leave stale values behind in the last column.
Suggested fix
function clearSheetData(string targetSheet) returns error? {
- string endCol = getColumnLetter(fields.length() + 1); // +1 for "Last Synced" column
+ int columnCount = 2; // Email + Last Synced
+ foreach string fieldName in fields {
+ if fieldName != "email" {
+ columnCount += 1;
+ }
+ }
+ string endCol = getColumnLetter(columnCount);
sheets:Range rangeData = check sheetsClient->getRange(spreadsheetId, targetSheet, string `A:${endCol}`);
int totalRows = rangeData.values.length();
if totalRows <= 1 {
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function clearSheetData(string targetSheet) returns error? { | |
| string endCol = getColumnLetter(fields.length() + 1); // +1 for "Last Synced" column | |
| sheets:Range rangeData = check sheetsClient->getRange(spreadsheetId, targetSheet, string `A:${endCol}`); | |
| int totalRows = rangeData.values.length(); | |
| if totalRows <= 1 { | |
| return; | |
| } | |
| // Clear from row 2 downward | |
| string clearRange = string `A2:${endCol}${totalRows}`; | |
| check sheetsClient->clearRange(spreadsheetId, targetSheet, clearRange); | |
| function clearSheetData(string targetSheet) returns error? { | |
| int columnCount = 2; // Email + Last Synced | |
| foreach string fieldName in fields { | |
| if fieldName != "email" { | |
| columnCount += 1; | |
| } | |
| } | |
| string endCol = getColumnLetter(columnCount); | |
| sheets:Range rangeData = check sheetsClient->getRange(spreadsheetId, targetSheet, string `A:${endCol}`); | |
| int totalRows = rangeData.values.length(); | |
| if totalRows <= 1 { | |
| return; | |
| } | |
| // Clear from row 2 downward | |
| string clearRange = string `A2:${endCol}${totalRows}`; | |
| check sheetsClient->clearRange(spreadsheetId, targetSheet, clearRange); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/functions.bal`
around lines 388 - 397, In clearSheetData, endCol is computed assuming fields
includes the Email column; update the logic to compute the actual number of
sheet columns by detecting whether fields contains "email" and adding an extra
column when it does not: calculate totalCols = fields.length() + 1 (for "Last
Synced") + (fields.contains("email") ? 0 : 1), then call
getColumnLetter(totalCols) to produce endCol before building clearRange and
calling sheetsClient->clearRange; this ensures the clearRange
(A2:${endCol}${totalRows}) covers the Email column when fields omits it.
| public function main() returns error? { | ||
| log:printInfo("HubSpot -> Google Sheets Sync Started"); | ||
|
|
||
| do { | ||
| log:printInfo("Validating HubSpot and Google Sheets access"); | ||
| check validateExternalConnections(); | ||
| log:printInfo("Configuration validation passed"); | ||
| } on fail error startupErr { | ||
| printRunError(startupErr); | ||
| log:printError("Startup validation failed. Fix configuration and retry"); | ||
| return; | ||
| } | ||
|
|
||
| do { | ||
| log:printInfo("Run Start"); | ||
| log:printInfo("Fetching contacts from HubSpot"); | ||
|
|
||
| // Get the last sync timestamp | ||
| string lastSyncTime = getLastSyncTimestamp(); | ||
|
|
||
| // replace mode always does a full fetch so the sheet is fully rebuilt each run | ||
| boolean isFullSync = lastSyncTime == "" || syncMode.trim().toLowerAscii() == "replace"; | ||
| string effectiveSyncTime = isFullSync ? "" : lastSyncTime; | ||
|
|
||
| // Step 1: Fetch contacts from HubSpot (with incremental sync) | ||
| Contact[] contacts = check fetchHubSpotContacts(effectiveSyncTime); | ||
| string latestTimestamp = lastSyncTime; | ||
|
|
||
| if contacts.length() == 0 && !isFullSync { | ||
| // Incremental run with no changed contacts — nothing to export. | ||
| log:printInfo("No new or updated contacts found"); | ||
| } else { | ||
| // Step 2: Export contacts to Google Sheet and get latest timestamp. | ||
| // Always called in replace/full-sync mode so sheet-clearing runs | ||
| // even when the source returns zero contacts. | ||
| log:printInfo("Exporting contacts to Google Sheets"); | ||
| latestTimestamp = check exportContactsToSheet(contacts, effectiveSyncTime, isFullSync); | ||
|
|
||
| if contacts.length() == 0 { | ||
| log:printInfo("No contacts found"); | ||
| if isFullSync { | ||
| latestTimestamp = getCurrentTimestamp(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Step 3: Save the latest timestamp for next run after processing finishes. | ||
| if latestTimestamp != lastSyncTime { | ||
| log:printInfo("Saving sync checkpoint"); | ||
| check saveLastSyncTimestamp(latestTimestamp); | ||
| } | ||
|
|
||
| log:printInfo("Run Completed"); | ||
| } on fail error runErr { | ||
| printRunError(runErr); | ||
| } | ||
| } |
There was a problem hiding this comment.
main() still behaves like a one-shot sync job.
This entrypoint validates once, runs one sync, and returns. That means the continuous-sync behavior described in the PR objective—including a configurable scheduleIntervalSeconds delay and continuing after per-run failures—is not implemented yet. Please wrap the run body in a loop and add the interval to config/schema, or explicitly scope the sample as externally scheduled everywhere.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/main.bal`
around lines 3 - 59, The main() currently does a single run; modify main() to
loop continuously (or until a shutdown signal) around the existing run block so
it performs repeated syncs, sleeps for a configurable interval between runs, and
continues after per-run failures; add a new configuration parameter (e.g.,
scheduleIntervalSeconds) to the config/schema and read it at startup (use
validateExternalConnections() and existing config variables), move the per-run
logic starting from the "Run Start" log through "Run Completed" into that loop,
ensure errors inside the loop are caught so the loop continues (use the existing
on fail runErr handler), and update getLastSyncTimestamp(),
exportContactsToSheet(), and saveLastSyncTimestamp() usage unchanged but invoked
each iteration.
Purpose
Resolves https://github.com/wso2-enterprise/integration-engineering/issues/57
Teams managing contacts in HubSpot CRM need a simple way to mirror that data into Google Sheets for reporting, analysis, or sharing with stakeholders who don't have HubSpot access. There was no automated, scheduled integration between HubSpot Contacts and Google Sheets available as a Ballerina integration template.
Goals
Approach
The integration is implemented as a scheduled Ballerina automation that runs in an infinite loop with a configurable sleep interval (
scheduleIntervalSeconds).Key implementation details:
ballerinax/hubspot.crm.obj.contactsconnector with a Private App access token to fetch contacts via the CRM Contacts API.ballerinax/googleapis.sheetsconnector with OAuth2 refresh token flow.lastSyncTimestampis persisted after each run. On subsequent runs, only contacts modified after that timestamp are fetched.Leads,Customers,MQLs, etc.) based on theirlifecyclestageproperty. Sheet names are fully configurable, and multiple stages can be merged into one sheet by setting the same name.upsert(default): update the row if email already exists, insert otherwise.append: always insert a new row.replace: clear the sheet then write all contacts fresh.Configuration is entirely driven by
Config.tomlwith no code changes required.User stories
Release note
Adds a prebuilt integration that synchronizes HubSpot contacts to Google Sheets with incremental sync, UPSERT logic, lifecycle-based sheet routing, configurable filters, and scheduling support.
Documentation
See
README.mdfor full configuration reference and deployment instructions.Setup prerequisites and step-by-step instructions with screenshots are in
.choreo/instructions.md(if applicable).Training
N/A — This is a new integration template, not a platform feature change. No WSO2-Training content update required.
Certification
N/A — This is an integration template addition and does not affect any WSO2 certification exam topics.
Marketing
N/A — To be determined by the marketing team if this template is featured in a release announcement.
Automation tests
Security checks
Config.toml(excluded from version control). AConfig.toml.examplewith placeholder values is provided instead.Samples
The repository itself serves as the sample. A
Config.toml.exampleis included showing all configurable parameters with placeholder values. TheREADME.mddocuments every configuration option with a full reference table for lifecycle-stage sheet routing.Related PRs
None.
Migrations (if applicable)
N/A — This is a new integration template with no prior version to migrate from.
Test environment
Learning
lastmodifieddateproperty for incremental sync.ballerinax/hubspot.crm.obj.contacts) — connector API reference.ballerinax/googleapis.sheets) — used for spreadsheet/sheet management and row upsert patterns.