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/loginis 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).

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:

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:

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:

Give it a name and pick a Direction:
| Direction | Meaning |
|---|---|
| Outbound | HubSpot → your external system (HubSpot is the source) |
| Inbound | your external system → HubSpot (HubSpot is the target) |
| Bidirectional | both, 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:

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_createagainst 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 singlehubspotschema, 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:

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:

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:

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}.

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 jarCreate 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-connectorOrg 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_secreton 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:

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/countreturnspolicy_activeandnever_matchedalongside 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
/docson 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).