Skip to Content
ReferenceConfig V3Flows

flows[] reference

A flow is a (source, event type) pair plus an ordered sequence of steps. When the flow worker receives a message it loads 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.
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 exact-vs-wildcard 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 (one exact, one wildcard) 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.

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: exact match wins over wildcard

When an exact-match flow and a wildcard flow both match the same concrete event, only the exact-match flow runs; the wildcard flow is skipped for that event. This is deterministic and does not depend on declaration order in flows[]:

[ {"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.

No precedence between two wildcards. The exact-wins rule above covers only exact-vs-wildcard. Two wildcard flows with nested prefixes - e.g. cliente.* and cliente.vip.* - that both match the same concrete event (cliente.vip.creato matches both: cliente. is a prefix of it, and so is cliente.vip.) both run, in declaration order. There is no “most specific prefix wins” rule. If you need one wildcard to take priority over another for the same event family, add an exact-match flow for that specific event instead (which does take precedence per the rule above), or keep the two wildcard flows independent so both running is the intended behavior.

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 for bidirectional conflicts is tracked separately in issue #1017.

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 (Compass does this with {"event": "upsert_company", ...}), you can record where the type lives via event_type_from:

{ "name": "compass_company_upsert", "source": "compass_inbound", "event_type": "upsert_company", "event_type_from": "$.event", "steps": [ {"id": "upsert_hubspot", "action": "upsert", "target": "hubspot", "entity": "company", "data": "$.payload"} ] }

Today the producer (the inbound API endpoint or source_worker) is responsible for placing the resolved event type on the SQS envelope as event_type. The flow worker then matches 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 projected into event_flow_manifests as a forward-compat hint; no current producer resolves the JSONPath automatically.

Dispatch model

The flow worker consumes from two SQS FIFO queues (inbound-sync.fifo and outbound-sync.fifo). For each message it:

  1. Loads the connector’s active config from DynamoDB (the worker hot-read replica written by the outbox dispatcher; see draft-publish.md).
  2. Walks config.flows[], picks 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 an exact-match flow and a wildcard flow both match, only the exact-match flow is picked (see Precedence).
  3. Runs the matched flows in declared order. $.event_type inside each flow’s steps is always the concrete event, never the wildcard pattern.

More than one flow can legitimately match the same message here: an exact flow plus a non-overlapping wildcard flow from a sibling event family, or two independent wildcard flows whose prefixes both cover the same concrete event (see No precedence between two wildcards). 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