Skip to Content
ReferenceConfig V3Top-level shape

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, with error_struct.error_code hubspot_portal_not_bound and an error_detail that 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 a 422 carrying 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:

CallWhat 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}/preflightThe 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 PATCH described 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 a 422 with {"error": "credential_not_hubspot_oauth", ...}.
  • The preflight says which portal answered. Its infos carry a preflight_candidate_credential finding 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, errors carries a candidate_credential_unusable finding: the run says nothing about whether the draft fits. Treat an empty errors as “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/adopt acts 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 hubspot source restarts from the new portal’s present, so nothing that happened on the new portal before the change is replayed. A journal_offset_expired warning 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 422 listing 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

FieldTypeRequiredDefaultNotes
version"3.0" or "3.1"yesPinned literal. Must equal "3.0" or "3.1".
entitiesarray of Entityno[]See entities.md.
flowsarray of Flowno[]See flows.md.
settingsobjectno{}Free-form. See “Settings” below.
conflictsConflictPolicy | nullnonullBidirectional conflict resolution. See “conflicts” below.
pipelinesarray of Pipelineno[]Custom pipeline/stage provisioning. See “pipelines” below.
sourcesarray of Sourceyes[]Event source definitions (name, type, config). The key must be present on every full write: see “sources” below.
ingress_profileIngressProfile | absentnoabsentAccepts 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:

KeyTypeDefaultEffect
retryobjectabsentTunes 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_modebooleanfalseArchive 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_bucketsinteger, 1 to 164Under 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.

KeyTypeDefaultEffect
file_fieldstringabsent (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_fieldstringabsentDotted 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_transformstring or pipeline listabsentTransform applied to the file_numdoc_field value, e.g. [{"name": "split", "args": {"sep": "/", "index": 0}}] to keep 9000 out of 9000/V2.
file_version_fieldstringabsentDotted path (relative to data) of the document version. It becomes the version part of the file name. Absent or unresolved, version is "".
file_version_transformstring or pipeline listabsentTransform 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" }
FieldTypeDefaultNotes
strategy"source_wins" | "target_wins" | "most_recent""source_wins"See below.
source_modified_pathstring ($... reference), array of strings, or nullnullOnly 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_sourcebool or nullnull (effective: true under most_recent, false otherwise)Opt-in replay guard, orthogonal to strategy. See below.
per_property_freshnessbool or nullnull (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:

  1. plugsync syncs phone to HubSpot (own write).
  2. A CRM user corrects phone in HubSpot (a foreign edit).
  3. 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_at fresher than the foreign edit, but with phone still 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 } ] } ] }
FieldTypeNotes
object_typestringNormalized like an entity’s object_type (singular standard forms canonicalize to the plural).
labelnon-blank stringThe pipeline’s HubSpot label. Unique together with object_type within the config, ignoring case: Ordini and ordini are the same pipeline.
stagesarray 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:

RuleMessage
Pipeline label not blankpipeline label must be non-blank
Stage label not blankpipeline stage label must be non-blank
At least one stagepipeline '<label>' must declare at least one stage
Stage labels unique, ignoring casepipeline '<label>' has duplicate stage labels (case-insensitive): [...]
One pipeline per (object_type, label), ignoring caseduplicate 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

RuleFailure message
entities[].name unique within the configentity names must be unique within a config
flows[].name unique within the configflow names must be unique within a config
sources[].name unique within the configsources['...'] is declared twice: source names must be unique within a config
pipelines[].(object_type, label) unique, ignoring caseduplicate 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_apisources['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_apisources['<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 connectorsettings.ordering must be one of ['record', 'connector'], got ...
settings.record_buckets is an integer from 1 to 16settings.record_buckets must be an integer from 1 to 16, got ...
One flow per (source, event_type) pairSee flows.md.
A step fields allowlist names the entity’s mapping targetsflow '<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 schemaflow '<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 propertyflow '<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.

Last updated on