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
| 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. |
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 exact-vs-wildcard precedence rule below: it fires even though both flows’event_typeare byte-identical, which is a different situation from two differentevent_typepatterns (one exact, one wildcard) 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.
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
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 (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:
- Loads the connector’s active config from DynamoDB (the worker hot-read replica written by the outbox dispatcher; see draft-publish.md).
- Walks
config.flows[], picks every flow wheresource == msg.source_nameandevent_typematchesmsg.event_type- exact string equality, or a supported suffix wildcard (cliente.*matchescliente.creato). When an exact-match flow and a wildcard flow both match, only the exact-match flow is picked (see Precedence). - Runs the matched flows in declared order.
$.event_typeinside 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.