Skip to Content
ReferenceConfig V3Flows

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

FieldTypeRequiredDefaultNotes
namestringyesUnique within the config. Used in logs and dashboard diffs.
sourcestringyesThe name of an event source attached to the connector. No wildcard support.
event_typestringyesThe event type to match: an exact string, or a suffix wildcard <prefix>.* (e.g. cliente.*). See Wildcard event_type below.
event_type_fromstring (JSONPath)nonullDeclarative hint; no current runtime resolver. The producer must place the matched event type on the message envelope.
enabledboolnotrueFlip to false to pause a flow without deleting it. A disabled flow still takes part in precedence.
suppress_echoboolnofalseOpt-in echo suppression for this flow’s HubSpot writes. Best-effort and path-dependent by design: see Echo suppression below.
freshness_checkboolnotrueContent-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_projectionboolnofalseRestrict 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.
stepsarray of StepyesSee step-actions.md.

Validators

  • Step ids must be unique within a flow. Violation: step ids within a flow must be unique.

  • An event_type that 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 as unsupported_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 same source AND the exact same event_type string 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_type are byte-identical, which is a different situation from two different event_type patterns (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 single event_type (a common case: a generic customer.changed webhook 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-step when conditions (see when conditions in step-actions.md) - instead of two separate flows that would collide on this check.

  • narrow_outbound_projection: true needs freshness_check: true on the same flow. Violation: narrow_outbound_projection requires freshness_check: true on the same flow.

  • The rows of an export_file step that reads an earlier step’s output ($<step_id>..., directly or as rows.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 fields allowlist, mapped_properties_only, the fields of an each body 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_keys warning.

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:

  1. an exact-match flow beats every wildcard;
  2. between wildcards, the one with the longest prefix wins (cliente.persona.* beats cliente.*).

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 post step of a journal-driven flow with freshness_check: true (the default), which fingerprints what it sends regardless of suppress_echo. Journal-driven is the operative word: the guard needs the event’s own propertyChanges to compare against, so a post on a flow fed by a webhook or the Event API fingerprints nothing. It is also skipped entirely when the connector’s conflicts block 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 of suppress_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 with skip_kind: "echo_suppressed": the event arrived, it was not lost. suppress_echo only decides which HubSpot writes get fingerprinted, so a connector without the flag can still produce these skips, for example from the fingerprints of its outbound post steps.

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 propertyChanges reports as changed, intersected with what this step maps (ctx.flow_touched_properties, the same set freshness_check already 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 in propertyChanges after 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:

  1. The connector’s currently published config is loaded (see draft-publish.md).
  2. config.flows[] is walked, picking every flow where source == msg.source_name and event_type matches msg.event_type - exact string equality, or a supported suffix wildcard (cliente.* matches cliente.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).
  3. The matched flows run in declared order. $.event_type inside 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.

Last updated on