Top-level ConnectorConfigV3 shape
The root config object. Everything else lives under it.
Connector and HubSpot portal
This config is scoped to one connector, and a connector is bound to exactly
one HubSpot portal — for writing records inbound and for listening to that
portal’s journal outbound (the hubspot event source, see
event-sources.md).
That binding is not one of the fields below: it lives on the connector
itself, not inside this config object, but every entity and flow declared
here runs against that one portal only.
An organization can connect several HubSpot portals via OAuth. To sync more than one portal, create one connector per portal — several connectors can share the same portal, but a single connector never spans two portals at once.
A connector with no portal
A connector can also exist with no portal bound: created from a template
before any portal was connected, created over the API without a
credential_id, or left without one by a disconnect (see “Disconnecting a
portal instead” below). The preflight (GET /api/connectors/{connector_id}/preflight) flags it with a
connector_has_no_credential warning. What such a connector does depends on
how many HubSpot portals your organization has connected:
- None. Nothing that reads or writes HubSpot can run for it until a portal is connected.
- Exactly one. The connector uses that portal, as if it were bound to it.
- More than one. plugsync does not choose a portal for it, so it writes
to none. Every event whose flows touch HubSpot ends
failed, witherror_struct.error_codehubspot_portal_not_boundand anerror_detailthat names the missing binding. It is not retried, because only a binding fixes it. One exception: when the same event also matches a flow that is temporarily held back, the event is attempted again for that other flow, and each attempt ends the same way, writing to no portal, until one is bound. A publish that has HubSpot properties to provision is refused with a422carrying the same message. Bind a portal, then replay the failed events.
Flows that never touch HubSpot run normally in every case. Binding the connector explicitly is the only setup that does not depend on how many portals the organization holds at a given moment.
Binding a portal to a connector that has none is a plain PATCH with the
credential_id, without confirmation, as long as the connector holds no
identity mapping. A connector that already holds identity mappings (it
synced through the organization’s only portal while unbound) is treated as
a portal change instead: the first attempt answers 409
rebind_confirmation_required with old_portal_id: null and the
identity_mapping_count, and the confirmed call discards the mappings and
the read position exactly as described under “What is discarded” below.
The mappings record no portal, so plugsync cannot prove they belong to the
portal being bound, even when it is the organization’s only one. Run the
preflight and adopt path before letting events flow again, as for any
portal change. Connecting a portal through the OAuth consent binds the
organization’s connectors that have no portal automatically, except these:
a connector that holds identity mappings, or whose mappings cannot be
counted at that moment, stays unbound until its portal is picked, and
confirmed, from its Settings section.
Changing the portal
Moving a connector to a different HubSpot portal does not mean rebuilding it. The binding lives on the connector, not in this config, so repointing it keeps everything else: the name, the direction, the draft, the published version history and which version is active, the entities, the flows, the event sources, and the credentials of the external systems the connector talks to. Nothing has to be re-authored, and nothing has to be re-published.
Connect the target portal first, from the “HubSpot” card at the top of the
main Connectors page — adding a portal there runs the same OAuth
consent as the first one. Then open the connector, go to its Settings
section, and pick the new portal in the HubSpot portal card. A confirmation
dialog states the change before it is applied. Over the API the same change
is PATCH /api/connectors/{connector_id} with the new credential_id; the
credential must be a HubSpot OAuth credential of your own organization, or
the call is refused with a 400.
If your plan allows only one connected portal, you cannot hold both at once: disconnect the current portal first (see “Disconnecting a portal” below), then connect the new one. Connectors left without a portal by that disconnect are bound to the newly connected portal automatically, so on a one-portal plan the disconnect-then-connect sequence is the portal change.
Checking the target portal first
A config written against one portal does not automatically fit another:
pipeline and stage IDs, association type IDs and custom properties are all
per-portal. Once the target portal is connected, and before the connector is
repointed, you can read it and validate the draft against it. Add
?credential_id=<id of the target portal's credential> to any of these
calls:
| Call | What it answers for the target portal |
|---|---|
GET /api/connectors/{connector_id}/hubspot-pipelines/{object_type} | Pipelines and their stages. |
GET /api/connectors/{connector_id}/hubspot-properties/{object_type} | Properties, with type and options. |
GET /api/connectors/{connector_id}/hubspot-association-types/{from_object_type}/{to_object_type} | Association types between the two object types: type_id, category, label. |
GET /api/connectors/{connector_id}/preflight | The draft’s preflight, with every portal check (properties, scopes, association types, existing records) run against the target portal. |
Without the parameter each call reads the portal the connector is bound to, exactly as before. With it:
- Nothing is bound and nothing is written. These are reads: the
connector keeps its portal, its identity mappings and its read position.
The portal change itself is still the confirmed
PATCHdescribed below. - The credential must be a HubSpot OAuth credential of your own
organization. An ID that does not exist and an ID that belongs to
another organization get the same
404(Credential not found). A credential of yours that is not a HubSpot portal gets a422with{"error": "credential_not_hubspot_oauth", ...}. - The preflight says which portal answered. Its
infoscarry apreflight_candidate_credentialfinding naming the credential and the portal, so a result for the target portal is never mistaken for one about the bound portal. The warning about a connector with no portal is left out of such a run, because it is not the question being asked. Naming the credential the connector is already bound to is the ordinary preflight, without that finding. - A target portal that cannot be read is an error, not a pass. If no
valid access token can be obtained for the credential, or HubSpot does not
confirm it,
errorscarries acandidate_credential_unusablefinding: the run says nothing about whether the draft fits. Treat an emptyerrorsas “fits” only when that finding is absent, which it always is on a run that could read the portal. - Adoption comes after the rebind. When the target portal holds records
without the identity property, the finding tells you to adopt them after
binding the credential:
POST /api/connectors/{connector_id}/identity/adoptacts on the portal the connector is bound to, so it cannot adopt records of the target portal earlier. - The reads count against the target portal’s API quota, like any other read plugsync makes on it.
On a plan that allows only one connected portal the target cannot be connected next to the current one, so there is nothing to name here: the check is the ordinary preflight, after the disconnect-then-connect.
It is confirmed, not silent
Repointing a connector that is already bound throws away per-portal state,
so the first attempt is refused on purpose. The dashboard turns that refusal
into a confirmation dialog naming the old portal, the new portal, and how
many identity mappings are about to be deleted; the API answers 409 with
{"error": "rebind_confirmation_required", ...} carrying the same impact
(old_portal_id, new_portal_id, identity_mapping_count, which is null
when the count could not be read). Repeat the call with
"confirm_rebind": true to go through.
Re-selecting the portal that is already bound is a pure no-op, and binding a connector that has no portal yet is frictionless: there is nothing to invalidate in either case. The one exception is a connector without a portal that already holds identity mappings, which asks for the same confirmation (see A connector with no portal).
What is discarded
- Every identity mapping of this connector, for every entity. They map your external ids onto record ids of the old portal, which mean nothing on the new one, so they are deleted rather than carried over. The records themselves are untouched: whatever the connector created on the old portal stays there exactly as it is, plugsync simply stops tracking it.
- The outbound read position. The connector’s
hubspotsource restarts from the new portal’s present, so nothing that happened on the new portal before the change is replayed. Ajournal_offset_expiredwarning inherited from the old portal is cleared at the same time, because it describes a loss that never happened on the new one (see The HubSpot journal source).
The purge runs before the binding is committed: if it fails, the request
fails with a 502 and the connector stays on its old portal with its
mappings intact. There is no half-applied state to clean up, and no
undo either — once the change goes through, the deleted mappings are gone.
What is kept, and re-applied to the new portal
- The active published version, unchanged and still active. No re-publish is needed.
- Schema provisioning. The custom properties, custom object schemas and
pipelines the active version needs are provisioned on the new portal as
part of the change (see the
Schema provisioning reference). It is
idempotent, and it is best-effort per entity: one entity whose spec the
new portal rejects costs only itself, the others are still provisioned,
and the binding succeeds either way. The bind response does not report
that, so on a portal you have not published against before, confirm the
properties are there — the connector’s next publish provisions the same
set and does surface a rejection, as a
422listing the entities that failed and what HubSpot refused for each. - Journal subscriptions, derived from the published flows and created on the new portal as part of the change, so outbound events start arriving without a re-publish.
- Event history, conflicts and version history, which stay readable as they were: they are records of what happened, not per-portal state.
Active flows keep running
Changing the portal does not pause anything. A connector that was active stays active and keeps processing events, now against the new portal, from the moment the change is committed. If you would rather not have events crossing the cut, pause the connector first, change the portal, then resume it — the mappings are purged either way, so pausing buys you a controlled restart, not a safer change.
The first events after the change
With the identity mappings gone, the connector no longer knows which records
on the new portal correspond to which external ids, and the next event for a
record it had already synced is treated as a first sighting. upsert finds
an existing record either through the identity map — now empty — or by
searching on the identity property itself, so a record on the new portal
that already carries the same identity property value is matched and
updated, while one that does not is created fresh. On a portal with real
history that second case is a duplicate waiting to happen: it is exactly the
day-0 situation the preflight and adopt path exists for. Run it before
letting events flow again, as described under
Existing portal? preflight -> adopt -> load
in the API-first quickstart.
Disconnecting a portal instead
Disconnecting a portal from the “HubSpot” card is the same destructive
change applied at once to every connector bound to it, and it is gated the
same way. Unconfirmed, the disconnect is refused with a 409
disconnect_confirmation_required listing the connectors that would be
unbound and the total number of identity mappings at stake. Confirmed, each
of those connectors has its mappings purged and its read position reset, and
is left with no portal at all — it keeps its whole configuration, and what
it can still do follows A connector with no portal. Clearing one connector’s binding on its
own (credential_id: null on the same PATCH) is the identical change
narrowed to that connector, and is gated identically: it is not a cheap way
to sidestep the confirmation before binding elsewhere.
Shape
{
"version": "3.0",
"entities": [],
"flows": [],
"sources": [],
"settings": {}
}Fields
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
version | "3.0" or "3.1" | yes | Pinned literal. Must equal "3.0" or "3.1". | |
entities | array of Entity | no | [] | See entities.md. |
flows | array of Flow | no | [] | See flows.md. |
settings | object | no | {} | Free-form. See “Settings” below. |
conflicts | ConflictPolicy | null | no | null | Bidirectional conflict resolution. See “conflicts” below. |
pipelines | array of Pipeline | no | [] | Custom pipeline/stage provisioning. See “pipelines” below. |
sources | array of Source | yes | [] | Event source definitions (name, type, config). The key must be present on every full write: see “sources” below. |
ingress_profile | IngressProfile | absent | no | absent | Accepts a legacy caller’s HTTP contract as it is. See Legacy-compatible ingress. |
version
Pydantic literal: Literal["3.0", "3.1"]. Anything else fails with
Input should be '3.0' or '3.1'. This pin is deliberate: it keeps draft
writers explicit about the schema version they target.
"3.0"is the baseline reference documented across these pages."3.1"adds the bulk step actions (upsert_many,create_many,update_many,delete_many,associate_many) and four step fields used by them (items,chunk_size,output,reconcile). See the relevant rows in step-actions.md.
entities and flows
See the dedicated reference pages. Both default to empty arrays; a config with no entities and no flows is syntactically valid but does nothing.
sources
One entry per event source of the connector, each {"name", "type", "config"}:
{
"sources": [
{"name": "shop_webhook", "type": "webhook", "config": {"inbound": {"event_type_path": "$.event"}}},
{"name": "crm_api", "type": "rest", "config": {"outbound": {"base_url": "https://api.example-crm.com"}}}
]
}name is unique within the connector and matches ^[a-z0-9_]{1,64}$; type
is one of the source types listed on that page and is immutable once a source
exists under that name; config is the per-type block documented in
EventSources, which is also where
the reserved pairing of the name event_api with the type event_api and
everything a publish does with this list are described.
A config always makes a claim about its sources, so sources: [] is the way to
say there are none: publishing it removes any the connector still has. A write
that drops the key instead of emptying it is refused with
422 sources_key_removal_not_supported, so a body that simply forgot to
mention its sources cannot be read as “delete them all”. Concretely: a
PUT /draft whose body omits sources, and a PATCH /draft that sends
{"sources": null}. A PATCH /draft that does not mention sources at all
leaves them as they are.
Unlike every other key here, only half of a source lives in the config: the
enabled flag, the webhook secret and the runtime state stay on the source
and are never versioned.
settings
Free-form object: there is no sub-schema, so an unknown key is accepted and
simply never read. The keys below are read by the runtime today, all
opt-in. retry and record_mode are documented in
Reliability: retry, failures, replay:
| Key | Type | Default | Effect |
|---|---|---|---|
retry | object | absent | Tunes the exponential backoff between retries of a transient failure. Without it the waits are 10, 20, 40, 80 s (capped at 160 s). backoff_base_seconds sets the first wait, doubled at each attempt, and backoff_max_seconds the cap (600 when a base is set); backoff_base_seconds: 0 turns the backoff off. The attempt count is unaffected. See Backoff. |
record_mode | boolean | false | Archive the raw payload of every event crossing the connector, enabling range replay and parallel runs. |
ordering | "record" | "connector" | absent (record) | How the connector’s events are ordered. record: events about the same record in order, different records in parallel across up to record_buckets sequences. connector: every event in one sequence, about one per second, for flows that depend on the order between different records. A source’s own config.ordering wins. Per-record ordering is being rolled out progressively: until it is enabled for your environment the setting is accepted and published but has no effect, and events keep the per-connector order. See Processing order and throughput. |
record_buckets | integer, 1 to 16 | 4 | Under record ordering, how many parallel sequences the connector’s records are spread over. A record always falls in the same sequence, so its events stay in order; records that share a sequence wait for one another. Each sequence runs at about one event per second, so size it on what the target system’s rate limit absorbs for this connector: the default suits a HubSpot portal. A higher value than the target can absorb does not speed the connector up. Changing it briefly lets an update already queued overtake a newer one of the same record, so change it when the connector is quiet. Like ordering, it has no effect until per-record ordering is enabled for your environment. See Processing order and throughput. |
The five file_* keys drive the file attachment handling of the Event
API: a PDF sent as a base64 string inside the event data is set aside at
ingest and replaced by a small reference that the
upload_file and
delete_attachments steps read. The
request side is described in
Event API: file attachments, the
complete setup in upload_file.
| Key | Type | Default | Effect |
|---|---|---|---|
file_field | string | absent (off) | Dotted path of the file field, relative to the event data (e.g. "linkfile" or "offer.pdf"). Not a $.payload.* reference. When set, a non-empty string at that path is decoded as base64 and replaced by {s3_key, filename, numdoc, version}, which flows address as $.payload.<file_field>. Absent, the field travels untouched. |
file_numdoc_field | string | absent | Dotted path (relative to data) of the document number. It becomes the numdoc part of the file name {numdoc}_{version}.pdf. Absent or unresolved, numdoc is "". |
file_numdoc_transform | string or pipeline list | absent | Transform applied to the file_numdoc_field value, e.g. [{"name": "split", "args": {"sep": "/", "index": 0}}] to keep 9000 out of 9000/V2. |
file_version_field | string | absent | Dotted path (relative to data) of the document version. It becomes the version part of the file name. Absent or unresolved, version is "". |
file_version_transform | string or pipeline list | absent | Transform applied to the file_version_field value, e.g. [{"name": "split", "args": {"sep": "/", "index": 1, "default": "V0"}}] to keep V2 out of 9000/V2. |
The two *_transform keys take the same forms as a field-mapping
transform: a single transform name, or a
pipeline list of {"name", "args"} steps. Use the list form for transforms
that need arguments (split, regex_extract, …): the single-name form
passes no arguments, so "file_numdoc_transform": "split" leaves the value
as it is. Only built-in transforms apply here. A custom lookup table declared
under config.transforms is not available at ingest: named on its own it
leaves the value unchanged, and inside a pipeline list it makes every event
carrying a file fail with a 503 until the settings are fixed.
ordering and record_buckets are checked whenever the configuration is
saved: a value outside the ones listed above is rejected. The two
*_transform keys are checked at publish: an unknown transform name (in the single-name form or in any
pipeline step) fails the publish with Config references unknown transforms: settings.file_numdoc_transform: ..., the same guard as a field-mapping
transform. Nothing else in the block is validated: a file_field path that
matches nothing in the event simply leaves the event untouched, and a
mistyped retry or record_mode value degrades to the default with a
logged warning rather than failing the publish.
The historical key scope_overrides from v2.1 has no v3 reader; do not
depend on it under v3.
conflicts
Only relevant on a bidirectional connector, where an inbound write can
race a human edit made directly in HubSpot. Absent block, or strategy: "source_wins" (the default): today’s behavior, zero extra reads, the inbound
write always applies as-is.
{
"strategy": "most_recent",
"source_modified_path": "$.payload.updated_at"
}| Field | Type | Default | Notes |
|---|---|---|---|
strategy | "source_wins" | "target_wins" | "most_recent" | "source_wins" | See below. |
source_modified_path | string ($... reference), array of strings, or null | null | Only read under most_recent. Resolved on the event context as the source system’s own edit-time for the record (e.g. $.payload.updated_at). Absent, falls back to the event envelope’s occurred_at (Event API/journal), then ingest time. $.occurred_at declares that envelope fallback explicitly - see below. |
reject_stale_source | bool or null | null (effective: true under most_recent, false otherwise) | Opt-in replay guard, orthogonal to strategy. See below. |
per_property_freshness | bool or null | null (effective: true under most_recent, false otherwise) | Per-property stale-echo protection inside most_recent. See below. |
source_modified_path must point at a field of the RECORD, not of the
webhook envelope. A field that is present, at the same path, on every
delivery regardless of which record changed (a request id, the webhook’s own
emission timestamp) is always “now” - most_recent then always thinks the
source is freshest and silently degenerates into source_wins, with no error
and no warning from a naive read of the config.
// WRONG: emesso_il is the webhook's own emission time (always "now"),
// not the record's own edit time - most_recent never lets HubSpot win.
{ "strategy": "most_recent", "source_modified_path": "$.payload.emesso_il" }// RIGHT: the record's own updated_at field.
{ "strategy": "most_recent", "source_modified_path": "$.payload.cliente.updated_at" }Multi-entity webhooks - a source system whose webhook envelope nests the
changed record under a different key per event_type (e.g. $.payload.cliente.*
for a customer event, $.payload.ordine.* for an order event,
$.payload.agente.* for a sales-rep event) cannot be covered by a single
path: the path that resolves for cliente events is simply absent on
ordine/agente events, and vice versa. source_modified_path accepts an
ordered fallback list for exactly this case - the first path that
resolves to a parseable timestamp on the current event wins:
{
"strategy": "most_recent",
"source_modified_path": [
"$.payload.cliente.updated_at",
"$.payload.ordine.updated_at",
"$.payload.agente.updated_at"
]
}A single string remains valid shorthand for a one-element list (backward compatible).
Flows with no event timestamp in the payload - some sources carry nothing
usable at all: a pure date (20260901) is not an edit-time, and a batch of
readings or measures may carry no timestamp whatsoever. The declared path then
misses on every one of those events, and the runtime logs
conflict_source_modified_path_unresolved on every single write while the
envelope fallback is already doing the right thing. $.occurred_at makes that
fallback declarable: it resolves to the event envelope’s own occurred_at (the
ingest instant when the producer sent none), so put it last in the
candidate list and the record-level paths still win whenever they resolve:
{
"strategy": "most_recent",
"source_modified_path": [
"$.payload.dati.aggiornatoIl",
"$.occurred_at"
]
}Order matters: $.occurred_at must be the LAST candidate. Candidates are
tried in order and the first that resolves wins, and the envelope time resolves
on practically every event (the ingest instant is substituted when the producer
sends no occurred_at). Declared anywhere but last it therefore always wins,
every record-level path after it is dead config, and most_recent arbitrates
on the ingest instant with no runtime warning to show for it - a candidate did
resolve, so conflict_source_modified_path_unresolved never fires. Preflight
and the publish response flag this as
conflict_source_modified_path_envelope_not_last, on any config: the trap is
in the ordering alone, so unlike the heuristic below it is not limited to
recognizably multi-entity configs.
$.occurred_at arbitrates but is never replay evidence. It feeds
most_recent exactly like any other candidate, but reject_stale_source gets
nothing from it and stays fail-open on those events. The timestamp comes from
the transport, not from the record: for every non-journal source it is stamped
at ingest, and the write for one event always postdates the ingest of
the next event already queued behind it - so arming the guard on it would
discard every rapid follow-up as stale, newest value included. Declaring the
envelope buys silence on a diagnostic; it does not manufacture evidence the
envelope never carried. To arm the guard, point the path at a timestamp the
record itself carries (a journal-sourced flow has one at
$.payload.event.occurredAt, inside the enriched payload).
This fallback list is orthogonal to, and composes with, each
entity’s own payload_root:
payload_root re-roots $.payload.* for an entity’s OWN
field_mappings/transform sources, while source_modified_path here is a
connector-wide, always-un-rooted list of full paths read against the raw
event - declaring payload_root on cliente/ordine/agente does not
require touching this list at all.
Preflight (GET .../preflight) and the publish response both
flag conflict_source_modified_path_envelope_like when the declared path (or
every candidate in the list) resolves outside every entity’s own record block
on a config recognizable as multi-entity - the structural symptom of a path
that can only be reading the envelope. This is a config-shape heuristic
(static analysis of field_mappings sources), not a runtime check: it does
not require live event samples and stays silent on a legitimately flat,
single-entity payload (e.g. the b2b_company_sync template’s
$.payload.updated_at). It complements, and is distinct from, the
conflict_source_modified_path_unresolved runtime warning (a declared path
that never resolves at all on some event) - a path can resolve on every event
and still be wrong, which is exactly this trap.
An $.occurred_at tail is judged apart from the record-level candidates: it is
a declared fallback, so it never counts as one of the paths that “resolve
outside the record block”, and a list whose record-level path does resolve
stays silent. Declared alone on a multi-entity config it still warns, for
the reason above - most_recent then has no record timestamp to arbitrate on
at all.
strategy semantics - decided per property, at write time, never per
whole record:
source_wins(default) - the inbound write always applies. No read, no detection, no rows in the Conflicts tab. This is not a broken conflict engine; it is the strategy doing what it says.target_wins- a property whose last HubSpot edit came from outside plugsync stays on the HubSpot side, unconditionally (no timestamp comparison).most_recent- the target wins that property only if its last edit is strictly newer than the source’s edit-time; ties and missing timestamps favor the source.
The one rule that makes this look “inert” if you don’t know it: target_wins
and most_recent only ever arbitrate a property whose last HubSpot edit was
made by a system OTHER than plugsync itself (“foreign”, sourceId != this
connector’s own HubSpot app id). If plugsync is the only system that has
ever written that property (e.g. a brand-new connector, or a property no
human has touched in the CRM), every inbound write is “foreign-free” and
never becomes a conflict - zero rows in the Conflicts tab is the correct,
expected result, not evidence the strategy isn’t running. The moment a
human (or another integration) edits the property in HubSpot, the next
inbound write against that property is arbitrated normally.
Two ways to check that the policy actually is running, without waiting for a
real conflict: GET /api/connectors/{id}/conflicts/count returns
policy_active (a detection-enabled strategy is published) and
never_matched (that policy has never once found a foreign edit to
arbitrate) - the dashboard’s Conflicts tab surfaces the same as a small hint
under the empty state. Structured logs also carry a per-property
"conflict check: skipped (own write)" line whenever a contested property was
excluded for exactly this reason.
Detection is not free: target_wins and most_recent add a read of the
record before every write, and the arbitration is decided on that snapshot,
so an edit landing in HubSpot between the read and the write is not seen.
Where the source system sends whole records rather than change sets, the
cheaper structural fix is to narrow what each step may write with a
fields allowlist, so a
property the source does not own is never contested in the first place.
reject_stale_source guards against a narrower, orthogonal problem: a
replayed or out-of-order-redelivered source event (a retry, an echo of
plugsync’s own last write coming back through the source system) whose
business-time is strictly OLDER than the target’s last known write - to ANY
contested property, regardless of who made that write. Unlike strategy,
this check counts an own-app write as evidence too, so it catches the one
case most_recent’s foreign-only arbitration cannot: a stale event racing
plugsync’s own echo.
The comparison is change-set-aware, per property: only the
contested properties whose OWN target write postdates the event’s business
time are treated as stale and ceded - a sibling property this same write
never touched (e.g. a full-record echo that genuinely changed one field while
merely carrying another, unrelated one along) is not swept into the same
discard. The event is only discarded WHOLESALE when every contested property
turns out stale (a genuine full replay); otherwise the stale subset is ceded
and the rest of the event still applies. Because a discard is just data loss
with a friendlier name, either outcome always leaves a row in the Conflicts
tab with resolution: "discarded_stale" (scoped to whichever properties were
actually ceded) so the drop is never invisible.
Leaving the field unset (null) is not “off”: it resolves to true under
most_recent (an unset guard used to let a stale/echoed event silently
overwrite the target) and to false under target_wins/source_wins
(opt-in only, unchanged since the guard’s introduction). Set it explicitly to
override either default.
per_property_freshness closes the remaining gap reject_stale_source
cannot: an echo that is NOT stale as a whole. On a bidirectional connector
with realistic source-side latency, this interleaving happens:
- plugsync syncs
phoneto HubSpot (own write). - A CRM user corrects
phonein HubSpot (a foreign edit). - Before that correction reaches the source system, a source-side operator
edits a DIFFERENT field of the same record - the source emits its usual
full-record payload: a record-level
updated_atfresher than the foreign edit, but withphonestill carrying the old, pre-correction value.
Record-level most_recent sees a fresher source and lets the whole record
win: the fresher foreign phone is overwritten on both sides - a coherent
but regressive convergence, with a source_wins conflict row that looks
perfectly legitimate. reject_stale_source does not fire either: the event
as a whole IS fresher than the target’s last write.
With per_property_freshness on, the freshness comparison becomes
per-property and evidence-based: a foreign-latest property whose incoming
value matches a prior version of its own HubSpot history (typically
plugsync’s own last write to it - the echo of the sync coming back) is
provably a value the target has already moved past. That property stays on
the HubSpot side and the conflict row records "reason": "stale_echo" for it
(inside target_data, per property); the rest of the record applies
normally. An incoming value that matches nothing in the property’s history is
a genuine concurrent edit and follows the plain record-level comparison,
unchanged.
Cost: zero extra reads - the rule only re-reads the version list already
fetched by most_recent’s read-before-write (propertiesWithHistory).
Scope: only most_recent; source_wins and target_wins are unaffected.
Like reject_stale_source, leaving the field unset (null) resolves to
true under most_recent and false otherwise; an explicit value always
wins. Known limit: a source that legitimately re-sets a field to a value it
held before is indistinguishable from an echo by history alone - if that is a
real pattern for your source, set per_property_freshness: false (or send
delta payloads instead of full-record snapshots).
Both guards on this page apply to writes TOWARDS HubSpot. The OUTBOUND
direction (HubSpot journal -> a non-HubSpot target) gets the same per-property
granularity via narrow_outbound_projection, opt-in per flow - see
flows.md.
pipelines
Declares custom pipeline(s)/stage(s) that publish provisions on the connected
portal (see the
Schema provisioning reference),
so the connector no longer needs a pipeline created by hand on the portal
with its numeric ids hardcoded into map transforms.
{
"pipelines": [
{
"object_type": "deals",
"label": "Ordini",
"stages": [
{ "label": "Ricevuto", "probability": 0.2 },
{ "label": "Spedito", "probability": 0.8 },
{ "label": "Chiuso", "probability": 1.0 }
]
}
]
}| Field | Type | Notes |
|---|---|---|
object_type | string | Normalized like an entity’s object_type (singular standard forms canonicalize to the plural). |
label | non-blank string | The pipeline’s HubSpot label. Unique together with object_type within the config, ignoring case: Ordini and ordini are the same pipeline. |
stages | array of {label, probability} | At least one; stage labels are non-blank and unique within the pipeline, ignoring case. probability is 0..1 (HubSpot’s own requirement for a deals pipeline stage). |
A pipeline and a stage accept only the keys above. The block is checked when you save the draft:
| Rule | Message |
|---|---|
| Pipeline label not blank | pipeline label must be non-blank |
| Stage label not blank | pipeline stage label must be non-blank |
| At least one stage | pipeline '<label>' must declare at least one stage |
| Stage labels unique, ignoring case | pipeline '<label>' has duplicate stage labels (case-insensitive): [...] |
One pipeline per (object_type, label), ignoring case | duplicate pipeline declared for object_type='deals' label='<label>' (case-insensitive): only one pipeline per (object_type, label) pair is supported |
Provisioning is create-only and idempotent per label (publish, and the
connect/rebind best-effort path): a pipeline whose label already exists on the
portal is left untouched - no update, no delete, no drift reconciliation of
stages an operator later edits directly on the portal. A provisioning failure
at publish time (e.g. the connected credential lacks
crm.schemas.deals.write) raises a 422 with an actionable message; no new
config version is persisted.
Runtime label resolution: a dealstage field_mapping may carry a
non-numeric business label instead of HubSpot’s numeric stage id. At upsert
time, a non-numeric dealstage value is resolved against the pipeline named
by this same record’s projected pipeline property - typically a const
mapping referencing the pipeline’s label declared above:
{ "source": "$.payload.stage", "target": "dealstage" },
{ "source": "$.payload.stage", "target": "pipeline",
"transform": "const", "args": { "value": "Ordini" } }A label that fails to resolve (typo, wrong pipeline) is left untouched and
logged rather than silently degrading the write; HubSpot itself rejects a
non-numeric dealstage outright.
Validators
| Rule | Failure message |
|---|---|
entities[].name unique within the config | entity names must be unique within a config |
flows[].name unique within the config | flow names must be unique within a config |
sources[].name unique within the config | sources['...'] is declared twice: source names must be unique within a config |
pipelines[].(object_type, label) unique, ignoring case | duplicate pipeline declared for object_type=... label=... (case-insensitive): ... (see “pipelines” above for the other pipeline rules) |
A source named event_api is of type event_api | sources['event_api']: the name 'event_api' is reserved for 'event_api' sources (the ingest endpoint stamps it on every event it accepts, so a '<type>' source under that name would never match a flow); give this source another name |
A source of type event_api is named event_api | sources['<name>']: an 'event_api' source must be named 'event_api' (the ingest endpoint stamps that source_name on every event it accepts, so no other name is ever matched) |
settings.ordering is record or connector | settings.ordering must be one of ['record', 'connector'], got ... |
settings.record_buckets is an integer from 1 to 16 | settings.record_buckets must be an integer from 1 to 16, got ... |
One flow per (source, event_type) pair | See flows.md. |
A step fields allowlist names the entity’s mapping targets | flow '<f>' step '<s>': `fields` entries [...] do not match any field_mappings target of entity '<e>' (known targets: [...]), and the other messages listed in step-actions.md |
The fields of an each body node name targets of its schema | flow '<f>' step '<s>': `each` node `fields` entries [...] do not match any field_mappings target of entity '<e>' schema '<schema>' (known targets: [...]) |
mapped_properties_only on an entity whose hubspot schema maps at least one property | flow '<f>' step '<s>': `mapped_properties_only` keeps only the properties entity '<e>' maps, and its hubspot schema maps none: the step would never write. Add field_mappings to the entity or drop `mapped_properties_only` |
mapped_properties_only only on an update_by_id step | `mapped_properties_only` is only supported on update_by_id steps, not on action '<action>' |
The cross-checks between a step and an entity skip an entity that is not in
the config yet (a draft may be saved half-edited; publish then rejects the
dangling reference), except for a step fields allowlist, which needs its
entity to be there.
Unknown top-level keys
A top-level key this table does not list is rejected with a 422 naming it
and listing the keys a config does accept — the one exception being
transforms, the escape hatch for custom lookup transforms. A typo at this
level is therefore a failed write, not a silently ignored key: treat the
table above as the whole vocabulary.