Skip to Content
ReferenceConfig V3Top-level shape

Top-level ConnectorConfigV3 shape

The root config object. Everything else lives under it.

Shape

{ "version": "3.0", "entities": [], "flows": [], "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.

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.

settings

Free-form object: there is no sub-schema, so an unknown key is accepted and simply never read. Two keys are read by the runtime today, both opt-in and both documented in Reliability: retry, failures, replay:

KeyTypeDefaultEffect
retryobjectabsentbackoff_base_seconds (default 0, off) and backoff_max_seconds (default 600) turn the flat retry wait into an exponential backoff. The attempt count is unaffected.
record_modebooleanfalseArchive the raw payload of every event crossing the connector, enabling range replay and parallel runs.

Because the block is unvalidated, a mistyped 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.
reject_stale_sourcebool or nullnull (effective: true under most_recent, false otherwise)Opt-in replay/redrive 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). This fallback list is orthogonal to, and composes with, each entity’s own payload_root (#766): 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.

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.

See ADR-0023 for the full design rationale (read-before-write cost, race window, #463 field ownership as the structural mitigation for snapshot-style ERPs).

reject_stale_source guards against a narrower, orthogonal problem: a replayed or out-of-order-redelivered source event (SQS redrive, 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 (#1044): 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 (SchemaProvisioner.provision_pipeline) - 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.
stagesarray of {label, probability}At least one; stage labels unique within the pipeline. probability is 0..1 (HubSpot’s own requirement for a deals pipeline stage).

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
pipelines[].(object_type, label) uniqueduplicate pipeline declared for object_type=... label=...

Forward-compat caveat

ConnectorConfigV3 declares model_config = ConfigDict(extra="allow") at the top level. This means unknown top-level keys do not fail validation. Typos pass silently. Treat the table above as authoritative; do not introduce new top-level keys without a schema change.

Last updated on