Skip to Content
Getting startedOverview

Getting started: build your first connector end-to-end

This tutorial walks a new, self-service user through the dashboard, start to finish: sign up, connect HubSpot, author a connector’s config, publish it, push one event through it, and see it land in HubSpot. Budget about 30 minutes. Every screenshot below is a real capture against a live staging environment (see (internal plugsync tooling - see the source repo) if you want to reproduce them or refresh them after a redesign) — nothing here is mocked up.

This is the dashboard-driven path. If you’d rather script the whole thing against the HTTP API (curl, no browser), the existing API-first quickstart already covers that end to end — it’s embedded as the description of the API’s own /docs page (source: backend/app/docs/ quickstart.md). The two paths converge at the same config and the same events; this tutorial is the complement, not a replacement.

Scripting or CI? The 15-minute JWT from /api/auth/login is meant for interactive sessions — from a script it means re-auth plumbing in a loop. Use an org API key (ps_org_...) as a long-lived Bearer token instead: Scripting it: long-lived sessions for CLI and automation below has the full recipe (where to create one, expiry, CLI usage).

What you’ll build

By the end you’ll have:

  • A plugsync account and organization.
  • One HubSpot portal connected via OAuth.
  • One connector with one entity (a HubSpot object + field mappings) and one event flow (what to do when an event of a given type arrives).
  • That config published as an active version.
  • One event pushed through it and visible in the connector’s Activity tab, linked to the HubSpot record it created or updated.

