flows[] reference
A flow is a (source, event type) pair plus an ordered sequence of steps. When
an event arrives, plugsync reads the connector’s published config,
matches every flow whose source equals the message envelope and whose
event_type equals or wildcard-matches it, and runs each matching flow’s
steps in order.
Fields
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | Unique within the config. Used in logs and dashboard diffs. | |
source | string | yes | The name of an event source attached to the connector. No wildcard support. | |
event_type | string | yes | The event type to match: an exact string, or a suffix wildcard <prefix>.* (e.g. cliente.*). See Wildcard event_type below. | |
event_type_from | string (JSONPath) | no | null | Declarative hint; no current runtime resolver. The producer must place the matched event type on the message envelope. |
enabled | bool | no | true | Flip to false to pause a flow without deleting it. A disabled flow still takes part in precedence. |
suppress_echo | bool | no | false | Opt-in echo suppression for this flow’s HubSpot writes. Best-effort and path-dependent by design: see Echo suppression below. |
freshness_check | bool | no | true | Content-based guard on the bidirectional round-trip: verifies outbound sends against HubSpot’s current state (superseded on mismatch) and drops inbound writes that merely echo this connector’s own recent write. Explicit false opts out per flow. |
narrow_outbound_projection | bool | no | false | Restrict an outbound post step’s body to the triggering event’s own change-set plus the entity’s HubSpot identity property. Requires freshness_check: true on the same flow. See Change-set-aware outbound projection below. |
steps | array of Step | yes | See step-actions.md. |
Validators
-
Step ids must be unique within a flow. Violation:
step ids within a flow must be unique. -
An
event_typethat uses*outside the supported suffix shape (a prefix wildcard like*.creato, a wildcard mid-pattern, a bare*, or an empty prefix like.*) is flagged by preflight asunsupported_event_type_wildcard- the flow will never match any real event. Preflight findings are informative only; they never block saving or publishing a draft.
-
Only one flow per literal
(source, event_type)pair. Two flows declaring the exact samesourceAND the exact sameevent_typestring are rejected at draft-save/publish time (schema validation runs on both), not merely at runtime dispatch. Violation:flows '<name1>' and '<name2>' both route source='<source>' event_type='<event_type>': only one flow per (source, event_type) pair is supported - merge their steps into a single flow. This is a literal string-pair check, independent of the specificity precedence rule below: it fires even though both flows’event_typeare byte-identical, which is a different situation from two differentevent_typepatterns (an exact and a wildcard, or two nested wildcards) both matching the same concrete event at runtime. If a source’s payload conflates more than one intent under a singleevent_type(a common case: a genericcustomer.changedwebhook that means either an upsert or a delete depending on a field in the payload), express every branch as steps inside one flow, gated by per-stepwhenconditions (seewhenconditions in step-actions.md) - instead of two separate flows that would collide on this check. -
narrow_outbound_projection: trueneedsfreshness_check: trueon the same flow. Violation:narrow_outbound_projection requires freshness_check: true on the same flow. -
The
rowsof anexport_filestep that reads an earlier step’s output ($<step_id>..., directly or asrows.each) must name a step that comes before it in the same flow. Violation:export_file step '<id>': rows ref '<ref>' names step '<step>', which is not an earlier step of flow '<flow>'. A ref rooted at$.(the event itself) is not checked. -
Step-level cross-checks against the entities of the same config (a step
fieldsallowlist,mapped_properties_only, thefieldsof aneachbody node) are listed in connector.md and next to each action in step-actions.md. -
A flow accepts unknown keys: they are kept as written and never read. Preflight reports them in an
unknown_flow_keyswarning.
Wildcard event_type
event_type supports one wildcard shape: a suffix wildcard <prefix>.*,
where <prefix> is a literal string with no * in it. A wildcard flow
matches every concrete event whose event_type starts with <prefix>.:
{
"name": "cliente_family",
"source": "erp",
"event_type": "cliente.*",
"steps": [
{"id": "upsert_hubspot", "action": "upsert", "target": "hubspot",
"entity": "contact", "data": "$.payload"}
]
}This single flow runs for both cliente.creato and cliente.aggiornato
(and any other future cliente.* event) without declaring a copy per event
type. Inside the flow’s steps, $.event_type always resolves to the
concrete event that triggered the run (e.g. "cliente.creato"), never
the wildcard pattern "cliente.*" - so a map transform keyed on
$.event_type still works exactly as it does for an exact-match flow.
Not supported: arbitrary regex, wildcard on source, a prefix wildcard
(*.creato), or a wildcard anywhere but the final segment
(cliente.*.foo). Any of these is flagged by preflight
(unsupported_event_type_wildcard) and never matches at runtime - it is
silently equivalent to a flow nobody can ever trigger.
Precedence: the most specific match wins
When more than one flow of the same source matches the same concrete event, exactly one level of specificity runs. The order is:
- an exact-match flow beats every wildcard;
- between wildcards, the one with the longest prefix wins
(
cliente.persona.*beatscliente.*).
This is deterministic and does not depend on declaration order in flows[].
Exact beats wildcard.
[
{"name": "cliente_family", "source": "erp", "event_type": "cliente.*",
"steps": [ ... ]},
{"name": "cliente_creato_special", "source": "erp", "event_type": "cliente.creato",
"steps": [ ... ]}
]For a cliente.creato event, only cliente_creato_special runs.
cliente.aggiornato (and any other cliente.* event) still falls through
to cliente_family, since the exact flow does not cover it. Use this to
carve out a special case for one event in a family while keeping the
wildcard as the default handler for the rest.
The narrower wildcard beats the broader one. Two wildcard flows with
nested prefixes - e.g. cliente.* and cliente.vip.* - both match
cliente.vip.creato (cliente. is a prefix of it, and so is
cliente.vip.). Only the longer prefix runs: cliente.vip.*. The broader
cliente.* keeps running for every event the narrower one does not
cover (cliente.creato, cliente.aggiornato, …). Use this to peel a
sub-family off a catch-all without touching the catch-all’s own flow.
Two wildcards whose prefixes do not nest (cliente.* and ordine.*)
never match the same event, so specificity never applies to them; a prefix
that merely shares characters (cliente.* vs clientela.*) does not nest
either, since nesting is on whole dot-separated segments.
If you actually want two flows to run on the same event, express both
behaviors as steps inside one flow (order between separate flows is not
guaranteed anyway). Preflight flags every overlapping wildcard pair on the
same source with an overlapping_event_type_wildcards warning, naming the
shadowed flow, so this precedence is visible before publish rather than
discovered from production output.
A disabled flow keeps its precedence
enabled: false keeps the flow in the published config, but the flow does
not run. An event it matches ends skipped, with the reason flow_disabled
and a detail naming the flow: The event flow '<name>' matching (source=<source>, event_type=<event_type>) is disabled; nothing was synced. Re-enable the flow and publish to resume.
Precedence is decided before enabled is looked at. A disabled flow
therefore still claims the events it is the most specific match for: with an
exact cliente.creato flow disabled and a cliente.* flow enabled, a
cliente.creato event is skipped, not handed to cliente.*. To let the
broader flow handle those events, delete the specific flow instead of
disabling it.
Echo suppression (suppress_echo)
Every write this flow makes to HubSpot produces a journal event that the
outbound path would bounce straight back to the system that originated it.
With suppress_echo: true, each HubSpot write registers a short-lived
fingerprint (a hash of the written property values - the store never holds
the values themselves) and the journal router drops outbound events of the
same connector whose changed properties match a recent fingerprint.
Sibling connectors on the portal still receive the event. Default false
means byte-identical behavior and zero extra I/O.
What the flag does NOT guarantee. Journal events carry no reliable “who wrote this” field, so recognition is by property fingerprint only and is deliberately conservative - best-effort and path-dependent. Any uncertainty lets the event pass:
- HubSpot normalized the written values (the journal reports something the fingerprint does not match);
- the journal event carries extra changed properties beyond what the flow wrote (someone else touched the record in the same window);
- the event payload has an unrecognized shape;
- the fingerprint expired (short TTL) or the fingerprint store was unreachable (fail-open on both sides).
A duplicate bounce is always preferred over a lost event (at-least-once).
In practice your own write can still come back one round: with unchanged
values the loop dies out on its own, and the inbound side of
freshness_check (default on) drops the echo before it rewrites HubSpot.
Do not design a flow or a test on the assumption that your own writes are
always filtered out - or that they always come back (see the quickstart’s
testing without a false negative
chapter). Per-property freshness on
bidirectional conflicts is a separate, inbound-side guard: see
conflicts.per_property_freshness.
Where you put the flag decides WHICH echoes are recognized. The reading
side is per-connector: as soon as any flow sets suppress_echo, the journal
router starts matching for that connector. The fingerprints it matches
against come from two independent places:
- the HubSpot writes of a flow that sets the flag (per-flow: a sibling writing flow without it is still not fingerprinted);
- every
poststep of a journal-driven flow withfreshness_check: true(the default), which fingerprints what it sends regardless ofsuppress_echo. Journal-driven is the operative word: the guard needs the event’s ownpropertyChangesto compare against, so aposton a flow fed by a webhook or the Event API fingerprints nothing. It is also skipped entirely when the connector’sconflictsblock disables the staleness guards.
So the flag on a journal-driven flow that writes nothing to HubSpot is not a no-op - it recognizes the echoes of that flow’s own outbound pushes. What it does not do is cover the connector’s inbound HubSpot writes: for those, set it on the flow that writes.
What a dropped echo leaves behind. Two different guards drop echoes, and they leave different traces:
- the journal router, on the outbound side, drops an echo before any flow
runs. It appears in the journal log with
status: "echo_suppressed"; - the inbound side of
freshness_check(default on, independent ofsuppress_echo) stops the inbound flow that is about to write to HubSpot when the properties it would write match a recent fingerprint of this connector. That flow is skipped withskip_kind: "echo_suppressed": the event arrived, it was not lost.suppress_echoonly decides which HubSpot writes get fingerprinted, so a connector without the flag can still produce these skips, for example from the fingerprints of its outboundpoststeps.
The sync event of an in-flow echo is skipped with
error_struct.context.skip_kind: "echo_suppressed" when no other flow of the
same record wrote; when a sibling flow wrote, the row is a success and the
skip is recorded only in that flow’s step trace. Both are listed by
GET /api/connectors/{connector_id}/sync-events?skip_kind=echo_suppressed
(pass since too: the filter scans the rows of the window). Count them per
flow with GET /api/connectors/{connector_id}/sync-events/summary?include=skips_by_flow
(see Why a record was skipped).
Suppressed echoes count as successes in the failure rate. A skipped
execution neither restarts nor advances the flow’s circuit breaker streaks
(only a successful write restarts them), but it counts as a success in its
failure rate (see
What counts as a failure for the rate).
On an inbound writing flow with freshness_check on, a window full of echoes
can therefore dilute the rate without the flow having written anything.
Measuring the health of such a flow means subtracting its echo_suppressed
executions first.
Preflight warns with echo_suppression_absent_on_bidirectional_overlap when
some enabled flow writes a HubSpot property, without the flag, that one of
this connector’s own enabled journal-driven flows reads back. It is a
warning, not an error: an echoing return leg can be intentional, and the flag
is not free (a fingerprint is stored on every HubSpot write and looked up on
every journal event). It stays quiet when the two directions do not actually
meet - a fields allowlist that excludes the property read back, disjoint
object types, a disabled flow on either side.
Change-set-aware outbound projection (narrow_outbound_projection)
A HubSpot-journal-driven post step (source type hubspot) typically maps
every property it is authoritative for into its body, regardless of which of
them the triggering journal event actually reports as changed
(propertyChanges). For a target whose own record can also be edited
directly outside plugsync (a typical bidirectional ERP), sending an untouched
property alongside a genuinely changed one can overwrite a fresher direct
edit on that target purely because it happened to ride along in the same
push - the write never even read the target’s current value for that field,
it just carried whatever HubSpot’s enrichment-time snapshot held.
With narrow_outbound_projection: true (and freshness_check: true on the
same flow - the change-set/staleness machinery both flags share lives
there), the body sent to the target is restricted to:
- the properties the triggering event’s own
propertyChangesreports as changed, intersected with what this step maps (ctx.flow_touched_properties, the same setfreshness_checkalready resolves), and - the entity’s HubSpot identity property (
entities[].schemas.hubspot.identity.property), always preserved so the target keeps identifying the record even though the identity value itself stops appearing inpropertyChangesafter creation.
A property the freshness check ALSO finds superseded by a later HubSpot write
(check_freshness) is dropped from the send too - but only that property.
Before this flag existed, ANY superseded property aborted the whole send
(SkipFlowSignalError); a sibling property this same event legitimately
changed and that is NOT superseded still goes out.
Nothing left to send skips the step, it never falls back to the full
body. A when gate on the step contributes to its touched set the same
way its body does (a gate condition’s own property can “survive” the
freshness check while the body’s mapped property does not - e.g. the body
maps only phone, superseded, while the when gate reads status, which
still matches). When narrowing determines that nothing in the body’s OWN
keys maps to a surviving property, the step is skipped
(SkipFlowSignalError) rather than sending the untouched, still-stale body -
falling back to the full body here would send the exact value the
conflicts row just recorded as superseded.
Recognized body shapes only. This only narrows a body whose every key can be attributed to a HubSpot property name with confidence:
- a bare
"$.payload.object.properties"string (the whole enrichment snapshot, passed straight through - every key of the resolved dict IS a HubSpot property name), or - a flat dict whose every value is a direct
"$.payload.object.properties.<name>"reference (e.g.{"telefono": "$.payload.object.properties.phone"}).
A federated body reading $.canonical.* behind a transform step, or a
post step that projects a target schema’s field_mappings instead of
declaring body explicitly, is not narrowed - this module has no way to
attribute an output key back to a HubSpot property name in those shapes, and
a wrong guess would silently drop the wrong field. Those steps keep sending
their full body regardless of the flag - documented invariant, the same
“unknowable -> unchanged” carve-out already applied to a source with no
per-property journaling upstream (a poller/scheduled/file source has no
change-set to narrow by in the first place).
Default false. Some targets legitimately want a full, idempotent
snapshot on every push regardless of what changed - narrowing would silently
change what such a target receives, so this is opt-in per flow, never
inferred from freshness_check alone.
See also: reject_stale_source and per_property_freshness
(connector.md) apply the same change-set
granularity to the OTHER write direction (writes towards HubSpot).
Worked example: event_type_from
When the inbound payload carries the event type itself (a webhook shaped like
{"event": "upsert_company", ...}), you can record where the type lives via
event_type_from:
{
"name": "acme_company_upsert",
"source": "acme_inbound",
"event_type": "upsert_company",
"event_type_from": "$.event",
"steps": [
{"id": "upsert_hubspot", "action": "upsert", "target": "hubspot",
"entity": "company", "data": "$.payload"}
]
}Today whatever produces the event (the ingest endpoint for a pushed event,
the source poll for a polled one) is responsible for placing the resolved
event type on the message envelope as event_type. Matching then compares
flow.event_type against envelope.event_type (exact, or suffix wildcard -
see Wildcard event_type) and never reads
event_type_from at runtime. The field is persisted on the flow definition
and carried into the published config as a forward-compat hint; no producer
resolves the JSONPath automatically today.
Dispatch model
Events travel on two channels, inbound and outbound, depending on the EventSource that emitted them. For each event:
- The connector’s currently published config is loaded (see draft-publish.md).
config.flows[]is walked, picking every flow wheresource == msg.source_nameandevent_typematchesmsg.event_type- exact string equality, or a supported suffix wildcard (cliente.*matchescliente.creato). When several flows match the same event, only the most specific level is picked: an exact match over any wildcard, and the longest prefix between wildcards (see Precedence).- The matched flows run in declared order.
$.event_typeinside each flow’s steps is always the concrete event, never the wildcard pattern.
More than one flow can still run for the same message, but only from the
same specificity level: two flows whose event_type strings differ yet
canonicalize to the same event both count as exact matches and both run
(the HubSpot journal alias, 0-2.modified and 0-2.update). Flows at
different levels never share an event: the more specific one takes it
whole. What can never happen is two flows with the exact same
(source, event_type) string pair both matching - the uniqueness
validator above rejects that combination before the config is
ever saved, so this dispatch step never has to choose between them.
Steps within a flow are sequential; flows themselves are independent and order between them is not guaranteed beyond declaration order.