Skip to content

Connector SDK worked example: HubSpot e2e import

This is a Connector SDK worked example, not a product tour. One runnable pass through the inbound-import layer using the HubSpot registered adapter (lib/integrations/hubspot/sync-provider.ts): registry, package, factory, bindings, authorized sync, contract tests.

Read first: SDK overview for the package-vs-SDK split, Concepts for the pipeline and binding contract, Auth for the tenant contract. The copy-paste factory skeleton lives in Examples; the stage-by-stage sequence is Build and Lifecycle.

Settings Integrations surface with providers and installable packages
Where the HubSpot package surfaces once registered. Records flow through the SDK, not this screen.

Grounded in docs/development/connector-sdk.md (last reviewed 2026-09-06) and docs/architecture/connector-tenant-contract.md via pasted product sources. Paths are relative to the PRESHos product repo unless noted. Anything beyond those sources is marked not in pasted sources, never guessed.

  • The HubSpot provider is registered and fail-closed for everything else.
  • The package wires Field mapping via mappingTemplate: 'crm'.
  • The factory exposes exactly objects, properties, records behind a refreshed tenant/connection-scoped client.
  • Bindings carry all three coordinates (org, package, connection) with a valid catalog destination.
  • The import runs from an authorized runner and the watermark advances only when failed === 0.
  • The adapter passes contract tests in order: create, update, retry, pause.
  • A product-repo checkout where you can read lib/integrations/crm-sync/ and lib/integrations/hubspot/sync-provider.ts.
  • A tenant (org) with a HubSpot connection credentialed through the product flow (Auth). Credential values never appear here.
  • The verification commands in docs/development/connector-sdk.md § Verification open beside you. The pasted sources name the four contract tests but not the runner invocation. Run that section verbatim; do not guess the command.

In your product-repo checkout, open the code-owned registry:

lib/integrations/crm-sync/provider.ts
  • A HubSpot provider is registered there (implementation: lib/integrations/hubspot/sync-provider.ts).
  • Unknown providers fail closed. No fallback, no guessing.
  • If the HubSpot entry is missing, stop. Nothing downstream runs until provider.ts registers it (Lifecycle §3).

Step 1: register the package with mappingTemplate: 'crm'

Section titled “Step 1: register the package with mappingTemplate: 'crm'”

Register the integration package so the platform knows it exists, with mappingTemplate: 'crm' to wire the shared Field-mapping editor:

  • Package registered (lib/integrations/packages/types.ts, visible at /automations/connectors/[packageKey]).
  • mappingTemplate: 'crm' set, so the Field mapping UI (components/integrations/package-object-mappings.tsx) renders via the Admin API /api/v1/integration-packages/[packageKey]/object-mappings into object-mapping-store.ts.

Step 2: implement the ConnectorProviderFactory

Section titled “Step 2: implement the ConnectorProviderFactory”

The adapter boundary is exactly three operations behind a refreshed tenant/connection-scoped client. Source contract: lib/integrations/crm-sync/provider-types.ts. Same skeleton as Examples, HubSpot values filled in:

import type { ConnectorProviderFactory } from '@/lib/integrations/crm-sync';
// `buildTenantClient` is the adapter's own tenant/connection-scoped client
// constructor (HubSpot: `lib/integrations/hubspot/sync-provider.ts`).
// It is not an importable SDK helper named `createTenantClient`.
// Same shape, your tenant scoping; never use it outside tenant/connection scope.
export const createProvider: ConnectorProviderFactory = async (context) => {
const client = await buildTenantClient(context);
return {
label: 'HubSpot',
objects: () => client.listSupportedObjects(),
properties: (objectKey) => client.listProperties(objectKey),
records: (objectKey, properties, after) =>
client.listRecordPage({ objectKey, properties, after }),
};
};
  • objects(), properties(objectKey), records(objectKey, properties, after) match provider-types.ts. No other operations at the boundary.
  • The client is scoped to tenant and connection, with refresh. Cross-scope use is a leakage bug.
  • Paging honors the after cursor. Watermarks depend on it.

Step 3: register the factory in crm-sync/provider.ts

Section titled “Step 3: register the factory in crm-sync/provider.ts”
  • Factory registered in lib/integrations/crm-sync/provider.ts following the registered-adapter convention (HubSpot: lib/integrations/hubspot/sync-provider.ts).
  • Unknown-provider path re-verified fail-closed after the edit.

Bindings live in the install’s settings.object_mappings, scoped to org, package, and connection. A mapping without all three coordinates is meaningless. Substitute your install’s values for the bracketed placeholders (coordinates, never secrets):

settings.object_mappings[<orgId>][hubspot][<connectionId>]:
contacts.email -> targetObject: 'work-items' + workItemTypeId: <workItemTypeId>
  • Bindings written with all three coordinates (org, package, connection) via the shared UI or the Admin API PUT /api/v1/integration-packages/[packageKey]/object-mappings (client-safe shape: object-mapping-contract.ts).
  • Identity namespace recorded: binding:<uuid> in ai.connector_object_map. Existing entries keep their meaning; new bindings get new UUIDs.
  • Destination valid per target-catalog.ts: targetObject: 'work-items' with workItemTypeId. Never the retired tasks/deliverables destinations.
  • Full contract: Concepts.

Step 5: run the import from an authorized runner

Section titled “Step 5: run the import from an authorized runner”
await syncConnectorObjectMappings({ orgId, connectionId, packageKey });
  • Call from an authorized runner: a new Inngest function registered via function-catalog. connector-sync-runner stays dead. Never revive it.
  • Watermark advances only when failed === 0. Partial success re-runs from the same watermark and never skips failed records.
  • Persistence goes through the inbound writer path (upsertConnectorCrmRecord). applyConnectorMappedRecord stays reachable only from authorized paths, never exposed unauthenticated (no public route, no unguarded call path).
  • First runs observed per org, package, and connection before any tenant promise.

Step 6: verify (contract tests, then a tenant session)

Section titled “Step 6: verify (contract tests, then a tenant session)”

Adapter contract tests, in order. Run the exact commands in docs/development/connector-sdk.md § Verification:

  1. Create: new HubSpot records arrive as mapped records.
  2. Update: changed records update via the upsert path.
  3. Retry: a failed run re-runs from the held watermark (no loss, no duplication, no skip).
  4. Pause: pausing stops scheduling cleanly; resume continues correctly.

Then prove it in a tenant session (Your first session):

  • Contract tests pass, in order: create, update, retry, pause.
  • Tenant session verified, in order: read, draft, gated execute, denial path.
  • Records land under the right org scope.

Test-story mechanics beyond the four contract tests (runners, fixtures, CI placement): not in pasted sources. Confirm in the product repo.

  • Binding health tracked per org, package, and connection (settings.object_mappings).
  • Run outcomes and denial patterns reviewed as design feedback.
  • Changes re-enter the lifecycle (Lifecycle §6). Never edited live under tenants.
  • Never expose applyConnectorMappedRecord unauthenticated.
  • Never revive connector-sync-runner for new scheduling.
  • Never advance the watermark when failed !== 0.
  • Never present Salesforce scaffolds as implementations; never promise outbound HubSpot writes (remain disabled).
  • Never paste credentials. Product flow only (Auth).