This repository is a reusable scaffold for rapidly bootstrapping HubSpot public app integrations. It provides a pre-configured HubSpot developer project (platform version 2025.2) with all core component types stubbed out, and a consistent pattern for standing up new integrations against external third-party APIs.
The goal is to eliminate the repetitive setup work each time a new HubSpot integration is built β auth flow, component directory structure, hsmeta configurations, UI extension boilerplate, webhook subscriptions, and workflow action schemas are all ready to clone and configure.
Integrations/
βββ hubspot.config.yml # HubSpot CLI authentication config (shared across all integrations)
βββ specs.md # This file β top-level project specs
β
βββ integration Scaffold/ # Reusable template β copy this to start a new integration
β βββ specs.md β (this file)
β β
β βββ hubspot/ # HubSpot developer project
β β βββ hsproject.json # Project config (name, srcDir, platformVersion)
β β βββ CLAUDE.md # AI agent guidance for HubSpot development
β β βββ AGENTS.md # Agent rules (mirrors CLAUDE.md)
β β βββ HUBSPOT_PROJECTS.md # HubSpot projects reference
β β βββ src/app/
β β βββ app-hsmeta.json # App config: OAuth, scopes, permittedUrls
β β βββ cards/ # CRM UI extension cards
β β βββ settings/ # App settings page
β β βββ pages/ # Full-page app UI
β β βββ webhooks/ # HubSpot webhook subscriptions
β β βββ workflow-actions/ # Custom workflow actions
β β βββ app-objects/ # Custom CRM object definitions
β β βββ app-object-associations/ # Object association definitions
β β
β βββ aws/ # AWS backend
β βββ infra/ # Terraform infrastructure-as-code
β β βββ specs.md # Infra specs (resources, modules, variables)
β βββ src/ # Lambda source code
β βββ specs.md # Source overview (shared utilities, build, env vars)
β βββ shared/ # Shared modules (HubSpot client, DynamoDB, Secrets, logger)
β βββ poller/ # Scheduled polling Lambda
β β βββ specs.md # Poller specs (flow, error handling, field mapping)
β βββ oauth/ # OAuth install/callback/uninstall Lambda
β β βββ specs.md # OAuth specs (flows, security, routes)
β βββ webhooks/ # HubSpot + external webhook receiver Lambda
β βββ specs.md # Webhook specs (flows, idempotency, security)
β
βββ <IntegrationName>/ # One directory per live integration (e.g., RunSignUp/)
βββ hubspot/ # Copied + configured from scaffold/hubspot/
βββ aws/ # Copied + configured from scaffold/aws/
- Distribution:
marketplace(public app, requires OAuth) - Auth type:
oauthwith configurable redirect URLs and scopes permittedUrls.fetch: list of external API base URLs the app is allowed to call viahubspot.fetch- Placeholder support contact info to be replaced per integration
- CRM record tab extension using
@hubspot/ui-extensions - Renders on contact records by default;
objectTypesis configurable - Entry point:
NewCard.tsxβ stub component usinghubspot.extend<'crm.record.tab'> - Uses
hubspot.fetch(notwindow.fetch) for all HTTP calls
- App settings UI rendered via
hubspot.extend<'settings'> - Used for per-installation configuration (API keys, preferences, etc.)
- Stub component in
SettingsPage.tsx
- Full-page app UI via
hubspot.extendfor home/dashboard views - Stub in
Home.tsx
- Pre-configured with example CRM object subscriptions (contact creation, property changes, deletion, privacy deletion)
- All subscriptions default to
active: falseβ enable per integration need targetUrlmust be updated to the integration's backend endpoint
- Custom action stub with example input fields (string textarea, enumeration select)
actionUrlmust be updated to the integration's backend endpointobjectTypesdefaults toCONTACT
- Stubs for defining custom CRM object schemas and their associations
- Only needed if the integration requires proprietary data objects in HubSpot CRM
Duplicate integration Scaffold/ and rename it to the integration name (e.g., RunSignUp/). Both hubspot/ and aws/ subdirectories come along β configure each independently.
- Update
hsproject.jsonβ setnameto the integration name - In
src/app/app-hsmeta.json:- Update
uid(e.g.,app-runSignUp) - Set
config.name - Add required OAuth scopes for the HubSpot APIs needed
- Add the external API's base URL(s) to
config.permittedUrls.fetch - Update support contact info
- Update
- In
webhooks/webhooks-hsmeta.jsonβ settargetUrlto the API Gateway webhook endpoint; enable relevant subscriptions - In
workflow-actions/workflow-actions-hsmeta.jsonβ setactionUrl; define input fields for this integration - Remove component directories not needed (e.g., delete
app-objects/if no custom CRM objects) - Implement
cards/NewCard.tsxandsettings/SettingsPage.tsx - Deploy:
hs project upload && hs project deploy
- Copy
terraform.tfvars.exampleβterraform.tfvars; fill inintegration_name,environment, region, SNS topic ARN terraform init && terraform apply- Manually populate the app-level secret in Secrets Manager (
/integrations/{name}/app)
- Implement
poller/mapper.jsβ the integration-specific field mapping from external record β HubSpot properties - Implement
oauth/callback.jsadditions β any integration-specific steps during install (e.g., external OAuth flow) - Implement
webhooks/external.jsif the external system supports outbound webhooks - Build and upload each Lambda artifact to S3
terraform applyto update Lambda with the new artifact
- Set the API Gateway callback URL in
hubspot/src/app/app-hsmeta.jsonβauth.redirectUrls - Set the webhook receiver URL in
webhooks/webhooks-hsmeta.jsonβconfig.settings.targetUrl - Redeploy the HubSpot project
- All integrations use HubSpot OAuth (
auth.type: "oauth") since they are public/marketplace apps redirectUrlsmust include the integration backend's OAuth callback endpointrequiredScopesshould be the minimum set needed β follow least-privilege- The backend is responsible for handling the OAuth token exchange and storing tokens per HubSpot portal
- Run
hs project devto start a local dev server with hot reload for cards and settings - Use
local.jsonto proxyhubspot.fetchcalls to a local backend:{ "proxy": { "https://your-api.example.com": "http://localhost:3000" } } - Inject
CLIENT_SECRETfor testing request signing:CLIENT_SECRET="your-secret" hs project dev
- Platform version:
2025.2 - UI components: only
@hubspot/ui-extensionsexports are valid in cards and settings hubspot.fetchrequires fully-qualified HTTPS URLs β no relative paths, no localhost- All external URLs called via
hubspot.fetchmust be listed inapp-hsmeta.jsonunderpermittedUrls.fetch - Component UIDs must be unique within the project
- Components must live in their designated directories (no nested subdirectories)
- The
appcomponent'sdistribution: "marketplace"meansauth.typemust beoauth
- One integration = one HubSpot project directory at the repo root
- All integration projects share the same
hubspot.config.ymlportal config - Each integration backend is external to this repo; this repo contains only the HubSpot project (frontend + config)
- Name UIDs with the pattern:
<componentType>-<integrationName>(e.g.,card-runSignUp,webhooks-runSignUp) - Keep unused scaffold components deleted β don't leave stubs in production projects
The standard integration pattern is a polling loop: the backend periodically queries the external system for changes and syncs them into HubSpot. This is preferable to full data replication β only IDs, references, and sync state are stored, not a copy of the source database.
EventBridge Scheduler (every 3 min)
β
βΌ
Lambda Poller
β
βββΊ For each active portal installation:
β β
β βββΊ Read last cursor/timestamp from DynamoDB (sync state)
β βββΊ Fetch changes from External API (since last cursor)
β βββΊ Map external records β HubSpot properties
β βββΊ Upsert records via HubSpot CRM API
β βββΊ Write new cursor/timestamp back to DynamoDB
β
βββΊ Emit metrics & logs to CloudWatch
- Pull, don't copy: The poller fetches only what changed since the last run using a cursor, timestamp, or changelog endpoint. No full data dumps.
- Least data principle: Only the external record's ID, its corresponding HubSpot record ID, and sync metadata are persisted. PII and source record content are never stored.
- Per-portal isolation: Each installed HubSpot portal is processed independently. A failure or rate limit on one portal does not block others.
- Idempotent upserts: Records are upserted by external ID, so re-running the poller on the same data is safe.
- Cursor-based pagination: If the external API supports cursors or
sinceparameters, use them. Fallback: store alastPolledAttimestamp and filter on the external side.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS Account β
β β
β βββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ β
β β EventBridge β β Lambda β Poller β β
β β Scheduler βββββΊβ Runtime: Node.js 22.x β β
β β rate(3 minutes) β β Timeout: 5 min β β
β βββββββββββββββββββ β Memory: 256 MB β β
β ββββββββββββ¬βββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββΌβββββββββββββββββββ β
β β β β β
β βΌ βΌ βΌ β
β βββββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββ β
β β DynamoDB β β Secrets Manager β βCloudWatch β β
β β β β /integrations/ β β Logs β β
β β [global] β β {name}/app β β Metrics β β
β β PortalRegistry β β β β Alarms β β
β β β β /integrations/ β βββββββββββββ β
β β [per install] β β {name}/{portal}/ β β
β β {portal}-{obj}- β β hubspot β β
β β SyncState β β external β β
β β IdMap β ββββββββββββββββββββ β
β β FieldMap β β
β βββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Property | Value |
|---|---|
| Runtime | Node.js 22.x |
| Trigger | EventBridge Scheduler β rate(3 minutes) |
| Timeout | 5 minutes (leave headroom under 15 min hard limit) |
| Memory | 256 MB (increase if processing large page sizes) |
| Concurrency | Reserved concurrency = 1 per integration (prevent overlapping runs) |
| IAM Role | Least-privilege: DynamoDB read/write, Secrets Manager read, CloudWatch put metrics |
Poller Lambda responsibilities:
- Load the portal registry from DynamoDB to get all active portal installations
- For each portal, read its sync state (last cursor/timestamp)
- Fetch credentials from Secrets Manager for that portal
- Call the external API for changes since last cursor
- Upsert records in HubSpot via the CRM API using the portal's stored OAuth token
- Refresh the HubSpot OAuth token if expired (write new token back to Secrets Manager)
- Write the updated cursor/timestamp to DynamoDB sync state
- Emit success/failure metrics to CloudWatch
Error handling:
- Catch and log per-portal errors without failing the entire invocation
- On HubSpot 429 (rate limit): back off and skip remaining records for that portal in this run; they will be caught on the next poll
- On external API errors: log and skip; preserve the previous cursor so records aren't lost
- Lambda itself is retried by EventBridge on invocation failure (configure retry = 0 for scheduled triggers to avoid duplicate runs)
Each HubSpot portal installation gets its own isolated set of DynamoDB tables. This is a deliberate security decision: a breach of one portal's data cannot expose any other portal's data, and a full uninstall is a clean table deletion with no residual data risk.
There is one global table shared across all portals, and three per-installation tables per synced object type created at install time and destroyed at uninstall.
Global:
{integrationName}-portal-registry
Per installation, per object type:
{integrationName}-{portalId}-{objectType}-sync-state
{integrationName}-{portalId}-{objectType}-id-map
{integrationName}-{portalId}-{objectType}-field-map
Example (RunSignUp, portal 23786096, syncing contacts):
runsignup-portal-registry
runsignup-23786096-contacts-sync-state
runsignup-23786096-contacts-id-map
runsignup-23786096-contacts-field-map
PortalRegistryβ created by Terraform at infra setup time. Never deleted by application code.- Per-portal tables β created by the OAuth Lambda at install time. Deleted by the OAuth Lambda at uninstall time. Terraform is not involved in their lifecycle; instead, Terraform grants the Lambda IAM permission to create and delete tables matching the
{integrationName}-*name pattern.
DynamoDB has a soft limit of 2,500 tables per AWS account (raisable to 10,000 by request). With 3 tables per object type per portal, capacity planning is:
| Object types synced | Tables per portal | Portals at soft limit | Portals at 10k limit |
|---|---|---|---|
| 1 | 3 | ~833 | ~3,333 |
| 2 | 6 | ~416 | ~1,666 |
| 3 | 9 | ~277 | ~1,111 |
Request a limit increase before launch if significant growth is expected.
Tracks every HubSpot portal that has installed the app. One row per installation.
| Attribute | Type | Description |
|---|---|---|
portalId (PK) |
String | HubSpot portal/hub ID |
installedAt |
String | ISO timestamp of OAuth install |
active |
Boolean | Whether this portal should be polled |
externalAccountId |
String | ID of the account in the external system β not credentials |
syncedObjectTypes |
List<String> | Object type slugs being synced (e.g., ["contacts"]) β determines which per-portal tables exist |
One table per synced object type per portal. Tracks polling progress so the poller can resume from where it left off without re-processing records.
| Attribute | Type | Description |
|---|---|---|
objectType (PK) |
String | Object type this row tracks (matches the table's object type β single-row table) |
lastCursor |
String | Cursor or ISO timestamp from the last successful poll |
lastPolledAt |
String | ISO timestamp of last poll attempt |
lastSuccessAt |
String | ISO timestamp of last fully successful poll |
consecutiveErrors |
Number | Count of consecutive failed polls (used for alerting) |
One table per synced object type per portal. Maps external system record IDs to HubSpot object IDs. This is the core of the least-data principle β IDs only, no record content.
| Attribute | Type | Description |
|---|---|---|
externalId (PK) |
String | Record ID from the external system |
hubspotObjectId |
String | Corresponding HubSpot CRM object ID |
syncedAt |
String | ISO timestamp of last sync for this record |
One table per synced object type per portal. Stores the field mapping configuration for this installation β which external fields map to which HubSpot properties. Allows per-portal customization of the mapping without code changes.
| Attribute | Type | Description |
|---|---|---|
externalField (PK) |
String | Field name from the external system |
hubspotProperty |
String | Target HubSpot property internal name |
transformType |
String | Optional transform to apply (none, uppercase, dateToTimestamp, etc.) |
enabled |
Boolean | Whether this mapping is active |
updatedAt |
String | ISO timestamp of last configuration change |
Default rows are written at install time from the integration's default mapping config. A portal admin can update rows via the settings UI without redeploying code.
What is NOT stored in any table:
- Source record field values or content from the external system
- Copies of HubSpot record properties
- PII (names, emails, phone numbers) β always fetched live from the authoritative system at query time
All credentials are stored in Secrets Manager using a consistent path scheme. No credentials ever touch DynamoDB or CloudWatch logs.
/integrations/{integrationName}/{portalId}/hubspot
/integrations/{integrationName}/{portalId}/external
/integrations/{integrationName}/app
Path: /integrations/{integrationName}/{portalId}/hubspot
{
"accessToken": "...",
"refreshToken": "...",
"expiresAt": "2026-03-16T21:00:00.000Z"
}- Updated in-place by the poller whenever a token refresh occurs
- Access is scoped to the Lambda IAM role; no other service or human should have read access in production
Path: /integrations/{integrationName}/{portalId}/external
{
"apiKey": "...",
"accountId": "..."
}- Populated during the OAuth/onboarding flow when the user connects their external account
- If the external system uses OAuth, stores access + refresh tokens in the same shape as HubSpot tokens above
Path: /integrations/{integrationName}/app
{
"hubspotClientId": "...",
"hubspotClientSecret": "...",
"externalApiKey": "..."
}- Shared across all portals β contains the app's own credentials, not per-installation credentials
- Rotated manually or via Secrets Manager rotation Lambda
| Log Group | Contents |
|---|---|
/aws/lambda/{integrationName}-poller |
Structured JSON logs from the poller Lambda |
Structured log format (per poll run):
{
"level": "INFO",
"portalId": "12345678",
"event": "sync_complete",
"recordsProcessed": 42,
"recordsFailed": 0,
"durationMs": 8200,
"newCursor": "2026-03-16T21:03:00.000Z"
}Namespace: Integrations/{IntegrationName}
| Metric | Unit | Description |
|---|---|---|
RecordsSynced |
Count | Records successfully upserted to HubSpot per run |
RecordsFailed |
Count | Records that failed to sync per run |
PollDuration |
Milliseconds | Total time for a full poll cycle |
PortalsPolled |
Count | Number of portals processed in a run |
TokenRefreshes |
Count | Number of OAuth token refreshes performed |
ExternalApiErrors |
Count | Errors calling the external system |
HubSpotApiErrors |
Count | Errors calling the HubSpot API |
All metrics include a PortalId dimension where applicable to allow per-portal filtering.
| Alarm | Condition | Action |
|---|---|---|
PollerErrors |
Lambda errors > 0 in any 5-min window | SNS β email/PagerDuty |
SyncLag |
consecutiveErrors > 3 for any portal |
SNS alert |
RecordsFailed |
> 0 records failed in a run | SNS alert |
PollerNotRunning |
No PollDuration metric for > 10 minutes |
SNS alert (scheduler failure) |
HubSpotApiErrors |
> 10 errors in 15 minutes | SNS alert (rate limit or outage) |
A single Lambda deployment serves all installed portals. Each portal's data is fully isolated in its own set of DynamoDB tables. The poller:
- Scans
PortalRegistryfor all portals whereactive = true - For each portal, reads
syncedObjectTypesto know which per-portal tables exist - For each object type, reads the portal's
{portalId}-{objectType}-sync-statetable for the cursor - Fetches credentials from Secrets Manager using the portal's ID
- Processes records and writes results back to the portal's isolated tables
- Emits metrics and logs tagged with
portalIdandobjectType
Adding a new portal requires no infrastructure changes β the OAuth Lambda creates the tables at install time and the poller picks up the new PortalRegistry row on its next run.
- Shared component library: common UI patterns (auth status banner, settings form fields, data table) extracted as shared utilities
- CLI scaffolding script: automate the copy-and-configure flow (rename UIDs, update URLs, set scopes) based on a config input
- Backend scaffold: a companion template for the Node.js/Express OAuth handler and webhook receiver that pairs with this HubSpot project scaffold
- IaC: Terraform or AWS CDK module that provisions the full Lambda + DynamoDB + Secrets Manager + CloudWatch stack from a single config file per integration
- Dead-letter queue: SQS DLQ on the EventBridge target to catch and inspect failed Lambda invocations
- Secrets rotation: automated Secrets Manager rotation Lambda for external API keys that support rotation