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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
from_entity | yes | Entity name of the source endpoint (used for the identity lookup + object type). |
from_external_id | yes | $... ref resolving to the source endpoint’s external id. |
to_entity | yes | Entity name of the target endpoint. |
to_external_id | yes | $... 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.
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
entity | yes | Entity name (used to resolve the HubSpot object type from the schema). |
object_id | yes | Required 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). |
data | yes | JSONPath or inline dict of properties to patch. |
items | no | $... 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).
| Field | Required | Description |
|---|---|---|
file_ref | yes | $... 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_id | yes | $... 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_body | no | Template for the Note body. The single token {filename} is substituted. Default: Allegato preventivo aggiornato {filename}. |
folder_path | no | HubSpot 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).
| Field | Required | Description |
|---|---|---|
file_ref | yes | $... 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_id | yes | $... 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_ref | no | Fallback $... 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_transform | no | Transform 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.
| Field | Required | Notes |
|---|---|---|
entity | yes | The entity name being transformed. |
to_schema | yes | Target schema key (usually canonical). |
from_schema | yes | Source 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.
| Field | Required | Notes |
|---|---|---|
target | yes | hubspot, or a REST destination (event source) name. |
entity | when target is hubspot | Drives the HubSpot object type. |
by | yes | {"property": "<hubspot property, or REST result item field>", "value": "<$-ref>"}. |
properties | no | HubSpot 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. |
url | when target is not hubspot | Path appended to the destination’s outbound base_url. Supports {$...} path refs, the path-side counterpart of params. |
results_path | no | REST only: jsonpath to the candidate list in the GET response body; omit when the body IS the list. |
params | no | REST only: GET query params dict; values may be $... refs (lookup chains). |
headers | no | REST only: extra headers merged over the destination’s resolved auth headers. |
cache_ttl_seconds | no | REST 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).
| Field | Required | Notes |
|---|---|---|
entity | yes | Parent entity name. Its hubspot schema object_type (or the friendly-name map) resolves the parent object type. |
source | yes | JSONPath to the parent HubSpot object id (e.g. $.payload.hs_object_id). |
associations | yes | List of association specs (see below). |
target | no | Defaults to hubspot; only hubspot is supported. |
Each entry in associations is a dict:
| Key | Required | Notes |
|---|---|---|
entity or object | yes | Target object/entity to fetch (resolved to a HubSpot plural object type). |
as | yes | Output key under which the hydrated result is stored in the step blob. |
cardinality | no | "one" (single object, or null if none) or "many" (list; default). |
properties | no | Properties 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.
sourceis a HubSpot object id, NOT an external id (#1150). This is the opposite ofassociate’sfrom_external_id/to_external_id, which take the external id and resolve it through the identity map.find_associated.sourcegets no lookup at all: whatever it resolves to is sent to HubSpot verbatim as the parent object id.The trap is that an
upsertstep’s output exposes both ids,.id(HubSpot) and.ext_id(external), so theassociateidiom copied onto afind_associatedin 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 eventTwo guardrails cover the mistake. Preflight (
GET .../preflight) and the publish response flagfind_associated_source_not_object_idwhensourceis statically provably not an object id: a$<step>.ext_idref, 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 aPermanentErrornaming the step id, thesourceexpression 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_objectper 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).
| Field | Required | Notes |
|---|---|---|
target | yes | "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). |
entity | yes | Entity name. |
data | no | Accepted by the schema; not read by the runtime for these four actions (see caveat below). |
associations | no | upsert/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}"}| Field | Required | Notes |
|---|---|---|
target | yes | The destination (event source) name. Unknown name -> delete: unknown target '<x>'. |
entity | yes | Entity whose identity mapping resolves the record to delete. |
url | yes | Path 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'. |
method | no | HTTP verb, DELETE by default. POST/PATCH/PUT for targets that model deletion as a state change (logical delete, e.g. an /archive endpoint). |
headers | no | Extra 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
.deleteevent, setidentity_source_pathexplicitly. The default chain resolves neither of its two candidates on a deletion:objectisnull(the enrichment GET 404s, by definition) and the id lives underevent. 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 warningdelete_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:
| Situation | Behaviour |
|---|---|
| No identity mapping | Quiet, 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. |
| 2xx | Deleted. The identity mapping is removed. |
| 404 | Treated 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, 429 | TransientError -> retry (429 honours the target’s Retry-After). The mapping is kept, so the retry can still resolve the id. |
| other 4xx | PermanentError -> 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).
| Field | Required | Notes |
|---|---|---|
to_entity | yes | Entity 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_id | yes | $... 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_id | no | Explicit 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.
| Field | Required | Notes |
|---|---|---|
entity | yes | Entity whose HubSpot identity property is renamed. |
from_external_id | yes | $... ref resolving to the OLD external id (the identity-map lookup key). |
to_external_id | yes | $... ref resolving to the NEW external id the record is renamed to. |
guard_when | no | A 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_missing | no | from_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_promoted | no | to_external_id is already mapped (redelivery, or already promoted independently). skip (default, idempotent) or fail. |
on_superseded | no | from_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_failure | no | The 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:
resolution | Written when | Governed by |
|---|---|---|
skipped_stale_guard | guard_when evaluated false against the record’s live properties: the promotion is stale and zero writes happened. | Config: guard_when |
superseded_source | on_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 |
recreated | on_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_superseded | A 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).
| Field | Required | Description |
|---|---|---|
target | yes | Must be hubspot. |
entity | yes | Entity whose hubspot field_mappings become the submitted form fields. |
form_id | yes | HubSpot form GUID: $... ref or literal. Unresolved at runtime is a hard error. |
context | no | Attribution $... 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.
| Field | Required | Notes |
|---|---|---|
target | yes | The schema key whose credential_name provides auth; also the entity schema key body projection reads when body is omitted (#795). |
entity | no | Entity whose schemas.<target>.field_mappings project the body when body is omitted (#795). Purely informational (logs) when body IS set. |
url | yes | Absolute or schema-base-relative URL. May carry {$...} path refs for dynamic segments. |
body | no | JSONPath or inline dict. Omit (with entity set) to project entities.<entity>.schemas.<target>.field_mappings instead — see “Body projection” below. |
headers | no | dict of headers. |
fields | no | Allowlist of field_mappings targets to project into the body (#463/#795). Requires entity; only meaningful when body is omitted. |
update_method | no | PUT (default) or PATCH: the verb used when the record already exists. Identity-aware writes only - see below. |
update_url | no | Update 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, notmethod.methodbelongs todeleteand is rejected on apostwith a 422 that points here. Apostis 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:
| Rule | Why |
|---|---|
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 call | Substituting 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 refused | URLs 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 untouched | It 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).
| Field | Required | Notes |
|---|---|---|
target | yes | Notification target. |
url | no | Required 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 theflow_workerprocess. This was the only form before issue #190. - Lambda (
plugin.logical_name):flow_workerinvokes 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.
| Field | Required | Notes |
|---|---|---|
module | legacy only | Python module name (in-process form). |
function | legacy only | Function name within the module (in-process form). |
plugin | Lambda only | JSON 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.
| Field | Required | Notes |
|---|---|---|
target | yes | Must equal "hubspot". Bulk actions are HubSpot-only. |
entity | yes | Entity name. |
items | yes | JSONPath expression resolving to a list of records to process. |
chunk_size | no | Batch size (1-100). Defaults to HubSpot’s API limit. |
output | no | JSONPath into the batch response, used by later steps in the same flow. |
reconcile | no | When true, the runtime emits per-record sync events after the batch call. |
external_id_ref | no | delete_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.
| Field | Required | Notes |
|---|---|---|
items | yes | JSONPath 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):
| Variant | Shape |
|---|---|
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.