Out of scope here

  • Plugin (custom code) steps. The Event Flow Manifest covers declarative mapping/branching, but HTTP calls to third systems, stateful logic, or anything imperative is a plugin. Plugins are an Enterprise-tier feature (plugin_system_enabled) — every plugin endpoint 402s otherwise. See “When do you need a plugin, and which tier enables it” in the Plugin runtime guide (ENG-58).
  • Multi-brand / federated entities (more than one schema per entity, canonical transform steps). See simple-vs-federated.md (ENG-56, issue #267).
  • The CLI. plugsync-cli (installable standalone: pip install plugsync-cli) wraps the same API this tutorial drives by hand. See the CLI reference (issue #957).

For the full field-by-field schema once you’re past this tutorial, see the Config V3 reference (ENG-56) and the Event Flow Manifest guide (ENG-57) — both linked again at the end.

Step 1: Sign up

Go to /signup on your plugsync dashboard domain and create an account with your name, email and a password (12 characters minimum).

Signup form

Submitting creates your user and a new organization (named after you, e.g. “Jane’s organization” — renamable later in Settings → Organization) and sends a verification email.

Step 2: Verify your email

After signup you land on /verify-email, which tells you to check your inbox:

Check your inbox

Click the link in the email. It carries a token that verifies your address; you’re then redirected to the login page (email prefilled) to sign in with your password, and from there straight into the HubSpot connection step below. The link never logs anyone in by itself — so forwarding it, or having a delegate click it, only verifies the address. Didn’t get the email? The same page has a “Resend verification email” button. Verification is mandatory — login is rejected until email_verified_at is set ((internal plugsync tooling - see the source repo)).

Step 3: Connect HubSpot (OAuth)

You land on /onboarding/connect-hubspot:

Connect your HubSpot

Click Connect HubSpot. You’re redirected to HubSpot’s own consent screen (app.hubspot.com/oauth/authorize) on the plugsync public app. Log in with the HubSpot user for the portal you want to connect (or pick it from HubSpot’s own account switcher if you’re signed into several) and approve.

plugsync asks for exactly the scopes it needs to read and write contacts, companies, deals and custom objects/schemas, plus the Files API for attachment uploads and the forms scope so submit_form steps can post to HubSpot’s secure Forms submissions endpoint — submission only: plugsync never reads your marketing content ((internal plugsync tooling - see the source repo), REQUIRED_SCOPES). You do not need to pre-create anything in HubSpot; properties and custom object schemas your config references get provisioned automatically the first time you publish (or re-provisioned automatically if you ever disconnect and reconnect a different portal). What exactly gets created for you, when, and the few things that are never created (forms, hs_* properties) are documented in the Schema provisioning reference.

On success, HubSpot redirects back to plugsync and you land on /onboarding/configure-connector. If the token exchange fails instead, see Troubleshooting: OAuth connection failed below.

You can connect additional portals later, or disconnect one, from the same “HubSpot” card at the top of the main Connectors page (HubSpotConnect, frontend/src/components/HubSpotConnect.tsx) — there’s no separate Settings page for this today.

Step 4: Create your connector

From the Connectors page, click + New connector:

Create connector dialog

Give it a name and pick a Direction:

DirectionMeaning
OutboundHubSpot → your external system (HubSpot is the source)
Inboundyour external system → HubSpot (HubSpot is the target)
Bidirectionalboth, with conflict resolution on overlapping fields (see Troubleshooting: conflict detection looks inactive for what “resolution” actually catches)

For Inbound or Bidirectional you can also set a default store ID (only relevant if your source system is multi-tenant/multi-brand; leave it blank otherwise). Click Create and you land in the connector’s workspace, starting from an empty draft.

There’s a second path here too: Setup with AI opens the in-app agent, which can create the connector and propose entities/flows for you from a plain-language description of your source system. The agent only ever writes to the draft — it has no publish tool (see the draft → publish model below) — so everything from Step 6 onward is identical either way. This tutorial takes the manual path so every screen is explicit.

Step 5: Author the draft — entities

Open the Entities section and add (or, as here, inspect) an entity. An entity maps one business object to one HubSpot object type:

Entity editor

The essentials:

  • HubSpot object type — which HubSpot object this entity syncs to (companies, contacts, deals, or a custom object).
  • Identity strategy — how plugsync decides whether an incoming event updates an existing HubSpot record or creates a new one. match_or_create against a property (here, domain) is the general-purpose default: events missing that property fail visibly instead of silently collapsing onto one record. See Troubleshooting: mapping errors for exactly what that failure looks like.
  • Field mappings — one row per event field → HubSpot property, with an optional transform. This is the simple entity mode (a single hubspot schema, no canonical/federation) — the right shape for the large majority of connectors. See simple-vs-federated.md if you need more than one target system on the same entity.

Every field you see here maps directly onto EntityV3 in the Config V3 reference if you want the exhaustive version (every field, every validator).

Step 6: Author the draft — flows

Open the Flows section. In simple mode, plugsync can generate the flow for you from the entity’s inbound event automatically (simple_mode_autoflow) — auto-generated flows open behind an “Advanced: edit flow” gate to discourage casual edits, but the editor underneath is identical to a hand-written flow’s, shown here:

Flow editor

A flow says: “when an event of Event type arrives from Source, run these steps.” Here, one upsert → hubspot step is enough to cover the entity above end to end. The Enabled checkbox lets you disable a flow without deleting it — useful while you’re still wiring things up. Toggle it and click Save flow:

Draft change indicator

The status bar now shows 1 Draft change. Nothing you’ve done so far has gone live — every write in the dashboard (and from the AI agent, and from the MCP server, and from setup scripts) lands in a mutable draft, one row per connector. Workers never read your draft; they only ever read the last published version, replicated to DynamoDB. That’s the whole draft → publish model this tutorial is walking you through by hand.

For the step DSL itself (there are several more actions beyond upsert: find, associate, transform, submit_form, upload_file, …) see step-actions.md, and for the event-sourcing side of a flow (what “Source” actually refers to, inbound vs. outbound), see the Event Flow Manifest guide.

Step 7: Review changes and publish

Click Review changes in the status bar:

Review changes drawer

This is a diff against the currently active version — not a preview of what running the flow would do (that’s the dry-run panel from Troubleshooting below). Any preflight findings (e.g. “this property will be auto-created on publish”) show here too, as informational warnings that never block a publish. Add an optional change summary and click Publish v{n}.

Published confirmation

The status bar flips to Published at the new version. Under the hood, this one click is atomic: a new immutable version row, a projection into event_flow_manifests, and an outbox row all commit in one PostgreSQL transaction; the DynamoDB replica the workers actually read from, plus an SQS notify, follow asynchronously and idempotently (the outbox dispatcher). Nothing runs on your connector until this step.

If publish fails with “your draft is out of date,” see Troubleshooting: publish conflicts.

Step 8: Trigger your first event

Events reach a connector over HTTP, authenticated with a connector API key (ps_live_... — distinct from an org-level key, ps_org_..., which authenticates the config plane instead; see Scripting it). Create one from the connector’s Settings section, API Keys card, Generate key: a dialog asks for a label (pre-filled with a sensible default you can override), then shows the raw key once — copy it now, it won’t be shown again.

Once you have a key, send an event:

curl -s -X POST $BASE/api/v1/events \ -H "x-api-key: $API_KEY" -H 'content-type: application/json' \ -d '{"event": "<your flow'\''s event type>", "data": {"name": "Acme Inc", "domain": "acme.example.com"}}' # -> 202 {"event_id": "...", "status": "processing", ...}

event must match the Event type you set on the flow in Step 6; the business payload goes under data (matching the event field names from your mappings, not the HubSpot property names on the right). The full contract — webhooks, bulk import, dry-run without touching HubSpot — is in the API-first quickstart mentioned at the top of this page; it isn’t repeated here to avoid the two drifting out of sync.

Migrating an existing source with real history? If that source delivers over a webhook, enable it FIRST, then run your initial/historical load — not the other way around. A webhook turned on after the load already ran misses every delivery that fired while it was still off, and plugsync has no replay/recovery engine (yet) to backfill that gap. See “Existing portal? preflight -> adopt -> load” in the API-first quickstart for the full adopt-before-first-event walkthrough.

Step 9: Verify the sync

Open the connector’s Activity tab (Events sub-tab). A successfully processed event shows its HubSpot record column linking straight to the record plugsync created or updated — click through to confirm it in HubSpot itself if you want the full loop closed. A connector with no traffic yet shows “No events processed yet. Send events via the API to see them here.” — exactly what you’d expect before Step 8. If your event doesn’t show up as a success, work through the next section.

Scripting it: long-lived sessions for CLI and automation

Everything above drives the dashboard as a human, with the browser holding your session. Once you’re scripting the config plane — CI, a setup script, the CLI (pip install plugsync-cli) — you want a credential that survives longer than a browser tab.

Logging in via /api/auth/login (as the API-first quickstart does) gets you a JWT that expires in 15 minutes; keeping a script alive past that means implementing the refresh-cookie-plus-CSRF-header dance described there. That recipe still works and is documented as the JWT path, but for scripts and CI the recommended credential is instead an org API key (ps_org_...) — the same Bearer token, no forced expiry, no refresh calls:

curl -s $BASE/api/connectors -H "Authorization: Bearer $ORG_API_KEY" # still works after hours, no refresh, no cookie jar

Create and manage org API keys from Settings → API Keys in the dashboard (owner-only): give the key a label, optionally set an expiry date, and copy the raw key — it’s shown once, like a connector’s ps_live_... key from Step 8. The same page lists every key’s prefix, last-used timestamp and expiry, and lets you revoke one instantly. Leave the expiry blank for a key that never lapses on its own (existing keys created before expiry support behave the same way); set one for a key you want to rotate automatically — once it’s past expires_at, requests with that key get a 401 same as a revoked key, no separate error to handle.

The CLI saves this directly, no /api/auth/login round-trip involved:

plugsync auth login --token ps_org_...

or, for CI, skip the config file entirely and set the environment variable the CLI already reads on every command:

export PLUGSYNC_API_KEY=ps_org_... plugsync pull my-connector

Org keys authenticate the config plane only — same distinction as Step 8 above (ps_org_... for config, ps_live_... per-connector for events); they do not work against /api/v1/events, which still needs the connector-level key.

Troubleshooting

OAuth connection failed

HubSpot’s OAuth callback exchanges a code for tokens ((internal plugsync tooling - see the source repo), hubspot_oauth_callback). If that exchange fails for any reason (bad code, HubSpot outage, misconfigured client secret), the backend redirects you back to /onboarding/connect-hubspot?error=token_exchange_failed

but the page currently renders identically either way: it doesn’t read that query parameter, so there’s no visible error banner or toast. If you’re stuck on the connect screen after coming back from HubSpot, check the browser’s own URL bar for ?error=token_exchange_failed — that confirms the exchange failed server-side even though the UI stayed silent. From there:

  • Retry: click Connect HubSpot again: transient HubSpot-side errors (rate limits, momentary outages) usually succeed on a second attempt.
  • If it keeps failing, it’s very likely an environment misconfiguration (missing/wrong hubspot_public_client_id/hubspot_public_client_secret on the API), not something fixable from the dashboard — contact support with the request’s approximate timestamp.

Event mapping errors

The most common way a first event goes wrong is a payload missing the property your entity’s identity strategy matches on. Rather than guess, use the flow’s Dry-run button (next to its name in the Flows section) before sending a real event: paste a sample payload and it traces every step without touching HubSpot. Here it is catching exactly that failure — a payload with no domain, against the entity from Step 5 that matches on domain:

Dry-run mapping error

ERR [upsert] upsert: identity property 'domain' missing from projected properties (mapping_failed: no external id) for docs_capture_company — by design, this fails visibly instead of silently collapsing every event onto the same HubSpot record, which is what would happen if plugsync guessed at a match. Fix the payload (or the mapping) and re-run the dry-run until the step shows OK before switching to real traffic. Already sent bad events for real? Check the connector’s Issues tab, Errors sub-tab, which has the same per-step trace for every event that actually failed. Once the mapping is fixed and published, replay those records one by one from the Activity tab: every row the platform can re-run carries a Replay action, and the replay runs against the config version you just published. See Reliability: retry, failures, replay.

Publish conflicts

The dashboard has a dedicated modal for this, DraftConflictModal (frontend/src/components/workspace/drawers/draft-conflict-modal.tsx):

Your draft is out of date — Someone published v{n} while you were editing. Your draft is based on v{m}. Reapplying your changes on top of v{n} is up to you. There is no auto-merge.

with two buttons: Discard my changes and load v{n}, or Keep editing (will not publish). It triggers on an HTTP 409 from POST .../publish when two people (or two tabs) edit and publish the same connector’s draft close together — there’s exactly one mutable draft per connector, and the first publish to land wins.

This screen isn’t captured above: reproducing the actual race requires the draft’s tracked base version and the live active version to genuinely diverge, and publish_service.publish() ((internal plugsync tooling - see the source repo)) keeps them in lockstep on every single commit — there’s no client-supplied “expected version” to go stale against, so two back-to-back publishes against the same connector self-heal instead of conflicting. The copy above is quoted verbatim from the component source rather than staged with a fabricated screenshot. If you do hit it for real (two teammates editing concurrently is the realistic trigger), the two buttons above are the entirety of your options — there’s no automatic merge by design.

Conflict detection looks inactive

On a Bidirectional connector with conflicts.strategy set to target_wins or most_recent, the Conflicts tab (Issues section) can stay empty even after you deliberately provoke a conflict (e.g. send a stale inbound event after editing the same field by hand in HubSpot). Before concluding the engine is inert, check the one rule that trips up almost everyone testing this for the first time:

target_wins and most_recent only arbitrate a property whose last HubSpot edit came from a system OTHER than plugsync itself. If the last write to that property was plugsync’s own previous sync — which is the common case on a connector you just set up, where plugsync has been the only writer so far — there is nothing “foreign” to arbitrate, and the inbound write proceeds with zero rows added to Conflicts. That is the strategy working as designed, not a broken engine. See conflicts in the Config V3 reference for the full semantics, and ADR-0023 for the design rationale.

Two ways to confirm the policy really is running without waiting for a real conflict:

  • The Conflicts tab itself shows a small hint under the empty state when the policy is active but has never matched anything yet.
  • GET /api/connectors/{id}/conflicts/count returns policy_active and never_matched alongside the pending count.

To actually see a conflict recorded: edit the contested field in HubSpot directly (as a human, via the CRM UI) first, then send the inbound event — that makes the last HubSpot edit genuinely foreign, which is what the policy is built to catch.

Where to go next

  • Config V3 reference — every field on ConnectorConfigV3, every validator, two full worked examples.
  • Event Flow Manifest guide — the step DSL in depth, EventSources, loop prevention for bidirectional connectors.
  • The API-first quickstart embedded at /docs on your API domain (backend/app/docs/quickstart.md) — webhooks, bulk import, dry-run from the command line, health/error contract.
  • Plugin authoring (custom imperative logic): the Plugin runtime guide (ENG-58).
Last updated on