Top-level ConnectorConfigV3 shape
The root config object. Everything else lives under it.
Shape
{
"version": "3.0",
"entities": [],
"flows": [],
"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. |
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:
| Key | Type | Default | Effect |
|---|---|---|---|
retry | object | absent | backoff_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_mode | boolean | false | Archive 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"
}| 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. |
reject_stale_source | bool or null | null (effective: true under most_recent, false otherwise) | Opt-in replay/redrive 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). 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:
- 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 (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 }
]
}
]
}| 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. |
stages | array 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
| 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 |
pipelines[].(object_type, label) unique | duplicate 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.