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.

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.
What this proves
Section titled “What this proves”- 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,recordsbehind 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.
Prerequisites
Section titled “Prerequisites”- A product-repo checkout where you can read
lib/integrations/crm-sync/andlib/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.
Step 0: confirm the registry entry
Section titled “Step 0: confirm the registry entry”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.tsregisters 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-mappingsintoobject-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)matchprovider-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
aftercursor. 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.tsfollowing the registered-adapter convention (HubSpot:lib/integrations/hubspot/sync-provider.ts). - Unknown-provider path re-verified fail-closed after the edit.
Step 4: bind fields for the target org
Section titled “Step 4: bind fields for the target org”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>inai.connector_object_map. Existing entries keep their meaning; new bindings get new UUIDs. - Destination valid per
target-catalog.ts:targetObject: 'work-items'withworkItemTypeId. Never the retiredtasks/deliverablesdestinations. - 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-runnerstays 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).applyConnectorMappedRecordstays 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:
- Create: new HubSpot records arrive as mapped records.
- Update: changed records update via the upsert path.
- Retry: a failed run re-runs from the held watermark (no loss, no duplication, no skip).
- 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.
Step 7: operate
Section titled “Step 7: operate”- 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 rules (from source)
Section titled “Never rules (from source)”- Never expose
applyConnectorMappedRecordunauthenticated. - Never revive
connector-sync-runnerfor 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).

