Skip to Content
ReferenceConfig V3Step actions

steps[] reference - action DSL

A step is a single instruction inside a flow. Every step has an id, an action, and (depending on the action) a handful of action-specific fields.

See also: flows, connector, simple-vs-federated.

Step skeleton

{ "id": "<unique-within-flow>", "action": "<one of the v3 actions; see below for the closed set>", "when": { /* optional WhenCondition */ } // action-specific fields below }

id must be unique within the flow (enforced by FlowV3._unique_step_ids). The action set is closed: any value outside the list above fails with

Unknown action '<x>'; must be one of ['associate', 'associate_many', 'create', 'create_many', 'delete', 'delete_attachments', 'delete_many', 'find', 'find_associated', 'notify', 'plugin', 'post', 'skip', 'submit_form', 'transform', 'update', 'update_by_id', 'update_many', 'upload_file', 'upsert', 'upsert_many']

The 10 baseline actions are available under config version: "3.0". The 5 *_many bulk actions (upsert_many, create_many, update_many, delete_many, associate_many) require version: "3.1" per connector.md.

StepV3 declares model_config = ConfigDict(extra="allow"), which means unknown step-level fields pass validation silently. Treat the per-action tables below as the authoritative list of fields the runtime consumes; typos at the step level will not be caught at draft-save time.

associate

Creates a single association between two objects that were already upserted (earlier in the same flow), each resolved by its external id. The batch form is associate_many. The HubSpot association type is auto-discovered via the association-types API. A missing endpoint external id (e.g. a client event that carried no contact) is a graceful no-op, not a failure.

FieldRequiredDescription
targetyesMust be hubspot.
from_entityyesEntity name of the source endpoint (used for the identity lookup + object type).
from_external_idyes$... ref resolving to the source endpoint’s external id.
to_entityyesEntity name of the target endpoint.
to_external_idyes$... ref resolving to the target endpoint’s external id.

Missing field error: associate step requires from_entity, to_entity, from_external_id, and to_external_id.

Example (link a company and contact upserted earlier in the same flow):

{"id": "link", "action": "associate", "target": "hubspot", "from_entity": "company", "from_external_id": "$.payload.idarca_cliente", "to_entity": "contact", "to_external_id": "$.payload.idarca_contatto"}

Stub upsert for a not-yet-synced FK (#934)

A row can reference a foreign key whose own record has never transited this connector — e.g. a DWH’s incremental contratto export can reference a COD_CLIENTE whose anagrafica file is pre-go-live and will never be republished. associate’s missing_endpoint no-op (above) only covers an EMPTY FK; it does nothing for a FK that has a value but resolves to no identity mapping yet — that association is silently skipped, and it stays skipped forever once the row itself won’t be reprocessed.

The fix is a manifest pattern, not a dedicated engine feature: an extra upsert step, earlier in the flow, that writes ONLY the FK entity’s identity property via a fields allowlist (see upsert’s fields), using the FK value itself as the row’s authority for that one property. This “stub” record is idempotent (match_or_create) and non-destructive: the fields allowlist means it can only ever touch the identity property, never overwrite a richer record’s other fields if the real anagrafica arrives later (or already ran first). The subsequent associate step then resolves normally, because the stub guarantees the identity mapping exists.

[ {"id": "stub_cliente", "action": "upsert", "target": "hubspot", "entity": "cliente", "fields": ["aurea_cod_cliente"], "when": {"all": [{"exists": "$.payload.cod_cliente"}, {"not": {"in": {"$.payload.cod_cliente": ["", null]}}}]}}, {"id": "upsert_contratto", "action": "upsert", "target": "hubspot", "entity": "contratto"}, {"id": "associa_cliente", "action": "associate", "target": "hubspot", "from_entity": "contratto", "from_external_id": "$.payload.cod_contratto", "to_entity": "cliente", "to_external_id": "$.payload.cod_cliente"} ]

Guard the stub with all + not in [..., null], never exists alone. exists means “present and non-null” (see when conditions) — an empty string IS non-null, so exists alone does NOT exclude a CSV-sourced FK column whose “no value” encoding is an empty field rather than an absent key. Gating the stub step with exists alone on that kind of source lets an empty-string FK reach upsert’s identity property, which upsert/upsert_many treat as a genuine per-record failure (mapping_failed: no external id) rather than a graceful skip — turning “no FK on this row” into a hard permanent_failure for the whole record instead of the intended “skip the stub, let associate’s existing missing_endpoint no-op handle it”. Verified on the bulk-flow path (execute_flow_batch) in (internal plugsync tooling - see the source repo).

update_by_id

Update a HubSpot object by an id resolved from $... context (not by external-key identity). Use when you already hold the HubSpot object id (e.g. $enrich.company.id from find_associated) and must patch a property without an identity-map lookup or search (which would miss-then-duplicate a record new to the external system).

Only target: hubspot is supported.

FieldRequiredDescription
targetyesMust be hubspot.
entityyesEntity name (used to resolve the HubSpot object type from the schema).
object_idyesRequired field: a $... ref to the HubSpot numeric id to update. If it resolves to None/empty at runtime, the step is a graceful no-op (see below).
datayesJSONPath or inline dict of properties to patch.
itemsno$... ref to a list; enables array (1->N) mode (see below).

Single form - update one object by its id:

{"id": "wb_company", "action": "update_by_id", "target": "hubspot", "entity": "company", "object_id": "$enrich.company.id", "data": {"company_id_arca": "$insofferta.body.newidarca_cliente.idarca_cliente"}}

Array form (1->N) - supply items (a $-ref to a list); each element is rebound as $._item, and object_id + data are resolved per element. A missing per-item id is skipped gracefully.

{"id": "wb_contacts", "action": "update_by_id", "target": "hubspot", "entity": "contact", "items": "$insofferta.body.newidarca_contatto", "object_id": "$._item.id_hubspot", "data": {"contact_id_arca": "$._item.idarca_contatto"}}

A missing/empty object_id or empty resolved data is a graceful no-op: the step records {"updated": false, ...} and performs no HubSpot call (single mode) or skips that element (array mode). This mirrors associate’s missing-endpoint no-op behavior. Array mode returns {"updated": <int>} (the count of elements successfully updated).

upload_file

Uploads a staged file to the HubSpot Files API and attaches it to a deal via a CRM v3 Note. The Event API ingest boundary offloads a base64 file field to an attachment store (S3) and replaces it with a claim-check ref {s3_key, filename, numdoc, version}; this action resolves that ref, fetches the bytes, uploads them, creates a Note carrying hs_attachment_ids, associates the Note to the deal, then deletes the staged object.

Only target: hubspot is supported (implicitly; the action always writes Notes).

FieldRequiredDescription
file_refyes$... ref to the claim-check dict the ingest boundary stamped (e.g. $.payload.linkfile). A missing/non-dict ref is a no-op traced as skipped_missing_ref.
object_idyes$... ref to the deal’s HubSpot id (e.g. $upsert_deal.id from a prior upsert). A missing id is a no-op traced as skipped_missing_ref.
note_bodynoTemplate for the Note body. The single token {filename} is substituted. Default: Allegato preventivo aggiornato {filename}.
folder_pathnoHubSpot Files folder (auto-created). Default: /deals_attachments.

Missing field error: upload_file step requires file_ref / upload_file step requires object_id.

Idempotent under SQS redelivery: before creating the Note the action scans the deal’s existing notes and skips if one already carries the same {numdoc}_{version}.pdf filename (matched as a distinct token, so 123_2.pdf never collides with 123_20.pdf); the Files API also uses RETURN_EXISTING. Error policy: 429/5xx re-raise for SQS redrive, 401/403 raise fatally (a missing crm.objects.notes / files scope surfaces loudly), other 4xx are swallowed and logged so the deal still syncs without the PDF.

Example:

{"id": "attach_pdf", "action": "upload_file", "file_ref": "$.payload.linkfile", "object_id": "$upsert_deal.id"}

delete_attachments

The delete-side counterpart of upload_file: archives a deal’s offer-PDF Note(s). Scans the deal’s associated notes, matches any note whose attachment filename starts with {numdoc}_ (fallback: the ref’s filename appearing in hs_note_body), and archives the matched notes. No server-side search by attachment value; the N+1 note scan is scoped to a single deal.

Only target: hubspot is supported (implicitly; the action always reads and archives Notes).

FieldRequiredDescription
file_refyes$... ref to the claim-check dict {s3_key, filename, numdoc, version} (e.g. $.payload.linkfile). The numdoc is the match key. A missing/non-dict ref falls back to numdoc_ref.
object_idyes$... ref to the deal’s HubSpot id (e.g. $upsert_deal.id from a prior upsert). A missing id is a no-op traced as skipped_missing_ref.
numdoc_refnoFallback $... ref for the {numdoc} match key when the event carries no claim-check ref (e.g. $.payload.deal.numerodoc on a file-less delete event). Used only when file_ref does not yield a numdoc.
numdoc_transformnoTransform pipeline refining numdoc_ref, e.g. [{"name": "split", "args": {"sep": "/", "index": 0}}] — the same shape as the ingest file_numdoc_transform setting. Requires numdoc_ref.

Missing field error: delete_attachments step requires object_id / delete_attachments step requires file_ref.

Idempotent: a deal with no matching note is a quiet no-op. A run where the numdoc (or the deal id) cannot be resolved at all does nothing and records skipped_missing_ref — not success — as the step’s trace status. The step output is {"operation": "delete_attachments", "deal_id", "numdoc", "archived_note_ids"}.

Example (the numdoc_ref pair makes the cleanup work on delete events that carry no file):

{"id": "cleanup_offer_pdf", "action": "delete_attachments", "file_ref": "$.payload.linkfile", "object_id": "$upsert_deal.id", "numdoc_ref": "$.payload.deal.numerodoc", "numdoc_transform": [{"name": "split", "args": {"sep": "/", "index": 0}}]}

transform

Reshapes data from one schema into another, typically inbound source into canonical. Reads from_schema’s field_mappings and, for every mapping whose target is a $.canonical.<key> path, applies its transform (if any) and its resolve marker (if any — owner_email_to_id / owner_id_to_email / owner_ids_to_emails, the same owner-directory lookup the hubspot projection uses — see entities.md for the list-aware multi-select marker) and writes the result into to_schema (ctx.canonical when to_schema is "canonical"). A mapping whose target is NOT a $.canonical. path is silently dropped by this filter — point target at $.canonical.<key>, never a plain property name, on any schema you intend to read via from_schema.

FieldRequiredNotes
entityyesThe entity name being transformed.
to_schemayesTarget schema key (usually canonical).
from_schemayesSource schema whose field_mappings project into to_schema. Required — there is no “read from inbound payload” fallback; omitting it (or pointing it at a schema with no $.canonical.* targets) used to silently leave canonical {} with no error anywhere (#842).

Missing field error: transform step requires entity, to_schema and from_schema.

Common mistake (#842): do not point from_schema at the entity’s own canonical schema (the one the “Switch to multi-system” dashboard button adds, or that infer_entity_mode uses to classify an entity as federated — see simple-vs-federated). canonical is conventionally written by transform steps, never read from, and its own field_mappings use plain (non-$.canonical.) targets by convention — a transform step reading it as from_schema always produces an empty canonical. Add a dedicated schema instead (any other name, e.g. hubspot_inbound) whose field_mappings target $.canonical.<key>, and read THAT as from_schema. Preflight (check_step_refs) flags both the missing- schema-reference case and this all-mappings-dead case as an error.

Example — inbound source into canonical:

{"id": "to_canonical", "action": "transform", "entity": "contact", "from_schema": "fooshop", "to_schema": "canonical"}

Example — resolving a HubSpot owner id into an email a later step can read (the declarative path for “owner_id -> email usable by subsequent steps”, outbound/journal-routed flow; the full pattern is a dedicated hubspot_inbound schema projecting into $.canonical.*):

{"entities": [{"name": "company", "schemas": { "hubspot_inbound": {"field_mappings": [ {"source": "$.payload.object.properties.hubspot_owner_id", "target": "$.canonical.owner_email", "resolve": "owner_id_to_email"} ]} }}]}
{"id": "map_company", "action": "transform", "entity": "company", "from_schema": "hubspot_inbound", "to_schema": "canonical"}

A later step reads the resolved email via $.canonical.owner_email (blank "" on a miss, with the unresolved id recorded as a non-fatal step warning in the trace, code owner_not_found — never as a key inside canonical itself).

find

Look up a single record by one property (EQ, limit 1); the match (or null) is exposed to later steps via $step, read by when: {found: ...}.

Against target: "hubspot", this is a HubSpot object search. Against any other target (a REST destination, i.e. an event source with an outbound block), it is a GET request whose response list is scanned locally for the match (#681) — same auth/header resolution as post, same single-property-EQ-limit-1 convention, same null-on-not-found output shape.

FieldRequiredNotes
targetyeshubspot, or a REST destination (event source) name.
entitywhen target is hubspotDrives the HubSpot object type.
byyes{"property": "<hubspot property, or REST result item field>", "value": "<$-ref>"}.
propertiesnoHubSpot only (#774): extra property names to fetch on the matched object, forwarded to the Search API. Same key as properties inside find_associated’s association specs.
urlwhen target is not hubspotPath appended to the destination’s outbound base_url. Supports {$...} path refs, the path-side counterpart of params.
results_pathnoREST only: jsonpath to the candidate list in the GET response body; omit when the body IS the list.
paramsnoREST only: GET query params dict; values may be $... refs (lookup chains).
headersnoREST only: extra headers merged over the destination’s resolved auth headers.
cache_ttl_secondsnoREST only: in-process cache TTL (seconds); a second identical lookup within the TTL skips the HTTP call.

Missing field errors: find step requires target, find step requires by, find step requires entity when target is 'hubspot', find step requires url when target is not 'hubspot'.

Example (HubSpot, requesting custom properties beyond HubSpot’s default set):

{"id": "find_company", "action": "find", "target": "hubspot", "entity": "company", "by": {"property": "domain", "value": "$.canonical.domain"}, "properties": ["external_id", "hubspot_owner_id"]}

Example (REST destination — resolve a country id by name, TTL-cached taxonomy lookup):

{"id": "find_country", "action": "find", "target": "matchpoint_outbound", "url": "/countries", "results_path": "$.data", "by": {"property": "name", "value": "$.payload.country"}, "cache_ttl_seconds": 3600}

Lookup chains (e.g. resolve a country id, then a region id scoped to that country) are just successive find steps: the later step’s params references the earlier step’s $step output to narrow the GET server-side, and its own by does the final local match:

[ {"id": "find_country", "action": "find", "target": "matchpoint_outbound", "url": "/countries", "results_path": "$.data", "by": {"property": "name", "value": "$.payload.country"}, "cache_ttl_seconds": 3600}, {"id": "find_region", "action": "find", "target": "matchpoint_outbound", "url": "/regions", "results_path": "$.data", "params": {"country_id": "$find_country.id"}, "by": {"property": "name", "value": "$.payload.region"}, "cache_ttl_seconds": 3600} ]

404, or an empty/absent candidate list, or no matching item: all store null (“not found”), never an error. Any other non-2xx status raises — a broken destination (bad auth, 5xx) surfaces loudly rather than degrading to a false “not found”. cache_ttl_seconds is a process-local, per-worker cache (see app/engine/lookup_cache.py): not shared across workers, cleared on worker restart — intended for STABLE reference lookups (taxonomies), not paged/ changing data.

find_associated

Fetch a parent HubSpot object’s ASSOCIATED objects and expose them for downstream steps. For a resolved parent object (its id taken from source), the action reads each association group via the HubSpot v4 associations API (ids only), then hydrates each associated id via a GET /crm/v3/objects/... read with the requested properties. Currently HubSpot-only (target defaults to / must be hubspot).

FieldRequiredNotes
entityyesParent entity name. Its hubspot schema object_type (or the friendly-name map) resolves the parent object type.
sourceyesJSONPath to the parent HubSpot object id (e.g. $.payload.hs_object_id).
associationsyesList of association specs (see below).
targetnoDefaults to hubspot; only hubspot is supported.

Each entry in associations is a dict:

KeyRequiredNotes
entity or objectyesTarget object/entity to fetch (resolved to a HubSpot plural object type).
asyesOutput key under which the hydrated result is stored in the step blob.
cardinalityno"one" (single object, or null if none) or "many" (list; default).
propertiesnoProperties to hydrate on each associated object.

The result blob is written to the step output: {<as>: <object> | [<objects>]}. Consume it downstream via $step.key chaining, e.g. $enrich.company.name, or iterate $enrich.contacts. With no associations, cardinality: one yields null and cardinality: many yields [], both read as not-found by when: {found: ...}.

Missing field error: find_associated step requires entity, source, associations.

source is a HubSpot object id, NOT an external id (#1150). This is the opposite of associate’s from_external_id / to_external_id, which take the external id and resolve it through the identity map. find_associated.source gets no lookup at all: whatever it resolves to is sent to HubSpot verbatim as the parent object id.

The trap is that an upsert step’s output exposes both ids, .id (HubSpot) and .ext_id (external), so the associate idiom copied onto a find_associated in the same flow publishes cleanly and then 404s on every event (Object not found. objectId are usually numeric.):

{"source": "$upsert_deal.ext_id"} // wrong: external id, 404s every event {"source": "$upsert_deal.id"} // right: HubSpot object id {"source": "$.payload.hs_object_id"} // right: the id carried by the event

Two guardrails cover the mistake. Preflight (GET .../preflight) and the publish response flag find_associated_source_not_object_id when source is statically provably not an object id: a $<step>.ext_id ref, or a non-numeric literal. It is a warning, never a gate, because a numeric string is indistinguishable from a real object id, so the check stays silent on anything it cannot prove ($.payload.* paths included). At runtime, a 404 on the parent’s association read fails the step with a PermanentError naming the step id, the source expression and the value it resolved to. That parent 404 is always a hard failure. A 404 while hydrating an individual associated id is a different case and IS tolerated (#1157): HubSpot’s association index keeps listing an object for a while after it was archived, so such an id is skipped with a warning and simply drops out of the blob. The tolerance never extends to the parent.

Note: hydration is one get_object per associated id (n+1 reads). Acceptable for low-cardinality parents; a batch read is a future optimization.

Example (build an enrichment blob from a deal’s associated company + contacts):

{"id": "enrich", "action": "find_associated", "target": "hubspot", "entity": "deal", "source": "$.payload.hs_object_id", "associations": [ {"entity": "company", "as": "company", "cardinality": "one", "properties": ["name", "domain"]}, {"entity": "contact", "as": "contacts", "cardinality": "many", "properties": ["email", "firstname"]} ]}

upsert, create, update, delete

Four variants of writing a single record. They share required fields and differ in semantics (upsert = create-or-update, create = insert only, update = modify only, delete = remove). upsert/create/update write to HubSpot only; delete also has a REST-destination variant (below).

FieldRequiredNotes
targetyes"hubspot" for all four. upsert/create/update accept nothing else - use post instead for external writes (issue #571); delete also accepts a REST destination name (issue #1158).
entityyesEntity name.
datanoAccepted by the schema; not read by the runtime for these four actions (see caveat below).
associationsnoupsert/create/update only (NOT delete, see below). List of {to_entity, to_external_id, association_type_id?} entries - see “Associations” below.

Missing field error: <action> step requires target and entity (where <action> is the actual action name, e.g., upsert step requires target and entity). On upsert/create/update, a target other than "hubspot" fails at execution with <action> currently only supports target=hubspot, got '<target>'; use action 'post' for writes to external targets.

data is dead weight on these four actions (#766). upsert/create/ update/delete write the entity’s hubspot schema field_mappings projection (_project_hubspot_props, see entities.md), never step.data - unlike update_by_id, which genuinely reads data as its literal request body. A "data": "$.payload.cliente" here, written hoping it re-roots the projection for a multi-entity envelope, silently does nothing: the field_mappings still read $.payload.* against the RAW top-level payload. The declarative fix is the entity’s own payload_root, not data.

upsert example:

{"id": "upsert_hubspot", "action": "upsert", "target": "hubspot", "entity": "company", "data": "$.payload"}

delete against a REST destination (#1158)

An outbound flow deletes on the target system with the same delete action, pointed at a destination instead of HubSpot - the same target-dispatch find uses. target: "hubspot" keeps the archive behaviour above unchanged; any other target name (an event source with an outbound config) issues an HTTP call to that destination.

{"id": "delete_company", "action": "delete", "target": "matchpoint", "entity": "company", "url": "/companies/{external_id}"}
FieldRequiredNotes
targetyesThe destination (event source) name. Unknown name -> delete: unknown target '<x>'.
entityyesEntity whose identity mapping resolves the record to delete.
urlyesPath appended to the destination’s outbound.base_url. Must contain the literal {external_id}, replaced with the mapped external id. May also carry {$...} path refs for other dynamic segments. Omitting the field fails validation with delete step requires url when target is not 'hubspot'; omitting the placeholder fails with delete step url must contain '{external_id}' when target is not 'hubspot'.
methodnoHTTP verb, DELETE by default. POST/PATCH/PUT for targets that model deletion as a state change (logical delete, e.g. an /archive endpoint).
headersnoExtra headers merged over the destination’s resolved auth headers (same mechanism as post).

No request body is ever sent: the record is identified by the url alone. That is why {external_id} is mandatory rather than conventional - it is the only mechanism that names THIS record (a {$...} path ref addresses a parent resource, not the record being deleted, and there are no params), so a url without it could only ever hit the collection endpoint while still dropping the identity mapping.

The external id comes from the identity map, never from the payload. The event carries HubSpot’s object id; the destination knows the record by its own id, and the identity map is the only place holding that pair. So the step resolves the HubSpot object id from the event (the entity’s target-schema identity_source_path, else $.payload.object.id / $.payload.objectId - same chain the identity-aware post uses), looks the mapping up by that id, and substitutes the resulting external id into url.

On a HubSpot journal .delete event, set identity_source_path explicitly. The default chain resolves neither of its two candidates on a deletion: object is null (the enrichment GET 404s, by definition) and the id lives under event. Without "identity_source_path": "$.payload.event.objectId" on the target schema, every deletion raises a permanent error and lands in the DLQ. Publishing such a config returns the warning delete_rest_identity_source_path_unresolvable, which names the path to declare; the runtime error names it too whenever the failing payload actually carries an id there. See reacting to a deletion.

Outcomes:

SituationBehaviour
No identity mappingQuiet, observable no-op (delete_noop_no_identity_mapping), no HTTP call. The record was never pushed to this target, or a previous delete already converged. Same semantics as the HubSpot branch, and what makes the step redelivery-safe.
2xxDeleted. The identity mapping is removed.
404Treated as already deleted: a delete is defined by its end state, and the record is gone. The identity mapping is removed, so redeliveries stop rather than DLQ forever. Since the record was never actually seen at that url, this case logs delete_target_mapping_dropped_unconfirmed (not delete_target_record) and sets already_absent: true in the step output - grep for it to audit mappings discarded without confirmation.
5xx, 429TransientError -> retry (429 honours the target’s Retry-After). The mapping is kept, so the retry can still resolve the id.
other 4xxPermanentError -> DLQ. The mapping is kept.

The mapping is dropped only once the target confirms - the same _raise_for_outbound_status classification as every other outbound call.

Out of scope for now: delete_many toward a REST destination (HubSpot only).

Associations (#844)

upsert/create/update accept an associations list: each entry associates the record the step just wrote to another entity’s EXISTING HubSpot object, using the same to_entity/to_external_id/ association_type_id contract as the dedicated associate action (identity-map lookup by entity + external id, auto-discovery, batching - never a raw HubSpot object id). Applied unconditionally after a successful write, so it runs identically whether the step just created the record or updated an existing one. delete does not read associations (archiving a record has nothing to attach it to).

FieldRequiredNotes
to_entityyesEntity name of the association target. Missing/falsy is a config error: the step raises <action> step '<id>': associations[<i>] missing 'to_entity', not a silent no-op.
to_external_idyes$... ref resolving to the target’s external id. An unresolved ref (e.g. the event carried no counterpart) is a graceful skip - no call, no error, mirrors associate’s missing_endpoint no-op.
association_type_idnoExplicit HubSpot association type id (e.g. 279 contact<->company). Omitted -> auto-discovery, same as associate.

Example (new/updated contact associated to an already-known company):

{"id": "upsert_contact", "action": "upsert", "target": "hubspot", "entity": "contact", "associations": [ {"to_entity": "company", "to_external_id": "$.payload.company_id", "association_type_id": 279} ]}

Before #844, this block was accepted by the schema (and documented) but silently ignored by the runtime in both create and update: zero errors, zero associations on HubSpot.

promote_identity

Renames a HubSpot record’s identity in place: the SAME physical record switches external id (e.g. a cart deal <cartId>_new_cart becoming the order <cartId>_<orderId> once confirmed) - history and associations are preserved, no second record is created. The old identity mapping is retained as a superseded alias (never deleted): a later upsert/update/delete that resolves it is skipped with an audit row instead of creating a duplicate or clobbering the promoted record (see “Late events on a superseded key” below). Identity lifecycle family (ADR-0021 regime 1); design: identity promotion primitive.

Always implicitly target: hubspot - there is no target field to declare.

FieldRequiredNotes
entityyesEntity whose HubSpot identity property is renamed.
from_external_idyes$... ref resolving to the OLD external id (the identity-map lookup key).
to_external_idyes$... ref resolving to the NEW external id the record is renamed to.
guard_whennoA when condition (see below) evaluated against the CURRENT record’s live HubSpot properties, scope $record.* (read-before-write, reusing ADR-0023). False means the promotion is stale: skipped with audit, zero writes. Omitted: no guard, only resolve + idempotency protect.
on_missingnofrom_external_id has no identity mapping. skip (default): the flow proceeds - a later upsert on to_external_id does a normal create (“first order recycles the cart, later ones create new deals”). fail: hard error.
on_promotednoto_external_id is already mapped (redelivery, or already promoted independently). skip (default, idempotent) or fail.
on_supersedednofrom_external_id is itself a superseded alias of ANOTHER key: the source was already consumed by an earlier promotion. fail (default): hard error, promotion chains are not supported (depth 1). skip: the promotion is declined with an audit row and the flow PROCEEDS, so the downstream upsert creates to_external_id as its own record. Use skip when the source is 1-to-N (one cart producing several orders): the second order is a new record, not a rename of the first.
on_failurenoThe promotion write fails permanently. fail (default): standard error classification (retry/DLQ). recreate (opt-in, destructive): archive the old record and create a new one under to_external_id, projecting the current event’s field_mappings - engagements/activity history of the old record do not migrate (declared data loss, mandatory audit).

guard_when’s $record.* scope is a special root, resolved against the live properties fetched right before the guard runs. Only on guard_when (not on a regular step when elsewhere in the manifest - see below), the right-hand side of equals/gt/lt/in is ALSO resolved when it is itself a $... string, so it can be compared against another live ref (e.g. the event’s own payload):

{"id": "promote_cart", "action": "promote_identity", "entity": "order", "from_external_id": "$.payload.cart_key", "to_external_id": "$.payload.order_key", "guard_when": {"equals": { "$record.external_object_id": "$.payload.cart_key"}}, "on_missing": "skip", "on_promoted": "skip", "on_failure": "fail"}

This guard reads as “is the record still a cart?” - true only while the record’s CURRENT identity property still equals the from_external_id this event captured; false means a concurrent/later event already moved it elsewhere, and this promotion is stale.

Outcome exposed to $step.*: outcome (promoted | skipped_missing | skipped_already_promoted | skipped_stale_guard | skipped_superseded_source | recreated), hubspot_id, from_external_id, to_external_id.

Resolutions in the conflicts table. Every declined or rewritten promotion leaves a row in the conflicts table (Conflicts tab in the dashboard) with strategy=promote_identity, so it is never silent. Four distinct resolution values come out of this family, and reading the tab means telling them apart:

resolutionWritten whenGoverned by
skipped_stale_guardguard_when evaluated false against the record’s live properties: the promotion is stale and zero writes happened.Config: guard_when
superseded_sourceon_superseded: skip declined the promotion because from_external_id is already a superseded alias of ANOTHER key. The flow proceeds and the downstream upsert creates to_external_id as its own record.Config: on_superseded
recreatedon_failure: recreate archived the old record and created a new one under to_external_id. Declared data loss, so the audit row is mandatory.Config: on_failure
identity_supersededA late upsert/update/delete resolved an identity mapping that is itself a superseded alias, and the write was skipped instead of clobbering the promoted record. Not produced by a promote_identity step at all: see “Late events on a superseded key” below.Platform, not configurable

The two superseded values are the pair most easily confused, and they answer different questions. superseded_source is about this promotion’s SOURCE key being already spent, and you get it only because you asked for it with on_superseded: skip. identity_superseded is about a write ARRIVING later on a spent key, and it is platform behavior on every HubSpot write path (the anti-clobber for late events): no config field turns it on or off.

skipped_missing and skipped_already_promoted write no conflict row. Both are ordinary idempotency outcomes (nothing to rename, or the destination key is already mapped) rather than declined work, and they are observable only in the step output and in the promote_identity_skipped worker log.

$step.outcome and resolution are not the same strings. For the same declined promotion the step output reports outcome: "skipped_superseded_source" while the conflicts row reports resolution: "superseded_source", without the skipped_ prefix: a filter written against one spelling will not match the other. skipped_stale_guard and recreated are spelled identically on both sides. identity_superseded exists only as a resolution, since no promote_identity outcome corresponds to it (the single-record delete step that hits the guard reports reason: "identity_superseded" in its own step output instead).

Late events on a superseded key. This is engine-level behavior, not specific to this step: once a promotion completes, any upsert/update/ delete step against target: hubspot (anywhere, any flow) that resolves an identity mapping whose metadata marks it superseded is skipped with an audit row instead of writing - the generalization of the guard above to every HubSpot write path, not just this one step. Batch forms (upsert_many, update_many, delete_many) apply the same guard per item.

That audit row is the one carrying resolution: identity_superseded, with target_data.superseded_by naming the key the record was promoted to. This is the anti-clobber that stops a late cart event from overwriting the order the cart became, and unlike on_superseded it is platform behavior rather than something the config chooses.

A delete against a REST destination never reaches this guard: it does not resolve by external id at all, it resolves the record through the identity map by HubSpot id, which already prefers the canonical (non-superseded) row.

Declared limits (ADR-0021 regime 1, rendered in the dashboard catalog): single record per invocation (no batch form); from/to on the same entity and store_id (no cross-entity/cross-namespace promotion); promoting FROM a key that is itself a superseded alias fails fast (depth 1 - no promotion chains, unless on_superseded: skip declines it instead); guard_when reads only the target record’s own properties (no cross-record lookups); on_failure: recreate never migrates engagements; a late write resolving a superseded key is always skipped with audit, with no override in v1.

submit_form

Submits the entity’s projected field mappings to a HubSpot form via the Forms v3 secure submissions API, carrying the visitor attribution context: the hutk cookie ties the submission to the analytics visitor and the form submission is recorded natively on the contact (what a plain contact upsert cannot express). The submitted field list is the entity’s hubspot field_mappings projected exactly like an upsert projection (absent sources are dropped), shaped as [{"name": <target>, "value": <projected value>}].

Only target: hubspot is supported. No HubSpot object id is returned and no identity mapping is recorded: HubSpot resolves the contact from the submitted email (submit_form_no_object_output limit).

FieldRequiredDescription
targetyesMust be hubspot.
entityyesEntity whose hubspot field_mappings become the submitted form fields.
form_idyesHubSpot form GUID: $... ref or literal. Unresolved at runtime is a hard error.
contextnoAttribution $... refs, e.g. {"hutk": ..., "pageUri": ..., "pageName": ...}. Values that resolve to nothing are omitted (hutk is optional in the Forms API); an all-empty context is not sent.

Missing field error: submit_form step requires target 'hubspot' / submit_form step requires entity / submit_form step requires form_id.

The step output is {"operation": "submit_form", "form_id", "fields", "context", "response"}.

Example (Collistar form event, ONE event per submission):

{"id": "submit_form", "action": "submit_form", "target": "hubspot", "entity": "form_contact", "form_id": "$.payload.context.form_id", "context": {"hutk": "$.payload.context.hutk", "pageUri": "$.payload.context.page_uri", "pageName": "$.payload.context.page_name"}}

post

Generic HTTP POST. Used for outbound flows pushing to a target’s REST API.

FieldRequiredNotes
targetyesThe schema key whose credential_name provides auth; also the entity schema key body projection reads when body is omitted (#795).
entitynoEntity whose schemas.<target>.field_mappings project the body when body is omitted (#795). Purely informational (logs) when body IS set.
urlyesAbsolute or schema-base-relative URL. May carry {$...} path refs for dynamic segments.
bodynoJSONPath or inline dict. Omit (with entity set) to project entities.<entity>.schemas.<target>.field_mappings instead — see “Body projection” below.
headersnodict of headers.
fieldsnoAllowlist of field_mappings targets to project into the body (#463/#795). Requires entity; only meaningful when body is omitted.
update_methodnoPUT (default) or PATCH: the verb used when the record already exists. Identity-aware writes only - see below.
update_urlnoUpdate path template, default url + / + the external id. Supports {external_id} and {$...} path refs. Identity-aware writes only.

Missing field error: post step requires target and url.

The field is update_method, not method. method belongs to delete and is rejected on a post with a 422 that points here. A post is not POST-only: see the identity-aware round trip below.

Example (HubSpot -> Compass, explicit body):

{"id": "push_compass", "action": "post", "target": "compass_outbound", "entity": "company", "url": "/api/companies/upsert", "body": "$.payload.object.properties"} (internal plugsync tooling - see the source repo)json { "entities": [{ "name": "company", "schemas": { "matchpoint": { "field_mappings": [ {"source": "$.canonical.name", "target": "name"} ] } } }], "flows": [{ "steps": [ {"id": "push_matchpoint", "action": "post", "target": "matchpoint", "entity": "company", "url": "/companies"} ] }] }

The push_matchpoint step above sends {"name": <resolved value>} as its body — no hand-written body template. Before #795 this same config silently sent an empty/no body (the field_mappings had zero consumers, only flagged by a non-gating preflight warning, issue #777); the runtime and preflight (check_field_mappings_without_consumer / check_step_refs’s unknown_post_projection_schema) now treat the projection as the real, checked consumer. See entities.md for the full field_mappings-consumer reference.

URL path templating (#1236)

A {$...} placeholder inside url (or update_url) resolves a $-ref into a path segment, which is what makes nested-resource APIs expressible declaratively:

[{"id": "push_company", "action": "post", "target": "matchpoint", "entity": "company", "url": "/companies", "body": "$.canonical.company"}, {"id": "create_lead", "action": "post", "target": "matchpoint", "entity": "lead", "url": "/companies/{$push_company.identity.external_id}/users:create-lead", "body": "$.canonical.lead"}]

Available on post, notify, find and delete against a REST destination, plus update_url. Any ref _resolve_value understands works ($.payload.*, $.canonical.*, $._item.*, $.external_id, $.event_type, $<step_id>...). An identity-aware post publishes its own external id under $<step_id>.identity.external_id, which is how a child step addresses the parent resource its sibling just created.

Rules, each with a reason:

RuleWhy
Values are url-encoded (/, ?, spaces included)A resolved value is data, never structure. Unencoded, an id containing / silently re-targets the call at a different resource.
An unresolved, empty or non-scalar ref raises PermanentError before the callSubstituting nothing yields /companies//users, which on many APIs is a 200 against the collection endpoint. A DLQ’d event beats a silent write to the wrong record.
$.credential.* is refusedURLs are logged (delete_target_record) and echoed in outbound error details, so a secret in a path leaks by construction. Put it in body or headers, which are never logged.
{external_id} is untouchedIt is not a $-ref. It keeps its own meaning (the identity map’s id for this step’s own entity) and its own substitution site, so manifests published earlier behave identically.

A property read only by a path still counts: the trigger-property derivation and the outbound-freshness touched set both extract refs out of {...} placeholders, so /companies/{$.payload.object.properties.mp_id}/users routes its own event.

notify

Send a notification (Slack, email, webhook, etc. depending on target type).

FieldRequiredNotes
targetyesNotification target.
urlnoRequired at runtime for delivery. Supports {$...} path refs like post - but since a notify swallows its failures, an unresolved ref surfaces as notify_failed in the step output rather than as a flow error.

Missing field error: notify step requires target.

plugin

Imperative escape hatch: run plugin code for logic the manifest can’t express (HTTP calls to third systems, stateful branching). Two runtime forms share this one action:

  • Legacy in-process (module + function): runs synchronously inside the flow_worker process. This was the only form before issue #190.
  • Lambda (plugin.logical_name): flow_worker invokes an AWS Lambda function via its resolved ARN + version. The full request/response contract, capability allow-list, and dev workflow are documented in the Plugin runtime guide; this table only covers the step-level schema.

Which form actually runs for a given step is controlled by the PLUGSYNC_PLUGIN_RUNTIME environment variable on the flow_worker process (python default, or lambda), independent of which fields the step declares — see dev-loop.md.

FieldRequiredNotes
modulelegacy onlyPython module name (in-process form).
functionlegacy onlyFunction name within the module (in-process form).
pluginLambda onlyJSON object. Authored as {"logical_name": "<plugin-name>"} plus an optional config object of static settings. function_arn/version/capabilities are resolved and embedded automatically at publish time — never author them by hand. See promotion.md.

A step must declare either module+function or plugin.logical_name. Missing both: plugin step requires either module+function (legacy in-process) or plugin.logical_name (Lambda).

upsert_many, create_many, update_many, delete_many (v3.1)

Bulk variants for high-volume ingestion. Process a batch of records through one HubSpot batch API call instead of N single calls.

FieldRequiredNotes
targetyesMust equal "hubspot". Bulk actions are HubSpot-only.
entityyesEntity name.
itemsyesJSONPath expression resolving to a list of records to process.
chunk_sizenoBatch size (1-100). Defaults to HubSpot’s API limit.
outputnoJSONPath into the batch response, used by later steps in the same flow.
reconcilenoWhen true, the runtime emits per-record sync events after the batch call.
external_id_refnodelete_many only. Per-item $... ref read directly as each item’s external id, bypassing field-mapping projection (see below).

Missing field errors:

  • <action> step requires items
  • <action> step requires entity
  • <action> step requires target 'hubspot'

Example:

{"id": "bulk_upsert", "action": "upsert_many", "target": "hubspot", "entity": "contact", "items": "$.canonical.contacts", "chunk_size": 50, "output": "$batch_out", "reconcile": true}

delete_many with external_id_ref: wiping discovered records

delete_many normally re-projects each item through the entity’s field_mappings to derive the external id to archive - which reconstructs the CURRENT event’s key. That cannot target records discovered by find_associated, whose hydrated HubSpot objects ({id, properties}) carry no source-event fields and whose real key the current event can no longer reproduce (e.g. wiping a promoted deal’s stale cart line items after promote_identity).

external_id_ref bypasses projection: it is a per-item ref (typically $._item.*) resolved against each item to read its external id straight off. The item is then archived through the normal identity-map path (archive + mapping delete, and the same superseded-key skip-with-audit as every other delete). An item whose ref resolves to nothing is a mapping_failed entry, not a crash. Hydrate the identity property on the find_associated side so the ref has something to read:

{"id": "find_stale", "action": "find_associated", "target": "hubspot", "entity": "deal", "source": "$upsert_deal.id", "associations": [ {"entity": "line_item", "as": "stale", "properties": ["collistar_externalobject_id"]} ]}, {"id": "wipe_stale", "action": "delete_many", "target": "hubspot", "entity": "line_item", "items": "$find_stale.stale", "external_id_ref": "$._item.properties.collistar_externalobject_id"}

associate_many (v3.1)

Bulk association creation. Attaches multiple records to a target record in one batch call.

FieldRequiredNotes
itemsyesJSONPath resolving to a list of association payloads.

Missing field error: associate_many step requires items.

Example:

{"id": "assoc_deals", "action": "associate_many", "items": "$.canonical.deal_associations"}

skip

No-op. Paired with when to express conditional branches.

{"id": "skip_if_test", "action": "skip", "when": {"equals": {"$.payload.env": "test"}}}

The action validator imposes no required fields on skip; it is only useful in combination with a when condition.

when conditions

The optional when field can carry any of the eight WhenCondition variants from (internal plugsync tooling - see the source repo):

VariantShape
WhenExists{"exists": "<jsonpath>"}
WhenFound{"found": "<step-id>"}
WhenEquals{"equals": {"<jsonpath>": <value>}}
WhenGt{"gt": {"<jsonpath>": <value>}}
WhenLt{"lt": {"<jsonpath>": <value>}}
WhenIn{"in": {"<jsonpath>": [<values...>]}}
WhenNot{"not": <inner WhenCondition>} (recursive)
WhenAll{"all": [<cond>, ...]} (AND compound; recursive)

On a REGULAR step when (this section), <value>/<values...> in equals/gt/lt/in is always a LITERAL - it is compared as-is, never resolved as a $... reference, even if it happens to be a string starting with $ (e.g. a currency-prefixed amount). This is deliberate and load-bearing for every already-published connector: the one documented exception is promote_identity’s own guard_when (see above), which opts into resolving a $-prefixed value too - scoped to that single call site, not a change to when in general.

  • {"all": [<cond>, ...]} - true iff every sub-condition is true (AND). An empty list is vacuously true. Use to combine conditions that no single predicate can express, e.g. a value inclusion with a not-empty guard: {"all": [{"in": {"$.x": ["a", "b"]}}, {"not": {"in": {"$.y": ["", null]}}}]}.

WhenFound’s <step-id> is a reference to an earlier find/find_associated step, with or without the leading $ ("find_company", "$find_company" and "$.find_company" all resolve identically). It is true only when that step’s stored output is non-null and non-empty; false both when the step ran and found nothing (a find with no match, or a find_associated with an empty association list, stores null/[]) and when the step was itself skipped by its own when (a skipped step never stores an output at all).

When a when condition evaluates to false the runtime skips the step and continues with the next one.

Last updated on